REST – REpresentational State Transfer – er en programvarearkitektur som sikrer løse koblinger mellom to tjenester i en integrasjon. I dag anvendes arkitekturen først og fremst for å utvikle såkalte REST APIer. Et REST API er en skalerbar webtjeneste hvor en klient kan lese og manipulere ressurser på en tjener, som regel med HTTP som kommunikasjonsprotokoll. REST ble først introdusert i en doktorgradsavhandling av Roy Fielding, som også er en av arkitektene bak HTTP.
Følgende figur illustrer den grunnleggende virkemåten til et REST API:
En klient sender en forespørsel om en ressurs til APIet. Når kommunikasjonen går over HTTP vil forespørselen inneholde en URL som identifiserer ressursen, nødvendige tilstandsdata og ønsket operasjon (f.eks. GET for å hente eller DELETE for å slette). Tjeneren prosesserer forespørselen og sender en respons tilbake til klienten. Responsen inneholder en representasjon av den etterspurte ressursen, vanligvis i formatet JSON eller XML.
Når man skal utvikle et REST API må man følge et sett med prinsipper. Fielding kalte disse prinsippene for arkitektoniske begrensninger, fordi de begrenser hvordan APIet kan prosessere og respondere på forespørsler fra en klient. Følger man disse prinsippene oppnår man i teorien en rekke fordeler knyttet til blant annet ytelse, skalerbarhet, vedlikeholdbarhet, portabilitet og pålitelighet. APIet er da "RESTful". Vi skal nå gå gjennom de viktigste prinsippene.
Klient/tjener er en arkitektur som separerer programvaren som etterspør en ressurs eller tjeneste (klienten) fra programvaren som prosesserer og responderer på forespørselen (tjeneren). All kommunikasjon mellom klienten og tjeneren foregår over en veldefinert protokoll. Som oftest brukes HTTP som kommunikasjonsprotokoll, men det er viktig å merke seg at andre protokoller kan benyttes. Klientens ansvarsområde er først og fremst å tilby et brukergrensesnitt. Forretningslogikk, databasetilgang og sikkerhet er tjenerens ansvarsområde. Denne separasjonen gir oss flere fordeler. Ulike klienter kan benytte samme tjener, det er enklere å skalere for økt trafikk ved å øke antall tjenere, og programvarekomponentene på klienten og tjeneren kan utvikles og vedlikeholdes uavhengig av hverandre – de er løst koblet.
Når en klient sender en forespørsel til tjeneren sender den med all informasjon som trengs for å behandle forespørselen. Dette gjør klienten hver gang en ny forespørsel sendes. Det er klienten som er ansvarlig for å til enhver tid ha oversikt over tilstanden i sesjonen. Det er derfor ikke nødvendig å mellomlagre tilstand på tjeneren. Siden tjeneren ikke kjenner klientens kontekst utover det som mottas i forespørselen sier vi at protokollen er tilstandsløs.
Denne begrensningen er viktig for å sikre en løs kobling mellom klienten og tjeneren. Ulempen er at klientens tilstand må sendes på nytt for hver forespørsel. Vi må altså sende mer data enn hvis tjeneren hadde mellomlagret tilstanden, noe som kan påvirke ytelsen i nettverket.
En klient vet ikke om den kommuniserer direkte med tjeneren eller via et mellomledd. Vi kan ha flere lag mellom klienten og den endelige tjeneren. I ett lag kan vi tilby belastningsfordeling, caching i et annet. I praksis betyr det at vi ofte er innom flere tjenere før vi får svar. Lagdeling gjør det enklere å skalere opp tjenestene våre, men det introduserer også økt kompleksitet i systemet. Har vi for mange mellomledd kan det dessuten oppstå forsinkelser, som igjen gir en dårligere brukeropplevelse.
I figur 3 har vi introdusert en ny tjener som fungerer som belastningsfordeler (LB, "load balancer") for APIet vårt. Prinsippet om lagdeling gjør dette mulig.
En cache er et midlertidig lager med data. Hvis en tjener mottar samme forespørsel mange ganger er det ofte en fordel å lagre en kopi av dataene som etterspørres i dette mellomlageret. Dette er relevant for data som ikke endres ofte, f.eks. bilder og statiske dokumenter. Klienten vil da få respons direkte fra mellomlageret, som er mer effektivt enn å måtte prosessere forespørselen gjennom alle lagene i systemet.
REST forteller oss at enhver respons må defineres som enten "cacheable" eller "non-cacheable". De faktiske mekanismene for å oppnå dette avhenger av kommunikasjonsprotokollen. I HTTP kan man angi når en respons foreldes ved å legge til "Expires" og en dato i headeren til responsen. Alternativt kan man benytte "Cache-Control" og "max-age" for å angi i sekunder hvor lenge en respons er gyldig.
I figuren under introduserer vi en egen tjener som fungerer som et mellomlager. Prinsippet om lagdeling er også gjeldende her.
En ressurs må kunne identifiseres gjennom en unik URI (Uniform Resource Identifier), og det skal kun være mulig å manipulere ressursen ved å bruke metodene som tilbys i den underliggende kommunikasjonsprotokollen. Det som overføres må dessuten være en representasjon av ressursen i et velkjent format. Med HTTP er URIen en URL, og det er HTTP-verbet som bestemmer operasjonen man ønsker å utføre. For å hente en ressurs bruker man GET, mens oppretting, endring og sletting gjøres med henholdsvis POST, PUT/PATCH og DELETE. Ressursens format angis med en bestemt media type (vanligvis JSON eller XML).
En forespørsel går fra en klient til en tjener og vil alltid inneholde et endepunkt, en metode og en header. Noen ganger sender vi også med tilstandsdata.
Når en klient sender en forespørsel må den oppgi et endepunkt. Endepunktet er en URL bestående av en rot og en sti. Rota er startpunktet til APIet, mens stien identifiserer ressursen vi er ute etter. Man kan også legge til informasjon på slutten av URLen gjennom en query-streng med nøkkel/verdi-par, også kalt query-parametre.
Eksempel med GitLabs REST API:
Startpunktet for GitLab sitt REST API er https://gitlab.com/api/v4
.
For å nå et bestemt repo angir man stien /projects/:id/repository
, hvor ":id" er en variabel.
Ønsker man å sammenlikne to brancher eller innsjekkingspunkter legger man til /compare
og benytter query-parametrene "from" og "to". Et spørsmålstegn i URLen betyr at det som følger er query-parametre. Har man flere parametre skiller man disse med "&"-tegnet. Query-strengen for å sammenlikne master med en feature branch blir da ?from=master&to=feature
.
Det hele og fulle endepunktet får man ved å slå sammen rota, stien og query-strengen: https://gitlab.com/api/v4/projects/:id/repository/compare?from=master&to=feature
.
Man angir hva slags metode man ønsker med et HTTP-verb. Man kan velge mellom følgende verb:
GET: Hent en spesifikk ressurs eller en samling med ressurser.
POST: Lag en ny ressurs.
PUT/PATCH: Oppdater en eksisterende ressurs.
DELETE: Fjern en spesifikk ressurs.
Metodene GET, PUT og DELETE er idempotente. Det vil si at de skal produsere det samme resultatet uansett hvor mange ganger de kjører. Med POST står man friere, og kan f.eks. gi tilbakemelding om at en gitt ressurs allerede eksisterer. Det er også verdt å merke seg at selv om både PUT og PATCH oppdaterer en ressurs, så er måten de gjør det på forskjellig. Med PATCH kan vi sende med kun den delen av ressursen som skal oppdateres. Bruker vi PUT må vi sende med informasjon om hele ressursen for å sikre at vi får likt resultat hver gang.
Ressursen vil alltid identifiseres gjennom samme endepunkt, mens metoden bestemmer om man ønsker å opprette, hente, endre eller slette ressursen. Dette gir oss grunnleggende CRUD-funksjonalitet: Create-Read-Update-Delete.
Blacboard Learn sitt REST API for kunngjøringer illustrerer konseptet på en fin måte:
Metode | Endepunkt | Beskrivelse |
---|---|---|
GET | /learn/api/public/v1/announcements | Hent alle kunngjøringer |
GET | /learn/api/public/v1/announcements/{announcementId} | Hent en gitt kunngjøring |
POST | /learn/api/public/v1/announcements | Lag en ny kunngjøring |
DELETE | /learn/api/public/v1/announcements/{announcementId} | Slett en gitt kunngjøring |
PATCH | /learn/api/public/v1/announcements/{announcementId} | Oppdater en gitt kunngjøring |
Alle forespørsler har en header. Headeren inneholder tilleggsinformasjon eller metadata i form av nøkkel/verdi-par. En forespørsel kan f.eks. angi at forventet format på innholdet er HTML ved å oppgi Accept: text/html
.
Når vi lager eller oppdaterer en ressurs må vi som regel sende med data fra klienten. Vi kan velge å sende disse som query-parametre, men det er ikke alltid hensiktsmessig. Når vi skal sende mye informasjon bør vi bruke forespørselens kropp (body), som er bedre egnet for å overføre JSON eller XML. Det er også verdt å merke seg at vi aldri bør sende sensitive data som en del av en URL, selv om vi benytter HTTPS.
Responsen har også en header. Ønsker man f.eks. å fortelle klienten at innholdet i responsen er JSON-formatert kan man oppgi Content-Type: application/json
.
Når en tjener svarer sender den med en statuskode i headeren:
200+ betyr at alt har gått bra.
300+ betyr at forespørselen har blitt videresendt til en annen URL.
400+ betyr at det er noe galt med forespørselen (klientens feil).
500+ betyr at det er noe galt på tjeneren.
Det er vanlig å sende data tilbake til klienten enten som HTML, XML eller JSON.
Tenk deg at vi har et REST API som tilbyr metadata om bøker. En klient sender følgende forespørsel for å få informasjon om en bestemt bok:
1GET /books/0134757599?include=isbn-13
2Accept: application/json
Tjeneren vil behandle forespørselen, typisk ved å hente ut data om den etterspurte boka fra et mellomlager eller en database, og returnere følgende:
x1HTTP/1.1 200 OK
2
3{
4 "isbn-13": "978-0134757599",
5 "author": "Martin Fowler",
6 "title": "Refactoring: Improving the Design of Existing Code (2nd Ed.)",
7 "publisher": "Addison-Wesley Professional"
8}
Hvis vi som utviklere skal gjøre større endringer i et REST API må vi passe på at vi ikke endrer grensesnittet. I prinsippet skal URLene vi tilbyr aldri endres, da dette bryter kontrakten som APIet har med klientene sine. Noen ganger må vi likevel gjøre korreksjoner og forbedringer i endepunktene. Vi løser denne utfordringen med versjonering. For mer omfattende endringer angir vi versjon ved å legge til et versjonsnummer i selve URLen. I eksemplene over kan vi se at GitLab er på versjon 4 av sitt API, mens Blackboard Learn er på versjon 1:
GitLab: https://gitlab.com/api/v4/
Blackboard Learn: /learn/api/public/v1/
Denne strategien passer best når man skal versjonere hele APIet samlet. Hvis vi vil versjonere for et enkelt endepunkt eller på ressurs-nivå kan vi benytte Accept-headeren.
Nå som vi har kunnskap om prinsippene bak REST kan vi bygge et enkelt REST API. De fleste moderne programmeringsspråk tilbyr mekanismer for å gjøre dette. Siden vi bruker Javascript i dette kurset vil vi implementere eksemplet vårt basert på Node.js og webtjeneren Express.
Vi skal utvikle et enkelt REST API for en Todo-app. APIet skal tilby endepunkter for å hente ut, legge til og slette oppgaver:
Metode | Endepunkt (sti) | Beskrivelse |
---|---|---|
GET | /api/v1/tasks | Hent alle oppgaver |
GET | /api/v1/tasks/:id | Hent en gitt oppgave |
POST | /api/v1/tasks | Lag en ny oppgave |
DELETE | /api/v1/tasks/:id | Slett en gitt oppgave |
For å komme i mål må vi gjennom følgende steg:
Før vi begynner må vi installere Node.js og NPM. Node.js er et runtime-system for å kjøre JavaScript-kode utenfor nettleseren, mens NPM er en pakkebehandler. Sistnevnte gjør det enklere å installere biblioteker vi kan benytte oss av når vi programmerer. Verktøyene lastes ned samlet fra nodejs.org.
Vi bruker Postman for manuell testing av APIet vårt. Verktøyet tilbyr et grafisk brukergrensesnitt for å lage og sende forespørsler. Last ned Postman fra postman.com/downloads.
Det første vi må gjøre er å opprette et nytt prosjekt. Lag en ny katalog og kall den todo-api:
xxxxxxxxxx
21mkdir todo-api
2cd todo-api
Initialiser prosjektet ved å generere filen "package.json" med npm:
xxxxxxxxxx
11npm init --yes
Filen "package.json" inneholder metadata som er relevant for prosjektet: prosjektnavn, versjon, kjøreskript, avhengigheter med mer. Når vi bruker flagget yes
vil npm fylles med standardverdier.
Neste steg er å installere og konfigurere bibliotekene vi trenger.
Express.js er et rammeverk som gjør det enkelt å sette opp en webtjener og legge til endepunkter for et REST API:
xxxxxxxxxx
11npm install express --save
Express er nå lagt til som en modul i prosjektet under node_modules-katalogen. Flagget save
registrerer modulen som en avhengighet i package.json-fila.
Vi må også installere Babel, fordi vi skal bruke nyere JS-synktaks:
xxxxxxxxxx
11npm install @babel/core @babel/cli @babel/preset-env @babel/node --save-dev
Babel er avhengig av en c-kompilator for visse funksjoner, så hvis du bruker macOS og får feilmeldingen "gyp: No Xcode or CLT version detected!" må du først følge instruksjonene på denne siden. Instruksjonene er også relevante hvis du får en liknende feilmelding i Windows.
Opprett så fila ".babelrc". Den må ligge i rotkatalogen for prosjektet, altså i "todo-api". Fila skal ha følgende innhold:
xxxxxxxxxx
31{
2 "presets": ["@babel/preset-env"]
3}
Fila forteller Babel at vi bruker nyere syntaks i form av ES6-kode (ECMAScript 2015). ES6 gir oss blant annet klasser og arrow-funksjoner. Node.js har ikke full støtte for denne syntaksen, så vi må transpilere ES6-koden vår til kompatibel form, altså ES5. En måte å gjøre det på er å bruke kommandoen node_modules/.bin/babel-node
. Vi forenkler kjøringen av denne kommandoen ved å installere et verktøy kalt nodemon
.
xxxxxxxxxx
11npm install nodemon --save-dev
Så legger vi til et skript i package.json-fila kalt "start":
xxxxxxxxxx
41"scripts": {
2 "test": "echo \"Error: no test specified\" && exit 1",
3 "start": "nodemon app.js --exec babel-node"
4}
Skriptet transpilerer kildekoden i "app.js" til kompatibel form. Samtidig starter det en tjener som lytter etter endringer i koden og fanger opp disse automatisk, noe som forenkler utviklingen av appen vår. Skriptet kan kjøres med følgende kommando:
xxxxxxxxxx
11npm run start
Kjører du skriptet nå vil det feile fordi filen "app.js" ikke eksisterer. Vi skal straks lage denne, men først må vi opprette en dummy-database og legge til litt testdata.
Hver oppgave i todo-listen vår har en unik id, en tittel og et flagg som sier om oppgaven er utført eller ikke. Senere skal vi se hvordan vi kan persistere oppgavene våre i en reell database, men i denne leksjonen fokuserer vi på endepunktene. Derfor lagrer vi ganske enkelt oppgavene i en liste-variabel i minnet, i det vi kan kalle en "dummy-database". Opprett filen "data.js" og legg inn følgende:
xxxxxxxxxx
191const tasks = [
2 {
3 id: 1,
4 title: "Les leksjon",
5 done: false
6 },
7 {
8 id: 2,
9 title: "Møt opp på forelesning",
10 done: false
11 },
12 {
13 id: 3,
14 title: "Gjør øving",
15 done: false
16 },
17];
18
19export default tasks;
Vi kan nå utvikle selve appen. Vi starter med å initalisere en ny Express-webtjener som lytter på port 3000. Lag filen "app.js" og skriv inn følgende kode:
xxxxxxxxxx
101import express from 'express';
2import tasks from './data';
3
4const app = express();
5app.use(express.json());
6
7const PORT = 3000;
8app.listen(PORT, () => {
9 console.info(`Server running on port ${PORT}`);
10});
Vi bruker import
for å hente inn ressurser fra andre moduler. Vi kan importere listen tasks
fordi den ble tilgjengeliggjort fra "data.js" med export default tasks
. Nå har vi tilgang til både express og testdataene våre. Linje 4 initialiserer appen vår, mens linje 5 gjør det mulig å tolke json i body-elementet til en forespørsel. Linje 7-10 forteller oss at webtjeneren skal lytte på port 3000. Startpunktet for vår lokale webtjener blir da http://localhost:3000
.
Start webtjeneren fra terminalen med følgende kommando:
xxxxxxxxxx
11npm run start
Neste steg er å legge inn endepunkter for å hente ut, opprette og slette oppgaver. Vi skal også teste endepunktene manuelt med Postman. Merk at det ikke er nødvendig å restarte APIet underveis fordi alle endringer i koden fanges opp automatisk med nodemon.
Utvid "app.js" med følgende kode:
xxxxxxxxxx
31app.get('/api/v1/tasks', (request, response) => {
2 response.json(tasks);
3});
Her definerer vi et endepunkt som henter ut alle opppgavene fra listen tasks
. Vi når endepunktet med ruten /api/v1/tasks
. Responsen vil inneholde oppgavene i json-format.
Vi kan nå teste det første endepunktet vårt med Postman:
Velg "GET" som metode.
Legg til URL-en localhost:3000/api/v1/tasks
Klikk på "Send"-knappen. Postman sender nå en forespørsel til APIet vårt. Responsen vil vises i den nederste delen av vinduet.
For å hente ut en bestemt oppgave må vi utvide "app.js" med følgende kodesnutt:
xxxxxxxxxx
101app.get('/api/v1/tasks/:id', (request, response) => {
2 const id = request.params.id;
3 const task = tasks.find(t => t.id == id);
4
5 if (task) {
6 response.json(task);
7 } else {
8 response.status(404).send(`Task with id '${id}' not found.`);
9 }
10});
Igjen oppretter vi et get-endepunkt, men ruten inneholder nå også variabelen :id
. Denne hentes ut på linje 2. På linje 3 bruker vi Array.find()
for å søke blant oppgavene våre. Får vi treff responderer vi med opppgaven i json-format (linje 6), hvis oppgaven ikke finnes returnerer vi en melding med statuskode 404 Not Found
(linje 8).
Velg GET som metode og legg inn localhost:3000/api/v1/tasks/2
som URL. APIet vil returnere oppgaven i json-format som vist i figuren under.
For å opprette en oppgave er det naturlig å bruke HTTP-metoden POST:
xxxxxxxxxx
181app.post('/api/v1/tasks', (request, response) => {
2 const task = request.body;
3
4 if (!task.hasOwnProperty('id') ||
5 !task.hasOwnProperty('title') ||
6 !task.hasOwnProperty('done')) {
7 return response.status(400).send('A task needs the following properties: id, title and done.');
8 }
9
10 if (tasks.find(t => t.id == task.id)) {
11 response.status(400).send(`A task with id '${task.id}' already exists.`);
12 } else {
13 tasks.push(task);
14 response.status(201);
15 response.location('tasks/' + task.id);
16 response.send();
17 }
18});
Vi forventer at oppgaven er json-formatert og ligger i forespørselens body-element. Den nye oppgaven hentes ut direkte på linje 2. På linje 4 - 10 kjøres enkle valideringsrutiner; vi sjekker at vi har mottatt nødvendige data og om forespørselen eksisterer fra før av. Hvis valideringen feiler svarer vi med en informativ melding og statuskoden 400 Bad Request
(linje 11). Hvis alt er som det skal oppdaterer vi tasks
med den nye oppgaven og sender en respons til klienten (linje 13-16).
I Postman må vi gjøre følgende:
Velg POST som metode.
Legg til URL-en localhost:3000/api/v1/tasks
Klikk på Body-fanen, velg "raw" og velg "JSON" som innholdstype fra nedtrekksmenyen.
Skriv inn følgende i input-feltet for Body og klikk på "Send"-knappen:
xxxxxxxxxx
51{
2 "id": 4,
3 "title": "Ny",
4 "done": false
5}
Hvis alt går bra vil APIet respondere med statuskoden 201 Created
og stien til den nye ressursen i headerfeltet Location
.
Til slutt gjør vi det mulig å slette en gitt oppgave. Vi bruker HTTP-metoden DELETE til dette:
xxxxxxxxxx
101app.delete('/api/v1/tasks/:id', (request, response) => {
2 const id = request.params.id;
3 const index = tasks.findIndex(t => t.id == id);
4 if (index != -1) {
5 tasks.splice(index, 1);
6 response.json(tasks);
7 } else {
8 response.status(404).send(`Failed to delete task with id '${id}'. Task not found.`);
9 }
10});
Igjen bruker vi variabelen :id
for å identifisere oppgaven vi er ute etter. På linje 3 bruker vi Array.findIndex()
som returnerer posisjonen til oppgaven eller -1 hvis den ikke finnes. Hvis oppgaven finnes fjerner vi den fra listen med Array.splice()
og sender en respons tilbake til klienten (linje 4-6). Hvis den ikke finnes svarer vi med en informativ feilmelding og statuskoden 404 Not Found
(linje 8).
Velg DELETE som metode og legg inn localhost:3000/api/v1/tasks/1
som URL. Dette vil slette den første oppgaven i lista. Hvis alt går som det skal vil APIet respondere med statuskode 200. For å illustrere at oppgaven med id "1" er fjernet fra listen sender vi også med den oppdaterte lista, selv om dette i praksis bryter med regelen om at DELETE skal være idempotent.
Fullstendig kildekode kan lastes ned fra https://gitlab.com/ntnu-dcst2002/todo-api.