Prinsipper for API-design
For å ivareta god brukervennlighet i APIene har vi etablert de følgende prinsippene. Her snakker vi både om designprinsipper, så vel som prinsipper for utviklingsflyt osv.
Innhold
Vi følger Cursor Connections Specification for paginering
- https://relay.dev/graphql/connections.htm
- https://graphql.org/learn/pagination/#complete-connection-model
Denne spesifikasjonen sier at paginering skal gjøres gjennom et såkalt "Connection"-mønster. Dette er best forklart i kontekst av et konkret skjema:
type Query {
person(
"""Antall resultater vi ønsker **etter** referansen i `after`-argumentet for fremoverpaginering."""
first: Int
"""`cursor` for elementet vi ønsker å fortsette fremoverpaginering fra. Normalt sett `pageInfo.endCursor`."""
after: String
"""Antall resultater vi ønsker **før** referansen i `before`-argumentet for bakoverpaginering."""
last: Int
"""`cursor` for elementet vi ønsker å fortsette bakoverpaginering fra. Normalt sett `pageInfo.startCursor`."""
before: String
): PersonConnection
}
type Person {
navn: String
}
"""Connection-typen representerer selve søket og inneholder metadata i tillegg til det faktiske resultatet."""
type PersonConnection {
pageInfo: PageInfo!
"""Treffene i resultatet samt metadata om hvert enkelt treff."""
edges: [PersonEdge]
"""Direktetilgang til elementene i resultatet."""
nodes: [Person]
"""Antall treff."""
totalCount: Int
}
"""
Edge-typen representerer et treff i søket og inneholder metadata i tillegg til det faktiske resultatet.
Her står vi fritt til å legge til egne felt, som for eksempel sannsynlighet for at dette er et relevant treff.
"""
type PersonEdge {
"""Selve resultatet."""
node: Person
"""
En peker til dette elementet i resultatet.
Normalt sett ikke særlig nyttig for annet enn rammeverkskode.
"""
cursor: String!
}
"""Overordnet metadata om pagineringen."""
type PageInfo {
"""Peker til det første elementet i resultatet."""
startCursor: String
"""Peker til det siste elementet i resultatet."""
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
Grunnen til at vi velger å følge denne standarden er flerdelt:
- Det er regnet som en "best practise" av GraphQL Foundation.
- Cursor-basert paginering unngår noen feilsituasjoner som kan skje med offset-basert paginering. For eksempel at nye elementer kommer etterhvert og kludrer til offsetet.
- Cursor-basert paginering kan føre til bedre ytelse om det blir implementert riktig.
- Som følge av at dette er en standard, så kan GraphQL-klienter bygge inn automatisk støtte slik at utvikleropplevelsen blir bedre.
Vi følger Global Object Identification-spesifikasjonen
https://graphql.org/learn/global-object-identification/
Kort forklart gir denne spesifikasjonen en entydig måte å identifisere og hente objekter unikt på tvers av hele grafen. Dette er svært viktig for at klienter enkelt og effektivt skal kunne cache objekter og oppdatere cachen ved behov. Det er i tillegg nødvendig for at klienter effektivt skal kunne paginere nøstede felt.
Spesifikasjonen definerer grensesnittet Node med feltet id av typen ID!.
Verdien til id-feltet må være globalt unik i hele grafen.
Alle typer i grafen som representerer viktige entiteter som klienter kommer til å interagere med ofte bør implementere Node-grensesnittet.
Dette betyr ikke at alle typer må implementere det.
Men om noe kommer til å bli etterspurt eller refert til ofte, så bør det være Node.
I tillegg sier spesifikasjonen at vi må man ha feltet Query.node for henting av et objekt gitt id.
Vi har videre funnet at det ofte er nyttig å kunne hente flere objekter slik at vi ønsker Query.nodes for dette formålet.
Endringer i API bør ikke ødelegge for klienter
Når vi gjør endringer i APIene våre, er det viktig at det gjøres på en måte som ikke ødelegger for klienter i produksjon eller under utvikling. Dette sikrer vi ved å:
- Gjøre bakoverkompatible endringer der det er mulig.
- Varsle brukerne i tilstrekkelig tid om bakoverinkompatible endringer i tråd med gjeldende stabilitetskontrakt.
- Sørge for at klienter i produksjon og klienter under utvikling bruker stabil- eller beta-kontrakt så langt det er mulig.
Hvordan gjøre bakoverkompatible endringer
Et konkret eksempel på en endring man kan ønske å gjennomføre er å gå fra:
extend type Campus {
navn: String
}
til:
extend type Campus {
navn: OversatteTekster
}
Dette er en ikke-kompatibel endring og er derfor uønsket. En kompatibel endring vi derimot kan gjøre, er å innføre et nytt felt og fase ut det gamle:
extend type Campus {
navn: String @deprecated(reason: "Dette feltet vil bli fjernet etter 31. mars 2026. Bruk studieprogramV2 i stedet.")
navnV2: OversatteTekster
}
Vi kaller dette versjonering på feltnivå.
Når vi endrer på versjonerte felt bør vi gå til et høyere versjonsnummer.
På den måten er det tydelig hvilket felt som er gjeldende versjon.
Ved utfasing bruker vi @deprecated-direktivet med en klar melding som også inneholder informasjon om når feltet vil fjernes.
Her følger vi reglene fra stabilitetsnivå.
Selv om det svir litt å etterlate seg det "beste" navnet, så har vi dessverre ikke noe valg.
Det er mulig å gjøre en ny endring etter 31. mars 2026 hvor vi tar tilbake navn-feltet og faser ut navnV2.
Vi mener dette ikke er hensiktsmessig og kun vil skape støy for brukerne våre.
Gjennomføring av bakoverinkompatible endringer
API-kontrakten definerer regler for hvordan bakoverinkompatible endringer kan gjennomføres. Det er forskjellige regler som gjelder avhengig av hvilket stabilitetsnivå funksjonaliteten er på:
- På eksperimentelt nivå kan bakoverinkompatible endringer gjøres uten forvarsel.
- På beta- og stabiltnivå må bakoverinkompatible endringer varsles på forhånd.
API-kontrakten definerer hvor lenge i forveien du må varsle.
Normalt vil man varsle bakoverinkompatible endringer ved å annotere et felt eller et endepunkt med @deprecated.
Det kan også være en god idé å varsle kjente brukere av APIet direkte på e-post, Slack eller lignende.
Legg merke til at eksperimentelt nivå åpner opp for bakoverinkompatible endringer uten varsel. Poenget med denne kontrakten er at den lar oss publisere forslag til skjemadesign som vi kan diskutere med konsumenter og endre fortløpende etterhvert som vi lærer. Det er med andre ord ikke tenkt at noen skal utvikle mot API på dette kontraktsnivået.
Vær raus mot kollegaene dine
Dersom et felt brukes av våre egne brukergrensesnitt eller tjenester, så oppfordrer vi til å være raus. Dersom noen ikke har rukket å migrere bort fra funksjonalitet som fases ut, så kan dette fort være som følge av høyt trykk og manglende kapasitet. Da er det ganske kjipt å få nedetid på toppen av det hele.
Hovedregelen bør være er at vi er på samme lag og jobber mot samme mål. Av samme årsak er det er ingen grunn til å vente til siste liten med å bevege seg bort fra felt som fases ut.
Vi innfører gjerne egne felt og typer for semantisk nyttige data-uttrekk
I mange tilfeller er det nyttig å lage egne felt for å hente ut eller manipulere data for en gitt forretningskontekst.
For eksempel er Query.mineSoknader et naturlig felt for å hente søknadene til den autentiserte søkeren.
Vi vil typisk ha generelle felt med mulighet for å filtrere for data-uthenting. Selv om brukerbehov kan løses med slike felt, vil det kunne gi en bedre brukeropplevelse med dedikerte semantiske felt. En annen fordel med dette er at vi tar eierskap til forretningslogikk i skjema-design.
Tilsvarende kan det være relevant å lage semantiske typer.
For eksempel kan man lage en MegSomSoker-type som inneholder data for en gitt innlogget søker.
For semantiske felt og typer er det spesielt viktig med gode navn og god dokumentasjon for å gjøre konteksten tydelig. Siden navnet bærer forretningslogikk vil det være naturlig å knytte dette opp mot begrepskatalogen. Det betyr at man bør beskrive begreper i katalogen om de mangler.
Noen eksempler på skjema med semantiske felt og typer:
Query {
"""Henter søknader gitt inputfilter"""
soknader(input: SoknadFilterInput): [Soknad]
"""Henter alle søknader for en autentisert søker"""
mineSoknader: [Soknad]
"""Henter alle [aktive søknader](URL til begrepskatalogen)"""
aktiveSoknader: [Soknad]
"""Henter et MegSomSoker-objekt med data for en autentisert søker"""
megSomSoker: MegSomSoker
}
input MegSomSoker {
id: ID!
soknader: [Soknad]
...
}