Validación de Datos en NestJS: De la Sintaxis a la Semántica

juan_carlosvalderrbano

Juan Carlos Valderrábano Hernández

Posted on August 10, 2024

Validación de Datos en NestJS: De la Sintaxis a la Semántica

Introducción:

En el mundo del desarrollo de aplicaciones web, la validación de datos es una de las tareas más críticas. Los datos que llegan a un servidor deben ser verificados para asegurarse de que cumplen con las expectativas de la aplicación y no comprometen su funcionamiento. En NestJS, un poderoso framework basado en Node.js, la validación se puede dividir en dos etapas clave: validación de sintaxis y validación semántica. En este artículo, exploraremos cómo NestJS permite manejar ambas etapas de manera efectiva, utilizando DTOs para la sintaxis y servicios para la semántica. Además, discutiremos cómo y cuándo transformar datos en los controladores para garantizar un flujo de datos limpio y coherente.

1. Validación de Sintaxis en NestJS: Un Enfoque a Través de DTOs

La validación de sintaxis se refiere a la verificación de que los datos recibidos en una petición HTTP tienen la estructura y el formato correcto. NestJS proporciona una manera eficiente de realizar esta validación utilizando DTOs (Data Transfer Objects) y la librería class-validator. Esta etapa es crucial porque garantiza que los datos que ingresan al sistema están en el formato correcto antes de ser procesados más a fondo.

1.1 ¿Qué son los DTOs en NestJS?

Los DTOs son clases TypeScript que definen la forma esperada de los datos en las peticiones HTTP. Al definir un DTO, estás especificando explícitamente qué campos espera tu aplicación, qué tipos de datos son aceptables y qué reglas deben cumplirse. Estos DTOs actúan como una capa protectora entre el cliente y tu aplicación, asegurando que solo los datos que cumplen con las reglas establecidas lleguen a los controladores.

Ejemplo básico de DTO:
import { IsString, IsNotEmpty, IsEmail, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty()
  @IsString()
  firstName: string;

  @IsNotEmpty()
  @IsString()
  lastName: string;

  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsOptional()
  @IsString()
  phone?: string;
}
Enter fullscreen mode Exit fullscreen mode

En este ejemplo, CreateUserDto define un DTO para crear un nuevo usuario. Aquí, estamos utilizando varios decoradores de class-validator:

  • @IsNotEmpty() asegura que el campo no sea nulo o vacío.
  • @IsString() verifica que el campo sea una cadena de texto.
  • @IsEmail() valida que el campo sea un correo electrónico válido.
  • @IsOptional() indica que el campo phone es opcional.

1.2 Uso de class-validator para validaciones complejas de sintaxis

La librería class-validator proporciona una amplia gama de decoradores para realizar validaciones de sintaxis. Algunos de los más comunes incluyen:

  • @IsInt(): Verifica que el valor sea un número entero.
  @IsInt()
  age: number;
Enter fullscreen mode Exit fullscreen mode
  • @IsBoolean(): Verifica que el valor sea un booleano.
  @IsBoolean()
  isActive: boolean;
Enter fullscreen mode Exit fullscreen mode
  • @Length(min: number, max: number): Verifica que la longitud de la cadena esté dentro de un rango específico.
  @Length(10, 20)
  username: string;
Enter fullscreen mode Exit fullscreen mode
  • @IsArray(): Verifica que el valor sea un array.
  @IsArray()
  tags: string[];
Enter fullscreen mode Exit fullscreen mode
  • @ArrayMinSize(size: number): Verifica que el array tenga un tamaño mínimo.
  @ArrayMinSize(1)
  tags: string[];
Enter fullscreen mode Exit fullscreen mode
Validación condicional y lógica más avanzada:

class-validator también permite realizar validaciones más avanzadas utilizando decoradores como @ValidateIf() y @Matches():

  • @ValidateIf(condition: (obj, value) => boolean): Este decorador permite aplicar una validación solo si se cumple una cierta condición.
  @ValidateIf(o => o.isActive)
  @IsNotEmpty()
  activationDate: string;
Enter fullscreen mode Exit fullscreen mode

En este caso, activationDate solo será requerido si isActive es true.

  • @Matches(pattern: RegExp, message?: string): Verifica que el valor coincida con una expresión regular.
  @Matches(/^[A-Z][a-z]*$/, {
    message: 'The lastName must start with an uppercase letter.',
  })
  lastName: string;
Enter fullscreen mode Exit fullscreen mode

1.3 Pipes personalizados: Añadiendo validaciones de sintaxis personalizadas

Mientras que class-validator cubre la mayoría de las necesidades de validación de sintaxis, en algunos casos, es necesario aplicar validaciones más específicas o personalizadas. Aquí es donde los pipes personalizados entran en juego. Los pipes son clases que implementan la interfaz PipeTransform y pueden ser utilizados para transformar o validar datos antes de que lleguen a un controlador.

Creando un pipe personalizado para validar un enum:

Imaginemos que tenemos un enum de roles y queremos asegurarnos de que un campo en particular solo acepte valores dentro de ese enum.

import { ArgumentMetadata, BadRequestException, PipeTransform } from '@nestjs/common';

enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  GUEST = 'guest',
}

export class ParseEnumPipe implements PipeTransform {
  constructor(private readonly enumType: object) {}

  transform(value: any, metadata: ArgumentMetadata) {
    if (!Object.values(this.enumType).includes(value)) {
      throw new BadRequestException(`Invalid value: ${value}`);
    }
    return value;
  }
}
Enter fullscreen mode Exit fullscreen mode

En este pipe, ParseEnumPipe valida que el valor de UserRole esté dentro de los valores permitidos en el enum. Si no es así, lanza una excepción de tipo BadRequestException.

Utilizando el pipe personalizado en un controlador:
@Post()
createUser(
  @Body('role', new ParseEnumPipe(UserRole)) role: UserRole,
  @Body() createUserDto: CreateUserDto,
) {
  return this.userService.createUser(createUserDto);
}
Enter fullscreen mode Exit fullscreen mode

Aquí estamos utilizando el ParseEnumPipe para asegurarnos de que el campo role solo reciba valores válidos según el enum UserRole. Si el valor no es válido, el pipe lanzará una excepción antes de que la solicitud llegue al servicio.

2. Validación Semántica en NestJS: Más Allá de la Sintaxis

Mientras que la validación de sintaxis garantiza que los datos tienen la forma correcta, la validación semántica se centra en verificar que los datos tienen sentido en el contexto de la aplicación. Esto es crucial para mantener la integridad de la lógica de negocio y garantizar que las acciones realizadas por los usuarios estén permitidas según las reglas de la aplicación.

2.1 ¿Qué es la validación semántica?

La validación semántica se ocupa de la lógica del negocio. Verifica si los datos, aunque sean sintácticamente correctos, son semánticamente válidos. Por ejemplo, si se intenta realizar una transacción en una cuenta bancaria, la validación semántica se encargaría de verificar que la cuenta existe, que tiene suficientes fondos y que la transacción está permitida según las reglas del negocio.

Ejemplos de validación semántica:
  • Verificación de existencia en la base de datos:
  async validateUser(userId: string) {
    const user = await this.userRepository.findOne(userId);
    if (!user) {
      throw new NotFoundException(`User with ID ${userId} not found`);
    }
    return user;
  }
Enter fullscreen mode Exit fullscreen mode

En este ejemplo, el método validateUser en un servicio verifica si un usuario con un userId específico existe en la base de datos. Si no existe, se lanza una excepción de tipo NotFoundException.

  • Validación de reglas de negocio:
  async validateTransaction(amount: number, accountId: string) {
    const account = await this.accountRepository.findOne(accountId);
    if (account.balance < amount) {
      throw new BadRequestException('Insufficient funds');
    }
    return true;
  }
Enter fullscreen mode Exit fullscreen mode

Aquí, el método validateTransaction se asegura de que una cuenta tenga suficientes fondos antes de permitir una transacción.

2.2 Implementando validación semántica en los servicios

La validación semántica generalmente se implementa en los servicios de la aplicación. Esto permite que la lógica de negocio esté encapsulada y separada de la lógica de enrutamiento que maneja el controlador. Esto es importante para mantener el código modular, fácil de mantener y testear.

Ejemplo de un servicio con validación semántica:
@Injectable()
export class OrderService {
  constructor(private readonly orderRepository: OrderRepository) {}

  async validateOrder(orderId: string): Promise<Order> {
    const order = await this.orderRepository.findOne(orderId);
    if (!order) {
      throw new NotFoundException(`Order with ID ${orderId} not found`);
    }

    if (order.status !== 'PENDING') {
      throw new BadRequestException('Order is not in a valid state');
    }

    return order;
  }
}
Enter fullscreen mode Exit fullscreen mode

En este servicio, validateOrder verifica si una orden con un orderId específico existe y está en un estado válido (PENDING). Si alguna de estas condiciones no se cumple, se lanzan excepciones apropiadas.

Utilizando la validación semántica en un controlador:
@Post(':orderId/confirm')
async confirmOrder(
  @Param('orderId') orderId: string,
) {
  const order = await this.orderService.validateOrder(orderId);
  return this.orderService.confirmOrder(order);
}
Enter fullscreen mode Exit fullscreen mode

En este controlador, confirmOrder utiliza el método validateOrder para asegurarse de que la orden existe y está en un estado válido antes de proceder con la confirmación.

3. Transformación de Datos en los Controladores: Manejando los Datos para la Coherencia

En algunos casos, es necesario transformar los datos antes de que lleguen a los servicios o después de recibir la respuesta. Esto puede incluir convertir datos de un formato a otro, agregar o eliminar propiedades, o realizar cálculos adicionales.

3.1 ¿Por qué y cuándo transformar los datos?

Transformar los datos puede ser necesario para mantener la coherencia entre las diferentes capas de la aplicación o para cumplir con las expectativas de la interfaz de usuario o las API externas. Algunos escenarios comunes donde es útil transformar datos incluyen:

  • Normalización de datos: Convertir los datos a un formato estándar.
  @Post()
  createUser(@Body() createUserDto: CreateUserDto) {
    const normalizedDto = {
      ...createUserDto,
      email: createUserDto.email.toLowerCase(),
    };
    return this.userService.createUser(normalizedDto);
  }
Enter fullscreen mode Exit fullscreen mode
  • Agregar información adicional: Enriquecer los datos con información adicional requerida por los servicios.
  @Post(':orderId/items')
  addItemToOrder(
    @Param('orderId') orderId: string,
    @Body() addItemDto: AddItemDto,
  ) {
    const enhancedDto = {
      ...addItemDto,
      orderId,
    };
    return this.orderService.addItemToOrder(enhancedDto);
  }
Enter fullscreen mode Exit fullscreen mode
  • Transformación de formatos: Cambiar el formato de los datos para cumplir con los requisitos de un servicio o API.
  @Post()
  createReport(@Body() reportDto: ReportDto) {
    const transformedDto = {
      ...reportDto,
      date: new Date(reportDto.date).toISOString(),
    };
    return this.reportService.createReport(transformedDto);
  }
Enter fullscreen mode Exit fullscreen mode

3.2 Uso de pipes para transformar datos

Los pipes no solo son útiles para la validación, sino también para la transformación de datos. Puedes crear pipes personalizados que realicen transformaciones antes de que los datos lleguen al controlador.

Ejemplo de un pipe para transformar una fecha:
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { isDate, parseISO } from 'date-fns';

@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
  transform(value: string): Date {
    const date = parseISO(value);
    if (!isDate(date)) {
      throw new BadRequestException('Invalid date format');
    }
    return date;
  }
}
Enter fullscreen mode Exit fullscreen mode

Este pipe convierte una cadena en una instancia de Date y se asegura de que sea válida.

Utilizando el pipe en un controlador:
@Post()
createEvent(@Body('date', ParseDatePipe) date: Date) {
  return this.eventService.createEvent({ date });
}
Enter fullscreen mode Exit fullscreen mode

Aquí, ParseDatePipe transforma la cadena de fecha antes de que llegue al servicio eventService.

Conclusión

La validación de datos en NestJS es una práctica fundamental que garantiza la integridad y seguridad de una aplicación. A través de la validación de sintaxis con DTOs y class-validator, y la validación semántica dentro de los servicios, NestJS permite construir aplicaciones robustas y resistentes a errores. Además, el uso estratégico de pipes para la transformación de datos asegura que las diferentes capas de la aplicación se mantengan coherentes y alineadas con las necesidades del negocio. Con estas herramientas, los desarrolladores pueden crear aplicaciones que no solo funcionan correctamente, sino que también son fáciles de mantener y escalar.

💖 💪 🙅 🚩
juan_carlosvalderrbano
Juan Carlos Valderrábano Hernández

Posted on August 10, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related