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 mongoose-unique-validator:

npm install mongoose mongoose-unique-validator --save

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,
    unique: 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, että 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 { ApolloServer, UserInputError, gql } = require('apollo-server')
const mongoose = require('mongoose')
const Person = require('./models/person')

mongoose.set('useFindAndModify', false)

const MONGODB_URI = 'mongodb+srv://fullstack:fullstack@cluster0-ostce.mongodb.net/graphql?retryWrites=true'

console.log('connecting to', MONGODB_URI)

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

const typeDefs = gql`
  ...
`

const resolvers = {
  Query: {
    personCount: () => Person.collection.countDocuments(),
    allPersons: (root, args) => {
      // filters missing
      return Person.find({})
    },
    findPerson: (root, args) => Person.findOne({ name: args.name })
  },
  Person: {
    address: root => {
      return {
        street: root.street,
        city: root.city
      }
    }
  },
  Mutation: {
    addPerson: (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: (root, args) => {
  return Person.find({})
},

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

Person.find({}).then( result => {
  // palautetaan kyselyn tuloksena result
})

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

Query: {
  // ..
  allPersons: (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 sopiva poikkeus:

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

      try {
        await person.save()
      } catch (error) {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      }
      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 UserInputError(error.message, {
          invalidArgs: args,
        })
      }
      return person
    }
}

Backendin lopullinen 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,
    unique: 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 RESTin 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.

Mutaatioiden resolverit seuraavassa:

const jwt = require('jsonwebtoken')

const JWT_SECRET = 'NEED_HERE_A_SECRET_KEY'

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

    return user.save()
      .catch(error => {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      })
  },
  login: async (root, args) => {
    const user = await User.findOne({ username: args.username })

    if ( !user || args.password !== 'secret' ) {
      throw new UserInputError("wrong credentials")
    }

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

    return { value: jwt.sign(userForToken, 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.

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.

GraphQL-playgroundissa headerin liittäminen pyyntöön tapahtuu seuraavasti

fullstack content

Laajennetaan sitten sovelluksen olion server määrittelyä lisäämällä konstruktorikutsuun kolmas parametri context:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {    const auth = req ? req.headers.authorization : null    if (auth && auth.toLowerCase().startsWith('bearer ')) {      const decodedToken = jwt.verify(        auth.substring(7), JWT_SECRET      )      const currentUser = await User.findById(decodedToken.id).populate('friends')      return { currentUser }    }  }})

Contextin palauttama olio annetaan kaikille resolvereille kolmantena parametrina, context on siis oikea paikka tehdä asioita, jotka ovat useille resolvereille yhteistä, kuten pyyntöön liittyvän käyttäjän tunnistaminen.

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
  }
},

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 käyttäjät.

Mutaatio addPerson muuttuu seuraavasti:

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

    if (!currentUser) {
      throw new AuthenticationError("not authenticated")
    }

    try {
      await person.save()
      currentUser.friends = currentUser.friends.concat(person)
      await currentUser.save()
    } catch (error) {
      throw new UserInputError(error.message, {
        invalidArgs: args,
      })
    }

    return person
  },
  //...
}

Jos kirjautunutta käyttäjää ei löydy kontekstista, heitetään poikkeus AuthenticationError. 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 }) => {
    const nonFriendAlready = (person) => 
      !currentUser.friends.map(f => f._id).includes(person._id)

    if (!currentUser) {
      throw new AuthenticationError("not authenticated")
    }

    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 }) => {

Backendin koodi on kokonaisuudessaan githubissa, branchissa part8-5.