Siirry sisältöön

c

Tietokanta ja käyttäjien hallinta

Laajennetaan sovellusta käyttäjänhallinnalla. Siirrytään kuitenkin ensin käyttämään tietokantaa datan tallettamiseen.

Mongoose ja Apollo

Otetaan käyttöön Mongoose ja asennetaan samalla dotenv:

npm install mongoose dotenv

Tehdään osien 3 ja 4 tapaa imitoiden.

Henkilön skeema on määritelty seuraavasti:

const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    minlength: 5
  },
  phone: {
    type: String,
    minlength: 5
  },
  street: {
    type: String,
    required: true,
    minlength: 5
  },
  city: {
    type: String,
    required: true,
    minlength: 3
  },
})

module.exports = mongoose.model('Person', schema)

Mukana on myös muutama validointi. Arvon olemassaolon takaava required: true on sikäli turha, koska GraphQL:n käyttö takaa sen, että kentät ovat olemassa. Validointi on kuitenkin hyvä pitää myös tietokannan puolella.

Saamme sovelluksen jo suurilta osin toimimaan seuraavilla muutoksilla:

// ...

const mongoose = require('mongoose')
mongoose.set('strictQuery', false)
const Person = require('./models/person')

require('dotenv').config()

const MONGODB_URI = process.env.MONGODB_URI

console.log('connecting to', MONGODB_URI)

mongoose.connect(MONGODB_URI)
  .then(() => {
    console.log('connected to MongoDB')
  })
  .catch((error) => {
    console.log('error connection to MongoDB:', error.message)
  })

const typeDefs = `
  ...
`

const resolvers = {
  Query: {
    personCount: async () => Person.collection.countDocuments(),
    allPersons: async (root, args) => {
      // filters missing
      return Person.find({})
    },
    findPerson: async (root, args) => Person.findOne({ name: args.name }),
  },
  Person: {
    address: (root) => {
      return {
        street: root.street,
        city: root.city,
      }
    },
  },
  Mutation: {
    addPerson: async (root, args) => {
      const person = new Person({ ...args })
      return person.save()
    },
    editNumber: async (root, args) => {
      const person = await Person.findOne({ name: args.name })
      person.phone = args.phone
      return person.save()
    },
  },
}

Muutokset ovat melko suoraviivaisia. Huomio kiinnittyy pariin seikkaan. Kuten muistamme, Mongossa olioiden identifioiva kenttä on nimeltään _id ja jouduimme aiemmin muuttamaan itse kentän nimen alaviivattomaan muotoon id. GraphQL osaa tehdä tämän muutoksen automaattisesti.

Toinen huomionarvoinen seikka on se, että resolverifunktiot palauttavat nyt promisen, aiemminhan ne palauttivat aina normaaleja oliota. Kun resolveri palauttaa promisen, Apollo server osaa lähettää vastaukseksi sen arvon mihin promise resolvoituu.

Eli esimerkiksi jos seuraava resolverifunktio suoritetaan,

allPersons: async (root, args) => {
  return Person.find({})
}

odottaa Apollo server promisen valmistumista ja lähettää promisen vastauksen kyselyn tekijälle. Apollo toimii siis suunnilleen seuraavasti:

allPersons: async (root, args) => {
  const result = await Person.find({})
  return result
}

Täydennetään vielä resolveri allPersons ottamaan huomioon optionaalinen filtterinä toimiva parametri phone:

Query: {
  // ..
  allPersons: async (root, args) => {
    if (!args.phone) {
      return Person.find({})
    }

    return Person.find({ phone: { $exists: args.phone === 'YES'  }})
  },
},

Eli jos kyselylle ei ole annettu parametria phone, palautetaan kaikki henkilöt. Jos parametrilla on arvo YES, palautetaan kyselyn

Person.find({ phone: { $exists: true }})

palauttamat henkilöt, eli ne joiden kentällä phone on jokin arvo. Jos parametrin arvo on NO, palauttaa kysely ne henkilöt, joilla ei ole arvoa kentällä phone:

Person.find({ phone: { $exists: false }})

Validoinnit

GraphQL:n lisäksi syötteet validoidaan nyt Mongoose-skeemassa määriteltyjä validointeja käyttäen. Skeemassa olevien validointivirheiden varalta save-metodeille täytyy lisätä virheen käsittelevä try/catch-lohko. Heitetään catchiin jouduttaessa vastaukseksi virhekoodilla BAD_USER_INPUT varustetu poikkeus GraphQLError:

Mutation: {
  addPerson: async (root, args) => {
      const person = new Person({ ...args })

      try {        await person.save()      } catch (error) {        throw new GraphQLError('Saving person failed', {          extensions: {            code: 'BAD_USER_INPUT',            invalidArgs: args.name,            error          }        })      }
      return person
  },
    editNumber: async (root, args) => {
      const person = await Person.findOne({ name: args.name })
      person.phone = args.phone

      try {        await person.save()      } catch (error) {        throw new GraphQLError('Saving number failed', {          extensions: {            code: 'BAD_USER_INPUT',            invalidArgs: args.name,            error          }        })      }
      return person
    }
}

Mongoosen virheen tiedot ja ongelman aiheuttanut data on nyt liitetty poikkeuksen konfiguraatio-olioon extensions, näin ne saadaan välitettyä kutsujalle.

Backendin koodi on kokonaisuudessaan GitHubissa, branchissa part8-4.

Käyttäjä ja kirjautuminen

Lisätään järjestelmään käyttäjänhallinta. Oletetaan nyt yksinkertaisuuden takia, että kaikkien käyttäjien salasana on sama järjestelmään kovakoodattu merkkijono. Osan 4 periaatteilla on toki suoraviivaista tallettaa käyttäjille yksilöllinen salasana, mutta koska fokuksemme on GraphQL:ssä, jätämme salasanaan liittyvät rönsyt tällä kertaa pois.

Käyttäjän skeema seuraavassa:

const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  username: {
    type: String,
    required: true,
    minlength: 3
  },
  friends: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Person'
    }
  ],
})

module.exports = mongoose.model('User', schema)

Käyttäjään siis liittyy kentän friends kautta joukko luettelossa olevia henkilöitä. Ideana on se, että kun käyttäjä, esim. mluukkai lisää henkilön, vaikkapa Arto Hellas luetteloon, liitetään henkilö käyttäjän friends-listaan. Näin kirjautuneilla henkilöillä on mahdollista saada sovellukseen oma personoitu näkymänsä.

Kirjautuminen ja käyttäjän tunnistautuminen hoidetaan samoin kuten teimme osassa 4 REST:in yhteydessä, eli käyttämällä tokeneita.

Laajennetaan skeemaa seuraavasti:

type User {
  username: String!
  friends: [Person!]!
  id: ID!
}

type Token {
  value: String!
}

type Query {
  // ..
  me: User
}

type Mutation {
  // ...
  createUser(
    username: String!
  ): User
  login(
    username: String!
    password: String!
  ): Token
}

Kysely me palauttaa kirjautuneena olevan käyttäjän. Käyttäjät luodaan mutaatiolla createUser ja kirjautuminen tapahtuu mutaatiolla login.

Asennetaan jsonwebtoken-kirjasto:

npm install jsonwebtoken

Mutaatioiden resolverit seuraavassa:

const jwt = require('jsonwebtoken')

Mutation: {
  // ..
  createUser: async (root, args) => {
    const user = new User({ username: args.username })

    return user.save()
      .catch(error => {
        throw new GraphQLError('Creating the user failed', {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.username,
            error
          }
        })
      })
  },
  login: async (root, args) => {
    const user = await User.findOne({ username: args.username })

    if ( !user || args.password !== 'secret' ) {
      throw new GraphQLError('wrong credentials', {
        extensions: {
          code: 'BAD_USER_INPUT'
        }
      })        
    }

    const userForToken = {
      username: user.username,
      id: user._id,
    }

    return { value: jwt.sign(userForToken, process.env.JWT_SECRET) }
  },
},

Käyttäjän luova mutaatio on suoraviivainen. Kirjautumisesta vastaava mutaatio tarkastaa onko käyttäjätunnus/salasana-pari validi ja jos on, palautetaan osasta 4 tuttu jwt-token. Jotta koodi toimisi, täytyy ympäristömuuttujalle JWT_SECRET muistaa antaa arvo .env-tiedostossa.

Käyttäjän luonti onnistuu nyt seuraavasti:

mutation {
  createUser (
    username: "mluukkai"
  ) {
    username
    id
  }
}

Kirjautumisen hoitaa seuraava mutaatio:

mutation {
  login (
    username: "mluukkai"
    password: "secret"
  ) {
    value
  }
}

Aivan kuten REST:in tapauksessa myös nyt ideana on, että kirjautunut käyttäjä liittää kirjautumisen yhteydessä saamansa tokenin kaikkiin pyyntöihinsä. REST:in tapaan token liitetään GraphQL-pyyntöihin headerin Authorization avulla. Apollo Explorerissa headerin liittäminen pyyntöön tapahtuu seuraavasti

fullstack content

Muutetaan backendin käynnistämistä siten, että annetaan käynnistyksestä huolehtivalle funktiolle startStandaloneServer toinen parametri context:

startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req, res }) => {    const auth = req ? req.headers.authorization : null    if (auth && auth.startsWith('Bearer ')) {      const decodedToken = jwt.verify(        auth.substring(7), process.env.JWT_SECRET      )      const currentUser = await User        .findById(decodedToken.id).populate('friends')      return { currentUser }    }  },}).then(({ url }) => {
  console.log(`Server ready at ${url}`)
})

Kontekstin avulla voidaan suorittaa jotain kaikille kyselyille ja mutaatioille yhteisiä asioita, esim. pyyntöön liittyvän käyttäjän tunnistaminen.

Contextin palauttama olio annetaan kaikille resolvereille kolmantena parametrina.

Määrittelemämme koodi siis asettaa kontekstin kenttään currentUser pyynnön tehnyttä käyttäjää vastaavan olion. Jos pyyntöön ei liity käyttäjää, on kentän arvo määrittelemätön.

Kyselyn me resolveri on erittäin yksinkertainen, se ainoastaan palauttaa kirjautuneen käyttäjän, jonka se saa resolverin kolmantena olevan parametrin context kentästä currentUser. Kannattaa huomata, että jos käyttäjä ei ole kirjautunut, ts. pyynnön headerina ei tule validia tokenia, vastaa kysely null:

Query: {
  // ...
  me: (root, args, context) => {
    return context.currentUser
  }
},

Jos headerissa on oikea arvo, palauttaa kysely headerin yksilöimän käyttäjän tiedot

fullstack content

Tuttavalista

Viimeistellään sovelluksen backend siten, että henkilöiden luominen ja editointi edellyttää kirjautumista, ja että luodut henkilöt menevät automaattisesti kirjautuneen käyttäjän tuttavalistalle.

Tyhjennetään ensin kannasta siellä ennestään olevat kenenkään tuttaviin kuulumattomat henkilöt.

Mutaatio addPerson muuttuu seuraavasti:

Mutation: {
  addPerson: async (root, args, context) => {    const person = new Person({ ...args })
    const currentUser = context.currentUser
    if (!currentUser) {      throw new GraphQLError('not authenticated', {        extensions: {          code: 'BAD_USER_INPUT',        }      })    }
    try {
      await person.save()
      currentUser.friends = currentUser.friends.concat(person)      await currentUser.save()    } catch (error) {
      throw new GraphQLError('Saving user failed', {
        extensions: {
          code: 'BAD_USER_INPUT',
          invalidArgs: args.name,
          error
        }
      })
    }

    return person
  },
  //...
}

Jos kirjautunutta käyttäjää ei löydy kontekstista, heitetään poikkeus GraphQLError asianomaisella virheilmoituksella varustettuna. Henkilön talletus hoidetaan nyt async/await-syntaksilla, koska joudumme onnistuneen talletuksen yhteydessä tallettamaan uuden henkilön käyttäjän tuttavalistalle.

Lisätään sovellukseen vielä mahdollisuus liittää jokin henkilö omalle tuttavalistalle. Mutaatio seuraavassa:

type Mutation {
  // ...
  addAsFriend(
    name: String!
  ): User
}

Mutaation toteuttava resolveri:

  addAsFriend: async (root, args, { currentUser }) => {
    if (!currentUser) {
      throw new GraphQLError('wrong credentials', {
        extensions: { code: 'BAD_USER_INPUT' }
      }) 
    }

    const nonFriendAlready = (person) => 
      !currentUser.friends.map(f => f._id.toString()).includes(person._id.toString())

    const person = await Person.findOne({ name: args.name })
    if ( nonFriendAlready(person) ) {
      currentUser.friends = currentUser.friends.concat(person)
    }

    await currentUser.save()

    return currentUser
  },

Huomaa miten resolveri destrukturoi kirjautuneen käyttäjän kontekstista, eli sen sijaan että currentUser otettaisiin erilliseen muuttujaan funktiossa

addAsFriend: async (root, args, context) => {
  const currentUser = context.currentUser

otetaan se vastaan suoraan funktion parametrimäärittelyssä:

addAsFriend: async (root, args, { currentUser }) => {

Omien tuttavien puhelinnumerot on mahdollista selvittää seuraavalla kyselyllä

query {
  me {
    username
    friends{
      name
      phone
    }
  }
}

Backendin koodi on kokonaisuudessaan GitHubissa, branchissa part8-5.