Når du er ferdig med dette kapittelet har du lært hvordan du designer en enkel GraphQL mutation.
Løse skrivebehov i GraphQL
GraphQL-skjemaspråket gir oss i utgangspunktet stor frihet i hvordan vi designer for å løse skrivebehov. Det har imidlertid etablert seg en viss beste praksis på området, først introdusert av Relay, som vi baserer oss på her. I fremstillingen under lener vi oss også på følgende blogg-innlegg: Apollo Blog: Design GraphQL Mutations.
Kanter på Mutation-rota
I forrige kapittel så vi at vi kan løse lesebehov ved å legge til kanter på Query-rota. For skrivebehov så er det Mutation-rota som gjelder:
type Mutation {
}
1 Gi kanten et passende navn
Vi forsøker alltid å gi mutasjonene våre navn som gir mening i kontekst av prosessene vi skal underbygge. Vi feiler heller i retning av å lage flere målrettede mutasjoner enn færre, mer generelle. Det å utføre en mutasjon er en handling, derfor skal navnet være en imperativsetning skrevet i lowerCamelCase. Navnet skal med andre ord starte med et verb i imperativform, etterfulgt av en substantivfrase. Verbet skal ha liten forbokstav, mens ordene i substantivfrasen skal begynne med store bokstaver. Navnet kan ikke inneholde mellomrom, så disse fjernes. Se eksempler under.
Eksempel på imperativsverb er "endre", "registrer", "trekk" og "semesterregistrer". Det er viktig at vi bruker verb som gir mening i kontekst av prosessen vi skal understøtte, og at vi i minst mulig grad velger navn basert på hvilke rader og tabeller i databasen som er involvert.
Objektet verbet utføres på beskrives av substantivfrasen. I enkle tilfeller så vil dette bare være ett substantiv, for eksempel navnet på noden i grafen (en node forstås her som noe som kan refereres til med en ID). Dette gjelder for eksempel når det som skal oppdateres er hele noden (ved oppretting eller sletting av en forekomst). Da får vi navn som "semesterregistrerStudent", "endreVurdering" eller "angiStudentbilde".
I vårt tilfelle skal vi ikke gjøre en handling på en student, men for en student. Dette fordrer flere ord som beskriver substantivet. Dette gir opphav til navn som "RegistrerSemesteravgiftBetalt" eller "registrerStudentSemesteravgiftBetalt". Hovedregelen for navnevalg er at vi foretrekker setninger som leser godt. Vi mener det første eksempelet her leser bedre enn det siste, så vi bruker det videre.
Vi bruker norske navn på mutasjonene våre.
type Mutation {
RegistrerSemesteravgiftBetalt
}
2 Alle våre mutasjoner skal gjøre bulk-oppdateringer
Når vi skriver skjema for GraphQL-mutasjoner, kan vi velge om vi skal legge opp til at klienten skal oppdatere én rad av gangen, eller flere rader samtidig. Vi har valgt å legge oss på en linje der klienten får mulighet til å oppdatere flere rader i alle mutasjonenene våre, da det gir bedre gjenbrukbarhet i APIet.
I tillegg har vi bestemt at når klienten utfører en mutasjon, skal den gjøres i én transaksjon, det vil si at om klienten prøver å oppdatere 10 rader, vil vi skrive 10 rader til databasen. Dersom én av radene feiler, vil ingenting bli skrevet til databasen.
3 Legg til argumenter på kanten
På samme måte som vi bruker argumenter for å fortelle APIet hva vi skal spørre etter, bruker vi argumenter for å angi hva som skal endres og hvordan. Her følger vi anbefalingen fra Relay og lager ett argument kalt "input", som peker på en unik input-type. Input-typen gir vi navn etter følgende mønster: [navn på mutasjonen]Input.
Vi har bestemt at alle mutasjonene skal støtte oppdatering av flere rader samtidig. Derfor lar vi mutation-typen ta inn en av inputtypen slik:
type Mutation {
RegistrerSemesteravgiftBetalt(input: [RegistrerSemesteravgiftBetaltInput!]!)
}
input RegistrerSemesteravgiftBetaltInput {
studentVedInstitusjonId: ID!
institusjonId: ID!
}
Vi må også angi hvilken termin semesterregistreringen gjelder:
type Mutation {
RegistrerSemesteravgiftBetalt(input: [RegistrerSemesteravgiftBetaltInput!]!)
}
input RegistrerSemesteravgiftBetaltInput {
studentVedInstitusjonId: ID!
institusjonId: ID!
termin: RegistrerSemesteravgiftBetaltTerminInput!
}
input RegistrerSemesteravgiftBetaltTerminInput {
ar: Int!
termintype: Termintype!
}
Input-typen mappes mot en tabell i kjerne-APIet med @table-direktivet. Felter i input-typen mappes med @field-direktiver, på samme måte som for lesebehov. @field-direktivet trengs ikke hvis kolonnen i kjerneapiet har samme navn som feltet i APIet:
type Mutation {
RegistrerSemesteravgiftBetalt(input: [RegistrerSemesteravgiftBetaltInput!]!)
}
input RegistrerSemesteravgiftBetaltInput @table(name: "SEMESTERREGISTRERING") {
studentVedInstitusjonId: ID!
institusjonId: ID!
termin: RegistrerSemesteravgiftBetaltTerminInput!
}
input RegistrerSemesteravgiftBetaltTerminInput {
ar: Int! @field(name: "ARSTALL")
termintype: Termintype!
}
ID-feltene studentVedInstitusjonId og institusjonId er referanser til andre kjerne-API-tabeller. Disse mappes også med
@field-direktiver, men skal mappes mot navnet på en ID i kjerne-APIet, i stedet for en kolonne:
type Mutation {
RegistrerSemesteravgiftBetalt(input: [RegistrerSemesteravgiftBetaltInput!]!)
}
input RegistrerSemesteravgiftBetaltInput @table(name: "SEMESTERREGISTRERING") {
studentVedInstitusjonId: ID! @field(name: "STUDENTID")
institusjonId: ID! @field(name: "BETALSTEDINSTITUSJONID")
termin: RegistrerSemesteravgiftBetaltTerminInput!
}
input RegistrerSemesteravgiftBetaltTerminInput {
ar: Int! @field(name: "ARSTALL")
termintype: Termintype!
}
Merk at selv om vi har en type Termin, kan vi ikke gjenbruke denne, da dette er en type ment for å hente data. Det er kun
enkle verdier, enumer og input-objekter som kan brukes i argumentene i GraphQL. Denne begrensningen viser seg å være heldig,
siden den gjør implementasjon vesentlig enklere uten at det skaper særlige problemer med å designe mutasjonene.
4 Kanten skal peke på en payload-type som returnerer de endrede dataene
Relay anbefaler videre at kanten peker på en type som er unik for denne mutasjonen. Typen kaller vi "[Navn på feltet]Payload". Payload-typen skal inneholde en liste over objektene som ble endret. I dette tilfellet er det en liste av semesterregistreringer som enten ble opprettet eller endret:
type Mutation {
RegistrerSemesteravgiftBetalt(input: [RegistrerSemesteravgiftBetaltInput!]!): RegistrerSemesteravgiftBetaltPayload!
}
input RegistrerSemesteravgiftBetaltInput @table(name: "SEMESTERREGISTRERING") {
studentVedInstitusjonId: ID! @field(name: "STUDENTID")
institusjonId: ID! @field(name: "BETALSTEDINSTITUSJONID")
termin: RegistrerSemesteravgiftBetaltTerminInput!
}
input RegistrerSemesteravgiftBetaltTerminInput {
ar: Int! @field(name: "ARSTALL")
termintype: Termintype!
}
type RegistrerSemesteravgiftBetaltPayload {
semesterregistreringer: [Semesterregistrering!]!
}
Det betyr at når vi returnerer en Semesterregistrering, får klienten mulighet til å hente alle data som er koblet til Semesterregistrering samtidig, dersom den ønsker det.
Payload for mutasjoner som oppdaterer flere tabeller
Noen mutasjoner vil føre til endringer i flere tabeller samtidig. Et eksempel er navneendringer, som oppdaterer fornavn og etternavn i en personprofil, men samtidig også et innslag i navnehistorikken. Her ønsker vi bare at databasen skal oppdateres dersom både oppdateringen av personprofil og oppdateringen av navnehistorikken lykkes.
Designmessig løser vi dette ved å lage en ny type som inneholder en personprofil og en navnehistorikk-oppføring, og
returnere en liste av disse i payloaden. Denne nye typen navngir vi [navn på mutasjonen]Success. I payloaden legger vi
inn et felt successes som returnerer en liste av den nye typen:
type Mutation {
endreNavnForPersonProfil (input: [EndreNavnForPersonProfilInput!]!):
EndreNavnForPersonProfilPayload!
}
input EndreNavnForPersonProfilInpput {
fornavn: String!
etternavn: String!
merknad: String
}
type EndreNavnForPersonProfilPayload {
successes: [EndreNavnForPersonProfilSuccess!]!
}
type EndreNavnForPersonProfilSuccess {
personProfil: PersonProfil!
navnehistorikk: Navnehistorikk!
}
Payload for slettinger
Vi kan også bruke mutasjoner for å slette fra databasen. I slike tilfeller er det ikke helt klart hva som er riktig å returnere i payloaden. Foreløpig har vi sett på følgende to alternativer, men ikke landet på en konklusjon ennå:
Returnere tilstanden til objektet før slettingen:
type SlettSemesterregistreringPayload {
semesterregistreringer: [Semesterregistrering!]!
}
Semesterregistrering vil her inneholde dataene slik de så ut før slettingen ble utført.
Returnere IDen til det slettede objektet:
type SlettSemesterregistreringPayload {
semesterregistreringId: ID
}
5 Definere skriveoperasjon for mutasjoner
Vi må definere hvilken skriveoperasjon som skal utføres. For de enkleste mutasjonene kreves det å definere hva slags
skriveoperasjon som skal gjøres mot databasen med mutationType-direktivet. Mer komplekse behov løses med servicer
i kjerne-APIet, som vi mapper til med service-direktivet.
Hvis det ikke er et slikt én-til-én-samsvar, eller dersom vi ønsker å innføre forretningslogikk utover det som allerede
ligger i databasen, må det legges til en service i kjerne-APIet, som vi mapper til med service-direktivet.
5.1 Enkle mutasjoner med mutationType
For at du skal kunne bruke mutationType-direktivet, må følgende krav være oppfylt:
- Inputen til mutasjonen er knyttet til en enkelt rad, eller en liste av rader i én og samme tabell, og vi forventer å få de samme radene ut i payloaden
- Mutasjonen skal ikke inneholde forretningslogikk utover det som allerede finnes i databasen
- Payloaden inneholder en node eller IDen til en node
- Graphitron støtter inntil videre bare UPDATE- og DELETE-operasjoner. Inntil videre må INSERT- og UPSERT-operasjoner løses med servicer
mutationType har foreløpig ikke støtte for feilhåndteringDet er foreløpig ikke støtte for å legge inn egen feilhåndtering for disse mutasjonene. Dette vil komme, men inntil videre må vi belage oss på GraphQLs innebygde feilhåndtering, eller bruke servicer (se under)
I eksempelet vårt tar mutasjonen inn felter for å oppdatere et gitt antall rader i tabellen SEMESTERREGISTRERING, og i payloaden forventer vi å finne igjen de samme radene. Det som skal gjøres i databasen, er imidlertid en UPSERT-operasjon, som ikke er støttet i Graphitron ennå. Vi bruker derfor en service til dette formålet (se under).
Vi kan imidlertid se for oss at vi lager en mutasjon for oppdatere en eksisterende semesterregistrering, for eksempel for å rette en feilregistrert institusjon for betaling av semesteravgift:
type Mutation {
endreInstitusjonForBetaltSemesteravgift (input: [EndreInstitusjonForBetaltSemesteravgiftInput!]!): EndreInstitusjonForBetaltSemesteravgiftPayload! @mutation(typeName: UPDATE)
}
input EndreInstitusjonForBetaltSemesteravgiftInput @table(name: "SEMESTERREGISTRERING") {
id: ID!
institusjonId: ID! @field(name: "BETALSTEDINSTITUSJONID")
}
type EndreInstitusjonForBetaltSemesteravgiftPayload {
semesterregistreringer: [Semesterregistrering!]!
}
5.2 Mer komplekse mutasjoner med service
Dersom vi har behov for å legge inn forretningslogikk i skriveoperasjonen, og/eller vi ønsker å returnere noe annet ut i payloaden enn den raden inputen forholder seg til, må vi skrive en service i kjerne-APIet for å håndtere denne logikken. For at vi skal kunne mappe til en service, må den være referert fra generatorkonfigurasjonen.
Vi mapper til riktig service ved hjelp av referansenavnet fra generatorkonfigurasjonen:
type Mutation {
endreNavnForPersonProfiler (input: [EndreNavnForPersonProfilerInput!]!):
EndreNavnForPersonProfilerPayload! @service(service: {name: "SERVICE_PERSONPROFIL"})
}
input EndreNavnForPersonProfilerInput {
fornavn: String!
etternavn: String!
merknad: String
}
type EndreNavnForPersonProfilerPayload {
successes: [EndreNavnForPersonProfilerSuccess!]!
}
type EndreNavnForPersonProfilerSuccess {
personProfil: PersonProfil!
navnehistorikk: Navnehistorikk!
}
Fordi Graphitron ikke støtter UPSERT-operasjoner ennå, må vi også bruke en service i eksempelet vårt:
type Mutation {
RegistrerSemesteravgiftBetalt(input: [RegistrerSemesteravgiftBetaltInput!]!): RegistrerSemesteravgiftBetaltPayload! @service(service: {name: "SERVICE_SEMESTERREGISTRERING"})
}
input RegistrerSemesteravgiftBetaltInput @table(name: "SEMESTERREGISTRERING") {
studentVedInstitusjonId: ID! @field(name: "STUDENTID")
institusjonId: ID! @field(name: "BETALSTEDINSTITUSJONID")
termin: RegistrerSemesteravgiftBetaltTerminInput!
}
input RegistrerSemesteravgiftBetaltTerminInput {
ar: Int! @field(name: "ARSTALL")
termintype: Termintype!
}
type RegistrerSemesteravgiftBetaltPayload {
semesterregistreringer: [Semesterregistrering!]!
}
6 Payloaden skal også inneholde eventuelle feilmeldinger for mutasjonen
GraphQL har innebygd feilhåndtering på toppnivå. Den innebygde funksjonaliteten gir oss imidlertid ikke alt vi trenger for feilhåndtering. Vi må derfor definere vårt eget skjema for feilmeldinger, som er konsistent på tvers av APIet. Vi har tatt utgangspunkt i følgende artikkel: A Guide to GraphQL Errors | Production Ready GraphQL. Argumentene for designvalgene finnes i denne artikkelen, og blir ikke repetert her.
6.1 Det skal finnes en type for hver feilmelding som kan komme fra en mutation
Dette er for at klienten skal vite hvordan potensielle feilmeldinger ser ut. La oss lage en feilmelding som kan komme hvis oppgitt institusjon ikke er godkjent for å motta semesteravgift. Følgende oppsett er en ganske standard måte å gjøre det på:
type KanIkkeMottaSemesteravgift {
message: String!
path: [String!]!
}
"message" skal inneholde en tekstlig beskrivelse av hva som gikk galt. "path" returnerer en liste med strenger som til sammen identifiserer hvor feilen oppsto.
Alle feilmeldingstypene skal implementere et felles interface. Dette gir oss en felles struktur på feilmeldingene:
interface Error {
message: String!
path: [String!]!
}
type KanIkkeMottaSemesteravgift implements Error {
message: String!
path: [String!]!
}
Et interface definerer ett eller flere felter som alle typer som implementerer interfacet, må inneholde. Det gir forutsigbarhet til klienten, men vi er fortsatt frie til å legge til flere felter for den enkelte feilmelding, ut fra hva klienten vil ha nytte av i det enkelte tilfelle:
interface Error {
message: String!
path: [String!]!
}
type KanIkkeMottaSemesteravgift implements Error {
message: String!
path: [String!]!
gyldigeInstitusjoner: [Institusjon!]!
}
6.2 Feiltypen må mappes mot en feil som er tilgjengelig i kjerne-APIet
Foreløpig er det bare støtte for å mappe feilmeldinger for mutasjoner som bruker @service-direktivet. For å kunne
mappe til en feilmelding fra en service, må feiltypen finnes i generatorkonfigurasjonen,
og legges til i dictionary i GraphqlServlet. Vi mapper til enumverdien for feilmeldingen med @error-direktivet:
type KanIkkeMottaSemesteravgift implements Error @error(error: {name: "KAN_IKKE_MOTTA_SEMESTERAVGIFT"}) {
message: String!
path: [String!]!
gyldigeInstitusjoner: [Institusjon!]!
}
Feilmeldinger som kommer fra databasen, vil inntil videre bli returnert som en "Internal Server Error". Etterhvert vil det komme
støtte for Jakarta Bean Validation som vil ivareta feilhåndtering også for mutasjoner annotert med @mutation-direktivet.
6.3 Payloaden skal inneholde en union type som inneholder alle mulige feilmeldinger for mutasjonen
Dette gjør det lettere for klienten å forutse hvilke feil som potensielt kan oppstå. En Union type gir mulighet til å returnere flere forskjellige typer i samme liste.
type Mutation {
RegistrerSemesteravgiftBetalt(input: [RegistrerSemesteravgiftBetaltInput!]!): RegistrerSemesteravgiftBetaltPayload! @service(service: {name: "SERVICE_SEMESTERREGISTRERING"})
}
input RegistrerSemesteravgiftBetaltInput @table(name: "SEMESTERREGISTRERING") {
studentVedInstitusjonId: ID! @field(name: "STUDENTID")
institusjonId: ID! @field(name: "BETALSTEDINSTITUSJONID")
termin: RegistrerSemesteravgiftBetaltTerminInput!
}
input RegistrerSemesteravgiftBetaltTerminInput {
ar: Int! @field(name: "ARSTALL")
termintype: Termintype!
}
type RegistrerSemesteravgiftBetaltPayload {
semesterregistreringer: [Semesterregistrering!]!
errors: [RegistrerSemesteravgiftBetaltror!]!
}
interface Error {
message: String!
path: [String!]!
}
union RegistrerSemesteravgiftBetaltror = KanIkkeMottaSemesteravgift | UgyldigId
type KanIkkeMottaSemesteravgift implements Error {
message: String!
path: [String!]!
gyldigeInstitusjoner: [Institusjon!]!
}
type UgyldigId implements Error {
message: String!
path: [String!]!
}
8 Husk å dokumentere
Som i forrige kapittel: Du er ikke ferdig før du har dokumentert:
type Mutation {
"""Registrer semesteravgift betalt for studenter"""
RegistrerSemesteravgiftBetalt(
"""Input-felter for mutasjonen"""
input: [RegistrerSemesteravgiftBetaltInput!]!
"""Data du kan velge å få i retur"""
): RegistrerSemesteravgiftBetaltPayload! @mutation(typeName: UPDATE)
}
"""Input-felter for registrering av semesteravgift betalt for en student"""
input RegistrerSemesteravgiftBetaltInput @table(name: "SEMESTERREGISTRERING") {
"""Studenten som har betalt semesteravgift"""
studentVedInstitusjonId: ID! @field(name: "STUDENTID")
"""Institusjonen semesteravgiften er betalt ved (må være en institusjon som er godkjent for å motta semesteravgift)"""
institusjonId: ID! @field(name: "BETALSTEDINSTITUSJONID")
"""Hvilken termin semesteravgiften er betalt for"""
termin: RegistrerSemesteravgiftBetaltTerminInput!
}
"""En termin for registrering av betalt semesteravgift"""
input RegistrerSemesteravgiftBetaltTerminInput {
"""Årstall for terminen"""
ar: Int! @field(name: "ARSTALL")
"""Termintype for terminen"""
termintype: Termintype!
}
"""Data som kan velges inn i responsen for registrering av betalt semesteravgift"""
type RegistrerSemesteravgiftBetaltPayload {
"""Semesterregistreringen som ble oppdatert med betalt semesteravgift"""
semesterregistreringer: [Semesterregistrering!]!
"""Eventuelle feilmeldinger"""
errors: [RegistrerSemesteravgiftBetaltror!]!
}
"""Interface for feilmeldinger"""
interface Error {
"""Tekstlig beskrivelse av feilen"""
message: String!
"""Liste av strenger som angir hvor feilen oppsto"""
path: [String!]!
}
"""Feil som kan oppstå ved regeistrering av semesteravgift betalt for en student"""
union RegistrerSemesteravgiftBetaltror = KanIkkeMottaSemesteravgift | UgyldigId
"""Feilmelding som returneres dersom oppgitt institusjon ikke er godkjent for å motta semesteravgift"""
type KanIkkeMottaSemesteravgift implements Error {
"""En melding som beskriver hva som gikk galt"""
message: String!
"""En liste av strenger som viser hvor feilen skjedde. Eks: "path": ["input", "id"]"""
path: [String!]!
"""En liste over institusjoner som er godkjent for betaling av semesteravgift"""
gyldigeInstitusjoner: [Institusjon!]!
}
"""Feil som kan oppstå dersom du sender inn en ID på ugyldig format"""
type UgyldigId implements Error {
"""En melding som beskriver hva som gikk galt"""
message: String!
"""En liste av strenger som viser hvor feilen skjedde. Eks: "path": ["input", "id"]"""
path: [String!]!
}