REST-rajapinnat
Rajapintaluennolla rakennettiin JSON-rajapinta, jossa oli järkevät osoitteet, HTTP-metodit ja tilakoodit. Tällä luennolla katsotaan, mitä periaatteita sen taustalla on ja mitä kaikkea hyvään rajapintaan kuuluu, kun sitä käyttää muukin kuin oma selainsivu: rinnakkaiset muokkaukset, välimuisti, sivutus, standardimuotoiset virheet, versiointi ja dokumentaatio. Kaikki luennon asiat löytyvät esimerkkisovelluksesta.
Mikä REST on
REST (Representational State Transfer) on Roy Fieldingin vuoden 2000 väitöskirjassaan kuvaama arkkitehtuurityyli, ei protokolla eikä standardi. Fielding kuvasi periaatteet, joiden varassa web itse toimii; hän oli myös HTTP/1.1-määrittelyn keskeinen kirjoittaja. Tyylin muodostavat rajoitteet:
| Rajoite | Merkitys |
|---|---|
| Asiakas–palvelin | käyttöliittymä ja tietojen tallennus ovat erillään ja voivat kehittyä toisistaan riippumatta |
| Tilattomuus | jokainen pyyntö sisältää kaiken, mitä sen käsittelyyn tarvitaan; palvelin ei muista edellistä pyyntöä |
| Välimuistikelpoisuus | vastaus kertoo, saako sen tallentaa ja kuinka pitkäksi aikaa |
| Yhtenäinen rajapinta | resurssit tunnistetaan osoitteilla, niitä käsitellään esitysmuotojen kautta, viestit kuvaavat itse itsensä, ja vastaukset sisältävät linkit seuraaviin toimintoihin |
| Kerroksellisuus | asiakas ei tiedä, puhuuko se palvelimelle vai välityspalvelimelle |
| Koodi tarvittaessa | valinnainen: palvelin voi lähettää suoritettavaa koodia, kuten javascriptiä |
Nimen sana representational viittaa esitysmuotoon, ja se on olennainen: asiakas ei
koskaan käsittele itse resurssia vaan sen esitystä, esimerkiksi varauksen
JSON-kuvausta. Sama resurssi voi olla tarjolla useana esitysmuotona, ja palvelin valitsee niistä sopivan Accept-otsakkeen perusteella.
Richardsonin kypsyysmalli
Kun puhutaan REST-rajapinnasta, tarkoitetaan useimmiten JSON-rajapintaa, joka käyttää HTTP:tä järkevästi. Fieldingin mukaan se ei vielä ole REST. Eron voi kuvata Leonard Richardsonin kypsyysmallilla:
| Taso | Kuvaus | Esimerkki |
|---|---|---|
| 0 | yksi osoite, kaikki POST-pyyntöinä | POST /api ja {"toiminto": "poista", "id": 7} |
| 1 | resursseilla omat osoitteet | POST /varaukset/7/poista |
| 2 | HTTP-metodit ja tilakoodit oikeassa merkityksessään | DELETE /varaukset/7 → 204 |
| 3 | vastauksissa linkit seuraaviin toimintoihin | varauksen JSONissa linkki sen muokkaamiseen |
Suurin osa käytännön rajapinnoista on tasolla 2, ja se riittää hyvin. Taso 3 käsitellään myöhemmin.
Resurssit ja osoitteet
- Osoite nimeää asian, metodi kertoo teon.
DELETE /varaukset/7, eiPOST /poistaVaraus?id=7. - Kokoelmat monikossa ja yksittäiset kohteet niiden alla:
/varauksetja/varaukset/7. - Suodatus, järjestys ja sivutus kyselyparametreina:
/varaukset?huone=Auditorio&koko=20. - Hierarkia vain, kun kyse on todellisesta omistussuhteesta.
/kilpailut/42/joukkueeton luonteva, mutta hyvin syvä polku kertoo, että resurssi kannattaisi nostaa omaksi kokoelmakseen. - Osoite ei kerro toteutuksesta. Tiedostopäätteet, taulujen nimet ja ohjelmointikieli eivät kuulu osoitteeseen.
HTTP-metodit
| Metodi | Käyttö | Turvallinen | Idempotentti |
|---|---|---|---|
| GET | hae esitys | kyllä | kyllä |
| POST | luo kokoelmaan uusi kohde | ei | ei |
| PUT | korvaa kohteen koko esitys | ei | kyllä |
| PATCH | muuta osa kentistä | ei | ei välttämättä |
| DELETE | poista | ei | kyllä |
Turvallinen tarkoittaa, että pyyntö ei muuta mitään. Siksi GET ei saa koskaan poistaa tai tallentaa: hakukoneet, välimuistit ja selaimen esihaku voivat tehdä GET-pyyntöjä kenenkään kysymättä.
Idempotentti tarkoittaa, että saman pyynnön voi toistaa ilman lisävaikutusta. Tällä on käytännön merkitystä: jos DELETE-pyynnön vastaus katoaa verkkovirheeseen, asiakas voi lähettää sen uudelleen turvallisesti. Toinen yritys saa 404:n, mutta varaus on poistettu vain kerran. POST-pyynnön toistaminen voisi sen sijaan luoda kaksi varausta.
PUT ja PATCH eroavat siinä, mitä rungossa lähetetään. PUT korvaa koko
esityksen, joten kaikki kentät on annettava; puuttuva kenttä olisi tulkittava kentän tyhjentämiseksi. PATCH muuttaa vain annetut kentät. Yksinkertaisin PATCH-muoto on
JSON Merge Patch (RFC 7396, mediatyyppi
application/merge-patch+json): lähetetään vain muuttuvat kentät.
@app.patch("/api/v1/varaukset/<int:vid>")
@vaatii_kirjautumisen
def muokkaa_varausta(vid):
rivi = hae_rivi(vid)
vain_omistaja(rivi)
vaadi_versio(rivi)
muutokset = lue_json()
# Vanhat arvot pohjaksi, muutokset päälle.
return tallenna_muutos(rivi, {k: muutokset.get(k, rivi[k]) for k in KENTAT})
POST luo ja kertoo osoitteen. Uuden kohteen vastaus on 201
Created, ja Location-otsake kertoo, mistä kohteen löytää. Asiakkaan ei tarvitse arvata tai muodostaa osoitetta itse.
Rajapintaluennon tilakoodien taulukko pätee sellaisenaan. Tällä luennolla siihen tulee neljä uutta: 304 Not Modified, 412 Precondition Failed, 428 Precondition Required ja 429 Too Many Requests.
Rinnakkaiset muokkaukset ja ehdolliset pyynnöt
Kaksi käyttäjää avaa saman varauksen muokattavaksi. Ensimmäinen tallentaa ja sitten toinen, jolloin toisen tallennus kumoaa ensimmäisen muutoksen kenenkään huomaamatta. Tämä on kadonneen päivityksen ongelma (engl. lost update).
HTTP:ssä ongelma ratkaistaan versiotunnisteella. Palvelin antaa jokaiselle
esitykselle ETag-otsakkeessa tunnisteen, joka muuttuu aina, kun kohde
muuttuu. Asiakas lähettää sen muokatessaan If-Match-otsakkeessa, ja
palvelin hyväksyy muutoksen vain, jos versio on yhä sama:
def etag(rivi):
return f'"{rivi["vid"]}-{rivi["versio"]}"'
def vaadi_versio(rivi):
ehto = request.headers.get("If-Match")
if ehto is None:
raise Ongelma(428, "versio-puuttuu", "If-Match-otsake vaaditaan")
if ehto != "*" and etag(rivi) not in [e.strip() for e in ehto.split(",")]:
virhe = Ongelma(412, "versio", "Varausta on muutettu")
virhe.otsakkeet["ETag"] = etag(rivi)
raise virhe
Tilakoodilla 428 palvelin vaatii asiakasta lähettämään version. Ilman sitä asiakas voisi jättää otsakkeen pois ja kirjoittaa kenen tahansa muutoksen päälle.
Jos kaksi pyyntöä saapuu yhtä aikaa, molemmat voivat läpäistä
If-Match-tarkistuksen ennen kuin kumpikaan on tallentanut. Versio on siksi tarkistettava samassa SQL-lauseessa, joka tallentaa muutoksen:
UPDATE varaus SET otsikko = :otsikko, versio = versio + 1
WHERE vid = :vid AND versio = :versio
Jos muutettuja rivejä on nolla, joku ehti väliin, ja vastaus on 412. Viikkotehtävässä 4 sama ongelma ratkaistiin Firestoren transaktioilla; tässä se ratkaistaan HTTP:n tasolla.
HTTP-välimuisti
XML-luennon välimuisti oli palvelimen oma: palvelin muisti ulkopuolisen lähteen vastauksen. HTTP-välimuisti on eri asia: siinä asiakas tai välityspalvelin muistaa rajapinnan vastauksen, ja palvelin kertoo HTTP-otsakkeilla, saako niin tehdä.
Cache-Control: public, max-age=3600— vastauksen saa tallentaa kuka tahansa ja käyttää tunnin ajan kysymättä. Sopii huoneluettelolle, joka muuttuu harvoin.Cache-Control: private, no-cache— vain asiakas saa tallentaa, ja ennen käyttöä sen on tarkistettava palvelimelta, onko kopio yhä ajan tasalla.Cache-Control: no-store— ei saa tallentaa lainkaan. Sopii arkaluonteiselle datalle.
Tarkistus tehdään samalla ETagilla kuin muokkauksessa, nyt
If-None-Match-otsakkeessa. Jos kohde ei ole muuttunut, vastaus on 304 ilman
runkoa:
Selain tekee ehdolliset haut itsestään, kun vastauksessa on ETag, eikä fetchiä käyttävän koodin tarvitse tehdä mitään. Muut asiakasohjelmat joutuvat lähettämään If-None-Match-otsakkeen itse.
Sivutus
Kokoelmaa ei palauteta kerralla kokonaan, koska se voi kasvaa rajatta. Kaksi yleistä tapaa:
- Sivunumero tai siirtymä (
?sivu=3&koko=20) on helppo toteuttaa SQL:nLIMIT- jaOFFSET-lausekkeilla, mutta se rikkoutuu, kun dataa lisätään tai poistetaan kesken selaamisen: rivejä toistuu tai jää näkemättä. Suurilla siirtymillä se on myös hidas. - Jatkotunniste (
?jalkeen=<tunniste>, engl. cursor tai continuation token) kertoo, mistä kohdasta jatketaan: se sisältää edellisen sivun viimeisen rivin lajitteluavaimen. Se ei rikkoudu, vaikka dataa muutettaisiin kesken selaamisen.
Varaukset järjestetään alkuajan mukaan. Samaan aikaan alkavia varauksia voi
kuitenkin olla useita, joten pelkkä alkuaika ei kerro yksiselitteisesti, mihin
edellinen sivu päättyi. Järjestykseen lisätään siksi varauksen tunniste
vid, ja jatkotunnisteeseen tallennetaan molemmat:
def hae_sivu(koko, jalkeen=None):
"""Palauttaa enintään koko varausta ja seuraavan sivun jatkotunnisteen."""
if jalkeen is None:
rivit = kanta().execute(
"SELECT * FROM varaus ORDER BY alkaa, vid LIMIT ?",
(koko + 1,)).fetchall()
else:
# Jatketaan edellisen sivun viimeisen rivin jälkeen.
alkaa, vid = pura_jatkotunniste(jalkeen)
rivit = kanta().execute(
"""SELECT * FROM varaus
WHERE alkaa > ? OR (alkaa = ? AND vid > ?)
ORDER BY alkaa, vid LIMIT ?""",
(alkaa, alkaa, vid, koko + 1)).fetchall()
# Haettiin yksi rivi liikaa: jos se löytyi, seuraava sivu on olemassa.
seuraava = None
if len(rivit) > koko:
rivit = rivit[:koko]
seuraava = koodaa_jatkotunniste(rivit[-1])
return rivit, seuraava
Ehto alkaa > ? OR (alkaa = ? AND vid > ?) tarkoittaa myöhemmin alkavia varauksia sekä samaan aikaan alkavista niitä, joiden tunniste on suurempi. Koska
ehto perustuu arvoihin eikä rivien järjestysnumeroon, uuden varauksen lisääminen
alkuun ei siirrä sivujen rajoja.
Näkymä muodostaa jatkotunnisteesta seuraavan sivun osoitteen ja antaa sen
Link-otsakkeessa. Asiakkaan ei tarvitse muodostaa osoitetta itse, vaan se
hakee seuraavan sivun otsakkeessa annetusta osoitteesta:
@app.get("/api/v1/varaukset")
def hae_varaukset():
rivit, jatko = hae_sivu(koko=20, jalkeen=request.args.get("jalkeen"))
vastaus = jsonify({"varaukset": [esitys(r) for r in rivit]})
if jatko is not None:
seuraava = url_for("hae_varaukset", jalkeen=jatko)
vastaus.headers["Link"] = f'<{seuraava}>; rel="next"'
return vastaus
Link: </api/v1/varaukset?jalkeen=MjAyNi0xMC0wMlQwOTowMHw3>; rel="next"
Jatkotunniste koodataan, jotta asiakkaat käsittelevät sitä pelkkänä merkkijonona eivätkä yritä tulkita tai muodostaa sitä itse. Silloin sen sisältöä voi myöhemmin muuttaa rikkomatta asiakkaita:
def koodaa_jatkotunniste(rivi):
teksti = f"{rivi['alkaa']}|{rivi['vid']}"
return base64.urlsafe_b64encode(teksti.encode()).decode()
def pura_jatkotunniste(jatko):
alkaa, vid = base64.urlsafe_b64decode(jatko).decode().split("|")
return alkaa, int(vid)
Esimerkkisovelluksen versio tekee lisäksi kaksi asiaa: se suodattaa varaukset huoneen mukaan ja jättää menneet pois, ja se palauttaa virheellisestä jatkotunnisteesta tilakoodin 400.
Virheet: Problem Details
Rajapintaluennon yhtenäinen virhemuoto on hyvä periaate, mutta muoto oli kurssin oma. RFC 9457 Problem Details for HTTP APIs määrittelee standardimuodon, jonka monet kirjastot ja työkalut tunnistavat:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://appro.mit.jyu.fi/ties4080/luennot/rest/virheet/tarkistus",
"title": "Tarkista tiedot",
"status": 422,
"instance": "/api/v1/varaukset",
"kentat": {"otsikko": "Otsikko puuttuu."}
}
typeon virhetyypin tunniste osoitteen muodossa. Se on tarkoitettu ohjelmien luettavaksi: asiakas voi haarautua sen perusteella tekstiä tulkitsematta.titleon ihmisen luettava kuvaus tyypistä,detailjuuri tämän tapauksen tarkennus.- Omia kenttiä saa lisätä, kuten tässä kenttäkohtaiset virheet
kentat-sanakirjassa.
Linkit vastauksissa
Kypsyysmallin taso 3 tarkoittaa, että vastaus kertoo, mitä sen jälkeen voi tehdä. Periaatteen nimi on HATEOAS (Hypermedia as the Engine of Application State):
{
"vid": 7,
"otsikko": "Projektipalaveri",
"_links": {
"self": {"href": "/api/v1/varaukset/7"},
"muokkaa": {"href": "/api/v1/varaukset/7", "method": "PATCH"},
"poista": {"href": "/api/v1/varaukset/7", "method": "DELETE"}
}
}
Hyöty näkyy asiakkaassa. Esimerkkisovelluksen selainasiakas ei rakenna yhtään
osoitetta itse: se aloittaa rajapinnan juuresta /api/v1 ja seuraa linkkejä.
Muokkaus- ja poistopainikkeet näytetään vain, jos palvelin tarjoaa niihin linkin. Oikeussääntö on siis kirjoitettu vain palvelimelle, ja asiakas noudattaa sitä automaattisesti.
Linkit eivät korvaa oikeuksien tarkistusta: palvelin tarkistaa ne silti jokaisessa pyynnössä, koska kuka tahansa voi lähettää pyynnön ilman linkkiä.
Tilattomuus ja tunnistautuminen
REST olettaa, ettei palvelin muista asiakkaan tilaa pyyntöjen välillä. Kurssin sovellukset käyttävät evästeistuntoa, mikä on selaimen käyttöliittymälle luontevaa, mutta muille asiakkaille — mobiilisovellukselle, toiselle palvelimelle, komentoriviohjelmalle — se on kömpelö. Niiden kanssa tunniste lähetetään jokaisessa pyynnössä:
Authorization: Bearer 3f9a…
Esimerkkisovellus hyväksyy molemmat, ja ero näkyy CSRF-suojauksessa: vain
evästeellä tunnistautunut pyyntö tarvitsee CSRF-tunnisteen. Selain lisää
evästeen jokaiseen pyyntöön itse, mutta Authorization-otsaketta se ei lisää
koskaan, joten toinen sivusto ei voi väärentää Bearer-pyyntöä.
if tapa == "istunto":
# Evästeellä tunnistautunut pyyntö on altis CSRF:lle, Bearer-pyyntö ei.
lahetetty = request.headers.get("X-CSRF-Token", "")
if not lahetetty or not hmac.compare_digest(lahetetty, session.get("csrf", "")):
raise Ongelma(403, "csrf", "CSRF-tunniste puuttuu tai on väärä")
Bearer-tunnisteeseen liittyy myös rajoite: se on salaisuus, joka ei saa päätyä selaimen tallennustilaan eikä lokeihin. Selaimen käyttöliittymälle eväste on edelleen
parempi, koska HttpOnly-evästettä javascript ei pysty lukemaan.
Rajapinnan tietoturva
OWASP API Security Top 10 luettelee rajapintojen yleisimmät haavoittuvuudet. Kolme ensimmäistä liittyvät suoraan tähän luentoon.
Kohdekohtainen valtuutus (Broken Object Level
Authorization, listan ensimmäinen). Rajapinta tarkistaa, että käyttäjä on
kirjautunut, mutta ei sitä, saako hän käsitellä juuri pyytämäänsä kohdetta. Osoitteessa
oleva tunniste on käyttäjän muutettavissa: PATCH /varaukset/8 onnistuu, jos
palvelin ei tarkista, kenen varaus 8 on.
def vain_omistaja(rivi):
if rivi["varaaja"] != g.kayttaja:
raise Ongelma(403, "oikeus", "Ei oikeutta", "Voit muuttaa vain omia varauksiasi.")
Kenttien joukkosijoitus (mass assignment). Jos palvelin
kopioi rungon kaikki kentät tietokantaan, asiakas voi asettaa kenttiä, joita sen ei
pitäisi: {"otsikko": "…", "varaaja": "Grace Hopper"}. Sallitut kentät
luetellaan siksi erikseen, ja muut hylätään:
KENTAT = ("huone", "otsikko", "alkaa", "loppuu")
vieraat = sorted(set(tiedot) - set(KENTAT))
if vieraat:
raise Ongelma(422, "tarkistus", "Tarkista tiedot",
kentat={k: "Kenttää ei voi asettaa." for k in vieraat})
Resurssien kulutus. Rajapintaa voi kuormittaa ohjelmallisesti paljon
nopeammin kuin käyttöliittymää. Pyyntöjen määrää rajoitetaan käyttäjää kohden, ja
rajan ylittävä pyyntö saa vastauksen 429 sekä Retry-After-otsakkeen, joka
kertoo, milloin voi yrittää uudelleen. Samaan aihepiiriin kuuluvat myös sivun ja pyynnön enimmäiskoko.
Versiointi
Julkista rajapintaa ei voi muuttaa rikkomatta asiakkaita, joita et tunne. Tavallisin
ratkaisu on versio osoitteessa (/api/v1/…); vaihtoehtoja ovat oma HTTP-otsake ja versio mediatyypissä. Osoite on yksinkertaisin, koska se näkyy lokeissa ja
toimii selaimessa sellaisenaan.
Versiota ei tarvitse vaihtaa jokaisesta muutoksesta. Uuden kentän lisääminen vastaukseen ei riko asiakkaita, jos ne ohittavat tuntemattomat kentät. Kentän poistaminen, nimen muuttaminen tai merkityksen muuttaminen rikkoo. Rajapinnan, jolla on vain oma selainsivu asiakkaana, voi muuttaa vapaasti: sivu ja rajapinta julkaistaan samalla kertaa.
Dokumentointi: OpenAPI
OpenAPI on koneluettava kuvaus rajapinnasta: osoitteet, metodit, parametrit, rungon rakenne, tilakoodit ja tunnistautuminen. Kuvauksesta syntyy luettava ja kokeiltava dokumentaatio, esimerkiksi Swagger UI, ja sen avulla voidaan tuottaa asiakaskirjastoja ja testata, että rajapinta vastaa kuvaustaan.
Esimerkin kuvaus on kirjoitettu käsin, jotta sen rakenne näkyy. Isommissa sovelluksissa kuvaus tuotetaan koodista, jolloin se ei pääse vanhenemaan; Flaskille tähän sopivat esimerkiksi APIFlask ja flask-smorest.
REST ja sen vaihtoehdot
- GraphQL: asiakas kertoo kyselyssä täsmälleen, mitkä kentät se tarvitsee, ja saa ne yhdellä pyynnöllä. Sopii, kun asiakkaita on monenlaisia ja tarpeet vaihtelevat. Hintana on, että kaikki pyynnöt ovat POST-pyyntöjä samaan osoitteeseen, jolloin HTTP-välimuisti ei auta.
- gRPC: binäärinen, tarkasti tyypitetty etäkutsu, joka on tarkoitettu palvelinten väliseen liikenteeseen. Selaimet eivät tue sitä suoraan.
- JSON-RPC: etäkutsut JSON-muodossa, eli kypsyysmallin taso 0.
REST on edelleen oletus julkisille rajapinnoille, koska se käyttää HTTP:tä sellaisenaan: välimuistit, välityspalvelimet, lokit ja selaimen kehittäjätyökalut ymmärtävät sen ilman erityistä tukea.
Esimerkkisovellus
Esimerkki on kokoushuoneiden varaus, sama aihe kuin
rajapintaluennolla, mutta erillinen ja laajempi
sovellus. Etusivu on rajapinnan selainasiakas, ja rajapinnan dokumentaatio on
osoitteessa /api/docs.
- Sovellus ja sen rajapinnan dokumentaatio
- varaus-rest.zip — lähdekoodi, testit ja julkaisuohjeet
Kokeile ainakin näitä:
- Versioristiriita kahdella välilehdellä. Kirjaudu, avaa sama sivu toiseen välilehteen ja aloita saman varauksen muokkaus molemmissa. Tallenna muutos ensin toisessa välilehdessä ja sitten ensimmäisessä: jälkimmäisenä tallentanut saa vastauksen 412, ja lomakkeelle ladataan uusin versio.
- Bearer-tunniste. Kirjautuneena etusivulla näkyy oma tunniste ja
valmis
curl-komento. Kokeile samaa pyyntöä ilman tunnistetta (401), väärällä metodilla (405) ja vieraalla kentällä, kuten"varaaja"(422). - Ehdollinen haku. Avaa yksittäisen varauksen osoite, esimerkiksi
/api/v1/varaukset/1, kehittäjätyökalujen Network-välilehti auki ja lataa sivu uudelleen: toisella kerralla selain lähettääIf-None-Match-otsakkeen ja palvelin vastaa 304 ilman runkoa. Sama tapahtuu, kun aloitat saman varauksen muokkauksen etusivulla toiseen kertaan. - Sivutus. Hae
/api/v1/varaukset?koko=1ja seuraaLink-otsaketta sivu kerrallaan.
Käyttäjien kommentit