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.
-
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.
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 tipoComment
(segundo cambio respecto al enfoque relacional). En el esquemaComment
el campousername
será una referencia al esquema de la colecciónUser
.
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.
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.
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
.
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.
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 |
4.1. Creación del esquema
Comenzamos cambiando las entidades generadas por el CLI de NestJS por esquemas.
-
Renombramos la carpeta
src/books/entities
porsrc/books/schemas
-
Renombramos el archivo
src/books/entities/book.entity.ts
porsrc/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.
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 |
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
.
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
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.
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
denumber
astring
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 |
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.
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 |
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 delid
recibido como parámetro en la URL para las operaciones de buscar uno, modificar y eliminar. -
Añadir decoradores de Swagger OpenAPI.
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.
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.
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.
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 |
|
4.7. Prueba de los endpoints
Probamos el endpoint POST /books
con los valores del ejemplo
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.
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.
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 |
5.1. Creación del esquema
Comenzamos cambiando las entidades generadas por el CLI de NestJS por esquemas.
-
Renombramos la carpeta
src/users/entities
porsrc/users/schemas
-
Renombramos el archivo
src/users/entities/book.entity.ts
porsrc/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 |
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
.
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
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.
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
denumber
astring
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 |
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.
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 |
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 delid
recibido como parámetro en la URL para las operaciones de buscar uno, modificar y eliminar. -
Añadir decoradores de Swagger OpenAPI.
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.
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.
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.
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 |
|
5.7. Prueba de los endpoints
Probamos el endpoint POST /users
con los valores del ejemplo
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
.
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.
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.
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.
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
.
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.
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 |
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.
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 |
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.
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 |
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 |
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.
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.
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.
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",
}
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.
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
.
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