Siirry sisältöön

a

Node.js ja Express

Siirrämme tässä osassa fokuksen backendiin eli palvelimella olevaan toiminnallisuuteen.

Backendin toteutusympäristönä käytämme Node.js:ää, joka on melkein missä vaan, erityisesti palvelimilla ja omalla koneellasikin toimiva Googlen V8-JavaScript-moottoriin perustuva JavaScriptin suoritusympäristö.

Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v20.11.0. Suosittelen, että omasi on vähintään yhtä tuore (ks. komentoriviltä node -v).

Kuten osassa 1 todettiin, selaimet eivät vielä osaa kaikkia uusimpia JavaScriptin ominaisuuksia, ja siksi selainpuolen koodi täytyy kääntää eli transpiloida esim Babel:illa. Backendissa tilanne on kuitenkin toinen, koska uusin Node hallitsee riittävissä määrin myös JavaScriptin uusia versioita, joten suoritamme Nodella kirjoittamaamme koodia suoraan ilman transpilointivaihetta.

Tavoitteenamme on tehdä osan 2 muistiinpanosovellukseen sopiva backend. Aloitetaan kuitenkin ensin perusteiden läpikäyminen toteuttamalla perinteinen "hello world" ‑sovellus.

Huomaa, että tässä osassa ja sen tehtävissä luotavat sovellukset eivät ole Reactia, eli emme käytä viteä tämän osan sovellusten rungon alustamiseen.

Osassa 2 oli jo puhe npm:stä, eli JavaScript-projektien hallintaan liittyvästä, alun perin Node-ekosysteemistä kotoisin olevasta työkalusta.

Mennään sopivaan hakemistoon ja luodaan projektimme runko komennolla npm init. Vastaillaan kysymyksiin sopivasti, ja tuloksena on hakemiston juureen sijoitettu projektin tietoja kuvaava tiedosto package.json:

{
  "name": "notebackend",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Matti Luukkainen",
  "license": "MIT"
}

Tiedosto määrittelee mm., että ohjelmamme käynnistyspiste on tiedosto index.js.

Tehdään kenttään scripts pieni lisäys:

{
  // ...
  "scripts": {
    "start": "node index.js",    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

Luodaan sitten sovelluksen ensimmäinen versio eli projektin juureen sijoitettava tiedosto index.js ja sille seuraava sisältö:

console.log('hello world')

Voimme suorittaa ohjelman joko "suoraan" nodella, komentorivillä

node index.js

tai npm-skriptinä

npm start

npm-skripti start toimii koska määrittelimme sen tiedostoon package.json:

{
  // ...
  "scripts": {
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

Vaikka esim. projektin suorittaminen onnistuukin suoraan käyttämällä komentoa node index.js, on npm-projekteille suoritettavat operaatiot yleensä tapana määritellä nimenomaan npm-skripteinä.

Oletusarvoinen package.json määrittelee valmiiksi myös toisen yleisesti käytetyn npm-skriptin eli npm test. Koska projektissamme ei ole vielä testikirjastoa, ei npm test kuitenkaan tee vielä muuta kuin suorittaa komennon

echo "Error: no test specified" && exit 1

Yksinkertainen web-palvelin

Muutetaan sovellus web-palvelimeksi:

const http = require('http')

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('Hello World')
})

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

Kun sovellus käynnistyy, konsoliin tulostuu

Server running on port 3001

Voimme avata selaimella osoitteessa http://localhost:3001 olevan vaatimattoman sovelluksemme:

selaimessa näkyy teksti Hello World

Palvelin toimii samalla tavalla riippumatta urlin loppuosasta, eli myös sivun http://localhost:3001/foo/bar sisältö on sama.

HUOM: jos koneesi portti 3001 on jo jonkun sovelluksen käytössä, aiheuttaa käynnistäminen virheen:

> notes-backend@1.0.0 start /Users/mluukkai/opetus/_koodi_fs/3/luento/notes-backend
> node index.js

Server running on port 3001
events.js:174
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE: address already in use :::3001
    at Server.setupListenHandle [as _listen2] (net.js:1280:14)
    at listenInCluster (net.js:1378:12)

Sulje portissa 3001 oleva sovellus (edellisessä osassa json-server käynnistettiin porttiin 3001) tai määrittele sovellukselle jokin toinen portti.

Tarkastellaan koodia hiukan. Ensimmäinen rivi

const http = require('http')

ottaa käyttöön Noden sisäänrakennetun web-palvelimen määrittelevän moduulin. Kyse on käytännössä samasta asiasta kuin mihin olemme selainpuolen koodissa tottuneet, mutta syntaksiltaan hieman erilaisessa muodossa:

import http from 'http'

Selaimen puolella käytetään nykyään ES6:n moduuleita, eli moduulit määritellään exportilla ja otetaan käyttöön importilla.

Node.js käyttää oletusarvoisesti ns. CommonJS-moduuleja. Syy tälle on siinä, että Node-ekosysteemillä oli tarve moduuleihin jo kauan ennen kuin JavaScript tuki moduuleja kielen tasolla. Nykyään Node tukee myös ES-moduuleja, mutta koska tuki ei ole vielä kaikilta osin täydellinen, pitäydymme CommonJS-moduuleissa.

CommonJS-moduulit toimivat melko samaan tapaan kuin ES6-moduulit, ainakin tämän kurssin tarpeiden puitteissa.

Koodi jatkuu seuraavasti:

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('Hello World')
})

Koodi luo http-moduulin metodilla createServer web-palvelimen, jolle se rekisteröi tapahtumankäsittelijän, joka suoritetaan jokaisen osoitteen http://localhost:3001 alle tulevan HTTP-pyynnön yhteydessä.

Pyyntöön vastataan statuskoodilla 200, asettamalla Content-Type-headerille arvo text/plain ja asettamalla palautettavan sivun sisällöksi merkkijono Hello World.

Viimeiset rivit sitovat muuttujaan app sijoitetun http-palvelimen kuuntelemaan porttiin 3001 tulevia HTTP-pyyntöjä:

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

Koska tällä kurssilla palvelimen rooli on pääasiassa tarjota frontille JSON-muotoista "raakadataa", muutetaan palvelinta siten, että se palauttaa kovakoodatun listan JSON-muotoisia muistiinpanoja:

const http = require('http')

let notes = [  {    id: 1,    content: "HTML is easy",    important: true  },  {    id: 2,    content: "Browser can execute only JavaScript",    important: false  },  {    id: 3,    content: "GET and POST are the most important methods of HTTP protocol",    important: true  }]const app = http.createServer((request, response) => {  response.writeHead(200, { 'Content-Type': 'application/json' })  response.end(JSON.stringify(notes))})
const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

Käynnistetään palvelin uudelleen (palvelin suljetaan painamalla konsolissa yhtä aikaa ctrl + c) ja refreshataan selain.

Headerin Content-Type arvolla application/json kerrotaan, että kyse on JSON-muotoisesta datasta. Muuttujassa notes oleva taulukko muutetaan JSON-muotoon metodilla JSON.stringify(notes).

Kun avaamme selaimen, on tulostusasu sama kuin osassa 2 käytetyn json-serverin tarjoamalla muistiinpanojen listalla:

Selain renderöi json-muotoisen datan

Express

Palvelimen koodin tekeminen suoraan Noden sisäänrakennetun web-palvelimen http:n päälle on mahdollista. Se on kuitenkin työlästä, erityisesti jos sovellus kasvaa hieman isommaksi.

Nodella tapahtuvaa web-sovellusten ohjelmointia helpottamaan onkin kehitelty useita http:tä miellyttävämmän ohjelmointirajapinnan tarjoavia kirjastoja. Näistä ylivoimaisesti suosituin on Express.

Otetaan Express käyttöön määrittelemällä se projektimme riippuvuudeksi komennolla

npm install express

Riippuvuus tulee nyt määritellyksi tiedostoon package.json:

{
  // ...
  "dependencies": {
   "express": "^4.18.2"
  }
}

Riippuvuuden koodi asentuu kaikkien projektin riippuvuuksien tapaan projektin juuressa olevaan hakemistoon node_modules. Hakemistosta löytyy Expressin lisäksi suuri määrä muutakin tavaraa:

komennon ls tulostama suuri määrä kirjastoja vastaavia hakemistoja

Kyseessä ovat Expressin riippuvuudet ja niiden riippuvuudet jne. eli projektimme transitiiviset riippuvuudet.

Projektiin asentui Expressin versio 4.18.2. package.json:issa versiomerkinnän edessä on väkänen, eli muoto on

"express": "^4.18.2"

npm:n yhteydessä käytetään ns. semanttista versiointia. Merkintä ^4.18.2 tarkoittaa, että jos projektin riippuvuudet päivitetään, asennetaan Expressistä versio, joka on vähintään 4.18.2, mutta asennetuksi voi tulla versio, jonka patch eli viimeinen numero tai minor eli keskimmäinen numero voi olla suurempi. Pääversio eli major täytyy kuitenkin olla edelleen sama.

Voimme päivittää projektin riippuvuudet komennolla

npm update

Jos aloitamme projektin koodaamisen toisella koneella, saamme haettua ajantasaiset, package.json:in määrittelyn kanssa yhteensopivat riippuvuudet komennolla

npm install

Jos riippuvuuden major-versionumero ei muutu, uudempien versioiden pitäisi olla taaksepäin yhteensopivia, eli jos ohjelmamme käyttäisi tulevaisuudessa esim. Expressin versiota 4.99.175, tässä osassa tehtävän koodin pitäisi edelleen toimia ilman muutoksia. Sen sijaan tulevaisuudessa joskus julkaistava Express 5.0.0 voi sisältää sellaisia muutoksia, että koodimme ei enää toimisi.

Web ja Express

Palataan taas sovelluksen ääreen ja muutetaan se muotoon

const express = require('express')
const app = express()

let notes = [
  ...
]

app.get('/', (request, response) => {
  response.send('<h1>Hello World!</h1>')
})

app.get('/api/notes', (request, response) => {
  response.json(notes)
})

const PORT = 3001
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Jotta sovelluksen uusi versio saadaan käyttöön, on sovellus käynnistettävä uudelleen.

Sovellus ei muutu paljoa. Heti alussa otetaan käyttöön express, joka on tällä kertaa funktio, jota kutsumalla luodaan muuttujaan app sijoitettava Express-sovellusta vastaava olio:

const express = require('express')
const app = express()

Seuraavaksi määritellään sovellukselle kaksi routea. Näistä ensimmäinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen juureen eli polkuun / tulevia HTTP GET ‑pyyntöjä:

app.get('/', (request, response) => {
  response.send('<h1>Hello World!</h1>')
})

Tapahtumankäsittelijäfunktiolla on kaksi parametria. Näistä ensimmäinen eli request sisältää kaikki HTTP-pyynnön tiedot ja toisen parametrin response:n avulla määritellään, miten pyyntöön vastataan.

Koodissa pyyntöön vastataan käyttäen response-olion metodia send, jonka kutsumisen seurauksena palvelin vastaa HTTP-pyyntöön lähettämällä selaimelle vastaukseksi send:in parametrina olevan merkkijonon <h1>Hello World!</h1>. Koska parametri on merkkijono, asettaa Express vastauksessa Content-Type-headerin arvoksi text/html. Statuskoodiksi tulee oletusarvoisesti 200.

Asian voi varmistaa konsolin välilehdeltä Network:

Avattu network-tabi näyttää että palvelin vastaa statuskoodilla 200

Routeista toinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen polkuun /api/notes tulevia HTTP GET ‑pyyntöjä:

app.get('/api/notes', (request, response) => {
  response.json(notes)
})

Pyyntöön vastataan response-olion metodilla json, joka lähettää HTTP-pyynnön vastaukseksi parametrina olevaa JavaScript-olioa eli taulukkoa notes vastaavan JSON-muotoisen merkkijonon. Express asettaa headerin Content-Type arvoksi application/json.

Selain renderöi json-muotoiset muistiinpanot

Pieni huomio JSON-muodossa palautettavasta datasta.

Aiemmassa, pelkkää Nodea käyttävässä versiossa, jouduimme muuttamaan palautettavan datan JSON-muotoon metodilla JSON.stringify:

response.end(JSON.stringify(notes))

Expressiä käytettäessä tämä ei ole tarpeen, sillä muunnos tapahtuu automaattisesti.

Kannattaa huomata, että JSON on merkkijono, eikä JavaScript-olio kuten muuttuja notes.

Seuraava interaktiivisessa node-repl:issä suoritettu kokeilu havainnollistaa asiaa:

js-objekti muuttuu string-tyyppiseksi JSON.stringify-operaation seurauksena

Saat käynnistettyä interaktiivisen node-repl:in kirjoittamalla komentoriville node. Komentojen toimivuutta on koodatessa kätevä kokeilla konsolissa, suosittelen!

nodemon

Jos muutamme sovelluksen koodia, joudumme ensin sulkemaan sovelluksen konsolista (ctrl + c) ja sitten käynnistämään sovelluksen uudelleen, jotta muutokset tulevat voimaan. Uudelleenkäynnistely tuntuu kömpelöltä verrattuna Reactin mukavaan workflow'hun, jossa selain päivittyi automaattisesti koodin muuttuessa.

Ongelmaan on ratkaisu nimeltä nodemon:

nodemon will watch the files in the directory in which nodemon was started, and if any files change, nodemon will automatically restart your node application.

Asennetaan nodemon määrittelemällä se kehitysaikaiseksi riippuvuudeksi (development dependency) komennolla:

npm install --save-dev nodemon

Tiedoston package.json sisältö muuttuu seuraavasti:

{
  //...
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.3"
  }
}

Jos nodemon-riippuvuus kuitenkin meni sovelluksessasi normaaliin "dependencies"-ryhmään, päivitä package.json manuaalisesti vastaamaan yllä näkyvää (kuitenkin versiot säilyttäen).

Kehitysaikaisilla riippuvuuksilla tarkoitetaan työkaluja, joita tarvitaan ainoastaan sovellusta kehitettäessä esim. testaukseen tai sovelluksen automaattiseen uudelleenkäynnistykseen kuten nodemon.

Kun sovellusta suoritetaan tuotantomoodissa eli samoin kuin sitä tullaan suorittamaan tuotantopalvelimella (esim. Fly.io:ssa, johon tulemme kohta siirtämään sovelluksemme), ei kehitysaikaisia riippuvuuksia tarvita.

Voimme käynnistää ohjelman nodemonilla seuraavasti:

node_modules/.bin/nodemon index.js

Huom: komennon tämä muoto ei välttämättä toimi Windowsilla. Se ei kuitenkaan haittaa sillä 5 sentin päästä kerrotaan komennosta parempi muoto.

Sovelluksen koodin muutokset aiheuttavat nyt automaattisen palvelimen uudelleenkäynnistymisen. Kannattaa huomata, että vaikka palvelin uudelleenkäynnistyy automaattisesti, selain täytyy kuitenkin refreshata, sillä toisin kuin Reactin yhteydessä, meillä ei nyt ole eikä tässä skenaariossa (jossa palautamme JSON-muotoista dataa) edes voisikaan olla selainta päivittävää hot reload ‑toiminnallisuutta.

Komento on ikävä, joten määritellään sitä varten npm-skripti tiedostoon package.json:

{
  // ..
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js",    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ..
}

Skriptissä ei ole tarvetta käyttää nodemonin polusta sen täydellistä muotoa node_modules/.bin/nodemon sillä npm osaa etsiä automaattisesti suoritettavaa tiedostoa kyseisestä hakemistosta.

Voimme nyt käynnistää palvelimen sovelluskehitysmoodissa komennolla

npm run dev

Toisin kuin skriptejä start tai test suoritettaessa, komennon tulee sisältää myös run.

REST

Laajennetaan sovellusta siten, että se toteuttaa samanlaisen RESTful-periaatteeseen nojaavan HTTP-rajapinnan kuin json-server.

Representational State Transfer eli REST on Roy Fieldingin vuonna 2000 ilmestyneessä väitöskirjassa määritelty skaalautuvien web-sovellusten rakentamiseksi tarkoitettu arkkitehtuurityyli.

Emme nyt rupea määrittelemään REST:iä fieldingiläisittäin tai rupea väittelemään siitä mitä REST on tai mitä se ei ole. Otamme hieman kapeamman näkökulman, jonka mukaan REST tai RESTful API:t yleensä tulkitaan web-sovelluksissa. Alkuperäinen REST-periaate ei sinänsä rajoitu web-sovelluksiin.

Mainitsimme jo edellisessä osassa, että yksittäisiä asioita, meidän tapauksessamme muistiinpanoja kutsutaan RESTful-ajattelussa resursseiksi. Jokaisella resurssilla on URL eli sen yksilöivä osoite.

Erittäin yleinen konventio on muodostaa resurssien yksilöivät URLit liittäen resurssityypin nimi ja resurssin yksilöivä tunniste.

Oletetaan, että palvelumme juuriosoite on www.example.com/api.

Jos nimitämme muistiinpanoja note-resursseiksi, yksilöidään yksittäinen muistiinpano, jonka tunniste on 10 URLilla www.example.com/api/notes/10.

Kaikkia muistiinpanoja edustavan kokoelmaresurssin URL taas on www.example.com/api/notes.

Resursseille voi suorittaa erilaisia operaatiota. Suoritettavan operaation määrittelee HTTP-operaation tyyppi, jota kutsutaan usein myös verbiksi:

URL verbi toiminnallisuus
notes/10    GET hakee yksittäisen resurssin
notes GET hakee kokoelman kaikki resurssit
notes POST luo uuden resurssin pyynnön mukana olevasta datasta
notes/10 DELETE    poistaa yksilöidyn resurssin
notes/10 PUT korvaa yksilöidyn resurssin pyynnön mukana olevalla datalla
notes/10 PATCH korvaa yksilöidyn resurssin osan pyynnön mukana olevalla datalla

Näin määrittyy suurin piirtein asia, jota REST kutsuu nimellä uniform interface, eli jossain määrin yhtenäinen tapa määritellä rajapintoja, jotka mahdollistavat (tietyin tarkennuksin) järjestelmien yhteiskäytön.

Tämänkaltaista tapaa tulkita REST:iä on nimitetty kolmiportaisella asteikolla kypsyystason 2 REST:iksi. REST:in kehittäjän Roy Fieldingin mukaan tällöin kyseessä ei vielä ole ollenkaan asia, jota tulisi kutsua REST API:ksi. Valtaosa maailman "REST" API ‑rajapinnoista ei täytäkään puhdasverisen fieldingiläisen REST API:n määritelmää.

Joissain yhteyksissä (ks. esim. Richardson, Ruby: RESTful Web Services) edellä esitellyn kaltaista suoraviivaisehkoa resurssien CRUD-tyylisen manipuloinnin mahdollistavaa API:a nimitetään REST:in sijaan resurssipohjaiseksi arkkitehtuurityyliksi. Emme nyt kuitenkaan takerru liian tarkasti määritelmällisiin asioihin vaan jatkamme sovelluksen parissa.

Yksittäisen resurssin haku

Laajennetaan nyt sovellusta siten, että se tarjoaa muistiinpanojen operointiin REST-rajapinnan. Tehdään ensin route yksittäisen resurssin katsomista varten.

Yksittäisen muistiinpanon identifioi URL, joka on muotoa /api/notes/10. Lopussa oleva luku vastaa resurssin muistiinpanon id:tä.

Voimme määritellä Expressin routejen poluille parametreja käyttämällä kaksoispistesyntaksia:

app.get('/api/notes/:id', (request, response) => {
  const id = request.params.id
  const note = notes.find(note => note.id === id)
  response.json(note)
})

Nyt app.get('/api/notes/:id', ...) käsittelee kaikki HTTP GET ‑pyynnöt, jotka ovat muotoa /api/notes/JOTAIN, jossa JOTAIN on mielivaltainen merkkijono.

Polun parametrin id arvoon päästään käsiksi pyynnön tiedot kertovan olion request kautta:

const id = request.params.id

Jo tutuksi tulleella taulukon find-metodilla haetaan taulukosta parametria vastaava muistiinpano ja palautetaan se pyynnön tekijälle.

Kun sovellusta testataan menemällä selaimella osoitteeseen http://localhost:3001/api/notes/1, havaitaan että se ei toimi, vaan selain näyttää tyhjältä. Tämä on tietenkin softadevaajan arkipäivää, ja on ruvettava debuggaamaan.

Vanha hyvä keino on alkaa lisäillä koodiin console.log-komentoja:

app.get('/api/notes/:id', (request, response) => {
  const id = request.params.id
  console.log(id)
  const note = notes.find(note => note.id === id)
  console.log(note)
  response.json(note)
})

Kun selaimella mennään jälleen osoitteeseen http://localhost:3001/api/notes/1, konsoliin (eli siihen terminaaliin, johon sovellus on käynnistetty) tulostuu

Konsoliin on tulostunut 'server running in port 3000' lisäksi 1 ja undefined

eli halutun muistiinpanon id välittyy sovellukseen aivan oikein, mutta find komento ei löydä mitään.

Päätetään tulostella konsoliin myös find-komennon sisällä olevasta vertailijafunktiosta, mikä onnistuu helposti kun tiiviissä muodossa oleva funktio note => note.id === id kirjoitetaan eksplisiittisen returnin sisältävässä muodossa:

app.get('/api/notes/:id', (request, response) => {
  const id = request.params.id
  const note = notes.find(note => {
    console.log(note.id, typeof note.id, id, typeof id, note.id === id)
    return note.id === id
  })
  console.log(note)
  response.json(note)
})

Vierailtaessa jälleen yksittäisen muistiinpanon sivulla jokaisesta vertailufunktion kutsusta tulostetaan nyt monta asiaa. Konsolin tulostus on seuraava:


1 'number' '1' 'string' false
2 'number' '1' 'string' false
3 'number' '1' 'string' false

Ongelman syy selviää. Muuttujassa id on tallennettuna merkkijono '1' kun taas muistiinpanojen id:t ovat numeroita. JavaScriptissä === vertailu katsoo kaikki eri tyyppiset arvot oletusarvoisesti erisuuriksi, joten 1 ei ole '1'.

Korjataan ongelma muuttamalla parametrina oleva merkkijonomuotoinen id numeroksi:

app.get('/api/notes/:id', (request, response) => {
  const id = Number(request.params.id)  const note = notes.find(note => note.id === id)
  response.json(note)
})

Nyt yksittäisen resurssin hakeminen toimii.

Yksittäistä muistiinpanoa vastaava json renderöityy

Toiminnallisuuteen jää kuitenkin pieni ongelma. Jos haemme muistiinpanoa sellaisella indeksillä, jota vastaavaa muistiinpanoa ei ole olemassa, vastaa palvelin seuraavasti:

Selaimeen ei renderöidy mitään, network-tab paljastaa että palvelin vastaa statuskoodilla 200

HTTP-statuskoodi on onnistumisesta kertova 200. Vastaukseen ei liity dataa, sillä headerin content-length arvo on 0, ja samaa todistaa selain: mitään ei näy.

Syynä tälle käyttäytymiselle on se, että muuttujan note arvoksi tulee undefined jos muistiinpanoa ei löydy. Tilanne tulee käsitellä palvelimella järkevämmin, eli statuskoodin 200 sijaan tulee vastata statuskoodilla 404 not found.

Tehdään koodiin muutos:

app.get('/api/notes/:id', (request, response) => {
  const id = Number(request.params.id)
  const note = notes.find(note => note.id === id)
  
  if (note) {    response.json(note)  } else {    response.status(404).end()  }})

Koska vastaukseen ei nyt liity mitään dataa, käytetään statuskoodin asettavan metodin status lisäksi metodia end ilmoittamaan siitä, että pyyntöön tulee vastata ilman dataa.

Koodin haarautumisessa hyväksikäytetään sitä, että mikä tahansa JavaScript-olio on truthy, eli katsotaan todeksi vertailuoperaatiossa. undefined taas on falsy eli epätosi.

Nyt sovellus palauttaa oikean virhekoodin. Sovellus ei kuitenkaan palauta mitään käyttäjälle näytettävää kuten web-sovellukset yleensä tekevät jos mennään osoitteeseen, jota ei ole olemassa. Emme kuitenkaan tarvitse nyt mitään näytettävää, sillä REST API:t ovat ohjelmalliseen käyttöön tarkoitettuja rajapintoja, ja pyyntöön liitetty virheestä kertova statuskoodi on riittävä.

Resurssin poisto

Toteutetaan seuraavaksi resurssin poistava route. Poisto tapahtuu tekemällä HTTP DELETE ‑pyyntö resurssin urliin:

app.delete('/api/notes/:id', (request, response) => {
  const id = Number(request.params.id)
  notes = notes.filter(note => note.id !== id)

  response.status(204).end()
})

Jos poisto onnistuu eli poistettava muistiinpano on olemassa, vastataan statuskoodilla 204 no content sillä mukaan ei lähetetä mitään dataa.

Ei ole täyttä yksimielisyyttä siitä, mikä statuskoodi DELETE-pyynnöstä pitäisi palauttaa jos poistettavaa resurssia ei ole olemassa. Vaihtoehtoja ovat lähinnä 204 ja 404. Yksinkertaisuuden vuoksi sovellus palauttaa nyt molemmissa tilanteissa statuskoodin 204.

Postman

HTTP GET ‑pyyntöjä on helppo testata selaimessa, mutta miten voimme testata poisto-operaatioita? Voisimme toki kirjoittaa JavaScript-koodin, joka testaa deletointia, mutta jokaiseen mahdolliseen tilanteeseen testikoodinkaan tekeminen ei ole aina paras ratkaisu.

On olemassa useita backendin testaamista helpottavia työkaluja, eräs näistä on Postman, jota käytämme tällä kurssilla.

Asennetaan Postmanin desktop sovellus täältä ja kokeillaan:

tehdään postmanilla operaatio DELETE http://localhost:3001/api/notes/2, huomataan että vastauksessa statuskoodi 204 no content

Postmanin käyttö on tässä tilanteessa suhteellisen yksinkertaista, riittää määritellä url ja valita oikea pyyntötyyppi.

Palvelin näyttää vastaavan oikein. Tekemällä HTTP GET osoitteeseen http://localhost:3001/api/notes selviää, että poisto-operaatio onnistui. Muistiinpanoa, jonka id on 2 ei ole enää listalla.

Koska muistiinpanot on talletettu palvelimen muistiin, uudelleenkäynnistys palauttaa tilanteen ennalleen.

Visual Studio Coden REST client

Jos käytät Visual Studio Codea, voit Postmanin sijaan käyttää VS Coden REST client ‑pluginia.

Kun plugin on asennettu, on sen käyttö erittäin helppoa. Tehdään projektin juureen hakemisto requests, jonka sisään talletetaan REST Client ‑pyynnöt .rest-päätteisinä tiedostoina.

Luodaan kaikki muistiinpanot hakevan pyynnön määrittelevä tiedosto get_all_notes.rest:

Luodaan tiedosto jonka sisältlö GET http://localhost:3001/api/notes

Klikkaamalla tekstiä Send Request, REST client suorittaa määritellyn HTTP-pyynnön, ja palvelimen vastaus avautuu editoriin:

VS codeen avautuu näkymä missä palvelimen palauttama json-muotoinen taulukko muistiinpanoja sekä operaatioon vastattu statuskoodi ja palautetut headerit

Datan vastaanottaminen

Toteutetaan seuraavana uusien muistiinpanojen lisäys, joka siis tapahtuu tekemällä HTTP POST ‑pyyntö osoitteeseen http://localhost:3001/api/notes ja liittämällä pyynnön bodyyn luotavan muistiinpanon tiedot JSON-muodossa.

Jotta pääsisimme pyynnön mukana lähetettyyn dataan helposti käsiksi, tarvitsemme Expressin tarjoaman json-parserin apua. Tämä tapahtuu lisäämällä koodiin komento app.use(express.json()).

Otetaan json-parseri käyttöön ja luodaan alustava määrittely HTTP POST ‑pyynnön käsittelyyn:

const express = require('express')
const app = express()

app.use(express.json())
//...

app.post('/api/notes', (request, response) => {  const note = request.body  console.log(note)  response.json(note)})

Tapahtumankäsittelijäfunktio pääsee dataan käsiksi olion request kentän body avulla.

Ilman json-parserin lisäämistä eli komentoa app.use(express.json()) pyynnön kentän body arvo olisi ollut määrittelemätön. Json-parserin toimintaperiaatteena on, että se ottaa pyynnön mukana olevan JSON-muotoisen datan, muuttaa sen JavaScript-olioksi ja sijoittaa request-olion kenttään body ennen kuin routen käsittelijää kutsutaan.

Toistaiseksi sovellus ei vielä tee vastaanotetulle datalle mitään muuta kuin tulostaa sen konsoliin ja palauttaa sen pyynnön vastauksessa.

Ennen toimintalogiikan viimeistelyä varmistetaan ensin Postmanilla, että lähetetty tieto menee varmasti perille. Pyyntötyypin ja urlin lisäksi on määriteltävä myös pyynnön mukana menevä data eli body:

Valitaan postmanissa JSON body-datan tyypiksi

Sovellus tulostaa lähetetyn vastaanottamansa datan terminaaliin:

Konsoliin tulostuu palvelimen vastaanottama json-objekti

HUOM: Kun ohjelmoit backendia, pidä sovellusta suorittava konsoli koko ajan näkyvillä. Nodemonin ansiosta sovellus käynnistyy uudelleen jos koodiin tehdään muutoksia. Jos seuraat konsolia, huomaat välittömästi jos sovelluksen koodiin tulee virhe:

konsoliin tulostuu epävalidista javascriptistä johtuva parse error ‑virheilmoitus

Konsolista kannattaa seurata myös, reagoiko backend odotetulla tavalla esim. kun sovellukselle lähetetään dataa metodilla HTTP POST. Backendiin kannattaa luonnollisesti lisäillä runsaat määrät console.log-komentoja kun sovellus on kehitysvaiheessa.

Eräs ongelmanlähde on se, että dataa lähettäessä headerille Content-Type ei aseteta oikeaa arvoa. Näin tapahtuu esim. jos Postmanissa bodyn tyyppiä ei määritellä oikein:

Valitaan postmanissa text body-datan tyypiksi

Headerin Content-Type arvoksi asettuu text/plain:

Nähdään postmanin headers-välilehdeltä että content-type on text/plain

Palvelin näyttää vastaanottavan ainoastaan tyhjän olion:

Konsoliin tulostuu tyhjä json

Ilman oikeaa headerin arvoa palvelin ei osaa parsia dataa oikeaan muotoon. Se ei edes yritä arvailla missä muodossa data on, sillä potentiaalisia datan siirtomuotoja eli Content-Typejä on olemassa suuri määrä.

Jos käytät VS Codea, edellisessä luvussa esitelty REST client kannattaa asentaa viimeistään nyt. POST-pyyntö tehdään REST clientillä seuraavasti:

VS codeen avautuu näkymä joka näyttää palvelimen palauttaman, luodun json-objektin, sekä siihen liittyvät headerit ja statuskoodin 200

Pyyntöä varten on siis luotu oma tiedosto create_note.rest. Pyyntö on muotoiltu dokumentaation ohjetta noudatellen.

REST clientin eräs suuri etu Postmaniin verrattuna on se, että pyynnöt saa kätevästi talletettua projektin repositorioon ja tällöin ne ovat helposti koko kehitystiimin käytössä. Postmanillakin on mahdollista tallettaa pyyntöjä, mutta tilanne menee helposti kaoottiseksi etenkin jos työn alla on useita toisistaan riippumattomia projekteja.

Tärkeä sivuhuomio

Välillä debugatessa tulee vastaan tilanteita, joissa backendissä on tarve selvittää, mitä headereja HTTP-pyynnöille on asetettu. Eräs menetelmä tähän on request-olion melko kehnosti nimetty metodi get, jonka avulla voi selvittää yksittäisen headerin arvon. request-oliolla on myös kenttä headers, jonka arvona ovat kaikki pyyntöön liittyvät headerit.

Ongelmia voi syntyä esim., jos jätät vahingossa VS Coden REST clientillä ylimmän rivin ja headerit määrittelevien rivien väliin tyhjän rivin. Tällöin REST client tulkitsee, että millekään headerille ei aseteta arvoa ja näin backend ei osaa tulkita pyynnön mukana olevaa dataa JSON:iksi.

Puuttuvan Content-Type-headerin ongelma selviää, kun backendissa tulostaa pyynnön headerit esim. komennolla console.log(request.headers).

Palataan taas sovelluksen pariin. Kun tiedämme, että sovellus vastaanottaa tiedon oikein, voimme viimeistellä sovelluslogiikan:

app.post('/api/notes', (request, response) => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => n.id)) 
    : 0

  const note = request.body
  note.id = maxId + 1

  notes = notes.concat(note)

  response.json(note)
})

Uudelle muistiinpanolle tarvitaan uniikki id. Ensin selvitetään olemassa olevista id:istä suurin muuttujaan maxId. Uuden muistiinpanon id:ksi asetetaan sitten maxId + 1. Tämä tapa ei ole kovin hyvä, mutta emme nyt välitä siitä, sillä tulemme pian korvaamaan tavan, jolla muistiinpanot talletetaan.

Tämänhetkisessä versiossa on vielä se ongelma, että voimme HTTP POST ‑pyynnöllä lisätä mitä tahansa kenttiä sisältäviä olioita. Parannellaan sovellusta siten, että kenttä content ei saa olla tyhjä. Kentälle important asetetaan oletusarvo false jos sen arvoa ei ole määritelty. Kaikki muut kentät hylätään:

const generateId = () => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => n.id))
    : 0
  return maxId + 1
}

app.post('/api/notes', (request, response) => {
  const body = request.body

  if (!body.content) {
    return response.status(400).json({ 
      error: 'content missing' 
    })
  }

  const note = {
    content: body.content,
    important: body.important || false,
    id: generateId(),
  }

  notes = notes.concat(note)

  response.json(note)
})

Tunnisteena toimivan id-kentän arvon generointilogiikka on eriytetty funktioon generateId.

Jos vastaanotetulta datalta puuttuu sisältö kentästä content, vastataan statuskoodilla 400 bad request:

if (!body.content) {
  return response.status(400).json({ 
    error: 'content missing' 
  })
}

Huomaa, että returnin kutsuminen on tärkeää. Ilman kutsua koodi jatkaisi suoritusta metodin loppuun asti, ja virheellinen muistiinpano tallettuisi!

Jos content-kentällä on arvo, luodaan muistiinpano syötteen perusteella. Jos kenttä important puuttuu, asetetaan sille oletusarvo false. Oletusarvo generoidaan nyt hieman erikoisella tavalla:

important: body.important || false,

Jos sovelluksen vastaanottamassa muuttujaan body talletetussa datassa on kenttä important, tulee lausekkeelle sen arvo. Jos kenttää ei ole olemassa, tulee lausekkeen arvoksi oikeanpuoleinen osa eli false.

Jos ollaan tarkkoja, niin kentän important arvon ollessa false, tulee lausekkeen body.important || false arvoksi oikean puoleinen false...

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa.

Tämän hetken koodi on branchissa part3-1:

Kuva havainnollistaa miten branchi löydetään githubista

Jos kloonaat projektin itsellesi, suorita komento npm install ennen käynnistämistä eli ennen komentoa npm start tai npm run dev.

Vielä pieni huomio ennen tehtäviä. Uuden id:n generoiva funktio näyttää seuraavalta:

const generateId = () => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => n.id))
    : 0
  return maxId + 1
}

Koodi sisältää hieman erikoisen näköisen rivin:

Math.max(...notes.map(n => n.id))

Mitä rivillä tapahtuu? notes.map(n => n.id) muodostaa taulukon, joka koostuu muistiinpanojen id-kentistä. Math.max palauttaa maksimin sille parametrina annetuista luvuista. notes.map(n => n.id) on kuitenkin taulukko, joten se ei kelpaa parametriksi komennolle Math.max. Taulukko voidaan muuttaa yksittäisiksi luvuiksi käyttäen taulukon spread-syntaksia, eli kolmea pistettä ...taulukko.

Huomioita HTTP-pyyntötyyppien käytöstä

HTTP-standardi puhuu pyyntötyyppien yhteydessä kahdesta ominaisuudesta, safe ja idempotent.

HTTP-pyynnöistä GET:in tulisi olla safe:

In particular, the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval. These methods ought to be considered "safe".

Safety tarkoittaa siis, että pyynnön suorittaminen ei saa aiheuttaa palvelimelle sivuvaikutuksia eli esim. muuttaa palvelimen tietokannan tilaa. Pyynnön tulee ainoastaan palauttaa palvelimella olevaa dataa.

Mikään ei automaattisesti takaa, että GET-pyynnöt olisivat luonteeltaan safe. Kyseessä onkin HTTP-standardin suositus palvelimien toteuttajille. RESTful-periaatetta noudattaessa GET-pyyntöjä käytetäänkin aina siten, että ne ovat safe.

HTTP-standardi määrittelee myös pyyntötyypin HEAD, jonka tulee olla safe. Käytännössä HEAD:in tulee toimia kuten GET, mutta se ei palauta vastauksenaan muuta kuin statuskoodin ja headerit. Viestin bodyä HEAD ei palauta ollenkaan.

HTTP-pyynnöistä muiden paitsi POST:in tulisi olla idempotentteja:

Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request. The methods GET, HEAD, PUT and DELETE share this property

Eli jos pyynnöllä on sivuvaikutuksia, lopputulos on sama suoritettaessa pyyntö yhden tai useamman kerran.

Esim. jos tehdään HTTP PUT ‑pyyntö osoitteeseen /api/notes/10 ja pyynnön mukana on { content: "ei sivuvaikutuksia", important: true }, on lopputulos sama riippumatta siitä, kuinka monta kertaa pyyntö suoritetaan.

Kuten metodin GET safety myös idempotence on HTTP-standardin suositus palvelimien toteuttajille. RESTful-periaatetta noudattaessa GET-, HEAD-, PUT- ja DELETE-pyyntöjä käytetäänkin aina siten, että ne ovat idempotentteja.

HTTP-pyyntötyypeistä POST on ainoa, joka ei ole safe eikä idempotent. Jos tehdään viisi kertaa HTTP POST ‑pyyntö osoitteeseen /api/notes siten että pyynnön mukana on { content: "monta samaa", important: true }, tulee palvelimelle viisi saman sisältöistä muistiinpanoa.

Middlewaret

Äsken käyttöönottamamme Expressin json-parseri on terminologiassa niin sanottu middleware.

Middlewaret ovat funktioita, joiden avulla voidaan käsitellä request- ja response-olioita.

Esim. json-parseri ottaa pyynnön mukana tulevan raakadatan request-oliosta, parsii sen JavaScript-olioksi ja sijoittaa olion request:in kenttään body

Middlewareja voi olla käytössä useita, jolloin ne suoritetaan peräkkäin siinä järjestyksessä, kuin ne on otettu koodissa käyttöön.

Toteutetaan itse yksinkertainen middleware, joka tulostaa konsoliin palvelimelle tulevien pyyntöjen perustietoja.

Middleware on funktio, joka saa kolme parametria:

const requestLogger = (request, response, next) => {
  console.log('Method:', request.method)
  console.log('Path:  ', request.path)
  console.log('Body:  ', request.body)
  console.log('---')
  next()
}

Middleware kutsuu lopussa parametrina olevaa funktiota next, jolla se siirtää kontrollin seuraavalle middlewarelle.

Middleware otetaan käyttöön seuraavasti:

app.use(requestLogger)

Middlewaret suoritetaan siinä järjestyksessä, jossa ne on otettu käyttöön sovellusolion metodilla use. Huomaa, että json-parseri tulee ottaa käyttöön ennen middlewarea requestLogger, muuten request.body ei ole vielä alustettu loggeria suoritettaessa!

Middlewaret tulee ottaa käyttöön ennen routeja, jos ne halutaan suorittaa ennen niitä. On myös eräitä tapauksia, joissa middleware tulee määritellä vasta routejen jälkeen. Käytännössä tällöin on kyse middlewareista, joita suoritetaan vain, jos mikään route ei käsittele HTTP-pyyntöä.

Lisätään routejen jälkeen seuraava middleware, jonka ansiosta saadaan routejen käsittelemättömistä virhetilanteista JSON-muotoinen virheilmoitus:

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

app.use(unknownEndpoint)

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part3-2.