logocloudstic

Resumen

NestJS es un framework para el desearrollo de aplicaciones Node.js en el lado del servidor. Se programa en TypeScript y proporciona una arquitectura en la aplicación que permite el desarrollo de aplicaciones más fáciles de mantener. Su arquitectura está bastante inspirada en Angular lo que facilita el trabajo al equipo de desarrollo al no tener que usar dos formas diferentes de trabajo en el backend y en el frontend.

Objetivos
  • Usar el CLI de Nest para la creación de componentes de la aplicación

  • Conocer el funcionamiento de los controladores y los servicios

  • Crear los métodos básicos CRUD de una aplicación

  • Saber cómo capturar los parámetros de las peticiones HTTP

  • Conocer las diferencias y utilidades de las entities de los ORM (Object Relational Mappers), las interfaces y los DTO (Data Transfer Objects)

  • Crear una API sencilla con datos mockeados

  • Crear servicios basados en bases de datos

  • Usar JWT como mecanismo de control de acceso

  • Usar Swagger para la documentación de la API

  • Registrar las operaciones de la aplicación en archivos de log

  • Usar Compodoc para la documentación de la aplicación

  • Ofrecer la salud de la API en una ruta concreta

  • Ofrecer las métricas de uso de los endpoints de la API en una ruta concreta

Disponible el repositorio usado en este tutorial.

1. Introducción

A la hora de desarrollar un proyecto es importante tener una estructura y una estrategia bien planeada para la organización del código. En situaciones donde además los requerimientos son cambiantes es fácil llegar pronto al desastre. Uno de los motivos por los que surge el framework NestJS es precisamente el facilitar que los desarrolladores puedan tener una estructura modular de código, lo que facilita el desarrollo de aplicaciones NodeJS empresariales.

NestJS es un framework NodeJS construido sobre NodeJS y TypeScript, y que hace uso de Express. Además ofrece soporte para las principales bases de datos (MySQL, PostgreSQL, Oracle, SQLite, MongoDB, …​), Swagger (OpenAPI), autenticación, logging, y una arquitectura inspirada en Angular, características que lo hacen un framework bastante interesante.

En este tutorial desarrollaremos una API (repositorio) sobre bases de datos (MySQL y PostgreSQL) que implementa endpoints para las operaciones básicas (find, findOne, create, update, delete). Comenzaremos creando un armazón con los controladores y servicios funcionando en modo mock. Una vez probada la conexión correcta entre ellos, se sustituirán los servicios para que interactúen con la base de datos. Además, la API implementará control de acceso a los endpoints mediante JSON Web Tokens, quedará documentada con Swagger y registrará sus operaciones en archivos de log.

En cuanto a la información de uso de la API se ve cómo usar Terminus para exponer el estado de salud de la aplicación y sus componentes.

2. Creación del proyecto

$ nest new tutorial-nest-js
$ cd tutorial-nest-js
$ npm run start:dev

Esto crea un proyecto y lo ejecuta en el puerto 3000 en modo live reload.

HelloWorld

Se puede cambiar el puerto en el que se sirve la aplicación modificando el archivo main.ts

  await app.listen(3000); (1)
1 Cambiar por el puerto deseado

2.1. Funcionamiento (Servicios y Controladores)

Los servicios se encargan de abstraer la complejidad y la lógica del negocio a una clase aparte. El CLI de NestJS añade el decorador @Injectable a los servicios durante su creación. Estos servicios se podrán inyectar en controladores o en otros servicios.

Archivo app.service.ts

import { Injectable } from '@nestjs/common';

@Injectable() (1)
export class AppService {
  getHello(): string { (2)
    return 'Hello World!';
  }
}
1 Decorador que permite que el servicio pueda ser inyectado en controladores y en otros servicios
2 Función que proporciona una funcionalidad determinada

El controlador se encarga por un lado de escuchar las peticiones que llegan a la aplicación. Por otro lado, se encarga de preparar las respuestas que proporciona la aplicación. El CLI de NestJS añade el decorador @Controller a los controladores durante su creación. NestJS permite el uso de rutas como parámetros del decorador @Controller

Archivo app.controller.ts

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service'; (1)

@Controller() (2)
export class AppController {
  constructor(private readonly appService: AppService) {} (3)

  @Get() (4)
  getHello(): string { (5)
    return this.appService.getHello(); (6)
  }
}
1 Importación del servicio
2 Decorador que indica a NestJS que es un controlador
3 Inyección del servicio
4 Tipo de petición HTTP y ruta (vacía) atendida por el controlador
5 Función a ejecutar al tras invocar la ruta con una petición GET
6 Invocación al servicio que resuelve la petición

2.2. Definir un prefijo para la API.

Archivo main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api/v1'); (1)
  await app.listen(3000);
}
bootstrap();
1 Prefijo global

La aplicación ahora deberá ser llamada incluyendo el prefijo:

http://localhost:3000/api/v1

Si no incluimos el prefijo y seguimos invocando a http://localhost:3000 obtenendremos el siguiente error. Este error indica que la aplicación no tiene nada que respponda en esa ruta a ese tipo de petición HTTP.

{
  "statusCode": 404,
  "message": "Cannot GET /",
  "error": "Not Found"
}

3. Creación de nuestro primer servicio y controlador

Desde la línea de comandos usaremos el CLI de NestJS.

$ nest g service books
$ nest g controller books

El servicio creado está disponible en books/books.service.ts y el controlador creado está disponible en books.controller.ts. Los archivos .spec.ts son archivos para pruebas que no trataremos aquí.

El CLI de NestJS ha generado el archivo del servicio books/books.service.ts con el decorador @Injectable y el archivo del controlador books.controller.ts con el decorador @Controller

La creación del servicio y del controlador han modificado el archivo app.module.ts incorporándolos a la lista de servicios y controladores de la aplicación.

El archivo app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BooksService } from './books/books.service';
import { BooksController } from './books/books.controller';

@Module({
  imports: [],
  controllers: [AppController, BooksController], (1)
  providers: [AppService, BooksService], (2)
})
export class AppModule {}
1 Lista de controladores
2 Lista de providers

Los providers son un concepto de un nivel de abstracción mayor al de los servicios. Cuando decíamos que los servicios se encargaban de abstraer la complejidad y la lógica del negocio a una clase aparte, realmente se debía a que esta abstracción es propia de los providers. Al ser un servicio un tipo particular de provider simplemente heredan su comportamiento.

Un provider puede ser un servicio, pero también puede ser un repositorio, una factoría o un helper.

3.1. El servicio

Implementamos las funciones que proporcionan los datos.

Es buena práctica comenzar desarrollando todas las funciones que necesitemos ofreciendo inicialmente la funcionalidad de mostrar simplemente que han sido llamadas. Posteriormente, le iremos añadiendo su lógica real de forma progresiva. Esto nos permite tener inicialmente los componentes y las llamadas funcionando e interactuando sin adentrarnos en la complejidad del dominio.

Archivo books/book.service.ts

import { Injectable } from '@nestjs/common';

@Injectable()
export class BooksService {
  findAll(): any { (1)
    return 'findAll funcionando';
  }
}
1 Ejemplo de función que se limita a indicar que está funcionando cuando es llamada

3.2. El controlador

Comenzamos añadiendo simplemente por ahora:

  • El constructor donde se inyecta el servicio para poder usarlo

  • Creando la primera ruta y el método HTTP asociado que vamos a probar

import { Controller, Get } from '@nestjs/common';
import { BooksService } from './books.service'; (1)

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

  @Get() (3)
  findAll() { (4)
    return this.booksService.findAll(); (5)
  }
}
1 Importación del servicio que proporciona los datos
2 Constructor con el servicio inyectado
3 Decorador para indicar la ruta atendida y el método HTTP
4 Método asociado a la petición
5 Llamada al método del servicio que resuelve la petición

Si ahora llamamos a http://localhost:3000/api/v1/books el controlador interceptará la petición, usará el servicio y obtendremos la respuesta siguiente.

PrimerServicio

4. Creación de la primera versión de los endpoints

Comenzaremos haciendo el armazón (scaffolfding) de los endpoints para todas las rutas permitidas pero en una versión muy preliminar. Los servicios se limitarán a mostrar que han sido llamados y a mostrar los parámetros pasados. Una vez que todos funcionen correctamente podremos sustituirlos por servicios que tengan la respuesta real que exige el problema.

Table 1. Endpoints
Método Endpoint Descripción

GET

/api/v1/books

Obtener lista de libros

GET

/api/v1/books/{bookId}

Devuelve información sobre un libro específico

POST

/api/v1/books

Crear un libro

DELETE

/api/v1/books/{bookId}

Eliminar un libro específico

PUT

/api/v1/books/{bookId}

Modificar un libro específico

4.1. Recuperación de un libro

4.1.1. El servicio

Añadimos la función que implementa el servicio de recuperación de un libro específico. Tomará como argumento el id del libro e inicialmente se limitará a devolver un mensaje con el propio nombre de la función y el id pasado como argumento. Esto permite comprobar que la función ha sido llamada correctamente.

Archivo books/book.service.ts

...
  findBook(bookId: string) {
    return `findBook funcionando con bookId: ${bookId}`;
  }
...

4.1.2. El controlador

Añadimos la ruta que implementa la petición. Tomará como parámetro el id del libro (bookId). Usaremos el decorador NestJS @Param para obtener el parámetro de la petición.

Archivo books/book.controller.ts

import { Param } from '@nestjs/common';
...
@Controller('books')
export class BooksController {
...
  @Get(':bookId') (1)
  findBook(@Param('bookId') bookId: string) { (2)
    return this.booksService.findBook(bookId); (3)
  }
...
1 bookId es el nombre que se le da al argumento en la petición
2 Método asociado a la petición con referencia al argumento de la petición y variable asociada para el método
3 Llamada al método del servicio que resuelve la petición

Normalmente se usa el mismo nombre para el parámetro HTTP que para la variable que lo maneja en el método. Sin embargo, son dos objetos diferentes. A continuación se muestra con quien empareja cada uno.

  @Get(':RequestedBookId')
  findBook(@Param('RequestedBookId') methodBookId: string) {
    return this.booksService.findBook(methodBookId);
  }

Si ahora llamamos a http://localhost:3000/api/v1/books/1 el controlador interceptará la petición, asignará 1 al parámetro bookId y obtendremos la respuesta siguiente.

GetBookV0

4.2. Filtrado mediante parámetros. Recuperación de todos los libros en orden descendente.

En la URL se pueden pasar parámetros en forma de una lista de pares clave valor. Por ejemplo: http://localhost:3000/api/v1/books?sort=1. Los parámetros son recogidos en NestJS con el decorador @Query()

Nuevo endpoint o sólo parametros

Puede surgir la duda de si la recuperación de libros de forma ordenada es un nuevo endpoint o se trata de añadir parámetros a un endpoint existente. Es decir, se trata de elegir entre estas dos alternativas:

Para resolver la duda nos debemos plantear si la estructura de los datos devueltos cambia de un caso a otro o es la misma en los dos casos. Si cambia estaríamos ante un nuevo endpoint. En cambio, si es la misma, estaríamos ante parámetros.

En este caso, la ordenación sigue presentando los datos siguiendo la misma estructura. Es decir, sigue siendo una lista de libros igualmente. Lo único es que se presenta ordenada. El servicio tendrá que capturar los parámetros y devolver los datos de acuerdo a la petición realizada.

Esta misma solución es aplicable si hay varios parámetros. Por ejemplo, ordenación, limitación de cantidad de resultados, offsets, filtrado por algún campo, etc. En todos estos casos se sigue devolviendo una lista de resultados con la misma estructura (p.e. libros).

La alternativa de uso de parámetros reduce la cantidad de endpoints a tratar y permite que los parámetros sean opcionales. El servicio tendrá que encargarse de determinar cómo trabajar con los parámetros de la petición.

Como la petición de recuperación de libros de forma ordenada sigue devolviendo una lista de libros con la misma estructura, optamos por implementar esta funcionalidad mediante parámetros, trasladando la lógica de su interptretación al servicio.

4.2.1. El servicio

La versión preliminar del servicio parametrizado modificará el servicio existente de recuperación de libros. La función tomará los argumentos y se limitará a devolver un mensaje con el propio nombre de la función y el argumento (si existe). Esto permite comprobar que la función ha sido llamada correctamente.

Archivo books/book.service.ts

...
  findAll(params): any {
    return params.length > 0
      ? `findAll funcionando con ${params}`
      : 'findAll funcionando';
  }
...

4.2.2. El controlador

Modificamos la ruta que implementa la petición. Tomará como parámetro el tipo de ordenación. Usaremos el decorador NestJS @Query para obtener el parámetro de la petición.

Archivo books/book.controller.ts

import { Query } from '@nestjs/common';
...
  @Get()
  findAll(@Query('order') order: string) { (1)
    let params = []; (2)

    if (order !== undefined) {
       params.push(`'${order}'`); (3)
    }

    return this.booksService.findAll(params); (4)
  }
...
1 Captura del parámetro order en una variable order
2 Array para almacenamiento de parámetros
3 Si se ha pasado el parámetro en la petición, se introduce en el array de parámetros
4 Llamada al servicio con los parámetros leídos

4.2.3. Una solución más dinámica

La solución planteada para el uso de parámetros hace que ante nuevos parámetros en las peticiones se tenga que modificar tanto el controlador (añadiendo nuevos decoradores @Query para los nuevos parámetros) como el servicio, que es el que hace uso de ellos.

El decorador @Req nos permite acceder a todos los datos de una petición. En nuestro caso estamos interesados en acceder a query. Esta query contiene un JSON con los pares parámetro-valor pasados en la petición. La idea es pasar directamente este JSON al servicio y que sea el servicio en que se encargue de acceder a su contenido y actuar como corresponda.

El servicio books/book.service.ts adaptado para un nuevo parámetro (limit) quedaría así.

...
  findAll(params): any {
    let msg = `findAll funcionando. Parámetros:`;

    if (params.order !== undefined) {
      msg = msg + ` order: ${params.order}`;
    }

    if (params.limit !== undefined) {
      msg = msg + ` limit: ${params.limit}`;
    }

    return msg;
  }
...

El controdor books/book.controller.ts ahora quedaría así:

import { Req } from '@nestjs/common';
import { BooksService } from './books.service';
import { Request } from 'express';
...

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

  @Get()
  findAll(@Req() request: Request) { (1)
    return this.booksService.findAll(request.query); (2)
  }
...
}
1 Inyección del objeto request
2 Llamada al servicio con el JSON con los pares clave-valor de los parámetros de la petición

Si hiciéramos la petición http://localhost:3000/api/v1/books?order=1&limit=10, request.query contendría lo siguiente:

{ order: '1', limit: '10' }

La pantalla siguiente muestra el resultado de realizar la petición con dos parámetros order y limit.

ParametrosDinamicos

4.3. Creación de un libro

Los objetos a crear se pasarán en el body de la petición en formato JSON. El cuerpo de la respuesta contedrá el objeto creado.

Supongamos que deseamos insertar el libro siguiente:

{
    "title": "El enigma de la habitación 622",
    "genre": "Ficción contemporánea",
    "description": "Vuelve el «principito de la literatura negra contemporánea, el niño mimado de la industria literaria» (GQ): el nuevo thriller de Joël Dicker es su novela más personal. ",
    "author": "Joël Dicker",
    "publisher": "Alfaguara",
    "pages": 624,
    "image_url": "https://images-na.ssl-images-amazon.com/images/I/41KiZbwOhhL._SX315_BO1,204,203,200_.jpg"
}

4.3.1. El servicio

La versión preliminar del servicio para crear un nuevo libro se limitará a devolver el libro que le llega como parámetro. Esto permite comprobar que la función ha sido llamada correctamente.

Archivo books/book.service.ts

...
  createBook(newBook: any) {
    return newBook;
  }
...

4.3.2. El controlador

El decorador @Body nos permite acceder al body enviado en una petición.

Archivo books/book.controller.ts

import {
  Post,
  Body,
} from '@nestjs/common';
import { BooksService } from './books.service';
...

@Controller('books')
export class BooksController {
  constructor(private booksService: BooksService) {}
...
  @Post() (1)
  createBook(@Body() body) { (2)
    let newBook: any = body; (3)
    return this.booksService.createBook(newBook); (4)
  }
}
1 Decorador para el método Post
2 Decorador para el objeto body. Los datos pasados para el nuevo libro se tratan en la variable body
3 Creación de un nuevo objeto para poder tratar los datos recibidos
4 Llamada al servicio de creación de libros con el libro recibido

La figura siguiente muestra el resultado de la operación POST con el nuevo libro y la respuesta obtenida.

PostBook

4.4. Eliminación de un libro

La eliminación es muy similar a la de búsqueda de un elemento por id. Se intercepta el id de la ruta y se llama al servicio.

4.4.1. El servicio

Añadimos la función que implementa el servicio de eliminación de un libro. Se trata de una función muy similar a la de buscar un libro. Tomará como argumento el id del libro e inicialmente se limitará a devolver un mensaje con el nombre de la función y el id pasado como argumento. Esto permite comprobar que la función ha sido llamada correctamente.

Archivo books/book.service.ts

...
  deleteBook(bookId: string) {
    return `deleteBook funcionando con bookId: ${bookId}`;
  }
...

4.4.2. El controlador

Añadimos la ruta que implementa la petición. Tomará como parámetro el id del libro (bookId). Usaremos el decorador NestJS @Delete

Archivo books/book.controller.ts

...
@Controller('books')
export class BooksController {
...
  @Delete(':bookId') (1)
  deleteBook(@Param('bookId') bookId: string) { (2)
    return this.booksService.deleteBook(bookId); (3)
  }
...
1 bookId es el nombre que se le da al argumento en la petición
2 Método asociado a la petición con referencia al argumento de la petición y variable asociada para el método
3 Llamada al método del servicio que resuelve la petición

Si ahora hacemos un DELETE contra http://localhost:3000/api/v1/books/1 el controlador interceptará la petición, asignará 1 al parámetro bookId y obtendremos la respuesta siguiente.

DeleteBookV0

4.5. Modificación de un libro

La modificación se puede ver como una operación que combina búsqueda y paso del body con los datos a actualizar. Se intercepta el id de la ruta el body de la petición.

4.5.1. El servicio

Añadimos la función que implementa el servicio de modificación de un libro. Tomará como argumentos el id del libro y los nuevos datos del libro. Inicialmente devolverá los datos del libro modificado. Esto permite comprobar que la función ha sido llamada correctamente.

Archivo books/book.service.ts

...
  updateBook(bookId: string, newBook: any) {
    return newBook;
  }
...

4.5.2. El controlador

Añadimos la ruta que implementa la petición. Tomará como parámetro el id del libro (bookId). Usaremos el decorador NestJS @Put

Archivo books/book.controller.ts

...
@Controller('books')
export class BooksController {
...
  @Put(':bookId') (1)
  updateBook(@Param('bookId') bookId: string, @Body() body) { (2)
    let newBook: any = body;
    return this.booksService.updateBook(bookId, newBook); (3)
  }
...
1 bookId es el nombre que se le da al argumento en la petición
2 Método asociado a la petición con referencia al argumento de la petición, variables asociada para el método y cuerpo con los nuevos datos del libro
3 Llamada al método del servicio que resuelve la petición

Si ahora hacemos un UPDATE contra http://localhost:3000/api/v1/books/1 y le pasamos en el body el JSON con los nuevos datos del libro, el controlador interceptará la petición, asignará 1 al parámetro bookId, pasará el cuerpo, el controlador los pasará al servicio y obtendremos la respuesta siguiente con los nuevos datos del libro.

PutBookV0

5. Tipado de objetos

Hasta ahora hemos tratados con el objeto libro, con el body de las peticiones que hacen POST o PUT y en ninguna hemos indicado un tipo de datos. Su tipo queda entonces como any. Sin embargo, esto no es una buena práctica. El uso de tipos nos permitirá durante el desarrollo determinar las propiedades aplicables a un objeto, la estructura que tienen que tener los objetos de las peticiones, y demás.

En este tutorial vamos a ver distintos tipos aplicables a los objetos. Para favorecer su comprensión seguimos con el ejemplo de los libros y suponemos que vamos a usar una base de datos para persistir los datos. En este caso tendríamos lo siguiente:

  • En la capa de base de datos los libros se podría modelar como una tabla en una base de datos relacional, como una colección en una base de datos de documentos,

  • Las entities. Si decidimos usar un ORM, ODM o similar, necesitaremos crear un objeto entity que represente la estructura de lo que se almacena en la base de datos. En nuestro caso, el objeto entity para libro podría tener las mismas propiedades que el objeto de la base de datos. Los objetos entity son los que se almacenan y se leen de la base de datos.

  • Las interfaces. En el nivel de desarrollo necesitamos manipular las propiedades de un objeto para no hacer referencia a propiedades inexistentes, evitar errores de tipado al trabajar con las propiedades de los objetos, y demás. Para ello, necesitaremos tener un tipo que represente a los objetos del negocio desde el punto de la programación. Estos tipos no tienen por que ser sustituidos por los tipos anteriores de los ORM/ODM, ya que nuestra aplicación puede que no usase ORM/ODM y no por ello dejarían de ser necesarios los tipos. Los tipos en este nivel los denominamos interfaces.

  • Los DTO (Data Transfer Objects). Por último, hemos visto que las peticiones envían sus datos para que sean procesados por los servicios. Sin embargo, los datos enviados en las peticiones no tienen por que tener la misma estructura que las interfaces o que las entities definidas. Por ejemplo, en la petición para crear un libro puede que no se envíe el id del libro a crear porque se trata de un valor generado por el sistema. Por tanto, el tipo usado en la petición podría no coincidir con alguno de los tipos anteriores (entities, DTO). Estaríamos hablando de un tipo exclusivo para la creación de libros (el tipo que contiene las propiedades que se pasan para crear un libro). Además, operaciones diferentes podrían usar tipos diferentes. Un caso sería que las modificaciones no permitiesen modificar todos los campos de un libro. Estaríamos ante un nuevo tipo, el tipo de los objetos a modificar. A este tipo de objetos se les denomina DTO. (Es habitual usar CreateBookDTO, UpdateBookDTO para representar los tipos de los datos pasados al crear y actualizar libros si los tipos son diferentes)

5.1. Creación de una interface para libros

Se define una interface con las propiedades que representan a un libro. En nuestro caso crearíamos un archivo book.class.ts

export class Book {
  id: number;
  title: string;
  genre: string;
  description: string;
  author: string;
  publisher: string;
  pages: number;
  image_url: string;
}

Definimos una clase en un lugar de una interface para poder instannciarla y simplificar el mockeado.

5.2. Creación de un DTO para libros

Se define una clase BookDto que representa a las propiedades de un libro que se especifican y se envían cuando se realiza una petición para crear un libro. Hablamos de los datos que van en la petición y no tienen por que tener una correspondencia directa con un objeto completo del dominio. Incluso pueden contener propiedades de varios objetos del dominio. Como su nombre indica, los DTO (Data Transfer Object) representan a la estructura o al tipo de los datos que se están intercambiando.

export class BookDto {
  readonly title: string;
  readonly genre: string;
  readonly description: string;
  readonly author: string;
  readonly publisher: string;
  readonly pages: number;
  readonly image_url: string;
}

El DTO de los libros no contiene el id del libro. Esto se debe a que es una propiedad que los usuarios no envían en sus peticiones.

5.3. Modificación del controlador para el uso de tipos

Archivo books/book.dto.ts

...
import { BookDto } from './book.dto'; (1)

@Controller('books')
export class BooksController {
...

  @Post()
  createBook(@Body() newBook: BookDto) { (2)
    return this.booksService.createBook(newBook); (3)
  }

....

  @Put(':bookId')
  updateBook(@Param('bookId') bookId: string, @Body() newBook: BookDto) { (4)
    return this.booksService.updateBook(bookId, newBook); (5)
  }
}
1 DTO de libro
2 Emparejamiento de lo recibido en el body de un POST al tipo BookDto
3 Llamada al servicio de creación de libros con el libro ya tipado
4 Emparejamiento de lo recibido en el body de un PUT al tipo BookDto
5 Llamada al servicio de actualización de libros con el libro ya tipado

En este ejemplo se observa que se los objetos nuevos y los objetos modificados tienen el mismo tipo. Es decir, cuando se pasa un objeto a modificar, en el body se pasa el libro sin id.

Este tipado permite manipular de forma segura las propiedades de los libros ayudando a detectarse errores derivados de asignación de valores a tipos incorrectos.

Uno o varios DTO

Un objeto puede tener DTO diferentes para operaciones diferentes. Por ejemplo, si decidiéramos que el DTO de un libro nuevo no contuviese el id, pero el DTO de un libro a modificar sí lo contuviese, tendríamos un caso de DTOs diferentes (p.e. CreateBook.dto.ts y UpdateBook.dto.ts)

Archivo CreateBook.dto.ts

export class CreateBookDto {
  readonly title: string;
  readonly genre: string;
  readonly description: string;
  readonly author: string;
  readonly publisher: string;
  readonly pages: number;
  readonly image_url: string;
}

Archivo UpdateBook.dto.ts

export class UpdateBookDto {
  readonly id: number; (1)
  readonly title: string;
  readonly genre: string;
  readonly description: string;
  readonly author: string;
  readonly publisher: string;
  readonly pages: number;
  readonly image_url: string;
}
1 DTO de un libro para modificar que sí lleva el id del libro modificado

5.4. Modificación del servicio para el uso de tipos

Archivo books/book.service.ts

...
import { BookDto } from './book.dto'; (1)

@Injectable()
export class BooksService {
...
  createBook(newBook: BookDto) { (2)
    return newBook;
  }

...

  updateBook(bookId: string, newBook: BookDto) { (3)
    return newBook;
  }
}
1 DTO de libro
2 Libro tipado al DTO
3 Libro tipado al DTO

Este tipado permite manipular de forma segura las propiedades de los libros ayudando a detectarse errores derivados de asignación de valores a tipos incorrectos.

6. Finalización del mockeado

Hasta ahora, las únicas operaciones que estaban mockeadas con objetos del dominio eran las operaciones de creación y de modificación. Las operaciones de consulta y eliminación se limitabana a devolver un texto indicando que se había alcanzado el endpoint. En este apartado, haremos que todas las operaciones trabajen con datos del dominio aunque todavía será algo preliminar, ya que serán sólo un par de libros almacenados en el propio código y ninguna operación tratará con datos reales (p.e. la búsqueda de un libro siempre devolverá el mismo libro, la actualización/eliminación siempre informará que se ha modificado/eliminado el mismo libro). No obstante, esto permite que el controlador ya trate con los tipos de datos que devolverán los servicios cuando implementen su funcionalidad real.

6.1. El servicio

El archivo books/boo.service.ts

import { Injectable, HttpStatus, HttpException } from '@nestjs/common';
import { BookDto } from './book.dto'; (1)
import { Book } from './book.class'; (2)

@Injectable()
export class BooksService {
  books: Book[] = [ (3)
    {
      id: 1,
      title: 'Una historia de España',
      genre: 'Historia',
      description:
        'Un relato ameno, personal, a ratos irónico, pero siempre único, de nuestra accidentada historia a través de los siglos. Una obra concebida por el autor para, en palabras suyas, «divertirme, releer y disfrutar; un pretexto para mirar atrás desde los tiempos remotos hasta el presente, reflexionar un poco sobre ello y contarlo por escrito de una manera poco ortodoxa.',
      author: 'Arturo Pérez-Reverte',
      publisher: 'Alfaguara',
      pages: 256,
      image_url:
        'https://images-na.ssl-images-amazon.com/images/I/41%2B-e981m1L._SX311_BO1,204,203,200_.jpg',
    },
    {
      id: 2,
      title: 'Historia de España contada para escépticos',
      genre: 'Historia',
      description:
        'Como escribe el autor, no pretende ser veraz, justa y desapasionada, porque ninguna historia lo es. No está hecha para halagar a reyes y gobernantes, ni pretende halagar a los banqueros, ni a la Conferencia Episcopal, ni al colectivo gay.',
      author: 'Juan Eslava Galán',
      publisher: 'Booket',
      pages: 592,
      image_url:
        'https://images-na.ssl-images-amazon.com/images/I/51IyZ5Mq8YL._SX326_BO1,204,203,200_.jpg',
    },
  ];
  findAll(params): Book[] { (4)
    return this.books;
  }

  findBook(bookId: string): Book { (5)
    return this.books[parseInt(bookId) - 1];
  }

  createBook(newBook: BookDto): Book { (6)
    let book = new Book();

    book.id = 99;
    book.author = newBook.author;
    book.description = newBook.description;
    book.genre = newBook.genre;
    book.image_url = newBook.image_url;
    book.pages = newBook.pages;
    book.publisher = newBook.publisher;
    book.title = newBook.title;

    return book;
  }

  deleteBook(bookId: string): Book { (7)
    return this.books[parseInt(bookId) - 1];
  }

  updateBook(bookId: string, newBook: BookDto): Book { (8)
    return this.books[parseInt(bookId) - 1];
  }
}
1 DTO del libro (no contiene el id)
2 Interface del libro (contiene el id)
3 Lista de libros de ejemplo mientras se desarrolla el acceso a BD del servicio
4 El método devuelve un array de Book con todos los libros
5 El método devuelve un Book, que contiene el id. Devuelve un libro a modo de ejemplo
6 El método toma un BookDto como argumento (libro sin id) y devuelve un libro completo (con el id). Devuelve el libro insertado
7 El método devuelve un Book, que contiene el id. Devuelve un libro a eliminado modo de ejemplo
8 El método toma un BookDto como argumento (libro sin id) y devuelve un Book, que sí contiene el id. Devuelve un libro modificado a modo de ejemplo

6.2. El controlador

Se trata de usar los tipos que usan los parámetros de las funciones en las peticiones y de los tipos que devuelven.

Archivo books/books.controller.ts

import {
  Controller,
  Get,
  Param,
  Req,
  Post,
  Body,
  Delete,
  Put,
} from '@nestjs/common';
import { BooksService } from './books.service';
import { Request } from 'express';
import { BookDto } from './book.dto';
import { Book } from './book.class';

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

  findAll(@Req() request: Request): Book[] {
    console.log(request.query);
    return this.booksService.findAll(request.query);
  }

  findBook(@Param('bookId') bookId: string): Book {
    return this.booksService.findBook(bookId);
  }

  createBook(@Body() newBook: BookDto): Book {
    return this.booksService.createBook(newBook);
  }

  deleteBook(@Param('bookId') bookId: string): Book {
    return this.booksService.deleteBook(bookId);
  }

  updateBook(@Param('bookId') bookId: string, @Body() newBook: BookDto): Book {
    return this.booksService.updateBook(bookId, newBook);
  }
}

7. Creación de servicios conectados a bases de datos

Hasta ahora, los servicios que hemos creado en este tutorial se limitan a proporcionar unos datos de prueba generando una salida por la consola. Su cometido se ha estado limitando a comprobar que son alcanzables desde los endpoints definidos en la API, mostrándonos simplemente el eco de su llamada. En este apartado vamos a ver cómo conectar el servicio a bases de datos. Primero lo haremos conectando los servicios a una base de datos MySQL y luego comprobaremos lo fácil que es pasarlo a una base de datos PostgreSQL.

7.1. Configuración de un servidor MySQL

Para trabajar localmente con persistencia necesitamos una base de datos a la que conectarnos. Para no tener que complicarnos con instalaciones y no acoplar el desarrollo a nuestro equipo utilizaremos una imagen Docker de MySQL 5.7. Crearemos una base de datos denominada tutorial. Usaremos las cuenta root con el password secret

$ docker run --name tutorial_mysql -e MYSQL_ROOT_PASSWORD=secret -p 3306:3306 -d mysql:5.7 (1)
1 Usaremos el password secret para la cuenta root

Tras unos instantes (algo más si la imagen de MySQL 5.7 no está descargada en el equipo) habrá un contenedor en ejecución con el nombre tutorial_mysql. Iniciaremos una sesión interactiva para crear una base de datos, a la que denominaremos tutorial

$ docker exec -it tutorial_mysql bash
root@d0512407a21d:/# mysql -u root -p
Enter password: (1)
...
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>
mysql> create database tutorial; (2)
Query OK, 1 row affected (0.00 sec)
1 Introducir el password secret
2 Crear la base de datos tutorial

7.2. ORM y el patrón de repositorio

Un ORM nos abstrae del acceso a un gestor de bases de datos específico. Esto nos aisla del gestor de base de datos elegido y hace que podamos cambiar de gestor de bases de datos de forma muy sencilla. TypeORM es un ORM para TypeScript y JavaScript que facilita la interacción con la base de datos. El uso de TypeORM acelera el proceso de desarrollo modelando entidades en el código y sincronizando estos modelos con la base de datos. Actualmente TypeORM ofrece soporte para varias bases de datos relacionales, como PostgreSQL, Oracle, Microsoft SQL Server, SQLite, e incluso para bases de datos NoSQL, como MongoDB.

Por otro lado, el patrón de repositorio nos abstrae de los detalles de la persistencia proporcionando métodos abstractos para las operaciones comunes (crear, guardar, buscar, buscar una, actualizar, eliminar, …​).

Resumiendo, el ORM trabaja con objetos de la base de datos y el repositorio trabaja con objetos del dominio.

Instalaremos los paquetes de TypeORM en el proyecto con

$ npm install --save @nestjs/typeorm typeorm mysql

7.3. Configuración de la conexión a la base de datos

Haremos la configuración de la base de datos en el archivo app.module.ts mediante TypeOrmModule.forRoot(). Se le pueden pasar los parámetros de configuración directamente. Sin embargo, existe otra opción que consiste en definir la configuración en un archivo ormconfig.json, que es el que de forma predeterminada busca TypeORM.

import { TypeOrmModule } from '@nestjs/typeorm';
...
@Module({
  imports: [
    TypeOrmModule.forRoot(), (1)
    ...
  ],
  ....
})
export class AppModule {}
1 De forma predeterminada, si no se pasa ningún argumento se buscan los valores en ormconfig.json en la raíz del proyecto.

A continuación se muestra el archivo ormconfig.json. Este archivo se almacena en la raíz del proyecto, junto al package.json.

Archivo ormconfig,json

{
  "type": "mysql",
  "host": "localhost",
  "port": 3306,
  "username": "root",
  "password": "secret",
  "database": "tutorial",
  "entities": ["dist/**/*.entity.js"], (1)
  "synchronize": true (2)
}
1 Dónde localizar los archivos de las entidades
2 Sincronización automática de la base de datos con las entidades
Configuración de los datos de conexión en el propio código

También se puede encontrar que los parámetros de conexión son colocados directamente como argumentos de TypeOrmModule.forRoot().

...
    TypeOrmModule.forRoot(
      {
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'example',
      database: 'my_nestjs_project',
      entities: ['dist/**/*.entity.js'],
      synchronize: true,
      }
...

El problema de este enfoque está en que las credenciales se adjuntarán en los commits que se hagan de este archivo. En cambio, si almacenamos las credenciales en un archivo ormconfig.json y lo incluimos en el archivo .gitignore, los datos sensibles almacenados en ormconfig.json no serán expuestos al hacer commit.

7.4. Creación de entidades

Las entidades son clases que se corresponden con tablas de la base de datos (colecciones si se trata de MongoDB). En las entidades se definen las columnas y relaciones. Una de esas columnas debe ser la clave primaria.

A continuación, para nuestro ejemplo de libros se muestra la definición de una entidad Book con las columnas siguientes:

  • id

  • title

  • genre

  • description

  • author

  • publisher

  • pages

  • image_url

Archivo books/book.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Book {
  @PrimaryGeneratedColumn() (1)
  id: number;

  @Column()
  title: string;

  @Column()
  genre: string;

  @Column('text') (2)
  description: string;

  @Column()
  author: string;

  @Column()
  publisher: string;

  @Column()
  pages: number;

  @Column()
  image_url: string;
}
1 Decorador para indicar que es una clave primaria autonumérica
2 Decorador para permitir texto largo

7.5. El servicio

El servicio implementa las funciones habituales para operaciones CRUD (find, findOne, create, delete y update). Se usa el patrón repositorio para trabajar directamente sobre objetos del dominio (libros en nuestro caso) y olvidarnos de los detalles de la persistencia. Como todas las funciones interactúan con bases de datos, todas se programan de forma asíncrona y devuelven una promesa, por lo que habrá que llamarlas con await.

Promesas, async y await

Cuando trabajamos con bases de datos las respuestas no son inmediatas. En JavaScript las promesas representan valores que pueden estar disponibles ahora, en el futuro o nunca. Para facilitar el trabajo con la programación asíncrona surge la pareja async/await. Con esta pareja:

  • Las funciones son definidas con async para indicar que devuelven una promesa.

  • Con await indicamos a JavaScript que espere hasta que la promesa se cumpla y devuelva su resultado.

await sólo funciona en funciones async. Se coloca en funciones async basadas en promesas para detener la ejecución hasta que se cumpla la promesa.

Archivo books/books.service.ts

import { Injectable, HttpStatus, HttpException } from '@nestjs/common';
import { BookDto } from './book.dto'; (1)
import { Book } from './book.entity'; (2)
import { InjectRepository } from '@nestjs/typeorm'; (3)
import { Repository } from 'typeorm'; (4)

@Injectable()
export class BooksService {

  constructor(
    @InjectRepository(Book) private booksRepository: Repository<Book>, (5)
  ) {}

  async findAll(params): Promise<Book[]> { (6)
    return await this.booksRepository.find(); (7)
  }

  async findBook(bookId: string): Promise<Book> {
    return await this.booksRepository.findOne({ where: { id: bookId } }); (8)
  }

  createBook(newBook: BookDto): Promise<Book> {
    return this.booksRepository.save(newBook);
  }

  async deleteBook(bookId: string): Promise<any> {
    return await this.booksRepository.delete({ id: parseInt(bookId) });
  }

  async updateBook(bookId: string, newBook: BookDto): Promise<Book> { (9)
    let toUpdate = await this.booksRepository.findOne(bookId); (10)

    let updated = Object.assign(toUpdate, newBook); (11)

    return this.booksRepository.save(updated); (12)
  }
}
1 Estructura de un libro para insertar (tiene todo menos el id, que se genera en la base de datos)
2 Estructura completa de un libro (incluye el id)
3 Decorador para inyectar repositorios
4 Repositorio de TypeORM
5 Uso del decorador @InjectRepository en el constructor para inyectar el Repository que manejará a la entidad Book
6 Las funciones del servicio se basan en funciones asíncronas del repositorio, que devuelven promesas y tendrán que ser llamadas con await. Por tanto, las funciones del servicio son async y devuelven promesas personalizadas al tipo con el que trabajan (libros, arrays de libros, …​)
7 La llamada a los métodos del repositorio devuelven promesas, por lo que llamaremos con await para esperar a que se resuelvan
8 Los parámetros en TypeORM se suelen pasar en JSON
9 La actualización se implementa como la recuperación del libro a modificar, la sustitución de todos sus valores excepto el id por los del libro pasado como parámetro y su posterior almacenamiento en la base de datos
10 Recuperación del libro a modificar
11 Asignación de todas las propiedades del libro nuevo al libro antiguo, excepto el id, que no está incluida en el libro nuevo
12 Almacenamiento del libro en la base de datos tras su modificación

7.6. El controlador

Básicamente, el controlador es el mismo que teníamos para el mockup salvo que ahora devuelve promesas, ya que las funciones del servicio ahora devuelven promesas. Además, se cambia el tipo del objeto libro. Dejamos de usar la interface para pasar a usar la entity del ORM.

Archivo books/books.controller.ts

import {
  Controller,
  Get,
  Param,
  Req,
  Post,
  Body,
  Delete,
  Put,
} from '@nestjs/common';
import { BooksService } from './books.service';
import { Request } from 'express';
import { BookDto } from './book.dto';
import { Book } from './book.entity'; (1)

@Controller('books')
export class BooksController {

  constructor(private booksService: BooksService) {}

  @Get()
  findAll(@Req() request: Request): Promise<Book[]> { (2)
    console.log(request.query);
    return this.booksService.findAll(request.query);
  }

  @Get(':bookId')
  findBook(@Param('bookId') bookId: string): Promise<Book> {
    return this.booksService.findBook(bookId);
  }

  @Post()
  createBook(@Body() newBook: BookDto): Promise<Book> { (3)
    return this.booksService.createBook(newBook);
  }

  @Delete(':bookId')
  deleteBook(@Param('bookId') bookId: string): Promise<Book> {
    return this.booksService.deleteBook(bookId);
  }


  @Put(':bookId')
  updateBook(
    @Param('bookId') bookId: string,
    @Body() newBook: BookDto, (4)
  ): Promise<Book> {
    return this.booksService.updateBook(bookId, newBook);
  }
}
1 El tipo de la interfaz y el de la entidad coinciden. Nos quedamos con el de la entidad.
2 Las funciones ahora devuelven promesas basadas en la entity
3 Cambiamos el tipo any del body por el tipo del DTO del libro a crear
4 Cambiamos el tipo any del body por el tipo del DTO del libro actualizado

7.7. Módulo para una mejor organización

Es buena práctica que en lugar de añadir cada uno de los providers y los controllers a app.module.ts, los agrupemos cada uno en un módulo con los providers y controllers. Posteriormente, ese módulo se importa en el array imports de app.module.ts. Además, las entidades se colocan en el módulo en un array, como argumento de TypeOrmModule.forFeature().

Archivo books/books.module.ts

import { Module } from '@nestjs/common';
import { Book } from './book.entity';
import { BooksService } from './books.service';
import { BooksController } from './books.controller';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [TypeOrmModule.forFeature([Book])], (1)
  providers: [BooksService], (2)
  controllers: [BooksController], (3)
})
export class BooksModule {}
1 Las entidades van aquí
2 El servicio
3 El controlador

Este archivo ya está preparado para ser colocado en el array imports de app.module.ts.

7.8. Mejora de la configuración del uso del ORM

Otra mejora que podríamos realizar para la configuración del uso del ORM podría ser el uso de variables de entorno. Esto evita la introducción de valores sensibles en el código, como contraseñas, usuarios de la base de datos, y demás.

La mejora que haremos se basará en lo siguiente:

  1. Inicialización de un archivo de variables de entorno.

  2. Creación de un servicio de configuración del ORM a partir de los valores de las variables de entorno.

  3. Modificación del archivo app.module.ts para usar la configuración anterior y cargar los módulos correspondientes (p.e. el de BooksModule creado antes).

7.8.1. Inicialización de un archivo de variables de entorno

Archivo .env

TUTORIAL_HOST=localhost
TUTORIAL_PORT=3306
TUTORIAL_USER=root
TUTORIAL_PASSWORD=secret
TUTORIAL_DATABASE=tutorial

7.8.2. Creación de un servicio de configuración del ORM

Definiremos un servicio de configuración que acceda a las variables de entorno, especifique las variables de entorno que hay que configurar y una función que las configure.

Se trata de un código precocinado que utilizaríamos en cada proyecto con TypeORM. Sólo hay que cambiar el tipo de gestor de base de datos que se va a usar (mysql, postgres, …​). Actualmente, tiene que estar en el código y no se puede pasar en una variable.

Archivo config/config.service.ts

import { TypeOrmModuleOptions } from '@nestjs/typeorm'; (1)

require('dotenv').config();

class ConfigService {
  constructor(private env: { [k: string]: string | undefined }) {}

  private getValue(key: string, throwOnMissing = true): string {
    const value = this.env[key];
    if (!value && throwOnMissing) {
      throw new Error(`config error - missing env.${key}`);
    }

    return value;
  }

  public ensureValues(keys: string[]) {
    keys.forEach(k => this.getValue(k, true));
    return this;
  }

  public getTypeOrmConfig(): TypeOrmModuleOptions { (2)
    return {
      type: 'mysql', (3)

      host: this.getValue('TUTORIAL_HOST'), (4)
      port: parseInt(this.getValue('TUTORIAL_PORT')),
      username: this.getValue('TUTORIAL_USER'),
      password: this.getValue('TUTORIAL_PASSWORD'),
      database: this.getValue('TUTORIAL_DATABASE'),

      entities: ['dist/**/*.entity.js'], (5)
      synchronize: true, (6)
    };
  }
}

const configService = new ConfigService(process.env).ensureValues([
  'TUTORIAL_HOST',
  'TUTORIAL_PORT',
  'TUTORIAL_USER',
  'TUTORIAL_PASSWORD',
  'TUTORIAL_DATABASE',
]);

export { configService };
1 Importación del módulo de configuración de TypeORM
2 Función que configura las opciones de TypeORM
3 Configuración del gestor de base de datos a usar
4 Configuración de valores mediante variables de entorno
5 Especificación del directorio de entidades
6 Actualización de las tablas ante cambios en las entidades

7.8.3. Actualización de app.module.ts para cargar la configuración del ORM y los módulos

Por último, modificamos el archivo app.module.ts para usar la configuración anterior y cargar el módulo BooksModule, que define su provider, controlador y la entidad contra la que se mapea.

Archivo app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BooksModule } from './books/books.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { configService } from './config/config/config.service';

@Module({
  imports: [
    BooksModule, (1)
    TypeOrmModule.forRoot( (2)
      configService.getTypeOrmConfig(),
    ),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
1 Importación del módulo
2 Configuración de los valores de TypeORM

7.9. Pruebas de los endpoints con persistencia en la base de datos

En el Apéndice A. Datos de ejemplo podemos encontrar datos para insertar en la base de datos. Se podrían como body en un método POST para su creación o PUT para su modificación.

Usaremos Postman para mostrar los resultados de utilizar los distintos endpoints implementados.

La figura siguiente muestra la creación de un libro. El libro nuevo se pasa en el body. Se devuelve el libro insertado, junto al id generado en la base de datos. El endpoint usado es /api/v1/books con el método POST.

MySQLPost

Tras insertar todos los libros del Apéndice A. Datos de ejemplo, la figura siguiente muestra el listado de todos libros. El endpoint usado es /api/v1/books con el método GET.

MySQLGet

La figura siguiente muestra los detalles de un libro concreto (el 2). El endpoint usado es /api/v1/books/2 con el método GET.

MySQLGetOne

La figura siguiente muestra la modificación de un libro. El id del libro a modificar se pasa como parámetro en la ruta y los datos del libro con sus modificaciones se pasan en el body. Se devuelve el libro modificado. El ejemplo muestra el cambio del número de páginas del libro 2 al valor 544. El endpoint usado es /api/v1/books/2 con el método PUT.

MySQLPut

La figura siguiente muestra la eliminación de un libro. El id del libro a eliminar se pasa como parámetro en la ruta. Se devuelve un JSON con los libros eliminados (affected). Por ejemplo, para eliminar el libro con id 3 usaríamos el endpoint /api/v1/books/3 con el método DELETE.

MySQLDelete

Si ahora volvemos a consultar todos los libros se verán los cambios en el número de páginas del libro 2 y que el libro 3 ha sido eliminado.

MySQLUpdated

7.10. Cambio a un servidor PostgreSQL

El cambio a un nuevo servidor de bases de datos es bastante sencillo. Se tendrían que seguir estos pasos:

  1. Instalación de los paquetes del nuevo gestor de bases de datos

  2. Cambiar las variables de entorno con los nuevos valores de conexión a la base de datos

  3. Cambio del tipo de base de datos en TypeORM

7.10.1. Instalación de los paquetes de PostgreSQL

npm install --save pg
Creación de un contenedor con PostgreSQL

Para facilitar la configuración de la base de datos, el script siguiente lanza un contenedor PostgreSQL y crea una base de datos tutorial con el password secret (los mismos datos que se usaron para el ejemplo con MySQL)

Archivo start-postgres.sh

#!/bin/bash
set -e

SERVER="tutorial_postgres";
PW="secret";
DB="tutorial";

echo "echo stop & remove old docker [$SERVER] and starting new fresh instance of [$SERVER]"
(docker kill $SERVER || :) && \
  (docker rm $SERVER || :) && \
  docker run --name $SERVER -e POSTGRES_PASSWORD=$PW \
  -e PGPASSWORD=$PW \
  -p 5432:5432 \
  -d postgres

# wait for pg to start
echo "sleep wait for pg-server [$SERVER] to start";
SLEEP 3;

# create the db
echo "CREATE DATABASE $DB ENCODING 'UTF-8';" | docker exec -i $SERVER psql -U postgres
echo "\l" | docker exec -i $SERVER psql -U postgres

7.10.2. Modificación de las variables de entorno

Cambios a realizar: en el archivo .env:

TUTORIAL_HOST=localhost
TUTORIAL_PORT=5432 (1)
TUTORIAL_USER=postgres (2)
TUTORIAL_PASSWORD=secret
TUTORIAL_DATABASE=tutorial
1 Puerto de PostgreSQL
2 Usuario de PostgreSQL

7.10.3. Modificación del tipo de gestor de bases de datos

Archivo config/config.service.ts

  public getTypeOrmConfig(): TypeOrmModuleOptions {
    return {
      type: 'postgres', (1)

      host: this.getValue('TUTORIAL_HOST'),
      port: parseInt(this.getValue('TUTORIAL_PORT')),
      username: this.getValue('TUTORIAL_USER'),
      password: this.getValue('TUTORIAL_PASSWORD'),
      database: this.getValue('TUTORIAL_DATABASE'),

      entities: ['dist/**/*.entity.js'],
      synchronize: true,
    };
  }
1 Servidor de bases de datos

Si ahora pedimos que nos devuelva todos los libros con el endpoint /api/v1/books y un método GET obtendremos una lista vacía, ya que partimos de una base de datos Postgres vacía.

PostgresEmpty

Tras introducir un nuevo libro y volver a consultar los libros vemos cómo se recuperan los datos sin problema, confirmándose lo sencillo que es cambiar de gestor de bases de datos si se usa un ORM.

PostgresWithOne

8. Autenticación con JSON Web Tokens

Queremos restringir el acceso a los endpoints de la aplicación de forma que sólo tengan acceso los usuarios autenticados. Pero no queremos que se tengan que autenticar para cada petición. Necesitamos una forma que permita a los usuarios indicar que tienen una sesión iniciada válida.

Una forma sencilla de hacer esto es mediante JWT. En nuestro caso, ya partimos de un servidor de autorización que genera tokens de acceso a partir de usuario y contraseña. En este tutorial sólo añadiremos a la aplicación la parte de comprobación de la validez de los tokens y la restricción del acceso a los endpoints para tokens válidos.

JWT (JSON Web Tokens)

JWT es un estándar que define un método compacto y autocontenido que permite compartir de forma segura entre dos partes aserciones (claims) sobre una entidad (subject). Los datos están codificados en formato JSON incluidos en un payload o cuerpo del mensaje y están firmados digitalmente.

De forma predeterminada, los tokens no están cifrados. La cadena del token es una serializalización en Base64 que se puede decodificar fácilmente. La cadena del token está formada por tres partes:

  • Cabecera: Indica algoritmo (p.e. HS256) y tipo de token (p.e. jwt)

  • Payload o cuerpo: Aparecen todos los datos que queremos añadir

  • Firma: Permite verificar si el token es válido

La firma del token se crea de forma que se pueda verificar si el remitente es quien dice ser. Dado que el token es una cadena fácilmente descifrable, si alguien manipula el token incluyendo datos o modificando el payload se verificaría que la firma del token no es correcta y no se puede confiar en el token recibido

Es conveniente incluir en el token una fecha de caducidad. Un token firmado es válido mientras no se haya superado su fecha de caducidad. Así, si alguien intercepta un token, sólo podrá usarlo mientras no caduque. Una fecha de caducidad corta no expondrá los recursos protegidos de la misma forma que si se intercepta una contraseña, que dejará los recursos expuestos mientras no se detecte la pérdida de la contraseña y no se cambie.

Instalaremos los paquetes siguientes:

$ npm install @nestjs/jwt passport passport-jwt @nestjs/passport

El JWT se enviará en la cabecera como Bearer Token.

Bearer Token o token de autorización es un esquema de autenticación HTTP. El método de autenticación Bearer debe entenderse como "dale acceso al portador (bearer) de este token".

Además, necesitaremos una estrategia Passport para la validación del token y configurar la clave secreta que se usó para firmar el token.

Passport y estrategias Passport

Passport es un middleware de autenticación para Node. Se usa para autenticar peticiones. Usa un mecanismo de estrategias para configurar la forma de autenticación (Facebook, Twitter, GitHub, Auth0, OAuth, Google, LDAP, …​). El módulo passport-jwt es una estrategia Passport que permite asegurar peticiones usando JWT sin sesiones.

Crearemos una carpeta utilities donde guardaremos dos archivos:

  • Estrategia JWT para Passport

  • Módulo de autorización para ser importado por los controladores que quieran asegurar sus endpoints

8.1. Configuración de la estrategia Passport

Configuraremos JWT como estrategia Passport para la autenticación. Definiremos:

  • Extracción de JWT en cabecera como tipo Bearer

  • Clave de verificación de firma del token

  • Función de validación del payload

Archivo utilities/jwt.strategy.ts

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { (1)
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), (2)
      secretOrKey: 'secret', (3)
    });
  }

  async validate(payload: any): Promise<any> { (4)
    if (!payload) {
      throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED);
    }
    return payload;
  }
}
1 La clase extiende la estrategia de Passport
2 Extracción del token de la cabecera de la petición
3 Clave de verificación de la firma del token
4 Función de validación del token

8.2. Módulo de autenticación

El módulo de autenticación define JWT como la estrategia Passport a usar para los que importen este módulo. Además, define una propiedad (user) para enviar el payload del token en las peticiones.

Archivo utilities/auth.module.ts

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
@Module({
  imports: [
    PassportModule.register({ (1)
      defaultStrategy: 'jwt', (2)
      property: 'user', (3)
      session: false,
    }),
  ],
  controllers: [],
  providers: [JwtStrategy], (4)
  exports: [PassportModule], (5)
})
export class AuthModule {}
1 Configuración del módulo Passport
2 Configuración a estrategia jwt
3 Definición de propiedad user para el envío del payload en las peticiones
4 provider configurado en el paso anterior
5 Exportar el módulo ya configurado

El valor jwt definido en defaultStrategy se usará posteriormente a la hora de proteger los endpoints.

8.3. Restricción del acceso de los endpoints

Añadimos el módulo AuthModule definido en el paso anterior al módulo de los endpoints que queremos proteger. El módulo AuthModule definía la configuración de la estrategia y el servicio de validación JWT a utilizar.

Archivo books/books.module.ts

...
import { AuthModule } from '../utilities/auth.module';

@Module({
  imports: [
    ...
    , AuthModule], (1)
  providers: [...],
  controllers: [...],
})

export class BooksModule {}
1 Importación del módulo definido

Una vez definido el módulo, ya sólo falta proteger los endpoints. Podremos hacerlo de dos formas:

  • Proteger de una vez todos los endpoints del controlador

  • Proteger sólo los endpoints indicados

La protección se hará usando el decorador @UseGuards(). Si el decorador se coloca antes de la definición de la clase, quedan protegidos todos los endpoints definidos en la clase. Si no se desea una protección de todos los endpoints, se colocará @UseGuards() antes de la definición de aquellos endpoints que se quieran proteger.

A @UseGuards() se le pasa como argumento el nombre de estrategia de autenticación definida. En nuestro caso, la nuestra la habíamos definido como jwt en Auth.module.ts.

Archivo books.controller.ts

import {
  ...
  UseGuards, (1)
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; (2)
...
@Controller('books')
@UseGuards(AuthGuard('jwt')) (3)
...
export class BooksController {
...
}
1 Importación del decorador UseGuards
2 Importación de AuthGuard para especificar la estrategia de autenticación a utilizar
3 Restricción del acceso a jwt de forma global (a nivel de clase) para todos los endpoints del controlador

Si tratamos de acceder sin token o con un token inválido a cualquier endpoint definido, obtendremos un mensaje de error 401 Unauthorized, tal y como muestra la figura.

JWT SinAutenticar

Si pasamos en la cabecera de autorización pasamos el token indicando que es Bearer Token tendremos acceso a los endpoints, tal y como muestra la figura.

JWT Autenticado

9. Documentación de la API con Swagger (OpenAPI)

NestJS cuenta con un módulo que permite la generación automática de la documentación en Swagger (OpenAPI). Esto permite obtener la documentación de la API y sus endpoints mediante decoradores en el código.

Comenzaremos instalando los paquetes de Swagger en el proyecto.

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

A continación hay que modificar el archivo main.js usando la clase SwaggerModule.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; (1)

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api/v1');

  // Configurar títulos de documnentación
  const options = new DocumentBuilder() (2)
    .setTitle('Bookstore REST API')
    .setDescription('API REST de Bookstore')
    .setVersion('1.0')
    .addBearerAuth( (3)
      { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
      'access-token', (4)
    )
    .build();
  const document = SwaggerModule.createDocument(app, options); (5)

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

  await app.listen(3000);
}
bootstrap();
1 Importaciones necesarias
2 Configuración de opciones generales de la documentación (título, versión, …​)
3 Habilita el uso de autenticación JWT con Bearer Token
4 Nombre asignado a esta configuración de autenticación
5 Creación de la documentación con las opciones configuradas
6 Especificación de la ruta relativa donde se sirve la documentación Swagger

La configuración de in: header en addBearerAuth() permite una autenticación global asignándole un nombre (p.e. access-token). Si a nivel de clase se especifica @ApiBearerAuth('access-token') todos los endpoints quedarían autenticados tras la autenticación global. En cambio, si se opta por una autenticación individual, habría que incluir @ApiBearerAuth('access-token') antes de cada endpoint que quisiera usar el método de autenticación denominado access-token.

9.1. Documentación de DTOs, entidades, clases e interfaces

En clases DTO, así como en entidades, clases e interfaces, incluiremos un decorador @ApiProperty() antes de cada propiedad. A este decorador se le puede pasar un ejemplo que facilite la introducción al uso de la API.

El uso de decoradores en los DTO y entidades permite que aparezcan el tipo y un ejemplo definido siempre que use un DTO o una entidad, lo que facilita bastante la interacción con la documentación.

Archivo books/book.dto.ts

import { ApiProperty } from '@nestjs/swagger'; (1)

export class BookDto {
  @ApiProperty({ example: 'Don Quijote de la Mancha' }) (2)
  readonly title: string;

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

  @ApiProperty({
    example: 'Esta edición del Ingenioso hidalgo don Quijote de la Mancha ...',
  })
  readonly description: string;

  @ApiProperty({ example: 'Miguel de Cervantes' })
  readonly author: string;

  @ApiProperty({ example: 'Santillana' })
  readonly publisher: string;

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

  @ApiProperty({ example: 'www.imagen.com/quijote.png' })
  readonly image_url: string;
}
1 Importación de decoradores
2 Configuración de propiedades

La anotación Swagger de la entidad es prácticamente igual a la del DTO salvo que también incluye el id.

Archivo books/book.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';

@Entity()
export class Book {
  @ApiProperty({ example: 99 })
  @PrimaryGeneratedColumn()
  id: number;

  @ApiProperty({ example: 'Don Quijote de la Mancha' })
  @Column()
  title: string;

  @ApiProperty({ example: 'Novela' })
  @Column()
  genre: string;

  @ApiProperty({
    example: 'Esta edición del Ingenioso hidalgo don Quijote de la Mancha ...',
  })
  @Column('text')
  description: string;

  @ApiProperty({ example: 'Miguel de Cervantes' })
  @Column()
  author: string;

  @ApiProperty({ example: 'Santillana' })
  @Column()
  publisher: string;

  @ApiProperty({ example: 592 })
  @Column()
  pages: number;

  @ApiProperty({ example: 'www.imagen.com/quijote.png' })
  @Column()
  image_url: string;
}

También hay que incluir decoradores @ApiProperty en interfaces y otras clases definidas para tipado.

9.2. Documentación de los controladores

Los métodos de los controladores se pueden agrupar mediante etiquetas Swagger. Para ello se usa el decorador @ApiTags(). Se puede usar el decorador a nivel de clase, lo que combinará a todos los métodos en el mismo grupo. También se puede usar a nivel de método.

Si se dispone de autenticación JWT, se incluirá el decorador @ApiBearerAuth() con el nombre usado para denominar al método de autenticación definido. Si el decorador se usa a nivel de clase, todos los endpoints de la clase quedarán autenticados al realizar una autenticación global.

En cada operación se incluirá:

  • Un decorador @ApiOperation() para proporcionar una descripción para la operación

  • Un decorador @ApiResponse() por cada respuesta que proporcione la operación (p.e. 200, 403, …​)

A continuación se muestra un fragmento de la anotación en books/books.controller.ts

...
import { BookDto } from './book.dto'; (1)
import { Book } from './book.entity'; (2)
import { (3)
  ApiOperation,
  ApiResponse,
  ApiTags,
  ApiBearerAuth,
} from '@nestjs/swagger';
...
@ApiTags('book') (4)
@Controller('books')
@UseGuards(AuthGuard('jwt')) (5)
@ApiBearerAuth('access-token') (6)
export class BooksController {
...
  /** (7)
   *
   * @returns {Book[]} Devuelve una lista de libros
   * @param {Request} request Lista de parámetros para filtrar
   */
  @Get()
  @ApiOperation({ summary: 'Obtener lista de libros' }) (8)
  @ApiResponse({ (9)
    status: 201,
    description: 'Lista de libros',
    type: Book, (10)
  })
  findAll(@Req() request: Request): Promise<Book[]> {
  ...
  }
...
}
1 Importación del DTO para enlazar bien la documentación
2 Importación de la entidad para enlazar bien la documentación
3 Importación de paquetes Swagger
4 Especificación de la etiqueta para combinar a todos las operaciones de este controlador en el grupo book
5 Protección con JWT a nivel de clase de todos los endpoints
6 Configuración de autenticación en Swagger a nivel de clase
7 Documentación del retorno y de los parámetros del endpoint
8 Descripción de la operación
9 Respuesta 201
10 Al especificar el tipo, se puede ver un ejemplo de la respuesta en la documentación

La figura siguiente muestra cómo quedaría inicialmente la documentación servida el la ruta docs. Como aún no se ha proporcionado el token, los endpoints aparecen con un candado abierto indicando que no se posible su acceso.

Swagger Inicio

Si probásemos un endpoint (p.e. GET /books para obtener la lista de todos los libros) con Try out se nos rechazaría el acceso, tal y como ilustra la figura siguiente.

Swagger NoAutenticado

Para introducir el token, pulsaremos el botón Authorize superior. En el cuadro de diálogo introducimos el token y pulsamos sobre Authorize

Swagger Token

Si el token introducido es válido, quedaremos autorizados.

Swagger TokenValido

Al quedar autorizados, como definimos la autenticación para todo el controlador, quedaría abierto el acceso a todos los endpoints, mostrándose ahora todos los candados cerrados.

Swagger Autenticado

Si ahora volvemos a probar el endpoint para obtener la lista de libros, la lista se recuperará y se mostrará en el propio Swagger.

Swagger Respuesta

Esto hace a Swagger una opción muy interesante para los proyectos de APIs ya que no sólo es una herramienta de documentación, sino que también permite la interacción directa con la API. Con una buena documentación enriquecida con la descripción de sus parámetros, tipos y ejemplos tendremos una plataforma extraordinaria para la documentación y uso de APIs.

9.3. Descarga de JSON

Para generar y poder descargar un archivo Swagger JSON basta con añadir -json a la ruta desde la que se sirve la documentación. Este archivo podrá ser alojado en una plataforma desde la que se sirva la documentación de las APIs de la organización.

En nuestro caso, http://localhost:3001/docs-json generará el archivo Swagger JSON de nuestra aplicación.

Swagger JSON

El elemento servers está sin definir. De cara a subir este JSON a un servidor de Swagger, se debería configurar este elemento con el nombre DNS o IP del servidor donde se aloja la API para poder interactuar con la API.

Para más información sobre Swagger, consultar la documentación oficial

9.4. Cambio del frontend

NestJS-Redoc es un frontend para la especificación de la API en Swagger. Está basado en Redoc y permite una presentación más sencilla y elaborada que la proporcionada por Swagger UI ofreciendo además funciones de búsqueda.

La instalación se realiza con

$ npm install --save nestjs-redoc@1.3.1

A fecha de la creación de este tutorial la versión actual de NestJS Redoc (1.3.2) tiene una incompatibilidad con la versión actual de NestJS (7.0.0). Mientras se resuelve hay que usar la versión 1.3.1 de NestJS Redoc.

NestJS-Redoc se apoya en la configuración realizada con Swagger y añade unas opciones propias (p.e. logo y título de la página). Al igual que con Swagger, la configuración de Redoc se realiza en main.ts. Sin embargo, hay que indicar que la documentación ya no la sirve Swagger UI, sino Redoc. De esto se encarga el método setup de RedocModule tal y como se muestra a continuación.

Archivo main.ts

...
import { RedocModule, RedocOptions } from 'nestjs-redoc'; (1)

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

  // Configurar títulos de documnentación
  const options = new DocumentBuilder() (2)
    .setTitle('Sample REST API')
    .setDescription('Sample API REST Description')
    .setVersion('1.0')
    .addBearerAuth(
      { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
      'access-token',
    )
    .build();
  const document = SwaggerModule.createDocument(app, options);

  const redocOptions: RedocOptions = { (3)
    favicon: 'https://www.ual.es/favicon.ico',
    title: 'API Reservas',
    logo: {
      url:
        'https://www.ual.es/application/themes/ual/images/logoual25-300px.png',
      backgroundColor: '#0082B7',
    },
    sortPropsAlphabetically: true,
    hideDownloadButton: false,
    hideHostname: false,
    noAutoAuth: false,
  };

  // La ruta en que se sirve la documentación
  //SwaggerModule.setup('docs', app, document); (4)
  await RedocModule.setup('/docs', app, document, redocOptions); (5)

  await app.listen(3000);
}
bootstrap();
1 Importaciones de Redoc
2 Configuración de opciones generales de la documentación Swagger
3 Configuración de las opciones de Redoc
4 La documentación ya no la sirve Swagger UI
5 Servir la documentación con Redoc usando las opciones definidas en redocOptions

La figura siguiente ilustra el nuevo aspecto de la documentación Swagger.

Redoc

Para más información sobre las opciones disponibles en Redoc, consultar la documentación oficial.

Puedes encontrar ejemplos de uso de Redoc en:

La opción de envío de peticiones a la API a través de Swagger (Try it out) es una función de pago en Redoc (Redocly) por lo que el uso de Redoc en su versión open source se limita a la documentación sin contar con la funcionalidad de envío de peticiones.

10. Logging

A medida que las aplicaciones se complican y a medida que se les exige mayor rendimiento se vuelve más necesario contar un registro de logs que nos ayude a encontrar fallos o problemas de rendimiento. NestJS incorpora un sistema de logging que permite controlar los mensajes que se registran en el log y especificar su salida. Sin embargo, Nest recomienda usar otros paquetes de logging más avanzados y versátiles para sistemas en producción, como Winston. Entre las características de Winston se encuentran: soporte para gran cantidad de opciones de almacenamiento, niveles de log y formateo de logs.

  • Opciones de almacenamiento: Winston es una librería de logging que permite varios transportes. Básicamente, un transporte es un dispositivo de almacenamiento para almacenar logs. Cada instancia de un logger de Winston puede tener varios transportes configurados para niveles diferentes. Ejemplos de transportes son consola, archivo, archivos de rotación diaria, Syslog, Datadog, ElasticSearch o MongoDB.

    Una opción de transporte centralizada, como la basada en ElasticSearch, evitaría el problema de la fragmentación de logs que se produce cuando tenemos varias copias de la aplicación (p.e. en varios contenedores), cada una con sus archivos de log independientes.

  • Niveles: Los niveles de log indican la gravedad, que van desde una caída del sistema hasta el aviso de una función marcada como obsoleta. Los niveles de log ayudan a ver rápidamente los logs que necesitan atención. Para cada nivel se puede configurar la cantidad de datos y de detalles a registrar.

    Los niveles de log se priorizan de 0 a 5 (de mayor a menor prioridad)

    • 0: error

    • 1: warn

    • 2: info

    • 3: verbose

    • 4: debug

    • 5: silly

    Al especificar un nivel de log para un transporte concreto, se registará cualquier cosa con ese nivel o con una prioridad mayor (p.e. si se especifica info, se registrará cualquier cosa al nivel info así como a las niveles warn y error.

  • Formato: Winston ofrece formateo en JSON, uso de colores y manipulación de formatos. ya que posteriormente surgen problemas si todo son cadenas.

10.1. Configuración de Winston

Comenzamos instalando con

npm install --save nest-winston winston

A continuación, se configuran las opciones de nivel de log, transporte y formato en app.module.ts. En este ejemplo se registran los logs con nivel info (que registrará info, warn y error). Las opciones de formato incluyen la fecha, la interpolación de cadenas y la salida en JSON. Como transportes, se usarán 3 archivos de logs independientes (uno para errores, otro para debug y otro para info) y salida por consola para nivel debug.

Archivo app.module.ts

...
import { WinstonModule } from 'nest-winston'; (1)
import * as winston from 'winston';
import * as path from 'path';

@Module({
  imports: [
    ...
    WinstonModule.forRoot({
      level: 'info', (2)
      format: winston.format.combine( (3)
        winston.format.timestamp({
          format: 'YYYY-MM-DD HH:mm:ss',
        }),
        winston.format.errors({ stack: true }),
        winston.format.splat(),
        winston.format.json(),
      ),
      transports: [ (4)
        new winston.transports.File({
          dirname: path.join(__dirname, './../log/debug/'),
          filename: 'debug.log',
          level: 'debug',
        }),
        new winston.transports.File({
          dirname: path.join(__dirname, './../log/error/'),
          filename: 'error.log',
          level: 'error',
        }),
        new winston.transports.File({
          dirname: path.join(__dirname, './../log/info/'),
          filename: 'info.log',
          level: 'info',
        }),
        new winston.transports.Console({ level: 'debug' }),
      ],
    }),
  ],
  controllers: [...],
  providers: [...],
})
export class AppModule {}
1 Importaciones necesarias de Winston y paths para tratar con las rutas de los archivos de log
2 Configuración del nivel info
3 Formato definido para las entradas de log
4 Transportes: 3 archivos y salida por consola para nivel mínimo de debug

El trasporte para archivos tiene otras opciones interesantes como:

  • maxsize: Tamaño máximo en bytes del archivo de log. Al superar el tamaño se crea un nuevo archivo de log.

  • maxFiles: Limita el número de archivos a crear cuando se excede el tamaño máximo del archivo de logs

  • zippedArchive: Si es true, se comprimen todos los archivos de log excepto el actual.

10.2. Registro de logs con Winston

Aquí vamos a ver cómo un endpoint registra una entrada de log. En el controlador y en general en cualquier clase que usase Winston, haríamos la configuración siguiente:

import { (1)
    ...
    Inject } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';

@Controller()
export class SomeController {
  constructor(
    ...
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, (2)
  ) {
  ...
  }
  ...
}
1 Importación de paquetes y opciones de Winston
2 Winston se inyecta en el constructor y queda disponible como logger

Comprobar que el Logger que se importa es el de Winston y no otro, como el de Nest o el de TypeORM.

Para crear una entrada de log se indica el nivel de la entrada de log, y concatenaríamos pares clave-valor que queremos registrar en el log.

    this.logger.log({
      level: 'info',
      message: 'Hola',
      service: 'Books',
    });

Como se trata de una entrada de tipo info, quedaría registrada en log/info.log:

{"level":"info","message":"Hola","service":"Books","timestamp":"2020-08-05 19:14:08"} (1)
1 timestamp puede ser incluido de forma automática si se configura así en las opciones de las entradas de log

En una entrada de log son obligatorios los campos level y message.

10.3. Log de operaciones de la API

Para finalizar veremos cómo registrar en el log operaciones de la API. Pasaremos por alto el control de errores y sólo haremos el caso feliz en que la operación se lleva a cabo con éxito. La entrada de log incluirá lo siguiente:

  • level: Indica el nivel de la entrada de log

  • message: Texto de la entrada

  • statusCode Código HTTP de la respuesta

  • method: Método HTTP de la petición

  • url: URL solicitada

  • user: Usuario que ha realizado la petición. Se obtiene del JWT enviado en la cabecera

  • duration: Tiempo en ms para resolver la petición

  • timestamp: Instante en el que se ha realizado la petición

La mecánica que usaremos para atender una petición de la API será la siguiente:

  1. Obtener la fecha del sistema

  2. Llamar al servicio que resuelve la petición

  3. Llamada a una función auxiliar que escribe una entrada en el log

  4. Devolver los datos de la petición

Para obtener datos de la petición, como el método HTTP, url, usuario y demás, incluiremos un parámetro de tipo Request en cada función de la API.

Archivo books/books.controller.ts

  ...
  @Get()
  ...
  findAll(@Req() request: Request): Promise<Book[]> { (1)
    let startTime = Date.now(); (2)
    let data = this.booksService.findAll(request.query); (3)

    this.writeLog(startTime, request, 200); (4)

    return data; (5)
  }
  ...
1 Incluir un parámetro Request para incluir datos como la url, método HTTP y demás en la entrada de log
2 Obtener la hora antes de llamar al servicio que resuelve la petición
3 Llamar al servicio
4 Llamar a la función auxiliar que escribe la entrada de log
5 Devolver los datos de la petición

Función auxiliar

Archivo books/books.controller.ts

...
  writeLog(startTime: any, request: any, statusCode: number) {
    let finishTime = Date.now();
    let elapsedTime = finishTime - startTime;

    this.logger.log({
      level: 'info',
      message: '',
      statusCode: statusCode,
      method: request['method'],
      url: request['url'],
      user: request['user'].username,
      duration: elapsedTime,
    });
  }
  ...

Tras hacer una petición GET /api/v1/books/1 obtendríamos esta entrada en el archivo de logs log/info/info.log

{"level":"info","message":"","statusCode":200,"method":"GET","url":"/api/v1/books/1","user":"mtorres","duration":8,"timestamp":"2020-08-06 13:01:49"}

En este ejemplo se ha optado por definir una entrada de log con campos independientes fuera de message. Otra opción es incluirlos dentro de message y usar interpolación de variables.

11. Mejora del controlador con estados HTTP estándar

Hasta ahora hemos devuelto códigos de estado en forma numérica. Es mejor práctica devolverlos codificados (p.e. OK, CREATED, …​). HttpStatus es un enum de NestJS que facilita la devolución de códigos de estado (ver lista de códigos de estado).

A continuación se muestra cómo quedaría en el controlador el código de la petición de recuperación de todos los libros.

Archivo src/books/books.controller.ts

...
import { HttpStatus } from '@nestjs/common'; (1)
...
  @Get()
  @ApiOperation({ summary: 'Obtener lista de libros' })
  @ApiResponse({
    status: HttpStatus.OK, (2)
    description: 'Lista de libros',
    type: [Book],
  })
  async findAll(@Req() request: Request, @Res() res): Promise<Book[]> {
    let startTime = Date.now();
    let data = await this.booksService.findAll(request.query); (3)

    this.writeLog(startTime, request, HttpStatus.OK); (4)
    return res.status(HttpStatus.OK) (5)
    .json({ (6)
      statusCode: HttpStatus.OK,
      message: message,
      data: data,
    });
  }
...
1 Enum HttpStatus para los códigos de estado HTTP
2 Swagger ahora devuelve el estado codificado
3 Almacenar en data para su uso posterior lo que devuelve la llamada al método del servicio
4 Entrada de log con el código de estado
5 Devolver código de estado codificado
6 El resultado ahora se devuelve en un JSON formado por tres elementos: statusCode, message y data

Análogamente, estos cambios también se deben llevar a cabo en el resto de rutas (endpoints) definidas en el controlador. A continuación se muestra el código completo.

Archivo src/books/books.controller.ts

import {
  Controller,
  Get,
  Param,
  Req,
  Post,
  Body,
  Delete,
  Put,
  Inject,
  UseGuards,
  Res,
} from '@nestjs/common';
import { BooksService } from './books.service';
import { Request } from 'express';
import { BookDto } from './book.dto';
import {
  ApiOperation,
  ApiResponse,
  ApiTags,
  ApiBearerAuth,
} from '@nestjs/swagger';
import { Book } from './book.entity';
import { AuthGuard } from '@nestjs/passport';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { HttpStatus } from '@nestjs/common'; (1)
@ApiTags('book')
@Controller('books')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth('access-token')
export class BooksController {
  constructor(
    private booksService: BooksService,
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
  ) {}


  @Get() (2)
  @ApiOperation({ summary: 'Obtener lista de libros' })
  @ApiResponse({
    status: HttpStatus.OK,
    description: 'Lista de libros',
    type: [Book],
  })
  async findAll(@Req() request: Request, @Res() res): Promise<Book[]> {
    let startTime = Date.now();
    let data = await this.booksService.findAll(request.query);

    this.writeLog(startTime, request, HttpStatus.OK);
    return res.status(HttpStatus.OK).json({
      statusCode: HttpStatus.OK,
      message: 'OK',
      data: data,
    });
  }

  @Get(':bookId') (3)
  @ApiOperation({ summary: 'Devuelve información sobre un libro específico' })
  @ApiResponse({
    status: HttpStatus.OK,
    description: 'Datos del libro',
    type: Book,
  })
  async findBook(
    @Req() request: Request,
    @Param('bookId') bookId: string,
    @Res() res,
  ): Promise<Book> {
    let message = 'OK';
    let startTime = Date.now();
    let data = await this.booksService.findBook(bookId);

    if (!data) {
      message = 'A book with the specified id was not found';
    }

    this.writeLog(startTime, request, HttpStatus.OK);
    return res.status(HttpStatus.OK).json({
      statusCode: HttpStatus.OK,
      message: message,
      data: data,
    });
  }

  @Post() (4)
  @ApiOperation({ summary: 'Crear un libro' })
  @ApiResponse({
    status: HttpStatus.CREATED,
    description: 'Datos del libro creado',
    type: Book,
  })
  @ApiResponse({ status: 403, description: 'Forbidden.' })
  async createBook(
    @Req() request: Request,
    @Body() newBook: BookDto,
    @Res() res,
  ): Promise<Book> {
    let startTime = Date.now();
    let data = await this.booksService.createBook(newBook);

    this.writeLog(startTime, request, HttpStatus.CREATED);
    return res.status(HttpStatus.CREATED).json({
      statusCode: HttpStatus.CREATED,
      message: 'OK',
      data: data,
    });
  }

  @Delete(':bookId') (5)
  @ApiOperation({ summary: 'Eliminar un libro específico' })
  @ApiResponse({
    status: 200,
    description: 'Datos del libro eliminado',
  })
  async deleteBook(
    @Req() request: Request,
    @Param('bookId') bookId: string,
    @Res() res,
  ): Promise<Book> {
    let message = 'OK';
    let startTime = Date.now();
    let data = await this.booksService.deleteBook(bookId);

    if (data['affected'] == 0) {
      message = 'A book with the specified id was not found';
      data = {};
    }

    this.writeLog(startTime, request, HttpStatus.OK);
    return res.status(HttpStatus.OK).json({
      statusCode: HttpStatus.OK,
      message: message,
      data: data,
    });
  }

  @Put(':bookId') (6)
  @ApiOperation({ summary: 'Actualizar un libro específico' })
  @ApiResponse({
    status: 200,
    description: 'Datos del libro actualizado',
    type: Book,
  })
  async updateBook(
    @Req() request: Request,
    @Param('bookId') bookId: string,
    @Body() newBook: BookDto,
    @Res() res,
  ): Promise<Book> {
    let message = 'OK';
    let startTime = Date.now();
    let data = await this.booksService.updateBook(bookId, newBook);

    if (!data) {
      message = 'A book with the specified id was not found';
    }

    this.writeLog(startTime, request, HttpStatus.OK);
    return res.status(HttpStatus.OK).json({
      statusCode: HttpStatus.OK,
      message: message,
      data: data,
    });
  }

  writeLog(startTime: any, request: any, statusCode: number) {
    let finishTime = Date.now();
    let elapsedTime = finishTime - startTime;

    this.logger.log({
      level: 'info',
      message: '',
      statusCode: statusCode,
      method: request['method'],
      url: request['url'],
      user: request['user'].username,
      duration: elapsedTime,
    });
  }
}
1 Enum HttpStatus para los códigos de estado HTTP
2 Ruta para devolver todos los libros adaptada para trabajar con códigos de HttpStatus
3 Ruta para devolver un libro específico adaptada para trabajar con códigos de HttpStatus
4 Ruta para crear un libro adaptada para trabajar con códigos de HttpStatus
5 Ruta para borrar un libro específico adaptada para trabajar con códigos de HttpStatus
6 Ruta para modificar un libro específico adaptada para trabajar con códigos de HttpStatus

12. Documentación del código

NestJS usa Compodoc, una herramienta de documentación para Angular. Al documentar el código, los miembros del equipo de desarrollo podrán entender fácilmente las características de la aplicación o librería. La documentación se anota mediante JSDoc siguiendo este esquema:

/**
 * Supported comment
 */

Entre los tags JSDoc, destacan:

  • @returns {Type} Description

  • @param {Type} Name Description

  • @ignore para excluir un fragmento de código de la documentación

Para instalar Compodoc en un proyecto NestJS basta con añadir el paquete:

$ npm i -D @compodoc/compodoc

La documentación se generará desde la línea de comandos mediante npx (una herramienta para ejecutar paquetes de Node disponible con npm 6). Esto generará una carpeta documentation en el proyecto que se podrá servir con el proyecto o en un portal de ámbito más global donde estén todas las documentaciones de los proyectos desarrollados por el equipo.

$ npx compodoc -p tsconfig.json -s --theme material

El parámetro -s inicia un servidor en el puerto 8080 para poder consultar la documentación. El parámetro --theme material aplica el tema material a la documentación. Para más información sobre las opciones de uso, consultar la documentación oficial

Compodoc genera una página Overview donde presenta un diagrama con los disntintos componentes y sus relaciones, algo muy interesante para hacerse una primera idea de la composición e interacción del software desarrollado.

Compodoc Overview

La figura siguiente ilustra el formato de la documentación de un componente de la aplicación.

Compodoc Documentacion

Para más información sobre JSDoc, consultar la documentación oficial

13. Estado de salud

Un aspecto de interés a tener en cuenta al crear una aplicación consiste en ofrecer en una ruta concreta el estado de salud en el que se encuentra la aplicación y sus componentes (p.e. base de datos). Esto facilita el trabajo posterior a la herramientas de monitorización a la hora de recopilación de datos y de disparo de alertas. NestJS se integra con el paquete Terminus. Este paquete nos permite mostrar el estado HTTP de la aplicación en su conjunto, así como indicadores concretos del estado de otros componentes como TypeORM, Mongoose, Sequelize o indicadores de uso de disco y de memoria.

El estado de salud se suele exponer en la ruta /health.

13.1. Configuración inicial del estado de salud

Comenzamos instalando el paquete Terminus con npm

$ npm install --save @nestjs/terminus

A continuación crearemos un módulo y un controlador health.

$ nest generate module health
$ nest generate controller health

Tras esto, por un lado tenemos el módulo health en la zona de imports de app.module.ts

Archivo app.module.ts
...
import { HealthModule } from './health/health.module';

@Module({
  imports: [
    ...
    HealthModule, (1)
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
1 Módulo HealthModule disponible para la aplicación

y por otro lado un módulo HealthModule que incluye al controlador Health creado. A continuación, añadiremos el módulo de Terminus en el módulo HealthModule.

Archivo health/health.module.ts
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { TerminusModule } from '@nestjs/terminus';

@Module({
  imports: [TerminusModule], (1)
  controllers: [HealthController],
})
export class HealthModule {}
1 Añadimos el módulo de Terminus en el módulo de health

Por último, para completar la configuración inicial añadiremos una ruta en el controlador health para exponer el estado de salud de la API

Archivo health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService,
  HealthCheck,
} from '@nestjs/terminus';

@Controller('health') (1)
export class HealthController {
  constructor(
    private health: HealthCheckService, (2)
  ) {}

  @Get()
  @HealthCheck() (3)
  check() { (4)
    return this.health.check([]); (5)
  }
}
1 Ruta en la que se sirve el estado de salud
2 Servicio HealthCheckService de Terminus inyectado en el constructor
3 Decorador HealthCheck
4 Método para devolver el estado de salud
5 Método básico que devuelve si se ha iniciado la API

Si ahora llamamos a la API en /health obtendremos la respuesta siguiente

{
    "status": "ok",
    "info": {},
    "error": {},
    "details": {}
}
1 API disponible

No es demasiada información, pero con esto nada más ya sabemos que la API está disponible, tal y como indica el elemento status.

13.2. Estado de salud de componentes

Terminus ofrece una serie de indicadores de salud interesantes. Entre los más interesantes, actualmente destacan los siguientes:

  • HttpHealthIndicator

  • TypeOrmHealthIndicator

  • MemoryHealthIndicator

  • DiskHealthIndicator

El uso de estos indicadores va a ofrecer información complementaria y más detallada del estado de la aplicación. Su uso consta de dos pasos a realizar en el controlador health:

  1. Inyectar en el constructor el indicador deseado.

  2. Añadir al array health.check una función anónima para cada prueba de salud a realizar en cada componente.

También hay indicadores para conocer el estado de Mongoose y Sequelize. Para ello, se usan los indicadores MongooseHealthIndicator y SequelizeHealthIndicator, respectivamente.

El código siguiente muestra:

  • El uso de comprobación del estado de la base de datos.

  • Si se ha sobrepasado el límite de la memoria (heap y memoria residente) estimada para la aplicación (50 MB y 150 MB, respectivamente).

  • Si se está utilizando más del 75% del disco en la máquina en la que se está ejecutando la aplicación

import { Controller, Get } from '@nestjs/common';
import { DiskHealthIndicator } from '@nestjs/terminus';
import {
  HealthCheckService,
  HealthCheck,
  TypeOrmHealthIndicator,
  MemoryHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor( (1)
    private health: HealthCheckService,
    private dbIndicator: TypeOrmHealthIndicator,
    private memoryIndicator: MemoryHealthIndicator,
    private diskHealthIndicator: DiskHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.dbIndicator.pingCheck('database'), (2)
      () => this.memoryIndicator.checkHeap('heap', 50 * 1024 * 1024), // process < 50MB (3)
      () => this.memoryIndicator.checkRSS('memory', 150 * 1024 * 1024), // process < 150MB (4)
      () =>
        this.diskHealthIndicator.checkStorage('disk <5> health', {
          thresholdPercent: 0.75,
          path: '/',
        }),
    ]);
  }
}
1 Inyección de servicios Terminus de indicación de salud
2 Comprueba que la base de datos esté activa y admita conexiones
3 Comprueba que el uso del heap está por debajo de 50 MB
4 Comprueba que el uso de memoria residente está por debajo de 150 MB
5 Comprueba que el uso del disco en la máquina en la que se ejecuta esta aplicación está por debajo del 75%

Si todos los indicadores devuelven ok, el status global es ok. Si alguno de los componentes no cumple la condición de check establecida, el status global de la aplicación pasa a ser error. Los componentes que funcionan correctamente se devuelven en el elemento info. En cambio, los componentes que presentan algún problema se devuelven en el elemento error.

Si consultamos las ruta /health, devuelve que el estado global es ok y que todos los componentes están ok.

statusOK

Para forzar un error, si reducimos la memoria RSS a un valor de 10 MB en lugar de los 150 MB anteriores, el estado de salud pasará a error y en el elemento error se creará una entrada para el elemento que presenta anomalías (memory en este caso).

statusError

Una vez definidas las pruebas de salud que queremos exponer de nuestra API e informamos del estado de cada uno de sus componentes, se facilita bastante el trabajo posterior a los sistemas de monitorización que se estén usando. Estos, periódicamente visitarán la ruta /health, recopilarán los datos para su posterior análisis y dispararán alguna alarma llegado el caso de que se produzca un error en algún componente.

Es posible construir indicadores de salud personalizados (p.e. chequear el estado de un servicio ElasticSearch asociado a nuestra API)

14. Activación de métricas

Las métricas ofrecen una forma de indicar diferentes medidas sobre el funcionamiento de una aplicación (p.e. memoria consumida, peticiones atendidas, cantidad de datos transferidos, …​). Estas medidas se recogen con una base temporal dando lugar a series temporales. Prometheus es un sistema de monitorización y alertas ampliamente extendido y que aquí usaremos para recoger y exponer las métricas de nuestra API.

El modo de funcionamiento suele ser el siguiente.

  1. La aplicación expone sus métricas en una ruta (normalmente /metrics).

  2. Añadir la aplicación como target en un sistema Prometheus.

  3. Prometheus recoge periódicamente las métricas y las almacena como series temporales.

14.1. Métricas de forma manual

prom-client es un cliente Prometheus para NodeJS que expone las métricas de Prometheus. También usaremos el paquete @willsoto/nestjs-prometheus para uso de Prometheus en NestJS. Para instalarlos, ejecutaremos desde la terminal

$ npm install @willsoto/nestjs-prometheus prom-client --save

El ejemplo que desarrollaremos aquí será añadir una métrica denominada books_served. Se trata de un contador que se incrementará en uno cada vez que se llame al servicio de recuperación de todos los libros. Por tanto, cada vez que se haga una petición satisfactoria al endpoint GET /books, éste llamará al método findAll del servicio BooksService. Dicho método será el que incremente en uno el contador books_served cada vez que sea ejecutado.

Tras instalar prom-client y @willsoto/nestjs-prometheus, realizamos la configuración del cliente de Prometheus en el módulo en el que está el servicio que actualizará la métrica. En el caso del ejemplo el módulo es src/books/books.module.ts. En él definiremos las métricas en las que estemos interesados (p.e. books_served).

...
import {
  makeCounterProvider,
  PrometheusModule,
} from '@willsoto/nestjs-prometheus'; (1)

@Module({
  imports: [
    ...
    PrometheusModule.register(), (2)
  ],
  ...
  providers: [
    ...
    makeCounterProvider({(3)
      name: 'books_served',
      help: 'books_help',
    }),
  ],
})
export class BooksModule {}
1 Importación del paquete de Prometheus para NestJS
2 Registro del módulo de Prometheus en el array imports
3 Creación de un nuevo contador denominado books_served y con el texto de ayuda books_help

Por último, en el servicio hay que realizar dos acciones:

  1. Inyectar la métrica en el constructor.

  2. Incluir en el método que actualizará la métrica en Prometheus (findAll) la acción sobre el contador de la métrica tras cada llamada al método.

import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus'; (1)
import { Counter } from 'prom-client'; (2)

@Injectable()
export class BooksService {
  constructor(@InjectMetric('books_served') public counter:  Counter<string>) {} (3)
  findAll(): any {
    this.counter.inc(); (4)
    return {
      status: HttpStatus.OK,
      message: 'findAll funcionando',
    };
  }

  findOne(bookId: string): any {
    return {
      status: HttpStatus.OK,
      message: `findOne funcionando con ${bookId}`,
    };
  }
}
1 Importación del inyector de métricas
2 Importación del objeto contador
3 Inyectar la métrica books_served
4 Incrementar el contador en cada ejecución del método

Tras llamar al endpoint GET /books, en la ruta /metrics de la API ya estará disponible la métrica books_served

# HELP books_served books_help
# TYPE books_served counter
books_served 1 (1)
1 Métrica disponible tras la llamada al endpoint

Si volvemos a llamar al endpoint GET /books el resultado en /metrics estará actualizado

# HELP books_served books_help
# TYPE books_served counter
books_served 2 (1)
1 Métrica actualizada tras la segunda llamada al endpoint

Tras esto, un sistema Prometheus podría añadir esta API como target y almacenar las métricas como series temporales.

Como cada métrica a usar se inyecta en el constructor del servicio, si hay servicios con varios métodos y cada uno tiene su propia métrica, habrá que inyectar todos las métricas en el constructor. En este caso, un enfoque de un archivo de servicio por endpoint y verbo podría generar un código más limpio ya que el constructor del servicio sólo tendría las métricas del método que implementa ese servicio.

14.2. Métricas con swagger-stats

swagger-stats es un middleware para telemetría de APIs. Permite recoger métricas de los endpoints de la API en cuanto a tiempos de resolución, payloads, códigos de estado HTTP, y demás. En su funcionamiento, detecta las operaciones analizando las rutas de la API.

Entre las caracterísitcas de swagger-stats cabe destacar que:

  • Expone las métricas en formato Prometheus por lo que se puede usar la combinación Prometheus-Grafana para monitorización y alertas. Disponer de estas métricas permitirá identificar errores, peticiones con tiempos elevados de ejecución, identificar los últimos erorres, identificar tendencias y demás operaciones de interés en la monitorización de una API.

  • Incorpora una interfaz de telemetría bastante avanzada de forma que permite comenzar a monitorizar una API sin necesidad de instalar nada más de primeras.

  • Permite especificar un host Elasticsearch para el almacenamiento de los datos.

Para instalarlo, ejecutaremos desde la terminal

$ npm install prom-client swagger-stats --save

Tras instalarlo, basta con habilitarlo en src/main.ts

...
import * as swStats from 'swagger-stats'; (1)
...

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  ...
  app.use(swStats.getMiddleware({})); (2)
  ...
}
bootstrap();
...
1 Importar swagger-stats
2 Habilitar el middleware swagger-stats en la API
Configuración de swagger-stats

Es posible configurar ciertos parámetros interesantes, como el nombre de la API, si se requiere autenticación y sus credenciales, URL de Elasticsearch y sus credenciales, por citar una cuantas. Para más información, consultar la documentación oficial.

A continuación se muestra un ejemplo de configuración en el archivo src/main.ts con parámetros de interés para uso de swagger-stats bajo autenticación y uso de Elastisearch.

...
  app.use(
    swStats.getMiddleware({
      name: 'api-catalog',
      authentication: true, (1)
      onAuthenticate: function(req, username, password) { (2)
        // simple check for username and password
        return username === 'admin' && password === 'secret';
      },
      elasticsearch: 'http://myelastic.com:9200', (3)
      elasticsearchUsername: 'admin', (4)
      elasticsearchPassword: 'secret', (5)
      elasticsearchIndexPrefix: 'book-catalog-' (6)

    }),
  );
...
1 Activación de la autenticación
2 Función callback para realizar la autenticación. Se usa para especificar usuario y contraseña de acceso a `swagger-stats
3 URL de Elasticsearch
4 Nombre de usuario de Elasticsearch
5 Password de Elasticsearch
6 Prefijo del índice en Elasticsearch. De forma predeterminada, el prefijo que usa swagger-stats es api

En un entorno de producción, no conviene dejar las credenciales directamente en el código. Se recomienda usar variables de entorno o cualquier otra técnica que no exponga directamente las credenciales.

La figura siguiente ilustra cómo llegan los datos a Elasticsearch y cómo se visualizan desde Kibana.

swagger stats elk

A continuación se muestra cómo luce swagger-stats tras varias interacciones con la API. La pantalla Summary muestra un resumen del tráfico, contadores de errores y peticiones, carga de CPU y RAM.

swagger stats summary

En la barra de menús superior aparecen controles para establecer el periodo de refresco (desde 1 segundo hasta 1 minuto). Además, es posible tener actualización automática o manual.

La pantalla Request muestra un informe con las peticiones, respuestas y sus tiempos asociados clasificadas por verbos HTTP.

swagger stats requests

La pantalla API muestra un resumen de los datos de cada endpoint de la API junto a su verbo HTTP asociado.

swagger stats api

La pantalla Rates & Durations informa de las tasas de transferencia y tiempos de ejecución.

swagger stats rates and durations

La pantalla Payload muestra el tamaño de las peticiones y de las respuestas.

swagger stats payload

La pantalla Last Errors informa de los últimos errores mostrando la fecha y el endpoint. Al hacer clic sobre la flecha de la izquierda del error se despliega una descripción del error y su contexto. En la figura se muestra que el error se debió a un error al realizar la petición. En concreto, se forzó el error haciendo una petición POST de un libro con un JSON mal formado en el body.

swagger stats last errors

La información extendida de una petición también indica la IP de origen.

La pantalla Longest Requests informa de las peticiones que han tardado más tiempo en resolverse, mostrando la fecha y el endpoint. Al hacer clic sobre la flecha de la izquierda de la petición se despliega una descripción de la petición, la respuesta y su contexto.

swagger stats longest requests

Tras esto, un sistema Prometheus podría añadir esta API como target y almacenar las métricas como series temporales.

Apéndice A. Datos de ejemplo

[
  {
    "title": "Una historia de España",
    "genre": "Historia",
    "description": "Un relato ameno, personal, a ratos irónico, pero siempre único, de nuestra accidentada historia a través de los siglos. Una obra concebida por el autor para, en palabras suyas, «divertirme, releer y disfrutar; un pretexto para mirar atrás desde los tiempos remotos hasta el presente, reflexionar un poco sobre ello y contarlo por escrito de una manera poco ortodoxa.",
    "author": "Arturo Pérez-Reverte",
    "publisher": "Alfaguara",
    "pages": 256,
    "image_url": "https://images-na.ssl-images-amazon.com/images/I/41%2B-e981m1L._SX311_BO1,204,203,200_.jpg"
  },
  {
    "title": "Historia de España contada para escépticos",
    "genre": "Historia",
    "description": "Como escribe el autor, no pretende ser veraz, justa y desapasionada, porque ninguna historia lo es. No está hecha para halagar a reyes y gobernantes, ni pretende halagar a los banqueros, ni a la Conferencia Episcopal, ni al colectivo gay.",
    "author": "Juan Eslava Galán",
    "publisher": "Booket",
    "pages": 592,
    "image_url": "https://images-na.ssl-images-amazon.com/images/I/51IyZ5Mq8YL._SX326_BO1,204,203,200_.jpg",
    "__v": 0
  },
  {
    "title": "El enigma de la habitación 622",
    "genre": "Ficción contemporánea",
    "description": "Vuelve el «principito de la literatura negra contemporánea, el niño mimado de la industria literaria» (GQ): el nuevo thriller de Joël Dicker es su novela más personal. ",
    "author": "Joël Dicker",
    "publisher": "Alfaguara",
    "pages": 624,
    "image_url": "https://images-na.ssl-images-amazon.com/images/I/41KiZbwOhhL._SX315_BO1,204,203,200_.jpg"
  }
]

Apéndice B. Generación de código

A la hora de abordar un proyecto de backend hay tareas repetitivas que son susceptibles de ser sometidas a algún grado de automatización. Esto es algo deseable ya que de forma directa esto aumentará nuestra eficacia por un lado, y por otro reducirá la introducción de errores. Veamos aquí dos generadores de código útiles.

Generador de archivos para una entidad

Normalmente, para cada entidad de nuestro proyecto de backend tendremos que crear un módulo, un controlador, un servicio, una clase para la entidad y algunos DTO (p.e. el de crear y el de modificar). Todos estos archivos pueden ser generados de forma automática por el CLI de NestJS generando lo que se denomina un recurso. Desde la carpeta del proyecto ejecutaremos el comando siguiente para cada recurso que queramos crear.

$ nest generate resource <nombre-recurso>

En primer lugar nos pedirá el tipo de nivel de transporte que queremos usar. Elegiremos REST API

? What transport layer do you use? (Use arrow keys)
❯ REST API
  GraphQL (code first)
  GraphQL (schema first)
  Microservice (non-HTTP)
  WebSockets

En segundo lugar aceptaremos la generación de los endpoints básicos para las operaciones CRUD.

? Would you like to generate CRUD entry points? (Y/n)

En el caso de que hayamos elegido crear los recursos para users se crearán los archivos siguientes y se actualizará src/app.module.ts para añadir el módulo del recurso creado (p.e. users.module).

CREATE src/users/users.controller.spec.ts (566 bytes)
CREATE src/users/users.controller.ts (890 bytes)
CREATE src/users/users.module.ts (247 bytes)
CREATE src/users/users.service.spec.ts (453 bytes)
CREATE src/users/users.service.ts (609 bytes)
CREATE src/users/dto/create-user.dto.ts (30 bytes)
CREATE src/users/dto/update-user.dto.ts (169 bytes)
CREATE src/users/entities/user.entity.ts (21 bytes)
UPDATE src/app.module.ts (312 bytes)
Archivo users.controller.ts creado
import { Controller, Get, Post, Body, Put, Param, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

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

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

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

  @Put(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update(+id, updateUserDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.usersService.remove(+id);
  }
}
Archivo users.service.ts creado
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UsersService {
  create(createUserDto: CreateUserDto) {
    return 'This action adds a new user';
  }

  findAll() {
    return `This action returns all users`;
  }

  findOne(id: number) {
    return `This action returns a #${id} user`;
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    return `This action updates a #${id} user`;
  }

  remove(id: number) {
    return `This action removes a #${id} user`;
  }
}

Voilà!! A partir de los archivos generados ya se pueden adaptar los endpoints del controlador, crear el código de los servicios y adaptar las entidades y los DTOs al caso de base de datos del proyecto.

Más información sobre CRUD generator en la documentación oficial

Generador del código de la entidad

typeorm-model-generator es un paquete NodeJS para trabajar con TypeORM que genera los modelos a partir de las tablas existentes en una base de datos. Actualmente soporta los siguientes DBMS:

  • Microsoft SQL Server

  • PostgreSQL

  • MySQL

  • MariaDB

  • Oracle Database

  • SQLite

typeorm-model-generator incorpora el driver para todas las bases de datos soportadas excepto para Oracle. Para Oracle se debe tener instalado Oracle Instant Client en el equipo donde se vaya a ejecutar typeorm-model-generator.

typeorm-model-generator se podrá invocar directamente mediante npx.

npx es una herramienta que permite ejecutar paquetes de binarios npm. npx queda instalado en versiones posteriores a npm 5.2.

A continuación se muestran unos parámetros habituales que utilizaremos al generar los modelos con typeorm-model-generator

  • -h: Host de la base de datos

  • -d: Nombre de la base de datos

  • -u: Usuario

  • -x: Contraseña

  • -e: DBMS (p.e. mysql, oracle, mssql, pgsql, …​)

  • -o: (opcional) Ruta en la que guardar los archivos generados

  • -p: (opcional) Puerto

A continuación se muestra un ejemplo de uso en Oracle

$ npx typeorm-model-generator -h localhost -d myDatabase -u myUser -x myPassword -e oracle -o ./reservations -p 1527

Esto generará un archivo de entidad para cada tabla encontrada en la base de datos indicada incluyendo la definición de cada uno de los campos de la tabla.

Usa los archivos generados para adaptar el contenido de los archivos de entidades generados con CRUD generator del apartado anterior Generador de archivos para una entidad.

Los archivos generados por typeorm-model-generator también se pueden usar para personalizar los DTO generados por CRUD generator. Para adaptar los DTOs normalmente quitaremos algunos de los campos de los modelos creados por CRUD generator.

A continuación se muestra un ejemplo de archivo creado por ``

import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { RstCalendarios } from "./RstCalendarios";

@Index("RST_DIAS_PK", ["yDia"], { unique: true })
@Entity("RST_DIAS") (1)
export class RstDias {
  @Column("varchar2", { name: "T_OBSERVACIONES", nullable: true, length: 100 }) (2)
  tObservaciones: string | null;

  @Column("varchar2", { name: "L_RESERVABLE", nullable: true, length: 1 })
  lReservable: string | null;

  @Column("date", { name: "F_CALENDARIO", nullable: true })
  fCalendario: Date | null;

  @Column("number", { primary: true, name: "Y_DIA" })
  yDia: number;

  @ManyToOne(() => RstCalendarios, (rstCalendarios) => rstCalendarios.rstDias) (3)
  @JoinColumn([{ name: "Y_CALENDARIO", referencedColumnName: "yCalendario" }])
  yCalendario: RstCalendarios;
}
1 Entidad con el nombre de la tabla con la que se corresponde
2 Definición de cada una de las columnas con sus tipos de datos, restricciones, …​
3 Anotaciones para relaciones