a
GraphQL-palvelin
Tälläkin kurssilla moneen kertaan käytetty REST on ollut pitkään vallitseva tapa toteuttaa palvelimen selaimelle tarjoama rajapinta ja yleensäkin verkossa toimivien sovellusten välinen integraatio.
RESTin rinnalle selaimessa ja mobiililaitteessa toimivan logiikan ja palvelimien väliseen kommunikointiin on viime vuosina noussut alunperin Facebookin kehittämä GraphQL.
GraphQL on filosofialtaan todella erilainen RESTiin verrattuna. REST on resurssipohjainen. Jokaisella resurssilla, esim. käyttäjällä on oma sen identifioiva osoite, esim. /users/10 ja kaikki resursseille tehtävät operaatiot toteutetaan tekemällä URL:ille kohdistuvia pyyntöjä, joiden toiminta määrittyy käytetyn HTTP-metodin avulla.
RESTin resurssiperustaisuus toimii hyvin useissa tapauksissa, joissain tapauksissa se voi kuitenkin olla hieman kankea.
Oletetaan että blogilistasovelluksemme sisältäisi somemaista toiminnallisuutta ja haluaisimme esim. näyttää sovelluksessa listan, joka sisältää kaikkien seuraamiemme (follow) käyttäjien blogeja kommentoineiden käyttäjien lisäämien blogien nimet.
Jos palvelin toteuttaisi REST API:n, joutuisimme todennäköisesti tekemään monia HTTP-pyyntöjä selaimen koodista, ennen kuin saisimme muodostettua halutun datan. Pyyntöjen vastauksena tulisi myös paljon ylimääräistä dataa ja halutun datan keräävä selaimen koodi olisi todennäköisesti kohtuullisen monimutkainen.
Jos kyseessä olisi usein käytetty toiminnallisuus, voitaisiin sitä varten toteuttaa oma REST-endpoint. Jos vastaavia skenaarioita olisi paljon, esim. kymmeniä, tulisi erittäin työlääksi toteuttaa kaikille toiminnallisuuksille oma REST-endpoint.
GraphQL:n avulla toteutettu rajapinta sopii tämänkaltaisiin tilanteisiin hyvin.
GraphQL:ssä periaatteena on, että selaimen koodi muodostaa kyselyn, joka kuvailee halutun datan ja lähettää sen API:lle HTTP POST ‑pyynnöllä. Toisin kuin REST:issä, GraphQL:ssä kaikki kyselyt kohdistetaan samaan osoitteeseen ja ovat POST-tyyppisiä.
Edellä kuvatun skenaarion data saataisiin haettua (suurinpiirtein) seuraavan kaltaisella kyselyllä:
query FetchBlogsQuery {
user(username: "mluukkai") {
followedUsers {
blogs {
comments {
user {
blogs {
title
}
}
}
}
}
}
}
Palvelimen vastaus pyyntöön olisi suunnilleen seuraavanlainen JSON-olio:
{
"data": {
"followedUsers": [
{
"blogs": [
{
"comments": [
{
"user": {
"blogs": [
{
"title": "Goto considered harmful"
},
{
"title": "End to End Testing with Cypress is most enjoyable"
},
{
"title": "Navigating your transition to GraphQL"
},
{
"title": "From REST to GraphQL"
}
]
}
}
]
}
]
}
]
}
}
Sovelluslogiikka säilyy yksinkertaisena ja selaimen koodi saa täsmälleen haluamansa datan yksittäisellä kyselyllä.
Skeema ja kyselyt
Tutustutaan GraphQL:n peruskäsitteistöön toteuttamalla GraphQL-versio osien 2 ja 3 puhelinluettelosovelluksesta.
Jokaisen GraphQL-sovelluksen ytimessä on skeema, joka määrittelee minkä muotoista dataa sovelluksessa vaihdetaan clientin ja palvelimen välillä. Puhelinluettelon alustava skeema on seuraavassa:
type Person {
name: String!
phone: String
street: String!
city: String!
id: ID!
}
type Query {
personCount: Int!
allPersons: [Person!]!
findPerson(name: String!): Person
}
Skeema määrittelee kaksi tyyppiä. Tyypeistä ensimmäinen Person määrittelee, että henkilöillä on viisi kenttää. Kentistä neljä on tyyppiä String, joka on yksi GraphQL:n määrittelemistä valmiista tyypeistä. String-arvoisista kentistä muilla paitsi puhelinnumerolla (phone) on oltava arvo, tämä on merkitty skeemaan huutomerkillä. Kentän id tyyppi on ID. Arvoltaan ID-tyyppiset kentät ovat merkkijonoja, mutta GraphQL takaa, että ne ovat uniikkeja.
Toinen skeeman määrittelemistä tyypeistä on Query. Käytännössä jokaisessa GraphQL-skeemassa määritellään tyyppi Query, joka kertoo mitä kyselyjä API:iin voidaan tehdä.
Puhelinluettelo määrittelee kolme erilaista kyselyä. Näistä ensimmäinen, eli personCount palauttaa kokonaisluvun, allPersons palauttaa listan Person-tyyppisiä olioita. Kysely findPerson saa merkkijonomuotoisen parametrin ja palauttaa Person-olion.
Queryjen paluuarvon ja parametrin määrittelyssä on jälleen käytetty välillä huutomerkkiä merkkaamaan pakollisuutta, eli personCount palauttaa varmasti kokonaisluvun. Kyselylle findPerson on pakko antaa parametriksi merkkijono. Kysely palauttaa Person-olion tai arvon null. allPersons palauttaa listan Person-olioita, listalla ei ole null-arvoja.
Skeema siis määrittelee mitä kyselyjä client pystyy palvelimelta tekemään, minkälaisia parametreja kyselyillä voi olla sekä sen, minkä muotoista kyselyjen palauttama data on.
Kyselyistä yksinkertaisin personCount näyttää seuraavalta
query {
personCount
}
Olettaen että sovellukseen olisi talletettu kolmen henkilön tiedot, vastaus kyselyyn näyttäisi seuraavalta:
{
"data": {
"personCount": 3
}
}
Kaikkien henkilöiden tiedot hakeva allPersons on hieman monimutkaisempi. Koska kysely palauttaa listan Person-olioita, on kyselyn yhteydessä määriteltävä mitkä kentät kyselyn halutaan palauttavan:
query {
allPersons {
name
phone
}
}
Vastaus voisi näyttää seuraavalta:
{
"data": {
"allPersons": [
{
"name": "Arto Hellas",
"phone": "040-123543"
},
{
"name": "Matti Luukkainen",
"phone": "040-432342"
},
{
"name": "Venla Ruuska",
"phone": null
}
]
}
}
Kysely voi määritellä palautettavaksi mitkä tahansa skeemassa mainitut kentät, esim. seuraava olisi myös mahdollista:
query {
allPersons{
name
city
street
}
}
Vielä esimerkki parametria edellyttävästä kyselystä, joka hakee yksittäisen henkilön tiedot palauttavasta kyselystä.
query {
findPerson(name: "Arto Hellas") {
phone
city
street
id
}
}
Kyselyn parametri siis annetaan suluissa, ja sen jälkeen määritellään aaltosuluissa paluuarvona tulevan olion halutut kentät.
Vastaus on muotoa:
{
"data": {
"findPerson": {
"phone": "040-123543",
"city": "Espoo",
"street": "Tapiolankatu 5 A"
"id": "3d594650-3436-11e9-bc57-8b80ba54c431"
}
}
}
Kyselyn paluuarvoa ei oltu merkitty pakolliseksi, eli jos etsitään tuntematonta henkilöä
query {
findPerson(name: "Joe Biden") {
phone
}
}
vastaus on null
{
"data": {
"findPerson": null
}
}
Kuten huomaamme, GraphQL-kyselyn ja siihen vastauksena tulevan JSON:in muodoilla on vahva yhteys, voidaan ajatella että kysely kuvailee sen minkälaista dataa vastauksena halutaan. Ero REST:issä tehtäviin pyyntöihin on suuri, REST:iä käytettäessä pyynnon url ja sen tyyppi (GET, POST, PUT, DELETE) ei kerro mitään palautettavan datan muodosta.
GraphQL:n skeema kuvaa ainoastaan palvelimen ja sitä käyttävien clientien välillä liikkuvan tiedon muodon. Tieto voi olla organisoituna ja talletettuna palvelimeen ihan missä muodossa tahansa.
Nimestään huolimatta GraphQL:llä ei itse asiassa ole mitään tekemistä tietokantojen kanssa, se ei ota mitään kantaa siihen miten data on tallennettu. GraphQL-periaattella toimivan API:n käyttämä data voi siis olla talletettu relaatiotietokantaan, dokumenttitietokantaan tai muille palvelimille, joita GraphQL-palvelin käyttää vaikkapa REST:in välityksellä.
Apollo Server
Toteutetaan nyt GraphQL-palvelin tämän hetken johtavaa kirjastoa Apollo Serveriä käyttäen.
Luodaan uusi npm-projekti komennolla npm init ja asennetaan tarvittavat riippuvuudet
npm install @apollo/server graphql
Alustava toteutus on seuraavassa
const { ApolloServer } = require('@apollo/server')
const { startStandaloneServer } = require('@apollo/server/standalone')
let persons = [
{
name: "Arto Hellas",
phone: "040-123543",
street: "Tapiolankatu 5 A",
city: "Espoo",
id: "3d594650-3436-11e9-bc57-8b80ba54c431"
},
{
name: "Matti Luukkainen",
phone: "040-432342",
street: "Malminkaari 10 A",
city: "Helsinki",
id: '3d599470-3436-11e9-bc57-8b80ba54c431'
},
{
name: "Venla Ruuska",
street: "Nallemäentie 22 C",
city: "Helsinki",
id: '3d599471-3436-11e9-bc57-8b80ba54c431'
},
]
const typeDefs = `
type Person {
name: String!
phone: String
street: String!
city: String!
id: ID!
}
type Query {
personCount: Int!
allPersons: [Person!]!
findPerson(name: String!): Person
}
`
const resolvers = {
Query: {
personCount: () => persons.length,
allPersons: () => persons,
findPerson: (root, args) =>
persons.find(p => p.name === args.name)
}
}
const server = new ApolloServer({
typeDefs,
resolvers,
})
startStandaloneServer(server, {
listen: { port: 4000 },
}).then(({ url }) => {
console.log(`Server ready at ${url}`)
})
Toteutuksen ytimessä on ApolloServer, joka saa kaksi parametria
const server = new ApolloServer({
typeDefs,
resolvers,
})
parametreista ensimmäinen typeDefs sisältää sovelluksen käyttämän GraphQL-skeeman.
Toinen parametri on olio, joka sisältää palvelimen resolverit, eli käytännössä koodin, joka määrittelee miten GraphQL-kyselyihin vastataan.
Resolverien koodi on seuraavassa:
const resolvers = {
Query: {
personCount: () => persons.length,
allPersons: () => persons,
findPerson: (root, args) =>
persons.find(p => p.name === args.name)
}
}
kuten huomataan, vastaavat resolverit rakenteeltaan skeemassa määriteltyjä kyselyitä:
type Query {
personCount: Int!
allPersons: [Person!]!
findPerson(name: String!): Person
}
eli jokaista skeemassa määriteltyä kyselyä kohti on määritelty oma kentän Query alle tuleva kenttänsä.
Kyselyn
query {
personCount
}
resolveri on funktio
() => persons.length
eli kyselyyn palautetaan vastauksena henkilöt tallentavan taulukon persons pituus.
Kaikki luettelossa olevat henkilöt hakevan kyselyn
query {
allPersons {
name
}
}
resolveri on funktio, joka palauttaa kaikki taulukon persons oliot
() => persons
Apollo Studio Explorer
Kun Apollo-serveriä suoritetaan sovelluskehitysmoodissa, ja mennään sivulle http://localhost:4000 päästään nappia Query your server painamalla sovelluskehittäjälle erittäin hyödyllisen Apollo Studio Explorer-näkymän, joka avulla on mahdollista tehdä kyselyjä palvelimelle.
Kokeillaan
Vasemmassa laidassa Explorer näyttää mukavasti myös automaattisesti skeeman perusteella muodosteutun API-dokumentaation.
Resolverin parametrit
Yksittäisen henkilön hakevan kyselyn
query {
findPerson(name: "Arto Hellas") {
phone
city
street
}
}
resolveri on funktio, joka poikkeaa kahdesta aiemmasta resolverista siinä että se saa kaksi parametria:
(root, args) => persons.find(p => p.name === args.name)
Parametreista toinen args sisältää kyselyn parametrit. Resolveri siis palauttaa taulukosta persons henkilön, jonka nimi on sama kuin args.name arvo. Ensimmäisenä olevaa parametria root resolveri ei tarvitse.
Itse asiassa kaikki resolverifunktiot saavat neljä parametria. JavaScriptissa parametrit voidaan kuitenkin jättää määrittelemättä, jos niitä ei tarvita. Tulemme käyttämään resolverien ensimmäistä ja kolmatta parametria vielä myöhemmin tässä osassa.
Oletusarvoinen resolveri
Kun teemme kyselyn, esim
query {
findPerson(name: "Arto Hellas") {
phone
city
street
}
}
osaa palvelin liittää vastaukseen täsmälleen ne kentät, joita kysely pyytää. Miten tämä tapahtuu?
GraphQL-palvelimen tulee määritellä resolverit jokaiselle skeemassa määritellyn tyypin kentälle. Olemme nyt määritelleet resolverit ainoastaan tyypin Query kentille, eli kaikille sovelluksen tarjoamille kyselyille.
Koska skeemassa olevan tyypin Person kentille ei ole määritelty resolvereita, Apollo on määritellyt niille oletusarvoisen resolverin, joka toimii samaan tapaan kuin seuraavassa itse määritelty resolveri:
const resolvers = {
Query: {
personCount: () => persons.length,
allPersons: () => persons,
findPerson: (root, args) => persons.find(p => p.name === args.name)
},
Person: { name: (root) => root.name, phone: (root) => root.phone, street: (root) => root.street, city: (root) => root.city, id: (root) => root.id }}
Oletusarvoinen resolveri siis palauttaa olion vastaavan kentän arvon. Itse olioon se pääsee käsiksi resolverin ensimmäisen parametrin root kautta.
Jos oletusarvoisen resolverin toiminnallisuus riittää, ei omaa resolveria tarvitse määritellä. On myös mahdollista määritellä ainoastaan joillekin tyypin yksittäisille kentille oma resolverinsa ja antaa oletusarvoisen resolverin hoitaa muut kentät.
Voisimme esimerkiksi määritellä, että kaikkien henkilöiden osoitteeksi tulisi Manhattan New York kovakoodaamalla seuraavat tyypin Person kenttien street ja city resolvereiksi:
Person: {
street: (root) => "Manhattan",
city: (root) => "New York"
}
Olion sisällä olio
Muutetaan skeemaa hiukan
type Address { street: String! city: String! }
type Person {
name: String!
phone: String
address: Address! id: ID!
}
type Query {
personCount: Int!
allPersons: [Person!]!
findPerson(name: String!): Person
}
eli henkilöllä on nyt kenttä, jonka tyyppi on Address, joka koostuu kadusta ja kaupungista.
Osoitetta tarvitsevat kyselyt muuttuvat muotoon
query {
findPerson(name: "Arto Hellas") {
phone
address {
city
street
}
}
}
vastauksena on henkilö-olio, joka sisältää osoite-olion:
{
"data": {
"findPerson": {
"phone": "040-123543",
"address": {
"city": "Espoo",
"street": "Tapiolankatu 5 A"
}
}
}
}
Talletetaan henkilöt palvelimella edelleen samassa muodossa kuin aiemmin.
let persons = [
{
name: "Arto Hellas",
phone: "040-123543",
street: "Tapiolankatu 5 A",
city: "Espoo",
id: "3d594650-3436-11e9-bc57-8b80ba54c431"
},
// ...
]
Nyt siis palvelimen tallettamat henkilö-oliot eivät ole muodoltaan täysin samanlaisia kuin GraphQL-skeeman määrittelemät tyypin Person-oliot.
Toisin kuin tyypille Person ei tyypille Address ole määritelty id-kenttää, sillä osoitteita ei ole talletettu palvelimella omaan tietorakenteeseensa.
Koska taulukkoon talletetuilla olioilla ei ole kenttää address oletusarvoinen resolveri ei enää riitä. Lisätään resolveri tyypin Person kentälle address:
const resolvers = {
Query: {
personCount: () => persons.length,
allPersons: () => persons,
findPerson: (root, args) =>
persons.find(p => p.name === args.name)
},
Person: { address: (root) => { return { street: root.street, city: root.city } } }}
Eli aina palautettaessa Person-oliota, palautetaan niiden kentät name, phone sekä id käyttäen oletusarvoista resolveria, kenttä address muodostetaan itse määritellyn resolverin avulla. Resolverifunktion parametrina root on käsittelyssä oleva henkilö-olio, eli osoitteen katu ja kaupunki saadaan sen kentistä.
Muutetaan kentän address resolveria vielä siten, että se destrukturoi tarvitsemansa kentät saamastaan parametrista:
const resolvers = {
Query: {
personCount: () => persons.length,
allPersons: () => persons,
findPerson: (root, args) => persons.find((p) => p.name === args.name),
},
Person: {
address: ({ street, city }) => { return {
street, city, }
},
},
}
Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part8-1.
Mutaatio
Laajennetaan sovellusta siten, että puhelinluetteloon on mahdollista lisätä uusia henkilöitä. GraphQL:ssä kaikki muutoksen aiheuttavat operaatiot tehdään mutaatioiden avulla. Mutaatiot määritellään skeemaan tyypin Mutation avaimina.
Käyttäjän lisäävä mutaation skeema näyttää seuraavalta
type Mutation {
addPerson(
name: String!
phone: String
street: String!
city: String!
): Person
}
Mutaatio siis saa parametreina käyttäjän tiedot. Parametreista phone on ainoa, jolle ei ole pakko asettaa arvoa. Mutaatioilla on parametrien lisäksi paluuarvo. Paluuarvo on nyt tyyppiä Person, ideana on palauttaa operaation onnistuessa lisätyn henkilön tiedot ja muussa tapauksessa null. Mutaatiossa ei anneta parametrina kentän id arvoa, sen luominen on parempi jättää palvelimen vastuulle.
Myös mutaatioita varten on määriteltävä resolveri:
const { v1: uuid } = require('uuid')
// ...
const resolvers = {
// ...
Mutation: {
addPerson: (root, args) => {
const person = { ...args, id: uuid() }
persons = persons.concat(person)
return person
}
}
}
Mutaatio siis lisää parametreina args saamansa olion taulukkoon persons ja palauttaa lisätyn olion.
Kentälle id saadaan luotua uniikki tunniste kirjaston uuid avulla.
Uusi henkilö voidaan lisätä seuraavalla mutaatiolla
mutation {
addPerson(
name: "Pekka Mikkola"
phone: "045-2374321"
street: "Vilppulantie 25"
city: "Helsinki"
) {
name
phone
address{
city
street
}
id
}
}
Kannattaa huomata, että lisättävä henkilö talletetaan taulukkoon persons muodossa
{
name: "Pekka Mikkola",
phone: "045-2374321",
street: "Vilppulantie 25",
city: "Helsinki",
id: "2b24e0b0-343c-11e9-8c2a-cb57c2bf804f"
}
Vastaus mutaatioon on kuitenkin
{
"data": {
"addPerson": {
"name": "Pekka Mikkola",
"phone": "045-2374321",
"address": {
"city": "Helsinki",
"street": "Vilppulantie 25"
},
"id": "2b24e0b0-343c-11e9-8c2a-cb57c2bf804f"
}
}
}
eli tyypin Person kentän address resolveri muotoilee vastauksena palautettavan olion oikean muotoiseksi.
Virheiden käsittely
Jos yritämme luoda uuden henkilön, mutta parametrit eivät vastaa skeemassa määriteltyä (esim. katuosoite puuttuu), antaa palvelin virheilmoituksen:
GraphQL:n validoinnin avulla pystytään siis jo automaattisesti hoitamaan osa virheenkäsittelyä.
Kaikkea GraphQL ei kuitenkaan pysty hoitamaan automaattisesti. Esimerkiksi tarkemmat säännöt mutaatiolla lisättävän datan kenttien muodolle on lisättävä itse. Niistä aiheutuvat virheet tulee hoitaa itse heittämällä sopivalla virhekoodilla varustettu GraphQLError.
Estetään saman nimen lisääminen puhelinluetteloon useampaan kertaan:
const { GraphQLError } = require('graphql')
// ...
const resolvers = {
// ..
Mutation: {
addPerson: (root, args) => {
if (persons.find(p => p.name === args.name)) { throw new GraphQLError('Name must be unique', { extensions: { code: 'BAD_USER_INPUT', invalidArgs: args.name } }) }
const person = { ...args, id: uuid() }
persons = persons.concat(person)
return person
}
}
}
Eli jos lisättävä nimi on jo luettelossa heitetään poikkeus GraphQLError.
Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part8-2.
Enum
Tehdään sovellukseen vielä sellainen lisäys, että kaikki henkilöt palauttavaa kyselyä voidaan säädellä parametrilla phone siten, että kysely palauttaa vain henkilöt, joilla on puhelinnumero
query {
allPersons(phone: YES) {
name
phone
}
}
tai henkilöt, joilla ei ole puhelinnumeroa
query {
allPersons(phone: NO) {
name
}
}
Skeema laajenee seuraavasti:
enum YesNo { YES NO}
type Query {
personCount: Int!
allPersons(phone: YesNo): [Person!]! findPerson(name: String!): Person
}
Tyyppi YesNo on GraphQL:n enum, eli lueteltu tyyppi, jolla on kaksi mahdollista arvoa, YES ja NO. Kyselyssä allPersons parametri phone on tyypiltään YesNo, mutta sen arvo ei ole pakollinen.
Resolveri muuttuu seuraavasti
Query: {
personCount: () => persons.length,
allPersons: (root, args) => { if (!args.phone) { return persons } const byPhone = (person) => args.phone === 'YES' ? person.phone : !person.phone return persons.filter(byPhone) }, findPerson: (root, args) =>
persons.find(p => p.name === args.name)
},
Numeron muutos
Tehdään sovellukseen myös mutaatio, joka mahdollistaa henkilön puhelinnumeron muuttamisen. Mutaation skeema näyttää seuraavalta
type Mutation {
addPerson(
name: String!
phone: String
street: String!
city: String!
): Person
editNumber( name: String! phone: String! ): Person}
ja sen toteuttaa seuraava resolveri:
Mutation: {
// ...
editNumber: (root, args) => {
const person = persons.find(p => p.name === args.name)
if (!person) {
return null
}
const updatedPerson = { ...person, phone: args.phone }
persons = persons.map(p => p.name === args.name ? updatedPerson : p)
return updatedPerson
}
}
Mutaatio siis hakee kentän name perusteella henkilön, jonka numero päivitetään.
Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part8-3.
Lisää kyselyistä
GraphQL:ssä on yhteen kyselyyn mahdollista yhdistää monia tyypin Query kenttiä, eli "yksittäisiä kyselyitä". Esim. seuraava kysely palauttaa puhelinluettelon henkilöiden lukumäärän sekä nimet:
query {
personCount
allPersons {
name
}
}
Vastaus näyttää seuraavalta
{
"data": {
"personCount": 3,
"allPersons": [
{
"name": "Arto Hellas"
},
{
"name": "Matti Luukkainen"
},
{
"name": "Venla Ruuska"
}
]
}
}
Yhdistetty kysely voi myös viitata useampaan kertaan samaan kyselyyn. Tällöin erillisille kyselyille on kuitenkin annettava vaihtoehtoiset nimet kaksoispistesyntaksilla
query {
havePhone: allPersons(phone: YES){
name
}
phoneless: allPersons(phone: NO){
name
}
}
Vastaus on muotoa
{
"data": {
"havePhone": [
{
"name": "Arto Hellas"
},
{
"name": "Matti Luukkainen"
}
],
"phoneless": [
{
"name": "Venla Ruuska"
}
]
}
}
Joissain tilanteissa voi myös olla hyötyä nimetä kyselyt. Näin on erityisesti tilanteissa, joissa kyselyillä tai mutaatiolla on parametreja. Tutustumme parametreihin pian.