Al finalizar este tutorial el estudiante estará en capacidad de realizar la implementación de los controladores de una entidad, utilizando Nest.js.
Para realizar este tutorial Ud. debe:
https://misovirtual.virtual.uniandes.edu.co/codelabs/MISW4403_202212_Logica/index.html#0
Los controladores son la capa final de una API, estos son los encargados de definir los métodos de acción de la API mediante los cuales se envían y reciben datos.
Para el caso de estudio hemos definido un controlador por cada recurso que exponga la API. Iniciamos creando el controlador para el recurso museo. Desde la terminal se ejecuta el comando nest g co museum --no-spec
.
Esto modificará el archivo src/museum/museum.module.ts
incluyendo en el atributo controllers
que hace parte del decorador la referencia al nuevo controlador. Además se creará el archivo src/museum/museum.controller.ts
dentro del cual estará definida la clase MuseumController
. Esta clase está decorada con @Controller
que es la anotación de NestJS para los controladores.
Como parámetro a la anotación @Controller
escribiremos la cadena ‘museums'
. Esta será la dirección principal sobre la cual se definirán las acciones del controlador.
Dentro de la clase MuseumController
definiremos el constructor que recibirá por parámetro una instancia del servicio para la entidad MuseumEntity
. Recordemos que todas las clases Service
están anotadas con @Injectable
, por lo cual se inyectará al controlador con solo recibirlo por parámetro.
constructor(private readonly museumService: MuseumService) {}
En este proyecto todos los errores que ocurren en la capa de servicio serán notificados al cliente mediante códigos de error HTTP. Para no hacer esta notificación de forma manual en cada controlador, usaremos la noción de Interceptor
. Un interceptor captura los errores y las excepciones que lanza la aplicación, los convierte en excepciones HTTP (HttpException
) las cuales contienen un mensaje descriptivo del error y un código de error HTTP.
Para este tutorial se han definido 3 códigos de error:
Para definir el interceptor ejecutaremos el comando nest g itc shared/interceptors/business-errors --no-spec
.
Dentro del archivo se habrá definido la clase BusinessErrorsInterceptor
que implementa la interfaz NestInterceptor
y que está anotado con @Injectable
.
Reemplazaremos el contenido del método intercept
por el que se muestra a continuación.
/* eslint-disable prettier/prettier */
import { CallHandler, ExecutionContext, HttpException, HttpStatus, Injectable, NestInterceptor } from '@nestjs/common';
import { catchError, Observable } from 'rxjs';
import { BusinessError } from '../errors/business-errors';
@Injectable()
export class BusinessErrorsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle()
.pipe(catchError(error => {
if (error.type === BusinessError.NOT_FOUND)
throw new HttpException(error.message, HttpStatus.NOT_FOUND);
else if (error.type === BusinessError.PRECONDITION_FAILED)
throw new HttpException(error.message, HttpStatus.PRECONDITION_FAILED);
else if (error.type === BusinessError.BAD_REQUEST)
throw new HttpException(error.message, HttpStatus.BAD_REQUEST);
else
throw error;
}));
}
}
/* archivo: src/shared/interceptors/business-errors.interceptor.ts */
Volvemos a la clase del controlador (MuseumController
) y anotamos así:
@UseInterceptors(BusinessErrorsInterceptor)
Una de las funciones de la capa de lógica es servir de intermediaria entre los controladores y la persistencia.
En este tutorial usaremos lo que se denomina un Data Transfer Object (DTO). Un DTO es una clase que representa una entidad en formato de un objeto plano. La ventaja de los DTO es que disminuyen la complejidad de una entidad y además se pueden usar posteriormente para hacer validaciones de los datos que recibe el API.
Para ejemplificar implementaremos la clase DTO de la entidad MuseumEntity
definida en la capa de persistencia. La convención de nombramiento de las clases DTO será el nombre de la entidad finalizado en el sufijo "Dto". Iremos a la carpeta museum
definida anteriormente, y dentro de esta crearemos una nueva clase denominada museum.dto.ts
. Recuerde que la clase la puede crear con el comando nest g cl museum/museum.dto --no-spec
.
La clase contendrá el siguiente código:
/* eslint-disable prettier/prettier */
import {IsNotEmpty, IsString, IsUrl} from 'class-validator';
export class MuseumDto {
@IsString()
@IsNotEmpty()
readonly name: string;
@IsString()
@IsNotEmpty()
readonly description: string;
@IsString()
@IsNotEmpty()
readonly address: string;
@IsString()
@IsNotEmpty()
readonly city: string;
@IsUrl()
@IsNotEmpty()
readonly image: string;
}
/* archivo: src/museum/museum.dto.ts */
Los atributos contienen el prefijo readonly
que indica que el atributo no se puede modificar después de haberse inicializado por primera vez.
Note que algunos atributos tienen anotaciones adicionales para validar el tipo de dato que reciben. Por ejemplo @IsString para validar que sea una cadena de caracteres, @IsNotEmpty para validar que no esté vacío y @IsUrl para validar que sea una URL.
Esas anotaciones están en la librería class-validator
. Para instalarla debe ejecutar el comando npm install class-validator --save
.
Para activar la validación debe incluir lo siguiente en el archivo src/main.ts
...
app.useGlobalPipes(new ValidationPipe());
...
También crearemos la clase ArtworkDto
(que usaremos posteriormente en este tutorial) con el siguiente contenido:
/* eslint-disable prettier/prettier */
import { IsNotEmpty, IsNumber, IsString, IsUrl } from "class-validator";
export class ArtworkDto {
@IsString()
@IsNotEmpty()
readonly name: string;
@IsNumber()
@IsNotEmpty()
readonly year: number;
@IsString()
@IsNotEmpty()
readonly description: string;
@IsString()
@IsNotEmpty()
readonly type: string;
@IsUrl()
@IsNotEmpty()
readonly mainImage: string;
}
/* archivo : src/artwork/artwork.dto.ts*/
Dentro de la clase del controlador, definiremos un método por cada operación del API. Estos métodos deben ir anotados con el verbo HTTP correspondiente (e.g., Get, Post, Put o Delete).
El método que retorna todos los museos existentes en la aplicación tendría la siguiente implementación:
@Get()
async findAll() {
return await this.museumService.findAll();
}
El método se anota con @Get
e invoca al método del servicio findAll.
El método que retorna un museo específico según su ID tendría la siguiente implementación:
@Get(':museumId')
async findOne(@Param('museumId') museumId: string) {
return await this.museumService.findOne(museumId);
}
Anotamos el método con @Get(':museumId') donde museumId corresponde al id del museo que se quiere consultar. Ese parámetro que viene en la URL de la petición se extrae y se convierte en una variable de TypeScript. Esto se hace con la anotación @Param('museumId') museumId: string.
Finalmente la variable se pasa como parámetro al método findOne del servicio que retornará el museo con el id proporcionado. Si el museo no existe el interceptor captura la excepción y notifica al cliente el código de error.
El método que crea un museo tendría la siguiente implementación:
@Post()
async create(@Body() museumDto: MuseumDto) {
const museum: MuseumEntity = plainToInstance(MuseumEntity, museumDto);
return await this.museumService.create(museum);
}
En este caso se utiliza la anotación @Post
ya que este es el método HTTP para agregar un recurso a la colección de recursos. El método tiene el parámetro @Body() museumDto: MuseumDto, el cual viene en el cuerpo de la petición. Este parámetro contendrá toda la información necesaria para crear un museo. Al usar esta clase nos aseguramos que el API haga las validaciones de los atributos.
Como el método del servicio recibe un MuseumEntity necesitamos convertir el Dto a Entity. Esta conversión se puede hacer de forma manual, sin embargo podemos usar el método plainToInstance
que se encarga de hacer ese trabajo de forma automática.
Este método está disponible en la librería class-transformer que se debe instalar con el comando npm install class-transformer --save
.
El método que actualiza un museo según su ID tendría la siguiente implementación:
@Put(':museumId')
async update(@Param('museumId') museumId: string, @Body() museumDto: MuseumDto) {
const museum: MuseumEntity = plainToInstance(MuseumEntity, museumDto);
return await this.museumService.update(museumId, museum);
}
Para el método update
se requiere de dos parámetros. El primero es el ID del museo que se quiere actualizar el cual viene en la URL de la petición. El segundo es el objeto DTO que contiene los atributos que se van a actualizar. El Dto se transforma a un Entity para enviarlo junto con el id del museo al método update de la lógica.
El método que elimina un museo según su ID tendría la siguiente implementación:
@Delete(':museumId')
@HttpCode(204)
async delete(@Param('museumId') museumId: string) {
return await this.museumService.delete(museumId);
}
Este método recibe por la URL el id del museo a eliminar. Por defecto el código de respuesta de un método anotado con @Delete es 200, sin embargo queremos notificar al cliente el código 204 que significa que la operación se ha ejecutado correctamente pero no incluye ningún contenido adicional. Por eso anotamos el método con @HttpCode(204).
La implementación completa del controlador del recurso museo es la siguiente:
/* eslint-disable prettier/prettier */
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, UseInterceptors } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { BusinessErrorsInterceptor } from '../shared/interceptors/business-errors.interceptor';
import { MuseumDto } from './museum.dto';
import { MuseumEntity } from './museum.entity';
import { MuseumService } from './museum.service';
@Controller('museums')
@UseInterceptors(BusinessErrorsInterceptor)
export class MuseumController {
constructor(private readonly museumService: MuseumService) {}
@Get()
async findAll() {
return await this.museumService.findAll();
}
@Get(':museumId')
async findOne(@Param('museumId') museumId: string) {
return await this.museumService.findOne(museumId);
}
@Post()
async create(@Body() museumDto: MuseumDto) {
const museum: MuseumEntity = plainToInstance(MuseumEntity, museumDto);
return await this.museumService.create(museum);
}
@Put(':museumId')
async update(@Param('museumId') museumId: string, @Body() museumDto: MuseumDto) {
const museum: MuseumEntity = plainToInstance(MuseumEntity, museumDto);
return await this.museumService.update(museumId, museum);
}
@Delete(':museumId')
@HttpCode(204)
async delete(@Param('museumId') museumId: string) {
return await this.museumService.delete(museumId);
}
}
/* archivo: src/museum/museum.controller.ts */
En el tutorial de lógica incluimos una sección en la que se creaba un servicio adicional para el manejo de las asociaciones. Esta misma decisión de diseño se puede aplicar a los controladores.
Iniciamos creando un nuevo controlador para la asociación museum-artwork. Para esto ejecutamos el comando nest g co museum-artwork --no-spec
. Esto actualizará el módulo museum-artwork y creará el archivo para el nuevo controlador.
Editamos el archivo museum-artwork.controller.ts
definiendo la ruta que resolverá el controlador, que para este caso será ‘museums', también agregamos la anotación para el interceptor e inyectamos el servicio en el constructor.
/* eslint-disable prettier/prettier */
import { Controller, UseInterceptors } from '@nestjs/common';
import { BusinessErrorsInterceptor } from '../shared/interceptors/business-errors.interceptor';
import { MuseumArtworkService } from './museum-artwork.service';
@Controller('museums')
@UseInterceptors(BusinessErrorsInterceptor)
export class MuseumArtworkController {
constructor(private readonly museumArtworkService: MuseumArtworkService){}
}
@Post(':museumId/artworks/:artworkId')
async addArtworkMuseum(@Param('museumId') museumId: string, @Param('artworkId') artworkId: string){
return await this.museumArtworkService.addArtworkMuseum(museumId, artworkId);
}
Este método agrega una obra de arte a un museo. A nivel del API se espera que la petición tenga la ruta /museums/museumId/artworks/artworkId. En la semántica de esta operación al museo con el id "museumId" se le agregará la obra de arte con el id "artworkId". Ambos parámetros se capturan en la anotación @Post y se pasan como parámetros al método. Luego se invoca al método de la lógica.
El código de estado por defecto se reescribe para enviar al cliente el código HTTP 201.
@Get(':museumId/artworks/:artworkId')
async findArtworkByMuseumIdArtworkId(@Param('museumId') museumId: string, @Param('artworkId') artworkId: string){
return await this.museumArtworkService.findArtworkByMuseumIdArtworkId(museumId, artworkId);
}
Este método obtiene una obra de arte de un museo. A nivel del API se espera que la petición tenga la ruta /museums/museumId/artworks/artworkId. En la semántica de esta operación al museo con el id "museumId" se le obtendrá la obra de arte con el id "artworkId". Ambos parámetros se capturan en la anotación @Get y se pasan como parámetros al método. Luego se invoca al método de la lógica.
@Get(':museumId/artworks')
async findArtworksByMuseumId(@Param('museumId') museumId: string){
return await this.museumArtworkService.findArtworksByMuseumId(museumId);
}
Este método obtiene las obras de arte de un museo. A nivel del API se espera que la petición tenga la ruta /museums/museumId/artworks/. En la semántica de esta operación al museo con el id "museumId" se le obtendrán todas las obras de arte. El id del museo se captura en la anotación @Get y se pasa como parámetro al método. Luego se invoca al método de la lógica.
@Put(':museumId/artworks')
async associateArtworksMuseum(@Body() artworksDto: ArtworkDto[], @Param('museumId') museumId: string){
const artworks = plainToInstance(ArtworkEntity, artworksDto)
return await this.museumArtworkService.associateArtworksMuseum(museumId, artworks);
}
Este método actualiza el listado de obras de arte de un museo. A nivel del API se espera que la petición tenga la ruta /museums/museumId/artworks/. En la semántica de esta operación al museo con el id "museumId" se actualizarán todas las obras de arte. El id del museo se captura en la anotación @Put. El listado de las nuevas obras de arte hace parte del body de la petición y se captura en la anotación @Body. El objeto Dto se transforma a Entity. Ambos parámetros se pasan al método. Luego se invoca al método de la lógica.
@Delete(':museumId/artworks/:artworkId')
@HttpCode(204)
async deleteArtworkMuseum(@Param('museumId') museumId: string, @Param('artworkId') artworkId: string){
return await this.museumArtworkService.deleteArtworkMuseum(museumId, artworkId);
}
Este método elimina una obra de arte de la lista de obras de arte de un museo. A nivel del API se espera que la petición tenga la ruta /museums/museumId/artworks/artworkId. En la semántica de esta operación al museo con el id "museumId" se le elimina la obra de arte con el id "artworkId". El id del museo y de la obra de arte se capturan en la anotación @Delete. Ambos parámetros se pasan al método. Luego se invoca al método de la lógica.