Saltar al contenido

c

Administración de usuarios

Queremos agregar autenticación y autorización de usuarios a nuestra aplicación. Los usuarios deben almacenarse en la base de datos y cada nota debe estar vinculada al usuario que la creó. La eliminación y edición de una nota solo debe permitirse para el usuario que la creó.

Comencemos agregando información sobre los usuarios a la base de datos. Existe una relación de uno a varios entre el usuario (user) y las notas (Note):

diagrama conectando user y notes

Si estuviéramos trabajando con una base de datos relacional, la implementación sería sencilla. Ambos recursos tendrían sus tablas de base de datos separadas, y la identificación del usuario que creó una nota se almacenaría en la tabla de notas como una clave externa.

Cuando se trabaja con bases de datos de documentos, la situación es un poco diferente, ya que hay muchas formas diferentes de modelar la situación.

La solución existente guarda todas las notas de la colección de notas en la base de datos. Si no queremos cambiar esta colección existente, entonces la opción natural es guardar a los usuarios en su propia colección, users por ejemplo.

Al igual que con todas las bases de datos de documentos, podemos usar ID de objeto en Mongo para hacer referencia a documentos en otras colecciones. Esto es similar al uso de claves externas en bases de datos relacionales.

Tradicionalmente, las bases de datos de documentos como Mongo no admiten consultas de unión que están disponibles en bases de datos relacionales, utilizadas para agregar datos de varias tablas. Sin embargo, a partir de la versión 3.2. Mongo ha admitido consultas de agregación de búsqueda. No examinaremos esta funcionalidad en este curso.

Si necesitamos una funcionalidad similar a las consultas de unión, la implementaremos en el código de nuestra aplicación realizando múltiples consultas. En determinadas situaciones, Mongoose puede encargarse de unir y agregar datos, lo que da la apariencia de una consulta de unión. Sin embargo, incluso en estas situaciones, Mongoose realiza varias consultas a la base de datos en segundo plano.

Referencias entre colecciones

Si estuviéramos usando una base de datos relacional, la nota contendría una clave de referencia para el usuario que la creó. En las bases de datos de documentos podemos hacer lo mismo.

Supongamos que la colección de users contiene dos usuarios:

[
  {
    username: 'mluukkai',
    _id: 123456,
  },
  {
    username: 'hellas',
    _id: 141414,
  },
];

La colección notes contiene tres notas que tienen un campo user que hace referencia a un usuario en la colección users:

[
  {
    content: 'HTML is easy',
    important: false,
    _id: 221212,
    user: 123456,
  },
  {
    content: 'The most important operations of HTTP protocol are GET and POST',
    important: true,
    _id: 221255,
    user: 123456,
  },
  {
    content: 'A proper dinosaur codes with Java',
    important: false,
    _id: 221244,
    user: 141414,
  },
]

Las bases de datos de documentos no exigen que la clave externa se almacene en los recursos de notas, podría también almacenarse en la colección de usuarios, o incluso ambos:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [221212, 221255],
  },
  {
    username: 'hellas',
    _id: 141414,
    notes: [221244],
  },
]

Dado que los usuarios pueden tener muchas notas, los identificadores relacionados se almacenan en una matriz en el campo notes.

Las bases de datos de documentos también ofrecen una forma radicalmente diferente de organizar los datos: en algunas situaciones, podría ser beneficioso anidar todo el conjunto de notas como parte de los documentos en la colección de usuarios:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [
      {
        content: 'HTML is easy',
        important: false,
      },
      {
        content: 'The most important operations of HTTP protocol are GET and POST',
        important: true,
      },
    ],
  },
  {
    username: 'hellas',
    _id: 141414,
    notes: [
      {
        content:
          'A proper dinosaur codes with Java',
        important: false,
      },
    ],
  },
]

En este esquema, las notas estarían estrechamente anidadas debajo de los usuarios y la base de datos no generaría identificadores para ellos.

La estructura y el esquema de la base de datos no es tan evidente como lo era con las bases de datos relacionales. El esquema elegido debe ser uno que admita mejor los casos de uso de la aplicación. Esta no es una decisión de diseño simple, ya que no se conocen todos los casos de uso de las aplicaciones cuando se toma la decisión de diseño.

Paradójicamente, las bases de datos sin esquema como Mongo requieren que los desarrolladores tomen decisiones de diseño mucho más radicales sobre la organización de datos al comienzo del proyecto que las bases de datos relacionales con esquemas. En promedio, las bases de datos relacionales ofrecen una forma más o menos adecuada de organizar datos para muchas aplicaciones.

Esquema de Mongoose para usuarios

En este caso, tomamos la decisión de almacenar los ID de las notas creadas por el usuario en el documento de usuario. Definamos el modelo para representar a un usuario en el archivo models/user.js :

const mongoose = require('mongoose')

const userSchema = new mongoose.Schema({
  username: String,
  name: String,
  passwordHash: String,
  notes: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Note'
    }
  ],
})

userSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
    // el passwordHash no debe mostrarse
    delete returnedObject.passwordHash
  }
})

const User = mongoose.model('User', userSchema)

module.exports = User

Los identificadores de las notas se almacenan dentro del documento del usuario como una matriz de IDs de Mongo. La definición es la siguiente:

{
  type: mongoose.Schema.Types.ObjectId,
  ref: 'Note'
}

El tipo de campo es ObjectId que hace referencia a documentos de tipo note. Mongo no sabe de manera inherente que este es un campo que hace referencia a notas, la sintaxis está puramente relacionada y definida por Mongoose.

Expandamos el esquema de la nota definida en el archivo model/note.js para que la nota contenga información sobre el usuario que la creó:

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  important: Boolean,
  user: {    type: mongoose.Schema.Types.ObjectId,    ref: 'User'  }})

En marcado contraste con las convenciones de las bases de datos relacionales, las referencias ahora se almacenan en ambos documentos: la nota hace referencia al usuario que la creó, y el usuario tiene una serie de referencias a todas las notas creadas por ellos.

Creando usuarios

Implementemos una ruta para crear nuevos usuarios. Los usuarios tienen un nombre de usuario único, un nombre y algo llamado passwordHash. El hash de la contraseña es el resultado de una función hash unidireccional aplicada a la contraseña del usuario. ¡Nunca es aconsejable almacenar contraseñas de texto plano sin cifrar en la base de datos!

Instalemos el paquete bcrypt para generar los hashes de contraseña:

npm install bcrypt

La creación de nuevos usuarios ocurre de acuerdo con las convenciones RESTful discutidas en la parte 3, al realizar una solicitud HTTP POST a la ruta users.

Definamos un enrutador separado para tratar con los usuarios en un nuevo archivo controllers/users.js. Usemos el enrutador en nuestra aplicación en el archivo app.js, de modo que maneje las solicitudes hechas a la URL /api/users:

const usersRouter = require('./controllers/users')

// ...

app.use('/api/users', usersRouter)

The contents of the file, controllers/users.js, that defines the router is as follows:

const bcrypt = require('bcrypt')
const usersRouter = require('express').Router()
const User = require('../models/user')

usersRouter.post('/', async (request, response) => {
  const { username, name, password } = request.body

  const saltRounds = 10
  const passwordHash = await bcrypt.hash(password, saltRounds)

  const user = new User({
    username,
    name,
    passwordHash,
  })

  const savedUser = await user.save()

  response.status(201).json(savedUser)
})

module.exports = usersRouter

La contraseña enviada en la solicitud no se almacena en la base de datos. Almacenamos el hash de la contraseña que se genera con la función bcrypt.hash.

Los fundamentos de almacenar contraseñas están fuera del alcance de este material del curso. No discutiremos qué significa el número mágico 10 asignado a la variable saltRounds, pero puedes leer más sobre ello en el material vinculado.

Nuestro código actual no contiene ningún manejo de errores o validación de input para verificar que el nombre de usuario y la contraseña están en el formato deseado.

La nueva funcionalidad, inicialmente puede y debe probarse manualmente con una herramienta como Postman. Sin embargo, probar las cosas manualmente se volverá demasiado engorroso rápidamente, especialmente una vez que implementemos la funcionalidad que obliga a los nombres de usuario a ser únicos.

Se necesita mucho menos esfuerzo para escribir pruebas automatizadas y esto hará que el desarrollo de nuestra aplicación sea mucho más fácil.

Nuestras pruebas iniciales podrían verse así:

const bcrypt = require('bcrypt')
const User = require('../models/user')

//...

describe('when there is initially one user in db', () => {
  beforeEach(async () => {
    await User.deleteMany({})

    const passwordHash = await bcrypt.hash('sekret', 10)
    const user = new User({ username: 'root', passwordHash })

    await user.save()
  })

  test('creation succeeds with a fresh username', async () => {
    const usersAtStart = await helper.usersInDb()

    const newUser = {
      username: 'mluukkai',
      name: 'Matti Luukkainen',
      password: 'salainen',
    }

    await api
      .post('/api/users')
      .send(newUser)
      .expect(201)
      .expect('Content-Type', /application\/json/)

    const usersAtEnd = await helper.usersInDb()
    expect(usersAtEnd).toHaveLength(usersAtStart.length + 1)

    const usernames = usersAtEnd.map(u => u.username)
    expect(usernames).toContain(newUser.username)
  })
})

Las pruebas utilizan la función auxiliar usersInDb() que implementamos en el archivo tests/test_helper.js. La función se utiliza para ayudarnos a verificar el estado de la base de datos después de que se crea un usuario:

const User = require('../models/user')

// ...

const usersInDb = async () => {
  const users = await User.find({})
  return users.map(u => u.toJSON())
}

module.exports = {
  initialNotes,
  nonExistingId,
  notesInDb,
  usersInDb,
}

El bloque beforeEach agrega un usuario con el nombre de usuario root a la base de datos. La función se utiliza para ayudarnos a verificar el estado de la base de datos después de que se crea un usuario:

describe('when there is initially one user in db', () => {
  // ...

  test('creation fails with proper statuscode and message if username already taken', async () => {
    const usersAtStart = await helper.usersInDb()

    const newUser = {
      username: 'root',
      name: 'Superuser',
      password: 'salainen',
    }

    const result = await api
      .post('/api/users')
      .send(newUser)
      .expect(400)
      .expect('Content-Type', /application\/json/)

    expect(result.body.error).toContain('expected `username` to be unique')

    const usersAtEnd = await helper.usersInDb()
    expect(usersAtEnd).toEqual(usersAtStart)
  })
})

El caso de prueba obviamente no pasará en este punto. Básicamente, estamos practicando desarrollo guiado por pruebas (TDD), donde las pruebas para la nueva funcionalidad se escriben antes de implementar la funcionalidad.

Mongoose no tiene un validador incorporado para verificar la unicidad de un campo. Podemos encontrar una solución lista para esto en el paquete npm mongoose-unique-validator. Vamos a instalarlo:

npm install mongoose-unique-validator

Debemos realizar los siguientes cambios en el esquema definido en el archivo models/user.js:

const mongoose = require('mongoose')
const uniqueValidator = require('mongoose-unique-validator')
const userSchema = mongoose.Schema({
  username: {    type: String,    required: true,    unique: true  },  name: String,
  passwordHash: String,
  notes: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Note'
    }
  ],
})

userSchema.plugin(uniqueValidator)
// ...

Nota: al instalar la librería mongoose-unique-validator, es posible que encuentres el siguiente mensaje de error:

error de dependencia sin resolver para mongoose unique validator

La razón de esto es que en el momento de la redacción (10.11.2023), la librería aún no es compatible con la versión 8 de Mongoose. Si encuentras este error, puedes retroceder a una versión anterior de Mongoose ejecutando el comando

npm install mongoose@7.6.5

También podríamos implementar otras validaciones en la creación de usuarios. Podríamos comprobar que el nombre de usuario es lo suficientemente largo, que el nombre de usuario solo consta de caracteres permitidos o que la contraseña es lo suficientemente segura. La implementación de estas funcionalidades se deja como ejercicio opcional.

Antes de continuar, agreguemos una implementación inicial de un controlador de ruta que devuelva todos los usuarios en la base de datos:

usersRouter.get('/', async (request, response) => {
  const users = await User.find({})
  response.json(users)
})

Para crear nuevos usuarios en un entorno de producción o desarrollo, puedes enviar una solicitud POST a /api/users/ mediante Postman o REST Client en el siguiente formato:

{
    "username": "root",
    "name": "Superuser",
    "password": "salainen"
}

La lista se ve así:

navegador en api/users mostrando matriz de notas en formato JSON

Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-7 de este repositorio de GitHub.

Creación de una nueva nota

El código para crear una nueva nota debe actualizarse para que la nota se asigne al usuario que la creó.

Expandamos nuestra implementación actual para que la información sobre el usuario que creó una nota se envíe en el campo userId del cuerpo de la solicitud:

const User = require('../models/user')
//...

notesRouter.post('/', async (request, response) => {
  const body = request.body

  const user = await User.findById(body.userId)
  const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    user: user.id  })

  const savedNote = await note.save()
  user.notes = user.notes.concat(savedNote._id)  await user.save()  
  response.status(201).json(savedNote)
})

El esquema de las notas también deberá cambiar cómo a continuación, en nuestro archivo models/note.js:

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  important: Boolean,
  user: {    type: mongoose.Schema.Types.ObjectId,    ref: 'User'  }})

Vale la pena notar que el objeto user también cambia. El id de la nota se almacena en el campo notes del objeto user:

const user = await User.findById(body.userId)

// ...

user.notes = user.notes.concat(savedNote._id)
await user.save()

Intentemos crear una nueva nota:

Postman creando una nueva nota

La operación parece funcionar. Agreguemos una nota más y luego visitemos la ruta para buscar todos los usuarios:

api/users devuelve JSON con usuarios y su matriz de notas

Podemos ver que el usuario tiene dos notas.

Asimismo, los IDs de los usuarios que crearon las notas se pueden ver cuando visitamos la ruta para buscar todas las notas:

api/notes muestra ids de usuarios en JSON

Populate

Nos gustaría que nuestra API funcione de tal manera que cuando se realiza una solicitud HTTP GET a la ruta /api/users, los objetos de usuario también contengan el contenido de las notas del usuario, y no solo su identificación. En una base de datos relacional, esta funcionalidad se implementaría con una consulta de unión.

Como se mencionó anteriormente, las bases de datos de documentos no admiten adecuadamente las consultas de unión entre colecciones, pero la librería Mongoose puede hacer algunas de estas uniones por nosotros. Mongoose logra la unión haciendo múltiples consultas, lo cual es diferente de las consultas de unión en bases de datos relacionales que son transaccionales, lo que significa que el estado de la base de datos no cambia durante el tiempo que se realiza la consulta. Con las consultas de unión en Mongoose, nada puede garantizar que el estado entre las colecciones que se están uniendo sea coherente, lo que significa que si hacemos una consulta que une al usuario y las colecciones de notas, el estado de las colecciones puede cambiar durante la consulta.

La unión de Mongoose se realiza con el método populate. Actualicemos la ruta que devuelve todos los usuarios primero:

usersRouter.get('/', async (request, response) => {
  const users = await User    .find({}).populate('notes')
  response.json(users)
})

El método populate se encadena después de que el método find realiza la consulta inicial. El parámetro dado al método populate define que los ids que hacen referencia a objetos note en el campo notes del documento user serán reemplazados por los documentos de note referenciados.

El resultado es casi exactamente lo que queríamos:

datos JSON en el navegador mostrando datos de notas y usuarios repetidos

Podemos usar el parámetro populate para elegir los campos que queremos incluir de los documentos. Además del campo id, ahora solo nos interesan content e important.

La selección de campos se realiza con la sintaxis de Mongo:

usersRouter.get('/', async (request, response) => {
  const users = await User
    .find({}).populate('notes', { content: 1, important: 1 })

  response.json(users)
})

El resultado es ahora exactamente como queremos que sea:

datos combinados sin repeticiones

También usemos populate en notes para mostrar información de usuario adecuada en las notas:

notesRouter.get('/', async (request, response) => {
  const notes = await Note
    .find({}).populate('user', { username: 1, name: 1 })

  response.json(notes)
});

Ahora la información del usuario se agrega al campo user de los objetos de nota.

JSON de notes ahora también tiene información de usuarios

Es importante entender que la base de datos en realidad no sabe que los IDs almacenados en el campo user de la colección de notas hacen referencia a documentos en la colección de usuarios.

La funcionalidad del método populate de Mongoose se basa en el hecho de que hemos definido "tipos" para las referencias en el esquema de Mongoose con la opción ref:

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  important: Boolean,
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }
})

Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-8 de este repositorio de GitHub.

NOTA: En esta etapa, en primer lugar, algunos tests fallarán. Dejaremos la corrección de los tests como un ejercicio no obligatorio. En segundo lugar, en la aplicación de notas desplegada, la función de crear una nota dejará de funcionar ya que el usuario aún no está vinculado al frontend.