di

Resumen

MongoDB es la base de datos NoSQL más popular debido a su sencillez, velocidad y a su capaciudad de escalado. Su versatilidad y su rapidez hacen que esté cada vez más presente en el desarrollo de aplicaciones de bases de datos, especialmente en aquellas que pueden crecer a escala Internet. En este tutorial veremos cómo crear una API REST NestJS para MongoDB con Mongoose, el ODM de referencia de MongoDB para Node.js.

Objetivos
  • Entender los conceptos de esquema y modelo en Mongoose.

  • Definir atributos multivaluados en un esquema.

  • Crear esquemas basados en otros esquemas.

  • Aprender a crear referencias entre esquemas diferentes.

  • Aprender a usar las técnicas para la creación de una API REST NestJS para MongoDB con Mongoose.

  • Filtrar datos de una petición mediante el uso de parámetros de consulta en la URL.

  • Introducir el uso de pipes para comprobar la validez de parámetros de entrada de la API.

Disponible el repositorio usado en este tutorial

1. Introducción

MongoDB es una base de datos que almacena documentos JSON y ofrece una gran escalabilidad y flexibilidad. Actualmente, de acuerdo con DB-Engines Ranking, es la base de datos NoSQL más popular. Su modelo de datos basado en documentos ofrece una forma de trabajar muy sencilla e intuitiva para desarrolladores.

Mongoose es un ODM (Object Document Mapper) para Node.js análogo a los ORM (Object Relational Mapper) como Hibernate para Java, Doctrine para PHP o SQLAlchemy para Python. A pesar de que MongoDB es una base de datos sin esquema, Mongoose proporciona una solución basada en esquemas que ofrece un sistema de tipos, validaciones, construcción de consultas y otras características que lo hacen una opción muy interesante para el desarrollo en de una API REST NestJS si la base de datos en MongoDB.

En este tutorial veremos cómo crear una API REST en NestJS para un ejemplo sencillo de un catálogo de libros en MongoDB y programado con Mongoose. Aunque el ejemplo es sencillo, introduciremos las cuestiones cotidianas que se suelen presentar al trabajar con MongoDB, como son el tratamiento que hace MongoDB de los atributos multivaluados, el manejo de relaciones 1:M como agregados y la referencia a objetos de otras colecciones de documentos.

La figura siguiente ilustra el modelo de agregados que usaremos en este tutorial.

modelo de agregados

Básicamente guardamos datos de libros, una lista de palabras clave de cada libro y los distintos comentarios que se realizan sobre ellos. Cada comentario es realizado por una persona y guardaremos una serie de datos de cada persona.

Con MongoDB no crearemos una colección para cada una de las entidades de dominio que aparecen en la figura (Book, Comment y User) y otra colección para keywords de Book (que aparece como un array de cadenas), lo que haría un total de cuatro colecciones. Si hiciésemos eso estaríamos utilizando implícitamente las técnicas de diseño de bases de datos relacionales en MongoDB. Y el único cambio que estaríamos haciendo es que en lugar de almacenar filas, estaríamos almacenando documentos JSON. Pero esto no es una buena opción porque no estaríamos aprovechando las ventajas que puede aportar MongoDB. Usar MongoDB implica algo más que cambiar los registros por documentos JSON. Implica cambiar la técnica de diseño. En este caso crearíamos una única colección para todo el agregado, que se corresponde con la zona sombreada de la figura. Denominaremos Book a esa colección y en su esquema habrá:

  • Los campos básicos del libro (id, title, genre, description, author, pages, image_url).

  • Un campo para el atributo multivaluado keywords. Se definiría como una array de cadenas (primer cambio respecto al enfoque relacional).

  • Un campo para la lista de comentarios comments. Se definiría como un array de documentos de tipo Comment(segundo cambio respecto al enfoque relacional). En el esquema Comment el campo username será una referencia al esquema de la colección User.

Vemos que la relación 1:M queda embebida dentro de los libros. En cambio, no incluimos los datos del usuario en cada comentario para no repetir todos los datos de un usuario cada vez que un usuario realice un comentario.

Por tanto, para este ejemplo tendremos únicamente dos colecciones: Book y User. Como escenario de aplicación se puede pensar en una página de un catálogo de libros de una tienda online de libros, en la que para cada libro se mostrarán tanto los datos del libro, como sus palabras clave y sus comentarios. Así, cada vez que se recupere un libro se recupera con todos los datos hay que mostrar.

Una implementación relacional habría creado 4 tablas (Book,Book-Keywords, Books-Comments y Comment-User). Este enfoque, además de ser más intuitivo, aumenta la velocidad de las consultas ya que se evitan los joins para recuperar las palabras clave y los comentarios de un libro. Pero ahí no queda todo. Dado que los datos relacionados se almacenan juntos, la base de datos puede escalar horizontalmente añadiendo más nodos a un cluster sin penalizar su rendimiento. Así, unos libros estarán en un nodo y otros estarán en otros, pero no habrá penalización en los joins ya que las palabras clave y los comentarios de cada libro están almacenados junto al libro, y se recuperarán juntos.

Casos de uso como aplicaciones IoT (p.e. agregando las medidas de los dispositivos, eventos en una cadena de producción, meteorología), Gaming o aplicaciones de streaming (p.e. guardando las últimas posiciones, eventos, reproducciones, posición de reproducción), Vistas simplificadas de los datos más importantes (p.e. datos calculados, colecciones de datos resumidos) y demás, hacen hoy día de MongoDB una solución de almacenamiento a considerar.

2. Preparación del proyecto

Comenzamos creando el proyecto y aceptamos las opciones predeterminadas.

$ nest new nestjs-mongodb

Nuestro proyecto tiene dependencias con Mongoose y Swagger OpenAPI. Las instalaremos con

$ npm install --save @nestjs/mongoose mongoose
$ npm install --save @nestjs/swagger swagger-ui-express

La configuración de Swagger se realiza en src/main.ts indicando las opciones de presentación en Swagger UI, como el título, descripción y ruta en la que se sirve la documentación de la API.

Archivo src/main.ts
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Configurar títulos de documentación
  const options = new DocumentBuilder() (1)
    .setTitle('MongoDB Book REST API')
    .setDescription('API REST para libros con MongoDB')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, options); (2)

  // La ruta en que se sirve la documentación
  SwaggerModule.setup('docs', app, document); (3)

  await app.listen(3000);
}
bootstrap();
1 Creación de la configuración de las opciones de presentación de Swagger
2 Preparación de la configuración creada para Swagger
3 Aplicación de la configuración y definición de docs como la ruta en la que se sirve la documentación

A continuación generamos con el CLI de NestJS dos resource para libros y usuarios aceptando los valores predeterminados de si queremos crear los recursos para una API REST y que genere los endpoints para CRUD.

$ nest generate resource books
$ nest generate resource users

La generación de un resource con el CLI de NestJS genera los archivos de los servicios, controladores, módulos, DTOs y entidades. Además, proporciona una pequeña implementación inicial para cada uno de ellos. En NestJS:

  • Los controladores se encargan de gestionar las peticiones que llegan y devuelven las respuestas al cliente.

  • Los servicios se encargan de resolver las peticiones. En nuestro caso se encargan de la interacción con MongoDB recuperando y almacenando documentos.

  • Los módulos permiten organizar la estructura de la aplicación e incluyen los controladores, servicios y los módulos que a su vez usan.

  • Los DTO definen la estructura de los objetos que se pasan en el cuerpo de las peticiones HTTP.

  • Una entidad representa a una clase que se persiste en la base de datos. En Mongoose se usa el término esquema en lugar de entidad para hacer referencia a la clase que se persiste en la base de datos.

Ponemos la aplicación en uso con

$ npm run start:dev

Al probar el endpoint GET /books en <url>:<port>/books (p.e. http://localhost:3000/books) obtendremos

This action returns all books

Al abrir el navegador en la ruta <url>:<port>/docs (p.e. http://localhost:3000/docs) vemos Swagger UI mostrando todos los endpoints de la API.

Swagger inicial

Si desplegamos el endpoint GET /books, pulsamos el botón Try it out y luego pulsamos el botón Execute se llamará al endpoint GET /books. En Server response se muestra el código de la respuesta en Code y el resultado en Response body.

swagger get books inicial

Como podemos comprobar, el uso de nest generate resource del CLI de NestJS nos ayuda enormemente al crear el scaffolding para los objetos del dominio de nuestra aplicación (p.e. books y users). Nos genera una base funcional para las operaciones CRUD con los endpoints HTTP disponibles paras las operaciones GET, POST, PATCH y DELETE y las asocia a sus respectivos métodos en los servicios creados. Como hemos visto al probar GET /books, la implementación de los servicios se limita a informar que han sido llamados. Posteriormente habrá que implementarlos programando la operación correspondiente de base de datos para cumplir su cometido real. Esta implementación la haremos cuando empecemos a desarrollar los respectivos módulos del dominio (books y users) en secciones posteriores.

Advertencia sobre la generación de un resource para Mongoose

La generación de resource con el CLI de NestJS está ideada para bases de datos relacionales. Esto se aprecia en que:

  • Genera entidades. Las entidades son una abstracción que representa la persistencia en la base de datos de una clase del dominio. El término entidad está asumido en el contexto del uso de ORMs en bases de datos relacionales.

  • Supone que los identificadores que se van a usar en la base de datos son numéricos. Es conocido el uso de enteros autoincrementales para definir claves primarias en tablas de bases de datos relacionales.

Sin embargo, cuando trabajamos con Mongoose:

  • Se usa el término esquema en lugar de entidad.

  • En MongoDB el _id de los documentos de una colección no es de tipo entero, sino que es una cadena hexadecimal de 24 caracteres.

Por tanto, habrá que hacer unas ligeras modificaciones sobre el código generado por el CLI de NestJS para adaptarlo a Mongoose. Estas modificaciones sobre el código generado para un resource son una opción más rápida que la creación y programación manual desde cero de los módulos, controladores, servicios, DTOs y esquemas que necesitaremos para cada objeto del dominio.

3. Configuración de app.module.ts

En app.module.ts se añade la configuración del acceso a MongoDB. Para nuestro ejemplo, MongoDB está en local, se accede a través del puerto 27017 y no necesita contraseña. Para otras configuraciones (p.e. replica sets, acceso autenticado, y demás, consultar la documentación oficial de Mongoose).

En este tutorial prepararemos una conexión local a MongDB creando una base de datos tutorial.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MongooseModule } from '@nestjs/mongoose';
import { UsersModule } from './users/users.module';
import { BooksModule } from './books/books.module';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/tutorial'), (1)
    UsersModule,
    BooksModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
1 Conexión a base de datos tutorial en MongoDB local

4. Configuración del módulo de libros

De acuerdo con el diagrama de Introducción tenemos objetos de dominio para libros y para los usuarios que realizan los comentarios sobre los libros. En esta sección nos centraremos sólo en los libros, sin incluir aún la relación con los autores de los comentarios. La inclusión de la referencia a los autores la dejamos para la sección Incorporación de comentarios

Es una buena técnica comenzar desarrollando inicialmente los módulos/clases/bloques de la API correspondiente a las clases del dominio que vamos a persistir en bases de datos. Una vez comprobado su funcionamiento por separado, se introducen las modificaciones en los esquemas Mongoose para incluir las relaciones a otros esquemas.

Tal y como hemos comentado en Introducción hay que hacer unos ligeros cambios sobre el código generado para el resource con el CLI de NestJS. En concreto, habrá que cambiar las referencias a entidades por esquemas, y cambiar los identificadores de bases de datos numéricos a cadenas, ya que los _id de MongoDB son cadenas hexadecimales de 24 caracteres.

4.1. Creación del esquema

Comenzamos cambiando las entidades generadas por el CLI de NestJS por esquemas.

  1. Renombramos la carpeta src/books/entities por src/books/schemas

  2. Renombramos el archivo src/books/entities/book.entity.ts por src/books/schemas/book.schema.ts

Para definir un esquema Mongoose hay que:

  • Añadir a la clase el decorador @Schema()

  • Definir en la clase cada campo de la colección y añadirle el decorador @Prop()

El decorador @Schema() sobre una clase hace que se cree una colección en MongoDB con el nombre de la clase, pero en plural (añadiéndole una "s"). El decorador @Prop() sobre una propiedad de la clase añade a la colección un campo con el nombre de la propiedad.

Archivo src/books/schemas/book.schema.ts
import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose'; (1)

export type BookDocument = Book & Document; (2)

@Schema() (3)
export class Book {
  @Prop() (4)
  genre: string;

  @Prop()
  description: string;

  @Prop()
  author: string;

  @Prop()
  pages: number;

  @Prop()
  image_url: string;

  @Prop([String]) (5)
  keywords: string[];
}

export const BookSchema = SchemaFactory.createForClass(Book); (6)
1 Importación de Document desde Mongoose
2 Definición del tipo de un documento libro
3 Decorador para crear una colección MongoDB para la clase
4 Decorador para añadir un campo a la colección
5 Indicación de un tipo no primitivo
6 Esquema Mongoose creado a partir de la clase Book

Para tipos no primitivos (como arrays, documentos o una combinación de ellos) hay que añadir en @Prop() el tipo de datos que se va usar.

4.2. Configuración del módulo

En el módulo tenemos que registrar el esquema para que cree la colección correspondiente en MongoDB. Esto lo haremos añadiendo el método forFeature de MongooseModule en el array imports.

Archivo src/books/books.module.ts
import { Module } from '@nestjs/common';
import { BooksService } from './books.service';
import { BooksController } from './books.controller';
import { MongooseModule } from '@nestjs/mongoose';
import { Book, BookSchema } from './schemas/book.schema';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }]), (1)
  ],
  controllers: [BooksController],
  providers: [BooksService],
})
export class BooksModule {}
1 Registro del esquema de los libros

Al guardar los cambios, Mongoose crea la colección books en la base de datos.

Si la base de datos no estaba creada aún, al guardar el primer esquema, se crea la base de datos y la colección asociada al esquema creado.

4.3. Creación del DTO

El DTO define la estructura de un objeto que se pasa en el cuerpo de una petición HTTP. Inicialmente, y de acuerdo con el diagrama de Introducción, los campos de los libros, excluídos los campos de relación, son los siguientes:

  • id como identificador del libro.

  • title para el título

  • genre para el género

  • description para una descripción completa

  • author para el autor del libro

  • pages para el número de páginas

  • image_url para la URL en la que está disponible la imagen del libro

  • keywords con una lista de palabras clave

Archivo src/books/dto/create-book.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class CreateBookDto {
  @ApiProperty({ (1)
    example: 'Nest.js: A Progressive Node.js Framework (English Edition)',
  })
  readonly title: string; (2)

  @ApiProperty({ example: 'Web Development' })
  readonly genre: string;

  @ApiProperty({
    example:
      'JavaScript frameworks go in and out of style very quickly as web technologies change and grow. Nest.js is a good starting point for many developers that are looking to use a modern web framework because it uses a language that is very similar to that of the most used language on the web to this day, JavaScript...',
  })
  readonly description: string;

  @ApiProperty({ example: 'Jay Bell' })
  readonly author: string;

  @ApiProperty({ example: 350 })
  readonly pages: number;

  @ApiProperty({
    example: 'https://m.media-amazon.com/images/I/41fveBeDWmL._SY346_.jpg',
  })
  readonly image_url: string;

  @ApiProperty({ example: ['NestJS', 'REST API'] }) (3)
  readonly keywords: string[];
}
1 Decorador para definir una propiedad para la documentación en Swagger OpenAPI
2 Definición de campo
3 Ejemplo como un array

En Swagger UI, al desplegar el endpoint POST /books el ejemplo muestra los valores configurados con el decorador @ApiProperty de Swagger OpenAPI. También aparecen como plantilla si probásemos a introducir un libro. Aún no probaremos a insertar el libro porque está sin implementar el servicio. Recordamos que la implementación actual del servicio es la que ha generado el CLI de NestJS y se limita a mostrar que el servicio ha sido llamado. Hay que cambiar su implementación para que interactúe con la base de datos.

dto libro

4.4. Implementación del servicio

Partimos del código generado por el CLI de NestJS para el servicio. Además de dar la implementación de la interacción con la base de datos mediante Mongoose habrá que:

  • Hacer que los métodos sean async ya que los métodos de acceso a la base de datos son asíncronos y devuelven promesas.

  • Configurar el tipo devuelto por los métodos.

  • Cambiar el parámetro id de number a string para que pueda tratar con el _id hexadecimal de MongoDB.

Recordamos que el CLI de NestJS genera el código de los métodos del servicio pensando en que la clave es de tipo numérico. Hay que cambiar el tipo del argumento id en los métodos findOne, update y remove a string para que sea válido para el _id de 24 caracteres hexadecimales de MongoDB.

Los métodos que usaremos de Mongoose serán

  • create() para la inserción de un documento.

  • find() para recuperar todo.

  • findOne() para recuperar un documento.

  • findOneAndUpdate() para la actualización de un documento.

  • remove() para la eliminación de un documento.

Archivo src/books/books.service.ts
import { Injectable } from '@nestjs/common';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { InjectModel } from '@nestjs/mongoose';
import { Book, BookDocument } from './schemas/book.schema';
import { Model } from 'mongoose';

@Injectable()
export class BooksService {
  constructor( (1)
    @InjectModel(Book.name) private readonly bookModel: Model<BookDocument>, (2)
  ) {}

  async create(createBookDto: CreateBookDto): Promise<Book> { (3)
    return this.bookModel.create(createBookDto); (4)
  }

  async findAll(): Promise<Book[]> { (5)
    return this.bookModel.find().exec();
  }

  async findOne(id: string): Promise<Book> { (6)
    return this.bookModel.findOne({ _id: id }).exec(); (7)
  }

  async update(id: string, updateBookDto: UpdateBookDto): Promise<Book> { (8)
    return this.bookModel.findOneAndUpdate({ _id: id }, updateBookDto, { (9)
      new: true, (10)
    });
  }

  async remove(id: string) { (11)
    return this.bookModel.findByIdAndRemove({ _id: id }).exec(); (12)
  }
}
1 Añadir un constructor
2 Definir un modelo para libros mediante inyección de dependencias
3 Cambiar a async y que devuelva Promise<Book> con el libro creado
4 Llamada al método de creación de documentos
5 Cambiar a async y que devuelva Promise<Book[]> con la lista de libros
6 Cambio del tipo id a string para adaptarlo al _id de MongoDB, cambiar a async y que devuelva Promise<Book> con el libro buscado
7 Llamada al método de búsqueda de un documento por id
8 Cambio del tipo id a string para adaptarlo al _id de MongoDB, cambiar a async y que devuelva Promise<Book> con el libro modificado
9 Llamada al método de actualización de documentos por id pasándole el JSON con las modificaciones
10 Opción para que devuelva el objeto modificado
11 Cambio del tipo id a string para adaptarlo al _id de MongoDB y cambiar a async
12 Llamada al método de eliminación de documentos por id

De forma predeterminada, el método findOneAndUpdate devuelve el objeto original, no el modificado. Para que devuelva el objeto ya modificado hay que pasar al método la opción de {new: true}.

Modelos en Mongoose

Los modelos en Mongoose son los homólogos de los repositorios en TypeORM.

Cuando estamos creando una API en NestJS con una base de datos relacional, en el constructor del servicio se inyecta un objeto repositorio que envuelve a la entidad que se persiste en la base de datos. El objeto repositorio ofrece todos los métodos para interactuar con la base de datos y abstraernos de los detalles (métodos create, find, findOne, save, …​).

En los servicios con Mongose inyectaremos un objeto modelo que envuelve al esquema que se persiste en MongoDB. El objeto modelo ofrece todos los métodos para interactuar con la base de datos y abstraernos de los detalles (métodos create, find, findOne, save, …​).

4.5. Modificación del controlador

En el controlador hay que hacer pocos cambios respecto al código generado por el CLI de NestJS. Sólo haremos cambios para

  • No convertir a number el valor del id recibido como parámetro en la URL para las operaciones de buscar uno, modificar y eliminar.

  • Añadir decoradores de Swagger OpenAPI.

Archivo src/books/books.controller.ts
import { Req } from '@nestjs/common';
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; (1)
import { BooksService } from './books.service';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';

@Controller('books')
@ApiTags('book') (2)

export class BooksController {
  constructor(private readonly booksService: BooksService) {}

  @Post()
  create(@Body() createBookDto: CreateBookDto) {
    return this.booksService.create(createBookDto);
  }

  @Get()
  findAll() {
    return this.booksService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.booksService.findOne(id); (3)
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateBookDto: UpdateBookDto) {
    return this.booksService.update(id, updateBookDto); (4)
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.booksService.remove(id); (5)
  }
}
1 Decorador de OpenAPI para agrupar los endpoints en Swagger UI
2 Agrupar los endpoints para una etiqueta en Swagger UI
3 Pasar al servicio el id como cadena porque el _id en MongoDB son cadenas hexadecimales
4 Pasar al servicio el id como cadena porque el _id en MongoDB son cadenas hexadecimales
5 Pasar al servicio el id como cadena porque el _id en MongoDB son cadenas hexadecimales

La configuración del decorador @ApiTags('book') ha creado una categoría book en Swagger UI para los libros.

categoria book

4.6. Filtrado de resultados mediante parámetros en la URL

Podemos añadir a nuestras peticiones de recuperación de datos opciones de filtrado. Lo haremos mediante parámetros de consulta en la URL (p.e. ?keywords=NestJS&pages=350). Para ello, añadiremos un objeto Request de Express al método findAll para permitir el uso de parámetros en la URL para el filtrado de resultados.

Archivo src/books/books.controller.ts
import { Req } from '@nestjs/common';
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express'; (1)
import { BooksService } from './books.service';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';

@Controller('books')
@ApiTags('book')
export class BooksController {
  constructor(private readonly booksService: BooksService) {}

  @Post()
  create(@Body() createBookDto: CreateBookDto) {
    return this.booksService.create(createBookDto);
  }

  @Get()
  findAll(@Req() request: Request) { (2)
    return this.booksService.findAll(request); (3)
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.booksService.findOne(id);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateBookDto: UpdateBookDto) {
    return this.booksService.update(id, updateBookDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.booksService.remove(id);
  }
}
1 Uso de objetos Request de Express en el controlador
2 Incluir un parámetro con un objeto Request para acceder a los parámetros de la consulta en la URL
3 Incluir el objeto Request en la llamada al servicio

Ahora introducimos los cambios en el servicio para permitir acceder a los parámetros introducidos en la URL para filtrar y cambiamos la implementación del método findAll para que use la búsqueda con filtros.

Archivo src/books/books.service.ts
import { Injectable } from '@nestjs/common';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { InjectModel } from '@nestjs/mongoose';
import { Book, BookDocument } from './schemas/book.schema';
import { Model } from 'mongoose';
import { Request } from 'express'; (1)

@Injectable()
export class BooksService {
  constructor(
    @InjectModel(Book.name) private readonly bookModel: Model<BookDocument>,
  ) {}

  async create(createBookDto: CreateBookDto): Promise<Book> {
    return this.bookModel.create(createBookDto);
  }

  async findAll(request: Request): Promise<Book[]> { (2)
    return this.bookModel
      .find(request.query) (3)
      .setOptions({ sanitizeFilter: true }) (4)
      .exec();
  }

  async findOne(id: string): Promise<Book> {
    return this.bookModel.findOne({ _id: id }).exec();
  }

  async update(id: string, updateBookDto: UpdateBookDto) {
    return this.bookModel.findOneAndUpdate({ _id: id }, updateBookDto, {
      new: true,
    });
  }

  async remove(id: string) {
    return this.bookModel.findByIdAndRemove({ _id: id }).exec();
  }
}
1 Añadir la dependencia con Request de Express para acceder a los parámetros de la consulta de una request
2 Añadir un parámetro Request para acceder a los parámetros de la consulta pasados en la URL
3 Llamada al método de búsqueda de documentos pasándole los parámetros de la consulta
4 Configuración para evitar la inyección de código malicioso

request.query devuelve una lista clave-valor con cada uno de los campos de filtrado y su valor correspondiente introducidos en la URL.

4.7. Prueba de los endpoints

Probamos el endpoint POST /books con los valores del ejemplo

book post

El resultado devuelto será similar a este, en el que se muestran los datos guardados en la base de datos junto al _id generado por MongoDB.

{
  "genre": "Web Development",
  "description": "JavaScript frameworks go in and out of style very quickly as web technologies change and grow. Nest.js is a good starting point for many developers that are looking to use a modern web framework because it uses a language that is very similar to that of the most used language on the web to this day, JavaScript...",
  "author": "Jay Bell",
  "pages": 350,
  "image_url": "https://m.media-amazon.com/images/I/41fveBeDWmL._SY346_.jpg",
  "keywords": [
    "NestJS",
    "REST API"
  ],
  "_id": "62594f8ddae1eebf6c6c209c",
  "__v": 0
}

Tras la inserción podemos probar que se recuperan correctamente los libros con el endpoint GET /books. Para probar el endpoint que devuelve un libro concreto, copiaremos el _id del libro, desplegaremos en Swagger UI el endpoint GET /books/{:id}, pulsamemos Try it out para probar el endpoint e introduciremos el _id del libro creado. Tras pulsar Execute vemos que se recupera correctamente el libro.

book findone

Para probar el filtrado comprobaremos con una condición de filtrado para libros con 350 páginas. Para ello, en la URL introduciremos la condición de esta forma

$ http://localhost:3000/books?pages=350

Para hacer esta prueba necesitaremos un cliente HTTP o bien introducir la URL en un navegador.

query params

Podemos concatenar varias condiciones y hará un AND lógico con ellas. Para devolver los libros de 350 páginas del género Web Development usaríamos

http://localhost:3000/books?pages=350&genre=Web%20Development

Además, permite la búsqueda en arrays. Para buscar los libros que contengan NestJS en sus palabras clave usaríamos

http://localhost:3000/books?keywords=NestJS

Para hacer una modificación se pasará un JSON en el cuerpo de la petición con los cambios a realizar. Por ejemplo, para cambiar el número de páginas a 400 pasaríamos este JSON en el cuerpo de la petición al endpoint PATCH /books/{id}

{
  "pages": 400
}

Si lanzamos la petición con el _id del libro a modificar, los cambios se almacenarán en la base de datos y nos devolverá los datos actualizados con el número de páginas a 400.

{
  "_id": "62594f8ddae1eebf6c6c209c",
  "genre": "Web Development",
  "description": "JavaScript frameworks go in and out of style very quickly as web technologies change and grow. Nest.js is a good starting point for many developers that are looking to use a modern web framework because it uses a language that is very similar to that of the most used language on the web to this day, JavaScript...",
  "author": "Jay Bell",
  "pages": 400,
  "image_url": "https://m.media-amazon.com/images/I/41fveBeDWmL._SY346_.jpg",
  "keywords": [
    "NestJS",
    "REST API"
  ],
  "__v": 0
}

Para eliminar un libro basta con usar el endpoint DELETE /book/{id} con el _id del libro a eliminar.

5. Configuración del módulo de usuarios

De acuerdo con el diagrama de Introducción tenemos objetos de dominio para libros y para los usuarios que realizan los comentarios sobre los libros. En esta sección nos centraremos en los usuarios.

Es una buena técnica comenzar desarrollando inicialmente los módulos/clases/bloques de la API correspondiente a las clases del dominio que vamos a persistir en bases de datos. Una vez comprobado su funcionamiento por separado, se introducen las modificaciones en los esquemas Mongoose para incluir las relaciones a otros esquemas.

Tal y como hemos comentado en Introducción hay que hacer unos ligeros cambios sobre el código generado para el resource con el CLI de NestJS. En concreto, habrá que cambiar las referencias a entidades por esquemas, y cambiar los identificadores de bases de datos numéricos a cadenas, ya que los _id de MongoDB son cadenas hexadecimales de 24 caracteres.

5.1. Creación del esquema

Comenzamos cambiando las entidades generadas por el CLI de NestJS por esquemas.

  1. Renombramos la carpeta src/users/entities por src/users/schemas

  2. Renombramos el archivo src/users/entities/book.entity.ts por src/users/schemas/user.schema.ts

Para definir un esquema Mongoose hay que:

  • Añadir a la clase el decorador @Schema()

  • Definir en la clase cada campo de la colección y añadirle el decorador @Prop()

El decorador @Schema() sobre una clase hace que se cree una colección en MongoDB con el nombre de la clase, pero en plural (añadiéndole una "s"). El decorador @Prop() sobre una propiedad de la clase añade a la colección un campo con el nombre de la propiedad.

Para el caso de usuarios introduciremos un cambio respecto al ejemplo de libros. Para los usuarios, el _id será gestionado por nosotros y el ´_id` de cada usuario será su login, que es único y también valdrá.

En una colección MongoDB podemos optar por tener nuestros propios identificadores de documentos siempre y cuando controlemos su unicidad. En tal caso, cada vez que se haga una inserción habrá que proporcionar un valor único para _id.

Archivo src/users/schemas/user.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose'; (1)

export type UserDocument = User & Document; (2)

@Schema() (3)
export class User {
  @Prop() (4)
  _id: string; (5)

  @Prop()
  name: string;

  @Prop()
  email: string;

  @Prop()
  country: string;
}

export const UserSchema = SchemaFactory.createForClass(User); (6)
}
1 Importación de Document desde Mongoose
2 Definición del tipo de un documento usuario
3 Decorador para crear una colección MongoDB para la clase
4 Decorador para añadir un campo a la colección
5 Gestión propia del _id. Guardaremos el login, y nos aseguraremos de que sea único.
6 Esquema Mongoose creado a partir de la clase User

5.2. Configuración del módulo

Tal y como hemos comentado anteriormente, en el módulo tenemos que registrar el esquema para que cree la colección correspondiente en MongoDB. Esto lo haremos añadiendo el método forFeature de MongooseModule en el array imports.

Archivo 'src/users/users.module.ts'
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { MongooseModule } from '@nestjs/mongoose';
import { User, UserSchema } from './schemas/user.schema';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), (1)
  ],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
1 Registro del esquema de los usuario

Al guardar los cambios, Mongoose crea la colección users en la base de datos.

Si la base de datos no estaba creada aún, al guardar el primer esquema, se crea la base de datos y la colección asociada al esquema creado.

5.3. Creación del DTO

Tal y como hemos comentado anteriormente, el DTO define la estructura de un objeto que se pasa en el cuerpo de una petición HTTP. Inicialmente, y de acuerdo con el diagrama de Introducción, los campos de los usuarios son los siguientes:

  • id como identificador del usuario.

  • name para el nombre del usuario

  • email para el email

  • country para el país del usuario

Archivo src/users/dto/create-user.dto.ts
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
  @ApiProperty({ example: 'johndoe' }) (1)
  readonly _id: string; (2)

  @ApiProperty({ example: 'John Doe' })
  readonly name: string;

  @ApiProperty({ example: 'johndoe@gmail.com' })
  readonly email: string;

  @ApiProperty({ example: 'Spain' })
  readonly country: string;
}
1 Decorador para definir una propiedad para la documentación en Swagger OpenAPI
2 Definición de campo

En Swagger UI, al desplegar el endpoint POST /users el ejemplo muestra los valores configurados con el decorador @ApiProperty de Swagger OpenAPI. También aparecen como plantilla si probásemos a introducir un usuario. Aún no probaremos a insertar el usuario porque está sin implementar el servicio. Recordamos que la implementación actual del servicio es la que ha generado el CLI de NestJS y se limita a mostrar que el servicio ha sido llamado. Hay que cambiar su implementación para que interactúe con la base de datos.

dto usuario

5.4. Implementación del servicio

Partimos del código generado por el CLI de NestJS para el servicio. Además de dar la implementación de la interacción con la base de datos mediante Mongoose habrá que:

  • Hacer que los métodos sean async ya que los métodos de acceso a la base de datos son asíncronos y devuelven promesas.

  • Configurar el tipo devuelto por los métodos.

  • Cambiar el parámetro id de number a string para que pueda tratar con el _id hexadecimal de MongoDB.

Recordamos que el CLI de NestJS genera el código de los métodos del servicio pensando en que la clave es de tipo numérico. Hay que cambiar el tipo del argumento id en los métodos findOne, update y remove a string para que sea válido para el _id de 24 caracteres hexadecimales de MongoDB.

Tal y como hemos comentado anteriormente, los métodos que usaremos de Mongoose serán

  • create() para la inserción de un documento.

  • find() para recuperar todo.

  • findOne() para recuperar un documento.

  • findOneAndUpdate() para la actualización de un documento.

  • remove() para la eliminación de un documento.

Archivo src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { User, UserDocument } from './schemas/user.schema';

@Injectable()
export class UsersService {
  constructor( (1)
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>, (2)
  ) {}

  async create(createUserDto: CreateUserDto): Promise<User> { (3)
    return this.userModel.create(createUserDto); (4)
  }

  async findAll(): Promise<User[]> { (5)
    return this.userModel.find().exec();
  }

  async findOne(id: string): Promise<User> { (6)
    return this.userModel.findOne({ _id: id }).exec(); (7)
  }

  async update(id: string, updateUserDto: UpdateUserDto) { (8)
    return this.userModel.findOneAndUpdate({ _id: id }, updateUserDto, { (9)
      new: true, (10)
    });
  }

  async remove(id: string) { (11)
    return this.userModel.findByIdAndRemove({ _id: id }).exec(); (12)
  }
}
1 Añadir un constructor
2 Definir un modelo para usuarios mediante inyección de dependencias
3 Cambiar a async y que devuelva Promise<User> con el usuario creado
4 Llamada al método de creación de documentos
5 Cambiar a async y que devuelva Promise<User[]> con la lista de usuarios
6 Cambio del tipo id a string para adaptarlo al _id de MongoDB, cambiar a async y que devuelva Promise<User> con el usuario buscado
7 Llamada al método de búsqueda de un documento por id
8 Cambio del tipo id a string para adaptarlo al _id de MongoDB, cambiar a async y que devuelva Promise<User> con el usuario modificado
9 Llamada al método de actualización de documentos por id pasándole el JSON con las modificaciones
10 Opción para que devuelva el objeto modificado
11 Cambio del tipo id a string para adaptarlo al _id de MongoDB y cambiar a async
12 Llamada al método de eliminación de documentos por id

De forma predeterminada, el método findOneAndUpdate devuelve el objeto original, no el modificado. Para que devuelva el objeto ya modificado hay que pasar al método la opción de {new: true}.

5.5. Modificación del controlador

Tal y como hemos comentado anteriormente, en el controlador hay que hacer pocos cambios respecto al código generado por el CLI de NestJS. Sólo haremos cambios para

  • No convertir a number el valor del id recibido como parámetro en la URL para las operaciones de buscar uno, modificar y eliminar.

  • Añadir decoradores de Swagger OpenAPI.

Archivo src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiTags } from '@nestjs/swagger'; (1)

@Controller('users')
@ApiTags('user') (2)

export class UsersController {
  constructor(private readonly userService: UsersService) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    return this.userService.create(createUserDto);
  }

  @Get()
  async findAll() {
    return this.userService.findAll();
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    return this.userService.findOne(id); (3)
  }

  @Patch(':id')
  async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.userService.update(id, updateUserDto); (4)
  }

  @Delete(':id')
  async remove(@Param('id') id: string) {
    return this.userService.remove(id); (5)
  }
}
1 Decorador de OpenAPI para agrupar los endpoints en Swagger UI
2 Agrupar los endpoints para una etiqueta en Swagger UI
3 Pasar al servicio el id como cadena porque el _id en MongoDB son cadenas hexadecimales
4 Pasar al servicio el id como cadena porque el _id en MongoDB son cadenas hexadecimales
5 Pasar al servicio el id como cadena porque el _id en MongoDB son cadenas hexadecimales

La configuración del decorador @ApiTags('user') ha creado una categoría user en Swagger UI para los usuarios.

categoria user

5.6. Filtrado de resultados mediante parámetros en la URL

Tal y como hemos comentado anteriormente, podemos añadir a nuestras peticiones de recuperación de datos opciones de filtrado. Lo haremos mediante parámetros de consulta en la URL (p.e. ?country=Spain). Para ello, añadiremos un objeto Request de Express al método findAll para permitir el uso de parámetros en la URL para el filtrado de resultados.

Archivo src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  Req
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express'; (1)

@Controller('users')
@ApiTags('user')

export class UsersController {
  constructor(private readonly userService: UsersService) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    return this.userService.create(createUserDto);
  }

  @Get()
  async findAll(@Req() request: Request) { (2)
    return this.userService.findAll(request); (3)
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    return this.userService.findOne(id);
  }

  @Patch(':id')
  async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.userService.update(id, updateUserDto);
  }

  @Delete(':id')
  async remove(@Param('id') id: string) {
    return this.userService.remove(id);
  }
}
1 Uso de objetos Request de Express en el controlador
2 Incluir un parámetro con un objeto Request para acceder a los parámetros de la consulta en la URL
3 Incluir el objeto Request en la llamada al servicio

Ahora introducimos los cambios en el servicio para permitir acceder a los parámetros introducidos en la URL para filtrar y cambiamos la implementación del método findAll para que use la búsqueda con filtros.

Archivo src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { User, UserDocument } from './schemas/user.schema';
import { Request } from 'express'; (1)

@Injectable()
export class UsersService {
  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
  ) {}

  async create(createUserDto: CreateUserDto): Promise<User> {
    return this.userModel.create(createUserDto);
  }

  async findAll(request: Request): Promise<User[]> { (2)
    return this.userModel
      .find(request.query) (3)
      .setOptions({ sanitizeFilter: true }) (4)
      .exec();
  }

  async findOne(id: string): Promise<User> {
    return this.userModel.findOne({ _id: id }).exec();
  }

  async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
    return this.userModel.findOneAndUpdate({ _id: id }, updateUserDto, {
      new: true,
    });
  }

  async remove(id: string) {
    return this.userModel.findByIdAndRemove({ _id: id }).exec();
  }
}
1 Añadir la dependencia con Request de Express para acceder a los parámetros de la consulta de una request
2 Añadir un parámetro Request para acceder a los parámetros de la consulta pasados en la URL
3 Llamada al método de búsqueda de documentos pasándole los parámetros de la consulta
4 Configuración para evitar la inyección de código malicioso

request.query devuelve una lista clave-valor con cada uno de los campos de filtrado y su valor correspondiente introducidos en la URL.

5.7. Prueba de los endpoints

Probamos el endpoint POST /users con los valores del ejemplo

user post

El resultado devuelto será similar a este, en el que se muestran los datos guardados en la base de datos junto al _id generado por MongoDB.

{
  "_id": "johndoe",
  "name": "John Doe",
  "email": "johndoe@gmail.com",
  "country": "Spain",
  "__v": 0
}

Insertaremos también otro usuario a modo de prueba con estos valores.

{
  "_id": "marysmith",
  "name": "Mary Smith",
  "email": "marysmith@gmail.com",
  "country": "France"
}

Tras la inserción podemos probar que se recuperan correctamente los usuarios con el endpoint GET /users.

all users

Para probar el endpoint que devuelve un usuario concreto, desplegaremos en Swagger UI el endpoint GET /users/{:id}, pulsamemos Try it out para probar el endpoint e introduciremos el _id del usuario creado (johndoe). Tras pulsar Execute vemos que se recupera correctamente el usuario.

user findone

Para probar el filtrado comprobaremos con una condición de filtrado para usuarios de España. Para ello, en la URL introduciremos la condición de esta forma

$ http://localhost:3000/users?country=spain

Para hacer esta prueba necesitaremos un cliente HTTP o bien introducir la URL en un navegador.

user query params

Para hacer una modificación se pasará un JSON en el cuerpo de la petición con los cambios a realizar. Por ejemplo, para cambiar el país a Italia pasaríamos este JSON en el cuerpo de la petición al endpoint PATCH /users/{id}

{
  "country": "Italy"
}

Si lanzamos la petición con el _id del usuario a modificar, los cambios se almacenarán en la base de datos y nos devolverá los datos actualizados con país a Italia.

{
  "_id": "johndoe",
  "name": "John Doe",
  "email": "johndoe@gmail.com",
  "country": "Italy",
  "__v": 0
}

Para eliminar un usuario basta con usar el endpoint DELETE /users/{id} con el _id del usuario a eliminar.

6. Rechazo de peticiones con _id inválido

Si en colecciones con _id de tipo ObjectID de MongoDB realizamos operaciones de búsqueda, actualización o eliminación con un id con formato inválido se producira un error. Si hacemos la prueba con el endpoint GET /books/{id} y le pasamos un id que no tenga el formato hexadecimal de 24 caracteres del los identificadores de MongoDB (p.e. 1), obtendremos un error.

error id

Una solución para este problema es usar un pipe de NestJS para que compruebe si el identificador pasado en la URL es correcto. Si no es correcto interceptará el error y lo informará. Si es correcto, el pipe devuelve el valor de entrada tal cual.

El pipe se apoya en el método isObjectIdOrHexString del paquete mongoose. que devuelve true si es un identificador MongoDB válido o false en caso contrario.

A continuación se muestra el código del pipe, que hemos colocado en la carpeta utilities.

Archivo utilities/parse-object-id-pipe.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import mongoose from 'mongoose';

@Injectable()
export class ParseObjectIdPipe
  implements PipeTransform<any, mongoose.Types.ObjectId>
{
  transform(value: any): mongoose.Types.ObjectId {
    const validObjectId: boolean = mongoose.isObjectIdOrHexString(value); (1)
    if (!validObjectId) {
      throw new BadRequestException('Invalid ObjectId'); (2)
    }
    return value; (3)
  }
}
1 Determinar si el identificador es válido para MongoDB
2 Lanzar una excepción si el identificador no es válido
3 Devolver el valor si el identificador es válido Uso del pipe en los controladores

El pipe ParseObjectIdPipe definido se usará en los controladores que tengan endpoints que acepten identificadores MongoDB. El pipe se colocará después del parámetro id leído en la URL.

 @Get(':id')
  findOne(@Param('id', ParseObjectIdPipe) id: string) { (1)
    return this.booksService.findOne(id);
  }
1 Uso del pipe tras leer el id de la URL.

Si ahora volvemos a lanzar la petición, ya no provocará un error en la aplicación y nos informará del error adecuadamente.

invalid objectid
Archivo src/books/books.controller.ts
import { Req } from '@nestjs/common';
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { BooksService } from './books.service';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { ParseObjectIdPipe } from '../utilities/parse-object-id-pipe.pipe';

@Controller('books')
@ApiTags('book')
export class BooksController {
  constructor(private readonly booksService: BooksService) {}

  @Post()
  create(@Body() createBookDto: CreateBookDto) {
    return this.booksService.create(createBookDto);
  }

  @Get()
  findAll(@Req() request: Request) {
    return this.booksService.findAll(request);
  }

  @Get(':id')
  findOne(@Param('id', ParseObjectIdPipe) id: string) { (1)
    return this.booksService.findOne(id);
  }

  @Patch(':id')
  update(
    @Param('id', ParseObjectIdPipe) id: string, (2)
    @Body() updateBookDto: UpdateBookDto,
  ) {
    return this.booksService.update(id, updateBookDto);
  }

  @Delete(':id')
  remove(@Param('id', ParseObjectIdPipe) id: string) { (3)
    return this.booksService.remove(id);
  }
}
1 Uso de ParseObjectIdPipe para la búsqueda de un libro
2 Uso de ParseObjectIdPipe para la modificación de un libro
3 Uso de ParseObjectIdPipe para la eliminación de un libro

7. Incorporación de comentarios

Cuando creamos el esquema para los libros, el campo keywords fue definido como un array. En una base de datos relacional habríamos definido una tabla aparte para guardar esa lista de valores. Sin embargo, en una base de datos como MongoDB las técnicas de diseño son diferentes y ese tipo de campos multivaluados se guardan directamente en un array.

El campo keywords fue definido como un array de cadenas en el esquema de los libros.

...

@Schema()
export class Book {
  ...

  @Prop([String]) (1)
  keywords: string[]; (2)
}

...
1 Array de String MongoDB
2 Palabras clave como un array de cadenas

Para tipos no primitivos (como arrays, documentos o una combinación de ellos) hay que añadir en @Prop() el tipo de datos que se va usar.

A continuación vamos a modificar el esquema de los libros para añadir un nuevo campo para los comentarios. De acuerdo con el esquema de Introducción los comentarios también son un campo multivaluado. Sin embargo, a diferencia de las palabras clave, no son un array de un tipo primitivo. Los comentarios son un array de documentos. En tal caso, crearemos un esquema para ellos.

7.1. Creación del esquema

Inicialmente, y de acuerdo con el diagrama de Introducción, exceptuando el identificador, los campos de los comentarios son los siguientes:

  • title como título del comentario.

  • stars como valoración en forma de estrellas que tiene el comentario.

  • comment como descripción del comentario.

  • username como autor del comentario.

En este esquema prestamos también atención a que los autores de los comentarios son los usuarios que tenemos definidos en el esquema User. Esto lo implementaremos mediante referencias en Mongoose.

Archivo src/books/schemas/comment.schema.ts
import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose';
import { ApiProperty } from '@nestjs/swagger';
import { Document } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
import mongoose from 'mongoose';

export type CommentDocument = Comment & Document;

@Schema()
export class Comment {
  @Prop()
  title: string;

  @Prop()
  stars: number;

  @Prop()
  comment: string;

  @Prop({ type: mongoose.Schema.Types.String, ref: 'User' }) (1)
  username: User; (2)
}

export const CommentSchema = SchemaFactory.createForClass(Comment);
1 Tipo como una referencia a los usuarios en los que la clave es un String, no un ObjectId
2 Creador del comentario de tipo User
Tipos de las referencias

Una referencia introduce una relación entre un campo y un esquema Mongoose. La referencia se configura en el decorador @Prop(). Hay que tener en cuenta dos cuestiones:

  • Con ref definimos el tipo de datos del campo que se está definiendo. Lo hacemos indicando el esquema de destino.

  • type es el tipo de datos Mongoose con el que enlaza la propiedad que se está definiendo. En el ejemplo de los usuarios, como el _id lo configuramos para que fuera el nombre de usuario y éste lo definimos como tipo cadena, la referencia del destino será a un tipo mongoose.Schema.Types.String. Si el campo referenciado fuese un identificador hexadecimal MongoDB, el tipo de datos sería mongoose.Schema.Types.ObjectId

7.2. Incorporación de los comentarios al esquema de los libros

Una vez definido el esquema de los comentarios podemos modificar el esquema de los libros para añadirle un nuevo campo para los comentarios. Su tipo de datos será un array de comentarios.

Archivo src/books/schemas/book.schema.ts
import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { CommentSchema } from './comment.schema';

export type BookDocument = Book & Document;

@Schema()
export class Book {
  @Prop()
  genre: string;

  @Prop()
  description: string;

  @Prop()
  author: string;

  @Prop()
  pages: number;

  @Prop()
  image_url: string;

  @Prop([String])
  keywords: string[];

  @Prop([CommentSchema]) (1)
  comments: Comment[]; (2)
}

export const BookSchema = SchemaFactory.createForClass(Book);
1 Configuración del tipo porque no es un tipo básico
2 Tipo de los comentarios

Como el tipo de datos de los comentarios no es un tipo primitivo, hay que indicar su tipo en el decorador @Prop.

Al guardar los cambios se modificarán los documentos almacenados en la colección books y les habrá añadido un campo comments cuyo tipo es un array de comentarios.

Si probamos cualquiera de los endpoints de recuperación de libros veremos que ahora los libros tienen la lista de comentarios. A continuación se muestra lo que devolvería el endpoint GET /users.

[
  {
    "_id": "62594f8ddae1eebf6c6c209c",
    "genre": "Web Development",
    "description": "JavaScript frameworks go in and out of style very quickly as web technologies change and grow. Nest.js is a good starting point for many developers that are looking to use a modern web framework because it uses a language that is very similar to that of the most used language on the web to this day, JavaScript...",
    "author": "Jay Bell",
    "pages": 350,
    "image_url": "https://m.media-amazon.com/images/I/41fveBeDWmL._SY346_.jpg",
    "keywords": [
      "NestJS",
      "REST API"
    ],
    "__v": 0,
    "comments": [] (1)
  }
]
1 Comentarios como un array
Los agregados en una base de datos NoSQL

La definición de un esquema para los comentarios no supone la creación de una nueva colección. Para definir una colección a partir de un esquema en Mongoose hay que crear un modelo. En el tutorial hemos creado dos modelos al inyectarlos como dependencia en los servicios creados para los libros y los usuarios. Crear un esquema para los comentarios simplemente define un tipo de documento que queda disponible para definir campos en un esquema Mongoose.

La idea de incorporación de datos anidados choca bastante inicialmente si se tiene una visión relacional profunda. La creación de estas estructuras complejas de arrays, subdocumentos y cualquier combinación de ellos se corresponde con el concepto de agregado de las bases de datos NoSQL. En una base de datos relacional habríamos segregado los comentarios a una tabla aparte debido a la relación 1:M que hay entre los libros y los comentarios. Sin embargo, el criterio de diseño en una base de datos NoSQL es que si los datos se van a recuperar juntos, se deben almacenar también juntos.

El planteamiento de los agregados reduce enormemente la complejidad de los esquemas de la base de datos y aumenta la velocidad de las consultas, ya que se reducen las operaciones de join porque los datos relacionados están almacenados junto a los datos que los referencian. Por tanto, es muy importante conocer a priori los tipos de consultas que vamos a tener porque condicionarán e influirán en el diseño de la base de datos para definir los agregados.

Para una visión panorámica sobre las bases de datos NoSQL, consulta NoSQL Databases: An Overview . Si no conoces las bases de datos NoSQL, tras su lectura cambiará tu forma de ver las bases de datos.

7.3. Creación del DTO

El DTO define la estructura de un objeto que se pasa en el cuerpo de una petición HTTP. Inicialmente, y de acuerdo con el diagrama de Introducción, para un comentario tendremos los mismos campos que para el esquema definido anteriormente. Cuando se guarden los comentarios, MongoDB les asignará un _id porque no se ha definido ningún _id específico en el esquema de los comentarios, pero esto es cosa de MongoDB. Desde el punto de vista del DTO, los datos que nos interesan son:

  • title como título del comentario.

  • stars como valoración en forma de estrellas que tiene el comentario.

  • comment como descripción del comentario.

  • username como autor del comentario.

Archivo src/books/dto/create-comment.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { User } from '../../users/schemas/user.schema';

export class CreateCommentDto {
  @ApiProperty({ example: 'Esperaba más' })
  readonly title: string;

  @ApiProperty({
    example: 3,
  })
  readonly stars: number;

  @ApiProperty({
    example:
      'Nisi officia fugiat id nulla laboris ex. Sit laboris culpa occaecat occaecat aliquip dolor non excepteur reprehenderit.',
  })
  readonly comment: string;

  @ApiProperty({ example: 'johndoe' })
  readonly username: User;
}

7.4. Implementación del método de creación de comentarios

A continuación hay que modificar el servicio de comentarios para implementar un nuevo método que permita añadir comentarios a un libro. El nuevo método recibirá dos argumentos: el id del libro al que hay que añadir el comentario y el objeto con el comentario. Su implementación consistirá en recuperar el libro, añadir el comentario al array de comentarios del libro y guardar el libro.

Archivo src/books/books.service.ts
import { Injectable } from '@nestjs/common';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { InjectModel } from '@nestjs/mongoose';
import { Book, BookDocument } from './schemas/book.schema';
import { Model } from 'mongoose';
import { Request } from 'express';

@Injectable()
export class BooksService {
  constructor(
    @InjectModel(Book.name) private readonly bookModel: Model<BookDocument>,
  ) {}

  async create(createBookDto: CreateBookDto): Promise<Book> {
    return this.bookModel.create(createBookDto);
  }

  async findAll(request: Request): Promise<Book[]> {
    return this.bookModel
      .find(request.query)
      .setOptions({ sanitizeFilter: true })
      .exec();
  }

  async findOne(id: string): Promise<Book> {
    return this.bookModel.findOne({ _id: id }).exec();
  }

  async update(id: string, updateBookDto: UpdateBookDto): Promise<Book> {
    return this.bookModel.findOneAndUpdate({ _id: id }, updateBookDto, {
      new: true,
    });
  }

  async remove(id: string) {
    return this.bookModel.findByIdAndRemove({ _id: id }).exec();
  }

  async addComment(id: string, comment: any) { (1)
    let book: BookDocument = await this.bookModel.findById(id); (2)
    book.comments.push(comment); (3)
    book.save(); (4)
    return book;
  }
}
1 Método para añadir un comentario a un libro
2 Recuperar el libro por su identificador
3 Añadir el comentario al array de comentarios
4 Guardar el libro

7.5. Modificación del controlador

Ahora vamos a añadir una nueva ruta al controlador de los libros que realice una operación POST. La ruta será /books/{id}/comment y hace referencia a que al libro {id} se le va a crear un comentario. El comentario se le pasará en el cuerpo de la petición de acuerdo con el DTO de creación de comentarios definido anteriormente.

import { Req } from '@nestjs/common';
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { BooksService } from './books.service';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { ParseObjectIdPipe } from '../utilities/parse-object-id-pipe.pipe';
import { CreateCommentDto } from './dto/create-comment.dto';

@Controller('books')
@ApiTags('book')
export class BooksController {
  constructor(private readonly booksService: BooksService) {}

  @Post()
  create(@Body() createBookDto: CreateBookDto) {
    return this.booksService.create(createBookDto);
  }

  @Get()
  findAll(@Req() request: Request) {
    return this.booksService.findAll(request);
  }

  @Get(':id')
  findOne(@Param('id', ParseObjectIdPipe) id: string) {
    return this.booksService.findOne(id);
  }

  @Patch(':id')
  update(
    @Param('id', ParseObjectIdPipe) id: string,
    @Body() updateBookDto: UpdateBookDto,
  ) {
    return this.booksService.update(id, updateBookDto);
  }

  @Delete(':id')
  remove(@Param('id', ParseObjectIdPipe) id: string) {
    return this.booksService.remove(id);
  }

  @Post(':id/comment') (1)
  async addComment(
    @Param('id', ParseObjectIdPipe) id: string, (2)
    @Body() comment: CreateCommentDto, (3)
  ) {
    return this.booksService.addComment(id, comment); (4)
  }
}
1 Ruta para añadir los comentarios
2 Captura del parámetro y paso por el pipe de comprobación de identificadores de MongoDB
3 Paso del comentario en el cuerpo de la petición
4 Llamada al método de incorporación de comentarios

7.6. Prueba del endpoint

Ya sólo nos falta ver en acción lo que hemos desarrollado para la incorporación de comentarios a los libros. Si desplegamos el nuevo endpoint de la API para añadir libros, crearemos un nuevo comentario pasándole el identificador del libro y dejaremos el comentario predeterminado.

agregar comentario predeterminado

Tras añadirlo, la respuesta del servidor sería el libro con el comentario añadido incorporando el usuario que ha realizado el comentario.

{
  "_id": "62594f8ddae1eebf6c6c209c",
  "genre": "Web Development",
  "description": "JavaScript frameworks go in and out of style very quickly as web technologies change and grow. Nest.js is a good starting point for many developers that are looking to use a modern web framework because it uses a language that is very similar to that of the most used language on the web to this day, JavaScript...",
  "author": "Jay Bell",
  "pages": 350,
  "image_url": "https://m.media-amazon.com/images/I/41fveBeDWmL._SY346_.jpg",
  "keywords": [
    "NestJS",
    "REST API"
  ],
  "__v": 1,
  "comments": [
    { (1)
      "title": "Esperaba más",
      "stars": 3,
      "comment": "Nisi officia fugiat id nulla laboris ex. Sit laboris culpa occaecat occaecat aliquip dolor non excepteur reprehenderit.",
      "username": "johndoe", (2)
      "_id": "625bade7c73a12ec6c5c9c16"
    }
  ]
}
1 Comentario añadido
2 Identificador del usuario que ha realizado el comentario

Añadiremos un segundo comentario al mismo libro a cargo de marysmith con el contenido siguiente.

{
  "title": "Me encantó",
  "stars": 5,
  "comment": "Non consequat laborum pariatur et tempor fugiat excepteur. Incididunt minim voluptate nostrud Lorem ullamco consectetur sint veniam amet non aliqua proident. Cillum reprehenderit veniam nulla ex eiusmod id sit tempor. Consectetur nostrud Lorem duis culpa sit incididunt aliqua.",
  "username": "marysmith",
}
agregar segundo comentario

El servidor devolvería ahora el libro con los dos comentarios añadidos.

{
  "_id": "62594f8ddae1eebf6c6c209c",
  "genre": "Web Development",
  "description": "JavaScript frameworks go in and out of style very quickly as web technologies change and grow. Nest.js is a good starting point for many developers that are looking to use a modern web framework because it uses a language that is very similar to that of the most used language on the web to this day, JavaScript...",
  "author": "Jay Bell",
  "pages": 350,
  "image_url": "https://m.media-amazon.com/images/I/41fveBeDWmL._SY346_.jpg",
  "keywords": [
    "NestJS",
    "REST API"
  ],
  "__v": 1,
  "comments": [
    { (1)
      "title": "Esperaba más",
      "stars": 3,
      "comment": "Nisi officia fugiat id nulla laboris ex. Sit laboris culpa occaecat occaecat aliquip dolor non excepteur reprehenderit.",
      "username": "johndoe",
      "_id": "625bade7c73a12ec6c5c9c16"
    },
    { (2)
      "title": "Me encantó",
      "stars": 5,
      "comment": "Non consequat laborum pariatur et tempor fugiat excepteur. Incididunt minim voluptate nostrud Lorem ullamco consectetur sint veniam amet non aliqua proident. Cillum reprehenderit veniam nulla ex eiusmod id sit tempor. Consectetur nostrud Lorem duis culpa sit incididunt aliqua.",
      "username": "marysmith",
      "_id": "625baedcc73a12ec6c5c9c19"
    }
  ]
}
1 Primer comentario
2 Segundo comentario

Si consultamos los libros con cualquiera de los dos endpoints disponibles, ahora cada libro incorporá sus comentarios.

7.7. Incoporación de los datos relacionados

La familia de los métodos find de Mongoose ofrece la posibilidad de añadir los datos relacionados a un resultado. Esto nos permite incorporar a los comentarios los datos completos de los usuarios en las consultas (p.e. país, nombre completo, …​). Recordemos que en cada comentario sólo se guardaba el username de un usuario (su identificador). El resto de los datos de los usuarios están en la colección users.

El método populate añadido a una operación de la famila find permite esa hidratación con datos relacionados. Al método populate se le pasa el campo que se quiere enriquecer. De forma predeterminada, se poblará siguiendo la relación definida en el esquema. No obstante, es posible poblar un campo con cualquier conjunto de datos.

A continuación se muestra cómo quedaría finalmente el servicio de los libros al que se ha incorporado el método populate del campo comments.username a los dos métodos find implementados.

Archivo src/books/books.service.ts
import { Injectable } from '@nestjs/common';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { InjectModel } from '@nestjs/mongoose';
import { Book, BookDocument } from './schemas/book.schema';
import { Model } from 'mongoose';
import { Request } from 'express';

@Injectable()
export class BooksService {
  constructor(
    @InjectModel(Book.name) private readonly bookModel: Model<BookDocument>,
  ) {}

  async create(createBookDto: CreateBookDto): Promise<Book> {
    return this.bookModel.create(createBookDto);
  }

  async findAll(request: Request): Promise<Book[]> {
    return this.bookModel
      .find(request.query)
      .populate({ path: 'comments.username' }) (1)
      .setOptions({ sanitizeFilter: true })
      .exec();
  }

  async findOne(id: string): Promise<Book> {
    return this.bookModel
      .findOne({ _id: id })
      .populate({ path: 'comments.username' }) (2)
      .exec();
  }

  async update(id: string, updateBookDto: UpdateBookDto): Promise<Book> {
    return this.bookModel.findOneAndUpdate({ _id: id }, updateBookDto, {
      new: true,
    });
  }

  async remove(id: string) {
    return this.bookModel.findByIdAndRemove({ _id: id }).exec();
  }

  async addComment(id: string, comment: any) {
    let book: BookDocument = await this.bookModel.findById(id);
    book.comments.push(comment);
    book.save();
    return book;
  }
}
1 Incorporación de datos de usuario
2 Incorporación de datos de usuario

Si ahora recuperáramos los libros con GET /books veríamos que los datos de los comentarios han sido enriquecidos con los datos de los usuarios.

[
  {
    "_id": "62594f8ddae1eebf6c6c209c",
    "genre": "Web Development",
    "description": "JavaScript frameworks go in and out of style very quickly as web technologies change and grow. Nest.js is a good starting point for many developers that are looking to use a modern web framework because it uses a language that is very similar to that of the most used language on the web to this day, JavaScript...",
    "author": "Jay Bell",
    "pages": 350,
    "image_url": "https://m.media-amazon.com/images/I/41fveBeDWmL._SY346_.jpg",
    "keywords": [
      "NestJS",
      "REST API"
    ],
    "__v": 2,
    "comments": [
      {
        "title": "Esperaba más",
        "stars": 3,
        "comment": "Nisi officia fugiat id nulla laboris ex. Sit laboris culpa occaecat occaecat aliquip dolor non excepteur reprehenderit.",
        "username": { (1)
          "_id": "johndoe",
          "name": "John Doe",
          "email": "johndoe@gmail.com",
          "country": "Italy",
          "__v": 0
        },
        "_id": "625bade7c73a12ec6c5c9c16"
      },
      {
        "title": "Me encantó",
        "stars": 5,
        "comment": "Non consequat laborum pariatur et tempor fugiat excepteur. Incididunt minim voluptate nostrud Lorem ullamco consectetur sint veniam amet non aliqua proident. Cillum reprehenderit veniam nulla ex eiusmod id sit tempor. Consectetur nostrud Lorem duis culpa sit incididunt aliqua.",
        "username": { (2)
          "_id": "marysmith",
          "name": "Mary Smith",
          "email": "marysmith@gmail.com",
          "country": "France",
          "__v": 0
        },
        "_id": "625baedcc73a12ec6c5c9c19"
      }
    ]
  }
]
1 Comentario enriquecido con los datos de usuario
2 Comentario enriquecido con los datos de usuario

Anexo I. Configuración de MongoDB local con Docker Compose

Para trabajar localmente con MongoDB necesitamos una base de datos a la que conectarnos. Para no tener que complicarnos con instalaciones y no acoplar el desarrollo a nuestro equipo, en este tutorial utilizaremos una instalación local de MongoDB con Docker Compose. Trabajaremos con una base de datos denominada tutorial que guarda los datos en un directorio mongo-data respecto al directorio en el que esté el archivo docker-compose.yml. Esta base de datos debe coincidir con la que se use a la hora de establecer la conexión. En el tutorial hemos realizado la conexión en app.module.ts.

Archivo docker-compose.yml
version: "3"

services:
  mongodb:
    container_name: mongodb
    image: mongo:latest
    environment:
      - MONGODB_DATABASE="tutorial"
    ports:
      - 27017:27017
    volumes:
      - ./mongo-data:/data/db