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 Node.js ja Expressb Sovellus internetiin
    d Validointi ja ESLint

    c

    Tietojen tallettaminen MongoDB-tietokantaan

    Ennen kuin siirrymmen osan varsinaiseen aiheeseen, eli tiedon tallettamiseen tietokantaan, tarkastellaan muutamaa tapaa Node-sovellusten debuggaamiseen.

    Node-sovellusten debuggaaminen

    Nodella tehtyjen sovellusten debuggaaminen on jossain määrin hankalampaa kuin selaimessa toimivan Javascriptin. Vanha hyvä keino on tietysti konsoliin tulostelu. Se kannattaa aina. On mielipiteitä, joiden mukaan konsoliin tulostelun sijaan olisi syytä suosia jotain kehittyneempää menetelmää, mutta en ole ollenkaan samaa mieltä. Jopa maailman aivan eliittiin kuuluvat open source -kehittäjät käyttävät tätä menetelmää.

    Visual Studio Code

    Visual Studio Coden debuggeri voi olla hyödyksi joissain tapauksissa. Saat käynnistettyä sovelluksen debuggaustilassa seuraavasti

    fullstack content

    Huomaa, että sovellus ei saa olla samalla käynnissä "normaalisti" konsolista, sillä tällöin sovelluksen käyttämä portti on varattu.

    Seuraavassa screenshot, missä koodi on pysäytetty kesken uuden muistiinpanon lisäyksen

    fullstack content

    Koodi on pysähtynyt nuolen osoittaman breakpointin kohdalle ja konsoliin on evaluoitu muuttujan note arvo. Vasemmalla olevassa ikkunassa on nähtävillä myös muuta ohjelman tilaan liittyvää.

    Ylhäällä olevista nuolista yms. voidaan kontrolloida debuggauksen etenemistä.

    Itse en jostain syystä juurikaan käytä Visual Studio Code debuggeria.

    Chromen dev tools

    Debuggaus onnisuu myös Chromen developer-konsolilla, käynnistämällä sovellus komennolla:

    node --inspect index.js

    Debuggeriin pääsee käsiksi klikkaamalla chromen devloper-konsoliin ilmestyneestä vihreästä ikonista

    fullstack content

    Debuggausnäkymä toimii kuten React-koodia debugattaessa, Sources-välilehdelle voidaan esim. asettaa breakpointeja, eli kohtia joihin suoritus pysähtyy:

    fullstack content

    Kaikki sovelluksen console.log-tulostukset tulevat debuggerin Console-välilehdelle. Voit myös tutkia siellä muuttujien arvoja ja suorittaa mielivaltaista Javascript-koodia:

    fullstack content

    Epäile kaikkea

    Full Stack -sovellusten debuggaaminen vaikuttaa alussa erittäin hankalalta. Kun kohta kuvaan tulee myös tietokanta ja frontend on yhdistetty backendiin, on potentiaalisia virhelähteitä todella paljon.

    Kun sovellus "ei toimi", onkin selvitettävä missä vika on. On erittäin yleistä, että vika on sellaisessa paikassa, mitä ei osaa ollenkaan epäillä, ja menee minuutti-, tunti- tai jopa päiväkausia ennen kuin oikea ongelmien lähde löytyy.

    Avainasemassa onkin systemaattisuus. Koska virhe voi olla melkein missä vaan, kaikkea pitää epäillä, ja tulee pyrkiä poissulkemaan ne osat tarkastelusta, missä virhe ei ainakaan ole. Konsoliin kirjoitus, Postman, debuggeri ja kokemus auttavat.

    Virheiden ilmaantuessa ylivoimaisesti huonoin strategia on jatkaa koodin kirjoittamista. Se on tae siitä, että koodissa on pian kymmenen ongelmaa lisää ja niiden syyn selvittäminen on entistäkin vaikeampaa. Toyota Production Systemin periaate Stop and fix toimii tässäkin yhteydessä paremmin kuin hyvin.

    MongoDB

    Jotta saisimme talletettua muistiinpanot pysyvästi, tarvitsemme tietokannan. Useimmilla laitoksen kursseilla on käytetty relaatiotietokantoja. Tällä kurssilla käytämme MongoDB:tä, joka on ns. dokumenttitietokanta.

    Dokumenttitietokannat poikkeavat jossain määrin relaatiotietokannoista niin datan organisointitapansa kuin kyselykielensäkin suhteen. Dokumenttitietokantojen ajatellaan kuuluvan sateenvarjotermin NoSQL alle. Lisää dokumenttitietokannoista ja NoSQL:stä Tietokantojen perusteiden viikon 7 materiaalista.

    Lue nyt Tietokantojen perusteiden dokumenttitietokantoja kuvaava osuus. Jatkossa oletetaan, että hallitset käsitteet dokumentti ja kokoelma (collection).

    MongoDB:n voi luonnollisesti asentaa omalle koneelle. Internetistä löytyy kuitenkin myös palveluna toimivia Mongoja, joista tämän hetken paras valinta on MongoDB Atlas.

    Jos ehdit jo konfiguroida itsellesi mlabin, löydät ohjeita sen käyttöön viime vuoden materiaalista.

    Kun käyttäjätili on luotu ja kirjauduttu, Atlas kehoittaa luomaan klusterin:

    fullstack content

    Valitaan AWS ja Frankfurt ja luodaan klusteri.

    fullstack content

    Odotetaan että klusteri on valmiina, tähän menee noin 10 minuuttia.

    HUOM älä jatka eteenpäin ennen kun klusteri on valmis!

    Luodaan security välilehdeltä tietokantakäyttäjätunnus joka on siis eri tunnus kuin se, jonka avulla kirjaudutaan MongoDB Atlasiin:

    fullstack content

    annetaan käyttäjälle luku- ja kirjoitustoikeus kaikkiin tietokantoihin

    fullstack content

    HUOM muutamissa tapauksissa uusi käyttäjä ei ole toiminut heti luomisen jälkeen. On saattanut kestää jopa useita minuutteja ennen kuin käyttäjätunnus on ruvennut toimimaan.

    Seuraavaksi tulee määritellä ne ip-osoitteet, joista tietokantaan pääsee käsiksi

    fullstack content

    Sallitaan yksinkertaisuuden vuoksi yhteydet kaikkialta:

    fullstack content

    Lopultakin ollaan valmiina ottamaan tietokantayhteyden. Valitaan Connect your application ja Short SRV connection string

    fullstack content

    Näkymä kertoo MongoDB URI:n eli osoitteen, jonka avulla sovelluksemme käyttämä MongoDB-kirjasto saa yhteyden kantaan.

    Osoite näyttää seuraavalta:

    mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/test?retryWrites=true

    Olemme nyt valmiina kannan käyttöön.

    Voisimme käyttää kantaa Javascript-koodista suoraan Mongon virallisen MongoDB Node.js driver -kirjaston avulla, mutta se on ikävän työlästä. Käytämmekin hieman korkeammalla tasolla toimivaa Mongoose-kirjastoa.

    Mongoosesta voisi käyttää luonnehdintaa object document mapper (ODM), ja sen avulla Javascript-olioiden tallettaminen mongon dokumenteiksi on suoraviivaista.

    Asennetaan Mongoose:

    npm install mongoose --save

    Ei lisätä mongoa käsittelevää koodia heti backendin koodin sekaan, vaan tehdään erillinen kokeilusovellus tiedostoon mongo.js:

    const mongoose = require('mongoose')
    
    if ( process.argv.length<3 ) {
      console.log('give password as argument')
      process.exit(1)
    }
    
    const password = process.argv[2]
    
    const url =
      `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true`
    
    mongoose.connect(url, { useNewUrlParser: true })
    
    const noteSchema = new mongoose.Schema({
      content: String,
      date: Date,
      important: Boolean,
    })
    
    const Note = mongoose.model('Note', noteSchema)
    
    const note = new Note({
      content: 'HTML on helppoa',
      date: new Date(),
      important: true,
    })
    
    note.save().then(response => {
      console.log('note saved!');
      mongoose.connection.close();
    })

    Koodi siis olettaa, että sille annetan parametrina MongoDB Atlasissa luodulle käyttäjälle määritelty salasana. Komentoriviparametriin se pääsee käsiksi seuraavasti

    const password = process.argv[2]

    Kun koodi suoritetaan komennolla node mongo.js salasana lisää Mongoose tietokantaan uuden dokumentin.

    Voimme tarkastella tietokannan tilaa MongoDB Atlasin hallintanäkymän collections-osasta

    fullstack content

    Kuten näkymä kertoo, on muistiinpanoa vastaava dokumentti lisätty tietokannan test kokoelmaan (collection) nimeltään notes.

    fullstack content

    Tietokanta lienee loogisempaa nimetä paremmin. Kuten dokumentaatio sanoo, kontrolloidaan kannan nimeä tietokanta-URI:in perusteella

    fullstack content

    eli tuhotaan kanta test. Päätetään käyttää tietokannasta nimeä note-app muutetaan siis tietokanta-URI muotoon

    mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/note-app?retryWrites=true

    Suoritetaan ohjelma uudelleen.

    fullstack content

    Data on nyt oikeassa kannassa. Hallintanäkymä sisältää myös toiminnon create database, joka mahdollistaa uusien tietokantojenluomisen hallintanäkymän kautta. Kannan luominen etukäteen hallintanäkymässä ei kuitenkaan ole tarpeen, sillä MongoDB Atlasosaa luoda kannan automaattisesti jos sovellus yrittää yhdistää kantaan, jota ei ole vielä olemassa.

    Skeema

    Yhteyden avaamisen jälkeen määritellään mustiinpanon skeema ja sitä vastaava model:

    const noteSchema = new mongoose.Schema({
      content: String,
      date: Date,
      important: Boolean,
    })
    
    const Note = mongoose.model('Note', noteSchema)

    Ensin muuttujaan noteSchema määritellään muistiinpanon skeema, joka kertoo Mongooselle, miten muistiinpano-oliot tulee tallettaa tietokantaan.

    Modelin Note määrittelyssä ensimmäisenä parametrina oleva merkkijono Note määrittelee, että mongoose tallettaa muistiinpanoa vastaavat oliot kokoelmaan nimeltään notes, sillä Mongoosen konventiona on määritellä kokoelmien nimet monikossa (esim. notes), kun niihin viitataan skeeman määrittelyssä yksikkömuodossa (esim. Note).

    Dokumenttikannat, kuten Mongo ovat skeemattomia, eli tietokanta itsessään ei välitä mitään sinne talletettavan tiedon muodosta. Samaan kokoelmaankin on mahdollista tallettaa olioita joilla on täysin eri kentät.

    Mongoosea käytettäessä periaatteena on kuitenkin se, että tietokantaan talletettavalle tiedolle määritellään sovelluksen koodin tasolla skeema, joka määrittelee minkä muotoisia olioita kannan eri kokoelmiin talletetaan.

    Olioiden luominen ja tallettaminen

    Seuraavaksi sovellus luo muistiinpanoa vastaavan model:in avulla muistiinpano-olion:

    const note = new Note({
      content: 'Selain pystyy suorittamaan vain javascriptiä',
      date: new Date(),
      important: false,
    })

    Modelit ovat ns. konstruktorifunktioita, jotka luovat parametrien perusteella Javascript-olioita. Koska oliot on luotu modelien konstruktorifunktiolla, niillä on kaikki modelien ominaisuudet, eli joukko metodeja, joiden avulla olioita voidaan mm. tallettaa tietokantaan.

    Tallettaminen tapahtuu metodilla save. Metodi palauttaa promisen, jolle voidaan rekisteröidä then-metodin avulla tapahtumankäsittelijä:

    note.save().then(result => {
      console.log('note saved!')
      mongoose.connection.close()
    })

    Kun olio on tallennettu kantaan, kutsutaan then:in parametrina olevaa tapahtumankäsittelijää, joka sulkee tietokantayhteyden komennolla mongoose.connection.close(). Ilman yhteyden sulkemista ohjelman suoritus ei pääty.

    Tallennusoperaation tulos on takaisinkutsun parametrissa result. Yhtä olioa tallentaessamme tulos ei ole kovin mielenkiintoinen, olion sisällön voi esim. tulostaa konsoliin jos haluaa tutkia sitä tarkemmin sovelluslogiikassa tai esim. debugatessa.

    Talletetaan kantaan myös pari muuta muistiinpanoa muokkaamalla dataa koodista ja suorittamalla ohjelma uudelleen.

    HUOM valitettavasti Mongoosen dokumentaatiossa käytetään joka paikassa takaisinkutsufunktioita, joten sieltä ei kannata suoraan copypasteta koodia, sillä promisejen ja vanhanaikaisten callbackien sotkeminen samaan koodiin ei ole kovin järkevää.

    Olioiden hakeminen tietokannasta

    Kommentoidaan koodista uusia muistiinpanoja generoiva osa, ja korvataan se seuraavalla:

    Note.find({}).then(result => {
      result.forEach(note => {
        console.log(note)
      })
      mongoose.connection.close()
    })

    Kun koodi suoritetaan, kantaan talletetut muistiinpanot tulostuvat.

    Oliot haetaan kannasta Note-modelin metodilla find. Metodin parametrina on hakuehto. Koska hakuehtona on tyhjä olio {}, saimme kannasta kaikki notes-kokoelmaan talletetut oliot.

    Hakuehdot noudattavat mongon syntaksia.

    Voisimme hakea esim. ainoastaan tärkeät muistiinpanot seuraavasti:

    Note.find({ important: true }).then(result => {
      // ...
    })

    Tietokantaa käyttävä backend

    Nyt meillä on periaatteessa hallussamme riittävä tietämys ottaa mongo käyttöön sovelluksessamme.

    Aloitetaan nopean kaavan mukaan, copypastetaan tiedostoon index.js Mongoosen määrittelyt, eli

    const mongoose = require('mongoose')
    
    // ÄLÄ KOSKAAN TALLETA SALASANOJA githubiin!
    const url =
      'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true'
    
    mongoose.connect(url, { useNewUrlParser: true })
    
    const noteSchema = new mongoose.Schema({
      content: String,
      date: Date,
      important: Boolean,
    })
    
    const Note = mongoose.model('Note', noteSchema)

    ja muutetaan kaikkien muistiinpanojen hakemisesta vastaava käsittelijä seuraavaan muotoon

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

    Voimme todeta selaimella, että backend toimii kaikkien dokumenttien näyttämisen osalta:

    fullstack content

    Toiminnallisuus on muuten kunnossa, mutta frontend olettaa, että olioiden yksikäsitteinen tunniste on kentässä id. Emme myöskään halua näyttää frontendille mongon versiointiin käyttämää kenttää __v.

    Eräs tapa muotoilla Mongoosen palauttamat oliot haluttuun muotoon on muokata kannasta haettavilla olioilla olevan toJSON-metodin palauttamaa muotoa. Metodin muokkaus onnistuu seuraavasti:

    noteSchema.set('toJSON', {
      transform: (document, returnedObject) => {
        returnedObject.id = returnedObject._id.toString()
        delete returnedObject._id
        delete returnedObject.__v
      }
    })

    Vaikka Mongoose-olioiden kenttä _id näyttääkin merkkijonolta, se on todellisuudessa olio. Määrittelemämme metodi toJSON muuttaa sen merkkijonoksi kaiken varalta. Jos emme tekisi muutosta, siitä aiheutuisi ylimääräistä harmia testien yhteydessä.

    Palautetaan HTTP-pyynnön vastauksena toJSON-metodin avulla muotoiltuja oliota:

    app.get('/api/notes', (request, response) => {
      Note.find({}).then(notes => {
        response.json(notes.map(note => note.toJSON()))
      });
    });

    Nyt siis muuttujassa notes on taulukollinen mongon palauttamia olioita. Kun suoritamme operaation notes.map(note => note.toJSON()) seurauksena on uusi taulukko, missä on jokaista alkuperäisen taulukon alkiota vastaava metodin toJSON avulla muodostettu alkio.

    Tietokantamäärittelyjen eriyttäminen moduuliksi

    Ennen kuin täydennämme backendin muutkin osat käyttämään tietokantaa, eriytetään Mongoose-spesifinen koodi omaan moduuliin.

    Tehdään moduulia varten hakemisto models ja sinne tiedosto note.js:

    const mongoose = require('mongoose')
    
    const url = process.env.MONGODB_URI
    console.log('commecting to', url)
    mongoose.connect(url, { useNewUrlParser: true })
      .then(result => {    console.log('connected to MongoDB')  })  .catch((error) => {    console.log('error connection to MongoDB:', error.message)  })
    const noteSchema = new mongoose.Schema({
      content: String,
      date: Date,
      important: Boolean,
    })
    
    noteSchema.set('toJSON', {
      transform: (document, returnedObject) => {
        returnedObject.id = returnedObject._id
        delete returnedObject._id
        delete returnedObject.__v
      }
    })
    
    module.exports = mongoose.model('Note', noteSchema)

    Noden moduulien määrittely poikkeaa hiukan osassa 2 määrittelemistämme frontendin käyttämistä ES6-moduuleista.

    Moduulin ulos näkyvä osa määritellään asettamalla arvo muuttujalle module.exports. Asetamme arvoksi modelin Note. Muut moduulin sisällä määritellyt asiat, esim. muuttujat mongoose ja url eivät näy moduulin käyttäjälle.

    Moduulin käyttöönotto tapahtuu lisäämällä tiedostoon index.js seuraava rivi

    const Note = require('./models/note')

    Näin muuttuja Note saa arvokseen saman olion, jonka moduuli määrittelee.

    Yhteyden muodostustavassa on pieni muutos aiempaan:

    const url = process.env.MONGODB_URI
    
    console.log('commecting to', url)
    
    mongoose.connect(url, { useNewUrlParser: true })
      .then(result => {
        console.log('connected to MongoDB')
      })
      .catch((error) => {
        console.log('error connection to MongoDB:', error.message)
      })

    Tietokannan osoitetta ei kannata kirjoittaa koodiin, joten osoite annetaan sovellukselle ympäristömuuttujan MONGODB_URI välityksellä.

    Yhteyden muodostavalle metodille on nyt rekisteröity onnistuneen ja epäonnistuneen yhteydenmuodostuksen käsittelevät funktiot, jotka tulostavat konsoliin tiedon siitä, onnistuuko yhteyden muodostaminen:

    fullstack content

    On useita tapoja määritellä ympäristömuuttujan arvo, voimme esim. antaa sen ohjelman käynnistyksen yhteydessä seuraavasti

    MONGODB_URI=osoite_tahan npm run watch

    Eräs kehittyneempi tapa on käyttää dotenv-kirjastoa. Asennetaan kirjasto komennolla

    npm install dotenv --save

    Sovelluksen juurihakemistoon tehdään sitten tiedosto nimeltään .env, minne tarvittavien ympäristömuuttujien arvot määritellään. Tiedosto näyttää seuraavalta

    MONGODB_URI=mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true
    PORT=3001

    Määrittelimme samalla aiemmin kovakoodaamamme sovelluksen käyttämän portin eli ympäristömuuttujan PORT.

    Tiedosto .env tulee heti gitignorata sillä emme halua julkaista tiedoston sisältöä verkkoon!

    dotenvissä määritellyt ympäristömuuttujat otetaan koodissa käyttöön komennolla require('dotenv').config() ja niihin viitataan Nodessa kuten "normaaleihin" ympäristömuuttujiin syntaksilla process.env.MONGODB_URI.

    Muutetaan nyt tiedostoa index.js seuraavasti

    require('dotenv').config()const express = require('express')
    const bodyParser = require('body-parser') 
    const app = express()
    const Note = require('./models/note')
    // ..
    
    const PORT = process.env.PORTapp.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`)
    })

    On tärkeää, että dotenv otetaan käyttöön ennen modelin note importtaamista, tällöin varmistutaan siitä, että tiedostossa .env olevat ympäristömuuttujat ovat alustettuja kun moduulin koodia importoidaan.

    Muut operaatiot

    Muutetaan nyt kaikki operaatiot tietokantaa käyttävään muotoon.

    Uuden muistiinpanon luominen tapahtuu seuraavasti:

    app.post('/api/notes', (request, response) => {
      const body = request.body
    
      if (body.content === undefined) {
        return response.status(400).json({ error: 'content missing' })
      }
    
      const note = new Note({
        content: body.content,
        important: body.important || false,
        date: new Date(),
      })
    
      note.save().then(savedNote => {
        response.json(savedNote.toJSON())
      })
    })

    Muistiinpano-oliot siis luodaan Note-konstruktorifunktiolla. Pyyntöön vastataan save-operaation takaisinkutsufunktion sisällä. Näin varmistutaan, että operaation vastaus tapahtuu vain jos operaatio on onnistunut. Palaamme virheiden käsittelyyn myöhemmin.

    Takaisinkutsufunktion parametrina oleva savedNote on talletettu muistiinpano. HTTP-pyyntöön palautetaan kuitenkin siitä metodilla toJSONformatoitu muoto:

    response.json(savedNote.toJSON())

    Yksittäisen muistiinpanon tarkastelu muuttuu muotoon

    app.get('/api/notes/:id', (request, response) => {
      Note.findById(request.params.id).then(note => {
        response.json(note.toJSON())
      })
    })

    Frontendin ja backendin yhteistoiminnallisuuden varmistaminen

    Kun backendia laajennetaan, kannattaa sitä testailla aluksi ehdottomasti selaimella, postmanilla tai VS Coden REST clientillä. Seuraavassa kokeillaan uuden muistiinpanon luomista tietokannan käyttöönoton jälkeen:

    fullstack content

    Vasta kun kaikki on todettu toimivaksi, kannattaa siirtyä testailemaan että muutosten jälkeinen backend toimii yhdessä myös frontendin kanssa. Kaikkien kokeilujen tekeminen ainoastaan frontendin kautta on todennäköisesti varsin tehotonta.

    Todennäköisesti voi olla kannattavaa edetä frontin ja backin integroinnissa toiminnallisuus kerrallaan, eli ensin voidaan toteuttaa esim. kaikkien muistiinpanojen näyttäminen backendiin ja testata että toiminnallisuus toimii selaimella. Tämän jälkeen varmistetaan, että frontend toimii yhteen muutetun backendin kanssa. Kun kaikki on todettu olevan kunnossa, siirrytään seuraavan ominaisuuden toteuttamiseen.

    Kun kuvioissa on mukana tietokanta, on tietokannan tilan tarkastelu MongoDB Atlasin hallintanäkymästä varsin hyödyllistä, usein myös suoraan tietokantaa käyttävät Node-apuohjelmat, kuten tiedostoon mongo.js kirjoittamamme koodi auttavat sovelluskehityksen edetessä.

    Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, branchissa part3-3.

    Virheiden käsittely

    Jos yritämme mennä selaimella sellaisen yksittäisen muistiinpanon sivulle, jota ei ole olemassa, eli esim. urliin http://localhost:3001/api/notes/5c41c90e84d891c15dfa3431 missä 5a3b80015b6ec6f1bdf68d ei ole minkään tietokannassa olevan muistiinpanon tunniste, jää selain "jumiin" sillä palvelin ei vastaa pyyntöön koskaan.

    Palvelimen konsolissa näkyykin virheilmoitus:

    fullstack content

    Kysely on epäonnistunut ja kyselyä vastaava promise mennyt tilaan rejected. Koska emme käsittele promisen epäonnistumista, ei pyyntöön vastata koskaan. Osassa 2 tutustuimme jo promisejen virhetilanteiden käsittelyyn.

    Lisätään tilanteeseen yksinkertainen virheidenkäsittelijä:

    app.get('/api/notes/:id', (request, response) => {
      Note.findById(request.params.id)
        .then(note => {
          response.json(note.toJSON())
        })
        .catch(error => {
          console.log(error);
          response.status(404).end()
        })
    })

    Kaikissa virheeseen päättyvissä tilanteissa HTTP-pyyntöön vastataan statuskoodilla 404 not found. Konsoliin tulostetaan tarkempi tieto virheestä.

    Tapauksessamme on itseasiassa olemassa kaksi erityyppistä virhetilannetta. Toinen vastaa sitä, että yritetään hakea muistiinpanoa virheellisen muotoisella id:llä, eli sellasiella mikä ei vastaa mongon id:iden muotoa.

    Jos teemme näin tulostuu konsoliin:

    
    Method: GET
    Path:   /api/notes/5a3b7c3c31d61cb9f8a0343
    Body:   {}
    ---
    { CastError: Cast to ObjectId failed for value "5a3b7c3c31d61cb9f8a0343" at path "_id"
        at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
        at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
        ...
    

    Toinen virhetilanne taas vastaa tilannetta, missä haettavan muistiinpanon id on periaatteessa oikeassa formaatissa, mutta tietokannasta ei löydy indeksillä mitään:

    
    Method: GET
    Path:   /api/notes/5a3b7c3c31d61cbd9f8a0343
    Body:   {}
    ---
    TypeError: Cannot read property 'toJSON' of null
        at Note.findById.then.note (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/index.js:27:24)
        at process._tickCallback (internal/process/next_tick.js:178:7)
    

    Nämä tilanteet on syytä erottaa toisistaan, ja itseasiassa jälkimmäinen poikkeus on oman koodimme aiheuttama.

    Muutetaan koodia seuraavasti:

    app.get('/api/notes/:id', (request, response) => {
      Note.findById(request.params.id)
        .then(note => {
          if (note) {        response.json(note.toJSON())      } else {        response.status(404).end()       }    })
        .catch(error => {
          console.log(error)
          response.status(400).send({ error: 'malformatted id' })    })
    })

    Jos kannasta ei löydy haettua olioa, muuttujan note arvo on undefined ja koodi ajautuu else-haaraan. Siellä vastataan kyselyyn 404 not found_

    Jos id ei ole hyväksyttävässä muodossa, ajaudutaan catch:in avulla määriteltyyn virheidenkäsittelijään. Sopiva statuskoodi on 400 bad request koska kyse on juuri siitä:

    The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications.

    Vastaukseen on lisätty myös hieman dataa kertomaan virheen syystä.

    Promisejen yhteydessä kannattaa melkeinpä aina lisätä koodiin myös virhetilainteiden käsittely, muuten seurauksena on usein hämmentäviä vikoja.

    Ei ole koskaan huono idea tulostaa poikkeuksen aiheuttanutta olioa konsoliin virheenkäsittelijässä:

    .catch(error => {
      console.log(error)
      response.status(400).send({ error: 'malformatted id' })
    })

    Virheenkäsittelijään joutumisen syy voi olla joku ihan muu kuin mitä on tullut alunperin ajatelleeksi. Jos virheen tulostaa konsoliin, voi säästyä pitkiltä ja turhauttavilta väärää asiaa debuggaavilta sessioilta.

    Aina kun ohjelmoit ja projektissa on mukana backend tulee ehdottomasti koko ajan pitää silmällä backendin konsolin tulostuksia. Jos työskentelet pienellä näytöllä, riittää että konsolista on näkyvissä edes pieni kaistale:

    fullstack content

    Virheidenkäsittelyn keskittäminen middlewareen

    Olemme kirjoittaneet poikkeuksen aiheuttavan virhetilanteen käsittelevän koodin muun koodin sekaan. Se on välillä ihan toimiva ratkaisu, mutta on myös tilanteita, joissa on järkevämpää keskittää virheiden käsittely yhteen paikkaan. Tästä on huomattava etu esim. jos virhetilanteiden yhteydessä virheen aiheuttaneen pyynnön tiedot logataan tai lähetetään johonkin virhediagnostiikkajärjestelmään, esim. Sentryyn.

    Muutetaan routen /api/notes/:id käsittelijää siten, että se siirtää virhetilanteen käsittelyn eteenpäin funktiolla next jonka se saa kolmantena parametrina:

    app.get('/api/notes/:id', (request, response, next) => {
      Note.findById(request.params.id)
        .then(note => {
          if (note) {
            response.json(note.toJSON())
          } else {
            response.status(204).end()
          }
        })
        .catch(error => next(error))
    })

    Eteenpäin siirrettävä virhe annetaan funktiolle next parametrina. Jos funktiota next kutsuttaisiin ilman parametria, käsittely siirtyisi ainoastaan eteenpäin seuraavaksi määritellylle routelle tai middlewarelle. Jos funktion next kutsussa annetaan parametri, siirtyy käsittely virheidenkäsittelymiddlewarelle.

    Expressin virheidenkäsittelijät ovat middlewareja, joiden määrittelevällä funktiolla on neljä parametria. Virheidenkäsittelijämme näyttää seuraavalta:

    const errorHandler = (error, request, response, next) => {
      console.error(error.message)
    
      if (error.name === 'CastError' && error.kind == 'ObjectId') {
        return response.status(400).send({ error: 'malformatted id' })
      } 
    
      next(error)
    }
    
    app.use(errorHandler)

    Virhekäsittelijä tarkastaa onko kyse CastError-poikkeuksesta, eli virheellisestä olioid:stä, jos on, se lähettä pyynnön tehneelle selaimelle vastauksen käsittelijän parametrina olevan response-olion avulla. Muussa tapauksessa se siirtää funktiolla next virheen käsittelyn Expressin oletusarvoisen virheidenkäsittelijän hoidettavavksi.

    Middlewarejen käyttöönottojärjestys

    Koska middlewaret suoritetaan siinä järjestyksessä, missä ne on otettu käyttöön funktiolla app.use on niiden määrittelyn kanssa oltava tarkkana.

    Oikeaoppinen järjestys seuraavassa:

    app.use(express.static('build'))
    app.use(bodyParser.json())
    app.use(logger)
    
    app.post('/api/notes', (request, response) => {
      const body = request.body
      // ...
    })
    
    const unknownEndpoint = (request, response) => {
      response.status(404).send({ error: 'unknown endpoint' })
    }
    
    // olemattomien osoitteiden käsittely
    app.use(unknownEndpoint)
    
    const errorHandler = (error, request, response, next) => {
      // ...
    }
    
    // virheellisten pyyntöjen käsittely
    app.use(errorHandler)

    bodyParser on syytä ottaa käyttöön melkeimpä ensimmäisenä. Jos järjestys olisi seuraava

    app.use(logger) // request.body on tyhjä
    
    app.post('/api/notes', (request, response) => {
      // request.body on tyhjä
      const body = request.body
      // ...
    })
    
    app.use(bodyParser.json())

    ei HTTP-pyynnön mukana oleva data olisi loggerin eikä POST-pyynnön käsittelyn aikana käytettävissä, kentässä request.body olisi tyhjä olio.

    Tärkeää on myös ottaa käyttöön olemattomien osoitteiden käsittely viimeisenä.

    Myös seuraava järjestys aiheuttaisi ongelman

    const unknownEndpoint = (request, response) => {
      response.status(404).send({ error: 'unknown endpoint' })
    }
    
    // olemattomien osoitteiden käsittely
    app.use(unknownEndpoint)
    
    app.get('/api/notes', (request, response) => {
      // ...
    })

    Nyt olemattomien osoitteiden käsittely on sijoitettu ennen HTTP GET -pyynnön käsittelyä. Koska olemattomien osoitteiden käsittelijä vastaa kaikkiin pyyntöihin 404 unknown endpoint, ei mihinkään sen jälkeen määriteltyyn reittiin tai middlewareen (poikkeuksena virheenkäsittelijä) enää mennä.

    Muut operaatiot

    Toteutetaan vielä jäljellä olevat operaatiot, eli yksittäisen muistiinpanon poisto ja muokkaus.

    Poisto onnistuu helpoiten metodilla findByIdAndRemove:

    app.delete('/api/notes/:id', (request, response, next) => {
      Note.findByIdAndRemove(request.params.id)
        .then(result => {
          response.status(204).end()
        })
        .catch(error => next(error))
    })

    Vastauksena on statauskoodi 204 no content molemmissa "onnistuneissa" tapauksissa, eli jos olio poistettiin tai olioa ei ollut mutta id oli periaatteessa oikea. Takaisinkutsun parametrin result perusteella olisi mahdollisuus haarautua ja palauttaa tilanteissa eri statuskoodi jos sille on tarvetta. Mahdollinen poikkeus siirretään jälleen virheenkäsittelijälle.

    Muistiinpanon tärkeyden muuttamisen mahdollistava olemassaolevan muistiinpanon päivitys onnistuu helposti metodilla findByIdAndUpdate.

    app.put('/api/notes/:id', (request, response, next) => {
      const body = request.body
    
      const note = {
        content: body.content,
        important: body.important,
      }
    
      Note.findByIdAndUpdate(request.params.id, note, { new: true })
        .then(updatedNote => {
          response.json(updatedNote.toJSON())
        })
        .catch(error => next(error))
    })

    Operaatio mahdollistaa myös muistiinpanon sisällön editoinnin. Päivämäärän muuttaminen ei ole mahdollista.

    Huomaa, että metodin findByIdAndUpdate parametrina tulee antaa normaali Javascript-olio, eikä uuden olion luomisessa käytettävä Note-konstruktorifunktiolla luotu olio.

    Pieni, mutta tärkeä detalji liittyen operaatioon findByIdAndUpdate. Oletusarvoisesti tapahtumankäsittelijä saa parametrikseen updatedNote päivitetyn olion ennen muutosta olleen tilan. Lisäsimme operaatioon parametrin { new: true } jotta saamme muuttuneen olion palautetuksi kutsujalle.

    Backend vaikuttaa toimivan postmanista ja VS Code REST clientistä tehtyjen kokeilujen perusteella ja myös frontend toimii moitteettomasti tietokantaa käyttävän backendin kanssa.

    Kun muutamme muistiinpanon tärkeyttä, tulostuu backendin konsoliin ikävä varoitus

    fullstack content

    Googlaamalla virheilmoitusta löytyy ohje ongelman korjaamiseen. Eli kuten mongoosen dokumentaatio kehottaa lisätään tiedostoon note.js yksi rivi:

    const mongoose = require('mongoose')
    
    mongoose.set('useFindAndModify', false)
    // ...
      
    module.exports = mongoose.model('Note', noteSchema) 

    Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, branchissa part3-4.