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:

REST-arkkitehtuurityylin rajoitteet
RajoiteMerkitys
Asiakas–palvelinkäyttöliittymä ja tietojen tallennus ovat erillään ja voivat kehittyä toisistaan riippumatta
Tilattomuusjokainen pyyntö sisältää kaiken, mitä sen käsittelyyn tarvitaan; palvelin ei muista edellistä pyyntöä
Välimuistikelpoisuusvastaus kertoo, saako sen tallentaa ja kuinka pitkäksi aikaa
Yhtenäinen rajapintaresurssit tunnistetaan osoitteilla, niitä käsitellään esitysmuotojen kautta, viestit kuvaavat itse itsensä, ja vastaukset sisältävät linkit seuraaviin toimintoihin
Kerroksellisuusasiakas ei tiedä, puhuuko se palvelimelle vai välityspalvelimelle
Koodi tarvittaessavalinnainen: 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:

Rajapinnan kypsyystasot
TasoKuvausEsimerkki
0yksi osoite, kaikki POST-pyyntöinä POST /api ja {"toiminto": "poista", "id": 7}
1resursseilla omat osoitteet POST /varaukset/7/poista
2HTTP-metodit ja tilakoodit oikeassa merkityksessään DELETE /varaukset/7 → 204
3vastauksissa 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

HTTP-metodit

Metodien merkitys
MetodiKäyttö TurvallinenIdempotentti
GEThae esityskylläkyllä
POSTluo kokoelmaan uusi kohdeeiei
PUTkorvaa kohteen koko esityseikyllä
PATCHmuuta osa kentistäeiei välttämättä
DELETEpoistaeikyllä

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:

Kadonneen päivityksen esto ETagilla Kaksi asiakasta hakee saman varauksen ja saa saman ETag-arvon 7-1. Ensimmäinen lähettää PATCH-pyynnön HTTP-otsakkeella If-Match: 7-1, ja palvelin hyväksyy muutoksen ja palauttaa uuden ETag-arvon 7-2. Toinen asiakas lähettää oman muutoksensa samalla vanhalla If-Match-arvolla 7-1, ja palvelin hylkää sen tilakoodilla 412 Precondition Failed ja kertoo uusimman ETag-arvon. Toinen asiakas hakee uusimman version ja yrittää uudelleen. Asiakkaat A ja Bkaksi välilehteäPalvelin/api/v1/varaukset/7 GET /api/v1/varaukset/7 (A ja B)1HTTP/1.1 200 OKETag: "7-1"{"vid": 7, "otsikko": "Projektipalaveri", …}2A tallentaa ensinPATCH /api/v1/varaukset/7If-Match: "7-1"Content-Type: application/merge-patch+json{"otsikko": "Projektipalaveri (siirretty)"}3HTTP/1.1 200 OKETag: "7-2"4B tallentaa vanhan version päällePATCH /api/v1/varaukset/7If-Match: "7-1" (vanhentunut){"otsikko": "Projektipalaveri, oma muutos"}5HTTP/1.1 412 Precondition FailedContent-Type: application/problem+jsonETag: "7-2"{"type": ".../virheet/versio", "title": "Varausta on muutettu", "status": 412}6B hakee uusimman version, näyttää sen käyttäjälle ja yrittää uudelleen
Molemmat asiakkaat muokkaavat versiota 7-1. Ensimmäinen muutos hyväksytään, toinen hylätään tilakoodilla 412, koska versio on ehtinyt vaihtua.
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.

Tarkista versio myös tallennuksessa

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ä.

Tarkistus tehdään samalla ETagilla kuin muokkauksessa, nyt If-None-Match-otsakkeessa. Jos kohde ei ole muuttunut, vastaus on 304 ilman runkoa:

Ehdollinen haku ja 304 Not Modified Asiakas hakee varauksen ja saa sen ETag-arvon. Kun asiakas hakee saman varauksen uudelleen HTTP-otsakkeella If-None-Match ja varaus ei ole muuttunut, palvelin vastaa tilakoodilla 304 Not Modified ilman runkoa, ja asiakas käyttää tallentamaansa kopiota. AsiakasvälimuistiPalvelin/api/v1/varaukset/7 GET /api/v1/varaukset/71HTTP/1.1 200 OKETag: "7-2"Cache-Control: private, no-cache{"vid": 7, "otsikko": …} (asiakas tallentaa)2no-cache: kopion saa käyttää vasta, kun palvelin on vahvistanut senGET /api/v1/varaukset/7If-None-Match: "7-2"3HTTP/1.1 304 Not ModifiedETag: "7-2" (ei runkoa)4
Ehdollinen haku: kun kopio on yhä ajan tasalla, palvelin vastaa 304:llä eikä lähetä runkoa uudelleen.

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:

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."}
}

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

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.

Kokeile ainakin näitä:

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti