Saltar al contenido

c

Administración de bases de datos y usuarios

Ahora agregaremos la administración de usuarios a nuestra aplicación, pero comencemos primero a usar una base de datos para almacenar datos.

Mongoose y Apollo

Instalar mongoose y dotenv:

npm install mongoose dotenv

Imitaremos lo que hicimos en las partes 3 y 4.

El esquema de persona se ha definido de la siguiente manera:

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)

También incluimos algunas validaciones. required: true, que asegura que el valor exista, es realmente redundante, ya que el solo uso de GraphQL asegura que los campos existan. Sin embargo, es bueno mantener también la validación en la base de datos.

Podemos hacer que la aplicación funcione principalmente con los siguientes cambios:

// ...
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 = gql`
  ...
`

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

Los cambios son bastante sencillos. Sin embargo, hay algunas cosas dignas de mención. Como recordamos, en Mongo el campo de identificación de un objeto se llama _id y previamente tuvimos que analizar el nombre del campo a id nosotros mismos. Ahora GraphQL puede hacer esto automáticamente.

Otra cosa digna de mención es que las funciones de resolución ahora devuelven una promesa, cuando antes devolvían objetos normales. Cuando un resolutor devuelve una promesa, el servidor Apollo devuelve el valor al que se resuelve la promesa.

Por ejemplo, si se ejecuta la siguiente función de resolución,

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

El servidor Apollo espera que se resuelva la promesa y devuelve el resultado . Entonces, Apollo funciona más o menos así:

Person.find({}).then( result => {
  // return the result 
})

Completemos la resolución de allPersons para que tome en cuenta el parámetro opcional phone:

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

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

Entonces, si la consulta no tiene un parámetro phone, todas las personas son devueltas. Si el parámetro tiene el valor YES, el resultado de la consulta

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

se devuelve, por lo que los objetos en los que el campo phone tiene un valor. Si el parámetro tiene el valor NO, la consulta devuelve los objetos en los que el campo phone no tiene valor:

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

Validación

Al igual que en GraphQL, la entrada ahora se valida utilizando las validaciones definidas en el esquema de mangosta. Para manejar posibles errores de validación en el esquema, debemos agregar un bloque de manejo de errores try/catch al método save. Cuando terminamos en la captura, lanzamos una excepción adecuada:

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

El código del backend se puede encontrar en Github, rama part8-4.

Usuario e inicio de sesión

Agreguemos la administración de usuarios a nuestra aplicación. Por simplicidad, supongamos que todos los usuarios tienen la misma contraseña que está codificada en el sistema. Sería sencillo guardar contraseñas individuales para todos los usuarios siguiendo los principios de la parte 4, pero debido a que nuestro enfoque está en GraphQL, esta vez dejaremos de lado toda esa molestia adicional.

El esquema de usuario es el siguiente:

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)

Cada usuario está conectado a un grupo de otras personas en el sistema a través del campo friends. La idea es que cuando un usuario, es decir, mluukkai , agrega una persona, es decir, Arto Hellas , a la lista, la persona se agrega a su lista de amigos. De esta manera, los usuarios registrados pueden tener su propia vista personalizada en la aplicación.

El inicio de sesión e identificación del usuario se maneja de la misma manera que usamos en la parte 4 cuando usamos REST, usando tokens.

Extendamos el esquema así:

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
}

La consulta me devuelve el usuario actualmente conectado. Los nuevos usuarios se crean con la mutación createUser y el inicio de sesión ocurre con la mutación login.

Los resolutores de las mutaciones son los siguientes:

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.name,
            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) }
  },
},

La nueva mutación de usuario es sencilla. La mutación de inicio de sesión comprueba si el par de nombre de usuario/contraseña es válido. Y si de hecho es válido, devuelve un token jwt familiar de parte 4.

Al igual que en el caso anterior con REST, la idea ahora es que un usuario que haya iniciado sesión agregue un token que reciba al iniciar sesión a todas sus solicitudes. Y al igual que con REST, el token se agrega a las consultas GraphQL usando el encabezado Authorization. Nota que la variable de entorno JWT_SECRET debe estar definida en el archivo .env.

La creación de usuarios ahora se realiza de la siguiente manera:

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

La mutación de inicio de sesión se realiza de la siguiente manera:

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

Exactamente como en el caso anterior con REST, la idea ahora es que un usuario que haya iniciado sesión agregue un token que reciba al iniciar sesión a todas sus solicitudes. Y al igual que con REST, el token se agrega a las consultas GraphQL usando el header Authorization.

En el explorador de Apollo, el header se puede agregar de la siguiente manera:

apollo explorer con énfasis en los headers

Modifica el inicio del backend dando a la función que maneja el inicio startStandaloneServer otro parámetro 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}`)
})

El objeto devuelto por el contexto se le da a todos los resolutores como su tercer parámetro. El contexto es el lugar adecuado para hacer cosas que comparten varios resolutores, como identificación de usuario.

Entonces, nuestro código establece el objeto correspondiente al usuario que realizó la solicitud al campo currentUser del contexto. Si no hay ningún usuario conectado a la solicitud, el valor del campo no está definido.

El resolutor de la consulta me es muy simple, simplemente devuelve el usuario que ha iniciado sesión que recibe en el campo currentUser del tercer parámetro del resolutor, context. Vale la pena señalar que si no hay un usuario que haya iniciado sesión, es decir, no hay un token válido en el encabezado adjunto a la solicitud, la consulta devuelve null:

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

Lista de amigos

Completemos el backend de la aplicación para que agregar y editar personas requiera iniciar sesión, y las personas agregadas se agreguen automáticamente al lista de amigos del usuario.

Primero eliminemos de la base de datos a todas las personas que no estén en la lista de amigos de nadie.

La mutación addPerson cambia así:

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

Si no se puede encontrar un usuario registrado en el contexto, se lanza un AuthenticationError. La creación de nuevas personas ahora se realiza con la sintaxis async / await, porque si la operación es exitosa, la persona creada se agrega a la lista de amigos del usuario.

También agreguemos funcionalidad para agregar un usuario existente a su lista de amigos. La mutación es la siguiente:

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

Y los resolutores de las mutaciones:

  addAsFriend: async (root, args, { currentUser }) => {
    const isFriend = (person) => 
      currentUser.friends.map(f => f._id.toString()).includes(person._id.toString())

    if (!currentUser) {
      throw new GraphQLError('wrong credentials', {
        extensions: { code: 'BAD_USER_INPUT' }
      }) 
    }

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

    await currentUser.save()

    return currentUser
  },

Observe cómo el resolutor desestructura al usuario que ha iniciado sesión desde el contexto. Entonces, en lugar de guardar currentUser en una variable separada en una función

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

se recibe directamente en la definición de parámetros de la función:

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

El código del backend se puede encontrar en Github rama part8-5.