c
Tietokanta ja käyttäjien hallinta
Tässä luvussa siirrytään käyttämään tietokantaa datan tallentamiseen ja laajennetaan sovellusta käyttäjänhallinnalla. Refaktoroidaan kuitenkin ensin backendin koodia. Phonebook-backendin tämänhetkinen koodi löytyy GitHubista, branchista part8-3.
Backendin refaktorointi
Olemme toistaiseksi kirjoittaneet kaiken koodin index.js-tiedostoon. Sovelluksen laajentuessa se ei ole enää järkevää, sillä tiedoston pituuden kasvaessa sen luettavuus ja hahmotettavuus kärsii. On myös hyvän ohjelmointitavan mukaista erottaa sovelluksen eri vastuualueet omiin moduuleihinsa.
Refaktoroidaan nyt backendin koodi jakamalla se useisiin tiedostoihin.
Aloitetaan eriyttämällä sovelluksen GraphQL-skeema tiedostoon schema.js:
const typeDefs = /* GraphQL */ `
type Address {
street: String!
city: String!
}
type Person {
name: String!
phone: String
address: Address!
id: ID!
}
enum YesNo {
YES
NO
}
type Query {
personCount: Int!
allPersons(phone: YesNo): [Person!]!
findPerson(name: String!): Person
}
type Mutation {
addPerson(
name: String!
phone: String
street: String!
city: String!
): Person
editNumber(name: String!, phone: String!): Person
}
`
module.exports = typeDefsSiirretään sitten resolvereista vastaava koodi omaan moduuliinsa resolvers.js:
const { GraphQLError } = require('graphql')
const { v1: uuid } = require('uuid')
let persons = [
{
name: 'Arto Hellas',
phone: '040-123543',
street: 'Tapiolankatu 5 A',
city: 'Espoo',
id: '3d594650-3436-11e9-bc57-8b80ba54c431',
},
{
name: 'Matti Luukkainen',
phone: '040-432342',
street: 'Malminkaari 10 A',
city: 'Helsinki',
id: '3d599470-3436-11e9-bc57-8b80ba54c431',
},
{
name: 'Venla Ruuska',
street: 'Nallemäentie 22 C',
city: 'Helsinki',
id: '3d599471-3436-11e9-bc57-8b80ba54c431',
},
]
const resolvers = {
Query: {
personCount: () => persons.length,
allPersons: (root, args) => {
if (!args.phone) {
return persons
}
const byPhone = (person) =>
args.phone === 'YES' ? person.phone : !person.phone
return persons.filter(byPhone)
},
findPerson: (root, args) => persons.find((p) => p.name === args.name),
},
Person: {
address: ({ street, city }) => {
return {
street,
city,
}
},
},
Mutation: {
addPerson: (root, args) => {
if (persons.find((p) => p.name === args.name)) {
throw new GraphQLError(`Name must be unique: ${args.name}`, {
extensions: {
code: 'BAD_USER_INPUT',
invalidArgs: args.name,
},
})
}
const person = { ...args, id: uuid() }
persons = persons.concat(person)
return person
},
editNumber: (root, args) => {
const person = persons.find((p) => p.name === args.name)
if (!person) {
return null
}
const updatedPerson = { ...person, phone: args.phone }
persons = persons.map((p) => (p.name === args.name ? updatedPerson : p))
return updatedPerson
},
},
}
module.exports = resolversYksinkertaisuuden vuoksi henkilöiden tiedoista vastaava taulukko persons on nyt sijoitettu resolvereiden kanssa samaan tiedostoon. Taulukko poistuu pian, kun siirrymme käyttämään tietojen tallennukseen tietokantaa.
Siirretään lopuksi myös Apollo-palvelimen käynnistämisestä vastaava koodi omaan tiedostoonsa server.js:
const { ApolloServer } = require('@apollo/server')
const { startStandaloneServer } = require('@apollo/server/standalone')
const resolvers = require('./resolvers')
const typeDefs = require('./schema')
const startServer = (port) => {
const server = new ApolloServer({
typeDefs,
resolvers,
})
startStandaloneServer(server, {
listen: { port },
}).then(({ url }) => {
console.log(`Server ready at ${url}`)
})
}
module.exports = startServerApollo-palvelimen käynnistys hoidetaan nyt itse määritellyn startServer-funktion sisällä. Näin voimme exportata funktion ja käynnistää palvelimen moduulin ulkopuolelta index.js-tiedostosta käsin. Funktiolle annetaan parametriksi portti, johon Apollo Server käynnistyy kuuntelemaan.
Asennetaan projektiin dotenv-kirjasto, jotta voimme määritellä ympäristömuuttujia .env-tiedostossa:
npm install dotenvTiedostoon index.js jää vain vähän koodia. Sen sisältö refaktoroinnin jälkeen on seuraava:
require('dotenv').config()
const startServer = require('./server')
const PORT = process.env.PORT || 4000
startServer(PORT)Ympäristömuuttujat luetaan ensin .env-tiedostosta käyttäen dotenv-kirjastoa. Käytettävä portti luetaan nyt ympäristömuuttujasta, jos sellainen on asetettu. Jos PORT-ympäristömuuttujaa ei löydy, käytetään oletusporttia 4000, josta myös frontend olettaa tällä hetkellä löytävänsä palvelimen. Lopuksi Apollo Server käynnistetään kutsumalla funktiota startServer.
Toistaiseksi index.js-tiedoston sisältö on tynkä, mutta sovelluksen laajentuessa se saa enemmän sisältöä. Esimerkiksi kun pian siirrymme käyttämään tietojen tallentamiseen tietokantaa, tulee tietokantayhteys muodostaa ennen palvelimen käynnistämistä.
Sovelluksen vastuut on nyt eroteltu selkeästi toisitaan:
- index.js toimii pääohjelmana, jonka vastuulla on nyt ainoastaan sovelluksen käynnistyslogiikka. Se huolehtii, että sovelluksen eri osat käynnistetään oikeassa järjestyksessä.
- GraphQL-skeema määritellään moduulissa schema.js. Se kuvailee APIn rakenteen eli esimerkiksi sen, mitä kyselyitä ja mutaatioita APIn kautta on mahdollista tehdä ja minkälaisia kenttiä eri olioilla on.
- Varsinainen sovelluslogiikka määritellään moduulissa resolvers.js. Sen vastuulla on esimerkiksi määritellä, mitä sovelluksessa todella tapahtuu eri kyselyiden kohdalla, mistä data haetaan ja miten sitä käsitellään.
- Apollo Serverin konfiguroinnista ja käynnistyksestä vastaava koodi määritellään erillisessä moduulissa server.js.
Mongoose ja Apollo
Siirrytään nyt käyttämään sovelluksessamme MongoDB-tietokantaa. Tehdään tietokannan käyttöönotto osien 3 ja 4 tapaa imitoiden.
Otetaan käyttöön Mongoose:
npm install mongooseMääritellään henkilön skeema tiedostossa models/person.js 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.
Luodaan tietokantayhteyden muodostavalle koodille oma moduuli db.js:
const mongoose = require('mongoose')
const connectToDatabase = async (uri) => {
console.log('connecting to database URI:', uri)
try {
await mongoose.connect(uri)
console.log('connected to MongoDB')
} catch (error) {
console.log('error connection to MongoDB:', error.message)
process.exit(1)
}
}
module.exports = connectToDatabaseModuuli luo funktion connectToDatabase, joka saa parametrina tietokanta-URI:n ja hoitaa tietokantaan yhdistämisen.
Otetaan moduuli käyttöön tiedostossa index.js:
require('dotenv').config()
const connectToDatabase = require('./db')const startServer = require('./server')
const MONGODB_URI = process.env.MONGODB_URIconst PORT = process.env.PORT || 4000
const main = async () => { await connectToDatabase(MONGODB_URI) startServer(PORT)
}
main()Koska async/await-syntaksi on käytettävissä ainoastaan funktioiden sisällä, määrittelemme nyt yksinkertaisen main-funktion, joka hoitaa sovelluksen käynnistämisen. Näin voimme kutsua tietokantayhteyden luovaa funktiota käyttäen await-koodisanaa.
Muuttujan MONGODB_URI arvo saadaan ympäristömuuttujasta, eli sille tulee lisätä sopiva arvo .env-tiedostoon osan 3 tapaan. Sovellus kutsuu ensin tietokantayhteyden luovaa funktiota, ja kun tietokantayhteys on luotu onnistuneesti, se käynnistää GraphQL-palvelimen.
Sovelluslogiikasta vastaavan tiedoston resolvers.js sisältö muuttuu lähes täysin. Saamme sovelluksen jo suurilta osin toimimaan seuraavilla muutoksilla:
const { GraphQLError } = require('graphql')
const Person = require('./models/person')
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: ({ street, city }) => {
return {
street,
city,
}
},
},
Mutation: {
addPerson: async (root, args) => {
const nameExists = await Person.exists({ name: args.name })
if (nameExists) {
throw new GraphQLError(`Name must be unique: ${args.name}`, {
extensions: {
code: 'BAD_USER_INPUT',
invalidArgs: args.name,
},
})
}
const person = new Person({ ...args })
return person.save()
},
editNumber: async (root, args) => {
const person = await Person.findOne({ name: args.name })
if (!person) {
return null
}
person.phone = args.phone
return person.save()
},
},
}
module.exports = resolversMuutokset 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 varustettu poikkeus GraphQLError:
Mutation: {
addPerson: async (root, args) => {
const nameExists = await Person.exists({ name: args.name })
if (nameExists) {
throw new GraphQLError(`Name must be unique: ${args.name}`, {
extensions: {
code: 'BAD_USER_INPUT',
invalidArgs: args.name,
},
})
}
const person = new Person({ ...args })
try { await person.save() } catch (error) { throw new GraphQLError(`Saving person failed: ${error.message}`, { extensions: { code: 'BAD_USER_INPUT', invalidArgs: args.name, error } }) } return person },
editNumber: async (root, args) => {
const person = await Person.findOne({ name: args.name })
if (!person) {
return null
}
person.phone = args.phone
try { await person.save() } catch (error) { throw new GraphQLError(`Saving number failed: ${error.message}`, { 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.
Luodaan käyttäjän skeema tiedostoon models/user.js:
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 GraphQL-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 jsonwebtokenUusien mutaatioiden resolverit ovat seuraavassa:
const jwt = require('jsonwebtoken')
const User = require('./models/user')
Mutation: {
// ..
createUser: async (root, args) => {
const user = new User({ username: args.username })
return user.save()
.catch(error => {
throw new GraphQLError(`Creating the user failed: ${error.message}`, {
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:

Backendissä pyynnön mukana saapuva token on kätevintä välittää resolvereille hyödyntäen Apollo Serverin kontekstia. Kontekstin avulla voidaan suorittaa jotain kaikille kyselyille ja mutaatioille yhteisiä asioita, esim. pyyntöön liittyvän käyttäjän tunnistaminen.
Muutetaan backendin käynnistämistä siten, että määritellään käynnistyksestä huolehtivan funktion startStandaloneServer toisena parametrina saamaan olioon context-kenttä ja luodaan apufunktio getUserFromAuthHeader tokenin kelvollisuuden tarkistamiseksi ja käyttäjän etsimiseksi tietokannasta:
const { ApolloServer } = require('@apollo/server')
const { startStandaloneServer } = require('@apollo/server/standalone')
const jwt = require('jsonwebtoken')
const resolvers = require('./resolvers')
const typeDefs = require('./schema')
const User = require('./models/user')
const getUserFromAuthHeader = async (auth) => { if (!auth || !auth.startsWith('Bearer ')) { return null } const decodedToken = jwt.verify(auth.substring(7), process.env.JWT_SECRET) return User.findById(decodedToken.id).populate('friends')}
const startServer = (port) => {
const server = new ApolloServer({
typeDefs,
resolvers,
})
startStandaloneServer(server, {
listen: { port },
context: async ({ req }) => { const auth = req.headers.authorization const currentUser = await getUserFromAuthHeader(auth) return { currentUser } }, }).then(({ url }) => {
console.log(`Server ready at ${url}`)
})
}
module.exports = startServerMäärittelemämme koodi siis eristää ensin pyynnöstä Authorization-headerin sisältämän tokenin. Apufunktio getUserFromAuthHeader dekoodaa tokenin ja etsii tietokannasta sitä vastaavan käyttäjän. Jos token ei ole kelvollinen tai käyttäjää ei löydy, on funktio palauttaa null-arvon.
Lopuksi kontekstin kenttään currentUser asetetaan pyynnön tehnyttä käyttäjää vastaava olio tai null, jos käyttäjää ei löytynyt:
context: async ({ req }) => {
const auth = req.headers.authorization
const currentUser = await getUserFromAuthHeader(auth)
return { currentUser } },Kontekstin arvo välitetään resolvereille kolmantena parametrina. Kyselyn me resolveri on erittäin yksinkertainen, se ainoastaan palauttaa kirjautuneen käyttäjän, jonka se saa resolverin parametrin context kentästä currentUser:
Query: {
// ...
me: (root, args, context) => {
return context.currentUser
}
},Jos headerissa on validi token, palauttaa kysely headerin yksilöimän käyttäjän tiedot

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 currentUser = context.currentUser if (!currentUser) { throw new GraphQLError('not authenticated', { extensions: { code: 'UNAUTHENTICATED', } }) }
const nameExists = await Person.exists({ name: args.name })
if (nameExists) {
throw new GraphQLError(`Name must be unique: ${args.name}`, {
extensions: {
code: 'BAD_USER_INPUT',
invalidArgs: args.name,
},
})
}
const person = new Person({ ...args })
try {
await person.save()
currentUser.friends = currentUser.friends.concat(person) await currentUser.save() } catch (error) {
throw new GraphQLError(`Saving person failed: ${error.message}`, {
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. Mutaation skeema on seuraava:
type Mutation {
// ...
addAsFriend(name: String!): User}Mutaation toteuttava resolveri:
addAsFriend: async (root, args, { currentUser }) => {
if (!currentUser) {
throw new GraphQLError('not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
const nonFriendAlready = (person) =>
!currentUser.friends
.map((f) => f._id.toString())
.includes(person._id.toString())
const person = await Person.findOne({ name: args.name })
if (!person) {
throw new GraphQLError("The name didn't found", {
extensions: {
code: 'BAD_USER_INPUT',
invalidArgs: 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.currentUserotetaan 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.