Fetch, XML, htmx, Firestore ja WebSocketit

Tässä ohjauksessa tehdään lomake, joka hakee tietoja palvelimelta Fetch-rajapinnalla sekä JSON- että XML-muodossa. Sama lomake toteutetaan myös htmx-kirjastolla ilman omaa javascriptiä. Lopuksi kokeillaan Firestoren javascript-rajapintaa ja tehdään pieni chat WebSocketeilla.

Ohjaus perustuu luentoihin Palvelinpuolen rajapinnat: fetch, CORS ja htmx, XML ja ulkopuoliset datalähteet palvelimella ja Avoimet yhteydet: SSE ja WebSocket. Jos javascript ja promiset eivät ole ennestään tuttuja, kertaa TIEA2120-kurssin luento Fetch ja asynkroniset funktiot ja MDN:n artikkeli Using promises.

Malliratkaisu

Kokeile ensin valmista sovellusta. Syötä postinumeroksi 40740, niin sovellus täyttää postitoimipaikan. Vaihda oppilaitosryhmää, niin oppilaitosluettelo vaihtuu. Lisää tilaaja ja kokeile myös jättää kenttiä tyhjiksi.

Tietokanta ja pohja

Pura pohja hakemistoon, jossa Flask-sovelluksesi on, esimerkiksi users.jyu.fi-palvelimen hakemistoon cgi-bin/ties4080/ohjaus5/. Pohjassa on:

Tietokannan rakenne: Oppilaitosryhma 1–M Oppilaitos 1–M Tilaaja M–1 Postitoimipaikka

SQLite tarkistaa viiteavaimet vain, jos se on erikseen pyydetty. Pohjan tietokanta()-funktio antaa siksi jokaiselle yhteydelle komennon PRAGMA foreign_keys = ON. Ilman sitä tietokantaan voisi tallentaa tilaajan, jonka postinumeroa ei ole olemassa.

Sivun osoitteet ovat suhteellisia: kun sivu on osoitteessa …/flask.cgi/, osoite postitoimipaikka tarkoittaa …/flask.cgi/postitoimipaikka. Näin koodiin ei tarvitse kirjoittaa omaa käyttäjätunnusta.

Flask ja JSON

Tehdään ensin reitti, joka palauttaa kaikki postitoimipaikat JSON-muodossa. Flask muuntaa listan ja sanakirjat JSONiksi itse, kun ne palautetaan näkymästä, ja asettaa mediatyypiksi application/json:

@app.get("/postitoimipaikat")
def postitoimipaikat():
    rivit = tietokanta().execute(
        "SELECT postinro, postitoimipaikka FROM Postitoimipaikka ORDER BY postinro").fetchall()
    # sqlite3.Row ei ole JSON-muotoinen, joten rivit muunnetaan sanakirjoiksi.
    return [dict(r) for r in rivit]

Kts. malli.

Tee sitten reitti postitoimipaikka, joka palauttaa vain kyselyparametrina annetun postinumeron. Esimerkiksi osoite …/flask.cgi/postitoimipaikka?postinro=40740 palauttaa

[{"postinro": "40740", "postitoimipaikka": "Jyv\u00e4skyl\u00e4"}]

Flask kirjoittaa muut kuin ASCII-merkit oletuksena koodattuina (\u00e4 on ä). Se on täsmälleen sama JSON kuin "Jyväskylä", ja selaimen JSON.parse tuottaa kummastakin saman merkkijonon.

Varmista, että sovellus ei kaadu, jos postinumeroa ei anneta tai sitä ei löydy tietokannasta. Palauta silloin tyhjä lista: tuntematon postinumero ei ole palvelimen virhe. Muista käyttää parametrisoitua kyselyä.

Flask ja XML

Toteuta samaan tapaan oppilaitosryhmien hakeminen, mutta palauta ne select-elementtinä, jonka voi liittää suoraan sivulle. Tee XML Jinja-templatella templates/ryhmat.xml:

<?xml version="1.0" encoding="UTF-8"?>
<select id="{{ name }}" name="{{ name }}" xmlns="http://www.w3.org/1999/xhtml">
{% for o in ryhmat %}
  <option value="{{ o['ryhmaid'] }}"{% if loop.first %} selected="selected"{% endif %}>{{ o['ryhmanimi'] }}</option>
{% endfor %}
</select>

Sarakkeiden nimet ovat tietokannassa RyhmaID ja RyhmaNimi. sqlite3.Row ei välitä kirjainkoosta, joten o['ryhmanimi'] toimii. Nimiavaruus xmlns on pakollinen: ilman sitä selain ei tunnista elementtiä XHTML:n select-elementiksi, kun se liitetään sivulle.

@app.get("/hae_ryhmat")
def hae_ryhmat():
    ryhmat = tietokanta().execute(
        "SELECT RyhmaID, RyhmaNimi FROM Oppilaitosryhma ORDER BY RyhmaNimi").fetchall()
    xml = render_template("ryhmat.xml", ryhmat=ryhmat, name="oppilaitosryhma")
    return Response(xml, mimetype="application/xml")

Kts. malli. XML:n mediatyyppi on application/xml. Merkistöä ei tarvitse asettaa erikseen: Flask lisää tekstimuotoisiin vastauksiin charset=utf-8 itse, ja XML-esittely kertoo merkistön dokumentin sisällä.

Flask suojaa templateissa merkit automaattisesti vain, kun tiedoston pääte on .html, .htm, .xml, .xhtml tai .svg. Jos templaten nimi päättyy muuhun, esimerkiksi .htmx, tietokannasta tuleva <-merkki päätyy sivulle sellaisenaan.

XML ElementTreellä

Jinja ei tiedä tuottavansa XML:ää, joten se ei voi taata, että tulos on hyvinmuodostunut. ElementTree rakentaa puun elementti kerrallaan ja suojaa merkit itse. Nimiavaruus annetaan elementin nimessä aaltosulkeissa, ja register_namespace tekee siitä oletusnimiavaruuden:

from xml.etree import ElementTree

XHTML = "http://www.w3.org/1999/xhtml"
ElementTree.register_namespace("", XHTML)

select = ElementTree.Element(f"{{{XHTML}}}select", {"id": "oppilaitosryhma",
                                                    "name": "oppilaitosryhma"})
for ryhma in ryhmat:
    option = ElementTree.SubElement(select, f"{{{XHTML}}}option",
                                    {"value": str(ryhma["ryhmaid"])})
    option.text = ryhma["ryhmanimi"]

xml = ElementTree.tostring(select, encoding="unicode", xml_declaration=True)
return Response(xml, mimetype="application/xml")
Sama minidomilla

minidom toteuttaa selaimesta tutun DOM-rajapinnan, vrt. Javascript ja DOM. minidom ei kirjoita nimiavaruuden määrittelyä ulos, vaikka nimiavaruus annetaan createDocument-kutsussa, joten xmlns-attribuutti on lisättävä itse. Lisää XML:n käsittelystä XML-luennolla.

from xml.dom.minidom import getDOMImplementation

doc = getDOMImplementation().createDocument(XHTML, "select", None)
select = doc.documentElement
select.setAttribute("xmlns", XHTML)
select.setAttribute("id", "oppilaitosryhma")
select.setAttribute("name", "oppilaitosryhma")

for ryhma in ryhmat:
    option = doc.createElement("option")
    option.setAttribute("value", str(ryhma["ryhmaid"]))
    option.appendChild(doc.createTextNode(ryhma["ryhmanimi"]))
    select.appendChild(option)

return Response(doc.toxml(encoding="UTF-8"), mimetype="application/xml")

Oppilaitokset

Toteuta oppilaitosten hakeminen. Rajaa oppilaitokset kyselyparametrilla ryhmaid; jos sitä ei anneta, palauta kaikki. Jos parametri ei ole kokonaisluku, vastaa tilakoodilla 400. Palauta oppilaitokset omassa XML-muodossa ja rakenna se ElementTreellä:

<oppilaitokset>
  <oppilaitos id="1">Helsingin kauppakorkeakoulu</oppilaitos>
  <oppilaitos id="2">Helsingin yliopisto</oppilaitos>
  ...
</oppilaitokset>

Kts. malli.

Javascript, Fetch ja JSON

Täydennä tiedostoa static/osoite.js. Toteutetaan ensin postitoimipaikan täyttäminen postinumeron perusteella.

  1. Lisää postinumerokentän change-tapahtumaan käsittelijä hae_postitoimipaikka.
  2. Kirjoita funktio, joka hakee postitoimipaikan aiemmin tekemästäsi osoitteesta:
    async function hae_postitoimipaikka() {
      const kentta = document.forms.lomake.elements.postitoimipaikka;
      // Suhteellinen osoite muunnetaan kokonaiseksi sivun osoitteen perusteella.
      const url = new URL(osoitteet.postitoimipaikka, location.href);
      // GET-pyynnön parametrit lisätään osoitteeseen: ?postinro=40740
      url.searchParams.set("postinro", document.forms.lomake.elements.postinumero.value);
    
      try {
        const vastaus = await fetch(url, { headers: { "Accept": "application/json" } });
        if (!vastaus.ok) {
          throw new Error(`HTTP-virhe ${vastaus.status}`);
        }
        // Datan rakenne on juuri se, jonka palvelin muodosti: lista.
        const data = await vastaus.json();
        kentta.value = data.length > 0 ? data[0].postitoimipaikka : "tuntematon";
      }
      catch (virhe) {
        // Verkkovirheet ja HTTP-virheet päätyvät tänne.
        kentta.value = "";
        console.log("Virhe postitoimipaikan hakemisessa: " + virhe.message);
      }
    }
  3. Kokeile sovellusta. Katso kehittäjätyökalujen Network-välilehdeltä, mitä tapahtuu, kun vaihdat postinumerokentän sisältöä, ja tarkista konsolin tulosteet.

Fetch ja XML

Toteutetaan seuraavaksi oppilaitoksen valinta oppilaitosryhmän perusteella.

  1. Tee funktio, joka hakee XML-dokumentin ja jäsentää sen. DOMParser ei heitä poikkeusta virheellisestä XML:stä vaan palauttaa virhedokumentin, joten jäsennyksen onnistuminen on tarkistettava itse:
    async function hae_xml(url) {
      const vastaus = await fetch(url, { headers: { "Accept": "application/xml" } });
      if (!vastaus.ok) {
        throw new Error(`HTTP-virhe ${vastaus.status}`);
      }
      const doc = new DOMParser().parseFromString(await vastaus.text(), "application/xml");
      if (doc.querySelector("parsererror")) {
        throw new Error("Palvelimen XML ei ole hyvinmuodostunutta");
      }
      return doc;
    }
  2. Tee funktio hae_ryhmat, joka korvaa sivun tyhjän select-elementin palvelimen palauttamalla:
    async function hae_ryhmat() {
      const doc = await hae_xml(new URL(osoitteet.ryhmat, location.href));
      // Tuodaan elementti tähän dokumenttiin ja korvataan sillä tyhjä select.
      const select = document.importNode(doc.documentElement, true);
      document.getElementById("oppilaitosryhma").replaceWith(select);
    
      select.addEventListener("change", hae_oppilaitokset);
      await hae_oppilaitokset();
    }
  3. Kutsu hae_ryhmat-funktiota sivun load-tapahtumassa:
    hae_ryhmat().catch(e => console.log("Virhe ryhmien hakemisessa: " + e.message));
  4. Tee funktio hae_oppilaitokset, joka hakee valitun ryhmän oppilaitokset (parametri ryhmaid) ja täyttää oppilaitosluettelon. Luo jokaisesta oppilaitos-elementistä option-elementti; nimi asetetaan textContent-ominaisuuteen ja tunniste value-ominaisuuteen.

Mieti, miksi hae_oppilaitokset-käsittelijää ei voi kytkeä load-tapahtumassa kuten postinumeron käsittelijää, vaan vasta hae_ryhmat-funktiossa. Vihje: fetch-kutsut ovat asynkronisia, ja ryhmien select-elementti korvataan kokonaan uudella.

Tilaajan lisääminen

Lisäys tehdään progressiivisen parantamisen periaatteella: ensin lomake toimii ilman javascriptiä, sitten sama osoite palvelee myös fetch-pyyntöä.

  1. Näytä lomakkeen alla tietokannan tilaajat. Hae ne reitistä tilaajat, joka palauttaa JSONin, ja lataa luettelo sivun latautuessa. Luo luettelon rivit textContent-ominaisuudella, jolloin käyttäjän syöttämä teksti ei voi muuttua HTML:ksi.
  2. Tee lomakkeesta tavalliseen tapaan toimiva: reitti lisaa_tilaaja vastaanottaa POST-pyynnön, tarkistaa tiedot ja tallentaa tilaajan. Onnistuessa se ohjaa takaisin lomakkeelle tilakoodilla 303; virheissä se näyttää sivun virheilmoituksineen tilakoodilla 422.
    • Etu- ja sukunimi sekä lähiosoite ovat pakollisia.
    • Postinumeron on löydyttävä tietokannasta.
    • Oppilaitos on valinnainen, mutta annetun on oltava olemassa.
  3. Lisää samaan reittiin vastaus fetch-pyynnöille. Kun pyynnön Accept-otsake pyytää JSONia, palauta onnistuessa tilakoodi 201 ja uuden tilaajan osoite Location-otsakkeessa, ja virheissä tilakoodi 422 ja kenttäkohtaiset virheet (kts. tilakoodit):
    {"virhe": "Tarkista tiedot", "kentat": {"etunimi": "Etunimi puuttuu"}}
  4. Muuta lomakkeen lähetys fetch-kutsuksi. Tilakoodi 422 on odotettu tilanne, joka käsitellään ennen muita virheitä. Pohjassa jokaisen kentän vieressä on span-elementti, jonka tunniste on kentän nimi ja pääte _error, joten virheet on helppo sijoittaa oikeaan paikkaan:
    async function lisaa_tilaaja(e) {
      // Tavallinen lähetys estetään vasta, kun tiedetään, että javascript toimii.
      e.preventDefault();
      const lomake = e.target;
    
      const vastaus = await fetch(lomake.action, {
        method: "POST",
        headers: { "Accept": "application/json" },
        body: new FormData(lomake)      // koko lomakkeen sisältö
      });
      const data = await vastaus.json();
    
      if (vastaus.status === 201) {
        nayta_virheet({});
        lomake.reset();
        await hae_tilaajat();
      }
      else if (vastaus.status === 422) {
        nayta_virheet(data.kentat);     // esim. #etunimi_error
      }
      else {
        throw new Error(`HTTP-virhe ${vastaus.status}`);
      }
    }

Lomake ei käytä kirjautumista eikä istuntoa, joten CSRF-suojausta ei tarvita. Kun lomakkeen lähettäminen vaatii kirjautumisen, tarvitaan CSRF-tunniste.

htmx 4

htmx-kirjastolla saman sovelluksen voi toteuttaa kirjoittamatta riviäkään javascriptiä. Palvelin palauttaa JSONin ja XML:n sijaan valmiita HTML-palasia, ja htmx sijoittaa ne sivulle attribuuttien ohjeiden mukaan. Kts. mallisovellus ja sen lähdekoodi paketissa ohjaus5-malli.zip (templates/htmx/ ja htmx_-alkuiset funktiot). Taustaa on fetch-luennon htmx-luvussa.

Postinumerokenttä hakee postitoimipaikan ja korvaa sillä postitoimipaikkakentän. Kentän oma arvo lähtee pyynnössä mukaan kyselyparametrina, koska kentällä on nimi:

<input type="text" id="postinumero" name="postinumero"
       hx-get="htmx/postitoimipaikka" hx-trigger="change"
       hx-target="#postitoimipaikka" hx-swap="outerHTML" />

Oppilaitosryhmän vaihto hakee oppilaitokset samalla tavalla. Lomakkeen lähetys korvaa koko lomakkeen palvelimen palauttamalla versiolla:

<form id="lomake" action="lisaa_tilaaja" method="post"
      hx-post="htmx/lisaa_tilaaja" hx-target="this" hx-swap="outerHTML">

Vertaa fetch- ja htmx-versioiden lähdekoodeja: palvelimen tiedot ja tarkistukset ovat samat, mutta htmx-versiossa näkymä muodostetaan kokonaan palvelimella.

Firebase, Firestore ja javascript-sovellus

Chrome ja moduuliskriptit

Firebasen javascript-kirjasto on jaettu ES-moduuleiksi, jotka ladataan import-lauseella. Chrome ei kuitenkaan suorita XHTML-sivulla (application/xhtml+xml) lainkaan moduuliskriptejä: konsoliin tulee ilmoitus Module scripts in XML documents are currently not supported, eikä koodia ajeta. Firefox suorittaa ne.

Ratkaisu on dynaaminen import(). Se on tavallisen skriptin sisällä käytettävä funktio, joka lataa moduulin ja palauttaa lupauksen, ja se toimii Chromessa myös XHTML-sivulla:

<!-- ei toimi Chromessa XHTML-sivulla -->
<script type="module">
  import { initializeApp } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js";
</script>

<!-- toimii: tavallinen skripti -->
<script src="static/sovellus.js"></script>
// static/sovellus.js
window.addEventListener("load", async function () {
  const { initializeApp } =
    await import("https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js");
  // ...
});

Tavallisessa skriptissä ei voi käyttää await-sanaa ylimmällä tasolla, joten koodi kirjoitetaan async-funktion sisään, esimerkiksi sivun load-tapahtuman käsittelijään.

Reaaliaikaiset päivitykset

Viikkotehtävän 5 tasolla 5 etusivun kilpailulistauksen on päivityttävä, kun joku muu muuttaa tietokantaa. Firestore hoitaa tämän itse: onSnapshot kutsuu annettua funktiota heti ensimmäisellä kerralla ja sen jälkeen aina, kun kyselyn tulos muuttuu. Selain pitää Firestoreen avoinna yhteyttä, joten tätä ei tarvitse toteuttaa omalla palvelimella.

window.addEventListener("load", async function () {
  const { initializeApp } =
    await import("https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js");
  const { getFirestore, collection, query, orderBy, onSnapshot } =
    await import("https://www.gstatic.com/firebasejs/12.10.0/firebase-firestore.js");

  const db = getFirestore(initializeApp(firebaseConfig));
  const lista = document.getElementById("kilpailut");

  const kysely = query(collection(db, "kilpailut"), orderBy("alkuaika", "desc"));

  // Palauttaa funktion, jolla kuuntelun voi lopettaa.
  const lopeta = onSnapshot(kysely, tulos => {
    const rivit = tulos.docs.map(d => {
      const li = document.createElement("li");
      li.textContent = `${d.data().kisanimi} ${d.data().alkuaika}`;
      return li;
    });
    lista.replaceChildren(...rivit);
  }, virhe => {
    console.log("Kuuntelu epäonnistui: " + virhe.message);
  });
});

WebSocketit

Tehdään pieni chat, jossa viestit välittyvät kaikille avoimille selaimille heti. Toteutetaan se kahdella tavalla ja verrataan:

Malliratkaisut ovat paketissa chat-malli.zip. Pitkäkestoiset yhteydet eivät toimi CGI:nä, joten aja chattia omalla koneellasi (python3 app.py) ja avaa se kahteen selainikkunaan. Taustaa on WebSocket-luvussa.

Selaimen oma WebSocket

Viestit ovat JSON-objekteja, joiden tyyppi kertoo, mistä on kyse. Nimimerkki lähetetään vain liityttäessä, ja palvelin sitoo sen yhteyteen. Myöhemmät viestit eivät siis voi väittää olevansa joltain muulta:

from flask_sock import ConnectionClosed, Sock

sock = Sock(app)
yhteydet = set()


@sock.route("/ws")
def ws(yhteys):
    nick = None
    try:
        while True:
            data = json.loads(yhteys.receive())
            if data.get("tyyppi") == "liity" and nick is None:
                nick = str(data.get("nick", "")).strip()[:30]
                yhteydet.add(yhteys)
                laheta_kaikille(viesti(nick, "saapuu paikalle"))
            elif data.get("tyyppi") == "viesti" and nick is not None:
                teksti = str(data.get("teksti", "")).strip()
                if 0 < len(teksti) <= 500:
                    laheta_kaikille(viesti(nick, teksti))
    except ConnectionClosed:
        pass
    finally:
        yhteydet.discard(yhteys)
        if nick is not None:
            laheta_kaikille(viesti(nick, "poistui"))

Mallissa on lisäksi Origin-otsakkeen tarkistus: same-origin policy ei koske WebSocket-kättelyä, joten muuten mikä tahansa sivusto voisi avata yhteyden chattiin. Selaimessa yhteys avataan ja uudelleenyhteys tehdään itse:

function avaa() {
  const osoite = new URL(document.body.dataset.ws, location.href);
  osoite.protocol = location.protocol === "https:" ? "wss:" : "ws:";
  yhteys = new WebSocket(osoite);

  yhteys.addEventListener("open", () => {
    viive = 1000;
    yhteys.send(JSON.stringify({ tyyppi: "liity", nick: nick }));
  });
  yhteys.addEventListener("message", e => nayta(JSON.parse(e.data)));
  yhteys.addEventListener("close", () => {
    setTimeout(avaa, viive);            // uusi yritys kasvavalla viiveellä
    viive = Math.min(viive * 2, 30000);
  });
}

Socket.IO

Socket.IO:ssa viestit ovat nimettyjä tapahtumia, ja kirjasto tunnistaa yhteydet tunnisteella request.sid:

from flask_socketio import SocketIO, emit

socketio = SocketIO(app)        # sallii oletuksena vain saman lähteen
nimimerkit = {}


@socketio.on("liity")
def liity(nick):
    nimimerkit[request.sid] = str(nick or "").strip()[:30]
    laheta_kaikille(viesti(nimimerkit[request.sid], "saapuu paikalle"))


@socketio.on("viesti")
def vastaanota(teksti):
    nick = nimimerkit.get(request.sid)
    teksti = str(teksti or "").strip()
    if nick is not None and 0 < len(teksti) <= 500:
        laheta_kaikille(viesti(nick, teksti))


@socketio.on("disconnect")
def poistu(*_):
    nick = nimimerkit.pop(request.sid, None)
    if nick is not None:
        laheta_kaikille(viesti(nick, "poistui"))

Selaimessa Socket.IO yhdistää itse uudelleen; liittyminen lähetetään uudelleen jokaisessa connect-tapahtumassa:

const socket = io();
socket.on("connect", () => socket.emit("liity", nick));
socket.on("viesti", nayta);
socket.emit("viesti", "Hei kaikki");

Chatin jatkokehitys

Molemmat mallit sitovat nimimerkin yhteyteen, tarkistavat viestien koon, muistavat 50 viimeisintä viestiä ja näyttävät viestit textContent-ominaisuudella. Mieti, miten ratkaisisit seuraavat:

Firestoren hierarkiat ja transaktiot

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti