Tämä on 3. periodilla pidetyn, jo päättyneen kurssin sisältö.
Jos haluat suorittaa kurssin nyt, mene osoitteeseen https://fullstackopen-2019.github.io/
a
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 chrome V8 -Javascriptmoottoriin perustuva Javascriptin suoritusympäristö.
Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v8.10.0. Huolehdi että omasi on vähintään yhtä tuore (ks. komentoriviltä node -v).
Kuten osassa 1 todettiin, selaimet eivät vielä osaa uusimpia Javascriptin ominaisuuksia ja siksi selainpuolen koodi täytyy kääntää eli transpiloida esim babel:illa. Backendissa tilanne on kuitenkin toinen, uusin Node hallitsee riittävissä määrin myös Javascriptin uusia versioita, joten suoritamme Nodella suoraan kirjoittamaamme koodia ilman transpilointivaihetta.
Tavoitteenamme on tehdä osan 2 muistiinpanosovellukseen sopiva backend. Aloitetaan kuitenkin ensin perusteiden läpikäyminen toteuttamalla perinteinen "hello world"-sovellus.
Huomaa, että kaikki tässä osassa ja sen tehtävissä luotavat sovellukset eivät ole Reactia, eli emme käytä create-react-app-sovellusta tämän osan sovellusten rungon alustamiseen.
Osassa 2 oli jo puhe npm:stä, eli Javascript-projektien hallintaan liittyvästä, alunperin 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 scriptinä
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-scriptin 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
Muutetaan sovellus web-palvelimeksi:
const http = require('http')
const app = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('Hello World')
});
const port = 3001
app.listen(port)
console.log(`Server running on port ${port}`)
Kun sovellus käynnistuu, konsoliin tulostuu
Server running on port 3001
Voimme avata selaimella osoitteessa http://localhost:3001 olevan vaatimattoman sovelluksemme:
Palvelin toimii itseasiassa täsmälleen 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:
➜ hello npm start
> hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/hello
> node index.js
Server running on port 3001
events.js:167
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE :::3001
at Server.setupListenHandle [as _listen2] (net.js:1330:14)
at listenInCluster (net.js:1378:12)
Sammuta 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, mihin olemme selainpuolen koodissa tottuneet hieman syntaksiltaan 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 kuitenkin käyttää ns. CommonJS-moduuleja. Syy tälle on siinä, että Node-ekosysteemillä oli tarve moduuleihin jo kauan ennen kuin Javascript tuki kielen tasolla moduuleja. Node ei toistaiseksi tue ES-moduuleja, mutta tuki on todennäköisesti jossain vaiheessa tulossa.
CommonJS-moduulit toimivat kohtuullisessa määrin 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-palvelimen 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 heti palvelinta siten, että se palauttaa kovakoodatun listallisen JSON-muotoisia muistiinpanoja:
const http = require('http')
let notes = [ { id: 1, content: 'HTML on helppoa', date: '2017-12-10T17:30:31.098Z', important: true, }, { id: 2, content: 'Selain pystyy suorittamaan vain javascriptiä', date: '2017-12-10T18:39:34.091Z', important: false, }, { id: 3, content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST', date: '2017-12-10T19:20:14.298Z', 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 sammutetaan painamalla ctrl ja c yhtä aikaa konsolissa) ja refreshataan selain.
Headerin Content-Type arvolla application/json kerrotaan, että kyse on JSON-muotoisesta datasta. Muuttujassa notes oleva taulukko muutetaan jsoniksi metodilla JSON.stringify(notes).
Kun avaamme selaimen, on tulostusasu sama kuin osassa 2 käytetyn json-serverin tarjoamalla muistiinpanojen listalla:
Voimme jo melkein ruveta käyttämään uutta backendiämme osan 2 muistiinpano-frontendin kanssa. Mutta vain melkein, sillä kun käynnistämme frontendin, tulee konsoliin virheilmoitus
Syy virheelle selviää pian, parantelemme kuitenkin ensin koodia muilta osin.
Palvelimen koodin tekeminen suoraan Noden sisäänrakennetun web-palvelimen http:n päälle on mahdollista, mutta 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 tarjoamia kirjastoja. Näistä ylivoimaisesti suosituin on express.
Otetaan express käyttöön määrittelemällä se projektimme riippuvuudeksi komennolla
npm install express --save
Riippuvuus tulee nyt määritellyksi tiedostoon package.json:
{
// ...
"dependencies": {
"express": "^4.16.4"
}
}
Riippuvuuden koodi asentuu kaikkien projektin riippuvuuksien tapaan projektin juuressa olevaan hakemistoon node_modules. Hakemistosta löytyy expressin lisäksi suuri määrä muutakin tavaraa
Kyseessä ovat expressin riippuvuudet ja niiden riippuvuudet ym... eli projektimme transitiiviset riippuvuudet.
Projektiin asentui expressin versio 4.16.4. Mitä tarkoittaa package.json:issa versiomerkinnän edessä oleva väkänen, eli miksi muoto on
"express": "^4.16.4"
npm:n yhteydessä käytetään ns. semanttista versiointia.
Merkintä ^4.16.4 tarkoittaa, että jos/kun projektin riippuvuudet päivitetään, asennetaan expressistä versio, joka on vähintään 4.16.4, 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
Vastaavasti 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.
Palataan taas sovelluksen ääreen ja muutetaan se muotoon:
const express = require('express')
const app = express()
let notes = [
...
]
app.get('/', (req, res) => {
res.send('<h1>Hello World!</h1>')
})
app.get('/notes', (req, res) => {
res.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 uudelleenkäynnistettävä.
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 oi varmistaa konsolin välilehdeltä Network
Routeista toinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen polkuun notes tulevia HTTP GET -pyyntöjä:
app.get('/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.
Pieni huomio JSON-muodossa palautettavasta datasta.
Aiemmassa, pelkkää Nodea käyttämä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:
Saat käynnistettyä interaktiivisen node-repl:in kirjoittamalla komentoriville node. Esim. joidenkin komentojen toimivuutta on koodatessa kätevä tarkastaa konsolissa, suosittelen!
Jos muutamme sovelluksen koodia, joudumme uudelleenkäynnistämään sovelluksen (eli ensin sammuttamaan konsolista ctrl ja c ja sitten käynnistämään uudelleen), jotta muutokset tulisivat voimaan. Verrattuna Reactin mukavaan workflowhun, missä selain päivittyi automaattisesti koodin muuttuessa tuntuu uudelleenkäynnistely kömpelöltä.
Ongelmaan ratkaisu on 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.16.4"
},
"devDependencies": {
"nodemon": "^1.18.9"
}
}
Jos nodemon-riippuvuus kuitenkin meni sovelluksessasi normaaliin "dependencies"-ryhmään, päivitä package.json manuaalisesti vastaamaan yllä näkyvää (versiot kuitenkin 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 kun sitä tullaan suorittamaan tuotantopalvelimella (esim. Herokussa, mihin tulemme kohta siirtämään sovelluksemme), ei kehitysaikaisia riippuvuuksia tarvita.
Voimme käynnistää ohjelman nodemonilla seuraavasti:
node_modules/.bin/nodemon index.js
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 (missä 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",
"watch": "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 watch
Toisin kuin skriptejä start tai test suoritettaessa, joudumme sanomaan myös run.
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äittämään mitä REST on tai mitä se ei ole vaan otamme hieman kapeamman näkökulman miten REST tai RESTful API:t yleensä tulkitaan Web-sovelluksissa. Alkuperäinen REST-periaate ei edes 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, mitä 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-apiksi. Maailman "REST"-apeista valtaosa ei täytäkään puhdasverisen Fieldingiläisen REST-apin määritelmää.
Joissain yhteyksissä (ks. esim Richardsom, 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.
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 notes/10, missä lopussa oleva numero vastaa resurssin muistiinpanon id:tä.
Voimme määritellä expressin routejen poluille parametreja käyttämällä kaksoispistesyntaksia:
app.get('/notes/:id', (request, response) => {
const id = request.params.id
const note = notes.find(note => note.id === id)
response.json(note)
})
Nyt app.get('/notes/:id', ...)
käsittelee kaikki HTTP GET -pyynnöt, jotka ovat muotoa note/JOTAIN, missä 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/notes/1, havaitaan että se ei toimi, 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('/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/notes/1 konsoliin, eli siihen terminaaliin, mihin sovellus on käynnistetty tulostuu
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, joka onnistuu helposti kun tiiviissä muodossa oleva funktio note => note.id === id kirjoitetaan eksplisiittisen returnin sisältävässä muodossa:
app.get('/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('/notes/:id', (request, response) => {
const id = Number(request.params.id)
const note = notes.find(note => note.id === id)
response.json(note)
})
ja nyt yksittäisen resurssin hakeminen toimii.
Toiminnallisuuteen jää kuitenkin pieni ongelma.
Jos haemme muistiinpanoa sellaisella indeksillä, mitä vastaavaa muistiinpanoa ei ole olemassa, vastaa palvelin seuraavasti
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 tulisi käsitellä palvelimella järkevämmin, eli statuskoodin 200 sijaan tulee vastata statuskoodilla 404 not found.
Tehdään koodiin muutos
app.get('/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 toimii, eli 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ä.
Toteutetaan seuraavaksi resurssin poistava route. Poisto tapahtuu tekemällä HTTP DELETE -pyyntö resurssin urliin:
app.delete('/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.
Herää kysymys miten voimme testata poisto-operaatiota? HTTP GET -pyyntöjä on helppo testata selaimessa. 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 edellisessä osassa nopeasti mainittu komentorivityökalu curl.
Käytetään nyt kuitenkin postman-nimistä sovellusta.
Asennetaan postman ja kokeillaan
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/notes selviää että poisto-operaatio oli onnistunut, muistiinpanoa, jonka id on 2 ei ole enää listalla.
Koska muistiinpanot on talletettu palvelimen muistiin, uudelleenkäynnistys palauttaa tilanteen ennalleen.
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
Klikkaamalla tekstiä Send Request, REST client suorittaa määritellyn HTTP-pyynnön ja palvelimen vastaus avautuu editoriin:
Toteutetaan seuraavana uusien muistiinpanojen lisäys, joka siis tapahtuu tekemällä HTTP POST -pyyntö osoitteeseen http://localhost:3001/notes ja liittämällä pyynnön mukaan eli bodyyn luotavan muistiinpanon tiedot JSON-muodossa.
Jotta pääsisimme pyynnön mukana lähetettyyn dataan helposti käsiksi, tarvitsemme body-parser-kirjaston apua.
Otetaan body-parser käyttöön ja luodaan alustava määrittely HTTP POST -pyynnön käsittelyyn
const express = require('express')
const app = express()
const bodyParser = require('body-parser')
app.use(bodyParser.json())
//...
app.post('/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 body-parser-käyttöönottoa pyynnön kentän body arvo olisi ollut määrittelemätön. body-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:
Sovellus tulostaa lähetetyn vastaanottamansa datan terminaaliin:
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 joku perustavanlaatuinen virhe:
Vastaavasti konsolista kannattaa seurata 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 potentiaalinen ongelmanlähde on se, että dataa lähettäessä, sen headerille Content-Type ei aseteta oikeaa arvoa. Näin tapahtuu esim. jos Postmanissa bodyn tyypiä ei määritellä oikein:
headerin Content-Type arvoksi asettuu text/plain
Palvelin näyttää vastaanottavan ainoastaan tyhjän olion
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 niin edellisessä luvussa esitelty REST client kannattaa asentaa viimeistään nyt. POST-pyyntö tehdään REST clientillä seuraavasti:
Eli pyyntöä varten on luotu oma tiedosto new_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 kaaoottiseksi 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 esim syntyä jos jätät vahingossa VS 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('/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 olemassaolevista id:istä suurin muuttujaan maxId. Uuden muistiinpanon id:ksi asetetaan sitten maxId + 1. Tämä tapa ei ole itseasiassa kovin hyvä, mutta emme nyt välitä siitä sillä tulemme pian korvaamaan tavan, jolla muistiinpanot talletetaan.
Tämän hetkisessä 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 vaaditaan. Kentille important ja date asetetaan oletusarvot. 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('/notes', (request, response) => {
const body = request.body
if (body.content === undefined) {
return response.status(400).json({
error: 'content missing'
})
}
const note = {
content: body.content,
important: body.important || false,
date: new Date(),
id: generateId(),
}
notes = notes.concat(note)
response.json(note)
})
Tunnisteena toimivan id-kentän arvon generointilogiikka on eriytetty funktioon generateId.
Jos vastaanotetulta datalta puuttuu kenttä content, vastataan statuskoodilla 400 bad request:
if (body.content === undefined) {
return response.status(400).json({
error: 'content missing'
})
}
Huomaa, että returnin kutsuminen on tärkeää, jos sitä ei tapahdu, jatkaa koodi suoritusta metodin loppuun asti ja virheellinen muistiinpano tallettuu!
Jos content-kentällä on arvo, luodaan muistiinpano syötteen perusteella. Kuten edellisessä osassa mainitsimme, aikaleimoja ei kannata luoda selaimen koodissa, sillä käyttäjän koneen kellon aikaan ei voi luottaa. Aikaleiman eli kentän date arvon generointi tapahtuukin nyt palvelimen toimesta.
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än hetkinen koodi on kokonaisuudessaan githubissa
Huomaa, että repositorion master-haarassa on myöhemmän vaiheen koodi, tämän hetken koodi on branchissa part3-1:
Jos kloonaat projektin itsellesi, suorita komento npm install ennen käynnistämistä eli komentoa npm start tai npm run watch.
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-kentisstä. 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.
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 siis tarkoittaa, 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 suoritetaanko pyyntö yhden tai useamman kerran.
Esim. jos tehdään HTTP PUT pyyntö osoitteeseen /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 5 kertaa HTTP POST -pyyntö osoitteeseen /notes siten että pyynnön mukana on { content: "monta samaa", important: true }, tulee palvelimelle 5 saman sisältöistä muistiinpanoa.
Äsken käyttöönottamamme body-parser on terminologiassa niin sanottu middleware.
Middlewaret ovat funktioita, joiden avulla voidaan käsitellä request- ja response-olioita.
Esim. body-parser 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ä kun 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ä bodyParser 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.