erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/historias-usuario/US-FUND-006-api-restful-base.md

29 KiB

US-FUND-006: API RESTful Básica

Epic: MAI-001 - Fundamentos del Sistema Story Points: 8 Prioridad: Alta Dependencias:

  • US-FUND-004 (Infraestructura Base)

Estado: Pendiente Asignado a: Backend Lead + Backend Dev


📋 Historia de Usuario

Como desarrollador frontend Quiero consumir una API RESTful bien estructurada y documentada Para integrar el frontend con el backend de manera eficiente, predecible y con manejo de errores robusto.


🎯 Contexto y Objetivos

Contexto

Este documento define los estándares y convenciones para TODAS las APIs del sistema de construcción. Incluye:

  • Convenciones REST (verbos HTTP, códigos de estado, naming)
  • Formato de respuestas estándar
  • Manejo de errores consistente
  • Validación de requests con DTOs
  • Paginación, filtrado y ordenamiento
  • Documentación Swagger automática
  • Rate limiting para prevenir abuso
  • Versionado de API

Objetivos

  1. Todas las APIs siguen convenciones REST estándar
  2. Respuestas consistentes (mismo formato en todos los endpoints)
  3. Errores informativos con códigos HTTP apropiados
  4. Validación automática con class-validator
  5. Paginación estándar para listados
  6. Swagger docs generadas automáticamente
  7. Rate limiting configurado globalmente

Criterios de Aceptación

CA-1: Convenciones REST

Dado un endpoint de la API Cuando se diseña siguiendo las convenciones REST Entonces:

  • Usa los verbos HTTP correctos:
    • GET: Obtener recursos (sin side effects)
    • POST: Crear recursos
    • PUT: Actualizar recurso completo
    • PATCH: Actualizar recurso parcial
    • DELETE: Eliminar recurso
  • URLs en plural: /api/projects, /api/budgets, /api/users
  • IDs en la URL: /api/projects/:id
  • Recursos anidados cuando corresponde: /api/projects/:id/budgets
  • Query params para filtros: /api/projects?status=active&page=2

CA-2: Códigos de Estado HTTP

Dado una respuesta de la API Cuando se retorna al cliente Entonces usa el código HTTP apropiado:

  • 200 OK: Operación exitosa (GET, PUT, PATCH)
  • 201 Created: Recurso creado (POST)
  • 204 No Content: Operación exitosa sin contenido (DELETE)
  • 400 Bad Request: Validación falló o parámetros inválidos
  • 401 Unauthorized: No autenticado (token faltante o inválido)
  • 403 Forbidden: Autenticado pero sin permisos
  • 404 Not Found: Recurso no existe
  • 409 Conflict: Conflicto (ej: email duplicado)
  • 429 Too Many Requests: Rate limit excedido
  • 500 Internal Server Error: Error del servidor

CA-3: Formato de Respuesta Estándar

Dado cualquier endpoint exitoso Cuando retorna datos Entonces sigue este formato:

{
  "statusCode": 200,
  "message": "Mensaje descriptivo",
  "data": { ... } // o [ ... ] para listas
}

Para errores:

{
  "statusCode": 400,
  "message": "Mensaje de error",
  "error": "Bad Request",
  "timestamp": "2025-11-17T10:30:00.000Z",
  "path": "/api/projects",
  "validationErrors": [ // Opcional, solo para errores de validación
    {
      "field": "name",
      "message": "El nombre es requerido"
    }
  ]
}

CA-4: Paginación Estándar

Dado un endpoint que retorna listas Cuando se solicitan datos paginados Entonces:

  • Acepta query params: ?page=1&limit=20
  • Defaults: page=1, limit=20, maxLimit=100
  • Retorna metadata de paginación:
{
  "statusCode": 200,
  "message": "Proyectos obtenidos exitosamente",
  "data": {
    "items": [ ... ],
    "meta": {
      "page": 1,
      "limit": 20,
      "totalItems": 150,
      "totalPages": 8,
      "hasNextPage": true,
      "hasPreviousPage": false
    }
  }
}

CA-5: Filtrado y Ordenamiento

Dado un endpoint de listado Cuando se aplican filtros y orden Entonces:

  • Filtros con query params: ?status=active&role=engineer
  • Ordenamiento: ?sortBy=createdAt&order=DESC
  • Búsqueda: ?search=proyecto+nuevo
  • Rango de fechas: ?startDate=2025-01-01&endDate=2025-12-31

CA-6: Validación con DTOs

Dado un request con datos inválidos Cuando se envía al endpoint Entonces:

  • Validación se ejecuta automáticamente (ValidationPipe)
  • Retorna 400 Bad Request
  • Muestra errores específicos por campo
  • Mensajes en español y descriptivos

CA-7: Documentación Swagger

Dado la aplicación en modo desarrollo Cuando accedo a /api/docs Entonces:

  • Swagger UI carga correctamente
  • Todos los endpoints están documentados
  • DTOs muestran sus propiedades y validaciones
  • Responses documentadas con ejemplos
  • Autenticación JWT configurada
  • Es posible ejecutar requests desde Swagger

CA-8: Rate Limiting

Dado un cliente realizando múltiples requests Cuando excede el límite permitido Entonces:

  • Retorna 429 Too Many Requests
  • Headers incluyen información del límite:
    • X-RateLimit-Limit: Límite máximo
    • X-RateLimit-Remaining: Requests restantes
    • X-RateLimit-Reset: Timestamp de reset
  • Límite por defecto: 100 requests/minuto por IP

🔧 Especificación Técnica Detallada

1. Response Transform Interceptor

Archivo: apps/backend/src/common/interceptors/transform.interceptor.ts

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  statusCode: number;
  message: string;
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    const ctx = context.switchToHttp();
    const response = ctx.getResponse();

    return next.handle().pipe(
      map((data) => {
        // Si el controller ya retorna el formato esperado, no transformar
        if (data && typeof data === 'object' && 'statusCode' in data) {
          return data;
        }

        // Transformar a formato estándar
        return {
          statusCode: response.statusCode,
          message: data?.message || 'Operación exitosa',
          data: data?.data !== undefined ? data.data : data,
        };
      }),
    );
  }
}

2. HTTP Exception Filter

Archivo: apps/backend/src/common/filters/http-exception.filter.ts

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { QueryFailedError } from 'typeorm';

interface ValidationError {
  field: string;
  message: string;
}

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Error interno del servidor';
    let error = 'Internal Server Error';
    let validationErrors: ValidationError[] | undefined;

    // Manejo de HttpException (NestJS)
    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();

      if (typeof exceptionResponse === 'string') {
        message = exceptionResponse;
      } else if (typeof exceptionResponse === 'object') {
        const responseObj = exceptionResponse as any;
        message = responseObj.message || exception.message;
        error = responseObj.error || exception.name;

        // Errores de validación
        if (Array.isArray(responseObj.message)) {
          validationErrors = this.formatValidationErrors(responseObj.message);
          message = 'Error de validación';
        }
      }
    }
    // Manejo de errores de TypeORM
    else if (exception instanceof QueryFailedError) {
      status = HttpStatus.BAD_REQUEST;
      message = this.handleDatabaseError(exception);
      error = 'Database Error';
    }
    // Otros errores
    else if (exception instanceof Error) {
      message = exception.message;
      error = exception.name;
    }

    // Log del error
    this.logger.error(
      `${request.method} ${request.url} - ${status} - ${message}`,
      exception instanceof Error ? exception.stack : undefined,
    );

    // Respuesta al cliente
    const errorResponse: any = {
      statusCode: status,
      message,
      error,
      timestamp: new Date().toISOString(),
      path: request.url,
    };

    if (validationErrors) {
      errorResponse.validationErrors = validationErrors;
    }

    response.status(status).json(errorResponse);
  }

  private formatValidationErrors(errors: any[]): ValidationError[] {
    return errors.map((err) => ({
      field: err.property || 'unknown',
      message: Object.values(err.constraints || {}).join(', '),
    }));
  }

  private handleDatabaseError(error: QueryFailedError): string {
    const message = error.message;

    // Unique constraint violation
    if (message.includes('unique constraint')) {
      if (message.includes('email')) {
        return 'El email ya está registrado';
      }
      if (message.includes('rfc')) {
        return 'El RFC ya está registrado';
      }
      return 'El valor ya existe en la base de datos';
    }

    // Foreign key constraint violation
    if (message.includes('foreign key constraint')) {
      return 'El registro está relacionado con otros datos y no puede ser eliminado';
    }

    // Not null constraint violation
    if (message.includes('null value')) {
      return 'Falta un campo requerido';
    }

    return 'Error en la base de datos';
  }
}

3. Pagination DTO

Archivo: apps/backend/src/common/dto/pagination.dto.ts

import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class PaginationDto {
  @ApiPropertyOptional({
    description: 'Número de página (inicia en 1)',
    minimum: 1,
    default: 1,
    example: 1,
  })
  @IsOptional()
  @Type(() => Number)
  @IsInt({ message: 'La página debe ser un número entero' })
  @Min(1, { message: 'La página debe ser mayor o igual a 1' })
  page?: number = 1;

  @ApiPropertyOptional({
    description: 'Cantidad de elementos por página',
    minimum: 1,
    maximum: 100,
    default: 20,
    example: 20,
  })
  @IsOptional()
  @Type(() => Number)
  @IsInt({ message: 'El límite debe ser un número entero' })
  @Min(1, { message: 'El límite debe ser mayor o igual a 1' })
  @Max(100, { message: 'El límite no puede ser mayor a 100' })
  limit?: number = 20;

  get skip(): number {
    return (this.page - 1) * this.limit;
  }
}

export class SortDto {
  @ApiPropertyOptional({
    description: 'Campo por el cual ordenar',
    example: 'createdAt',
  })
  @IsOptional()
  sortBy?: string = 'createdAt';

  @ApiPropertyOptional({
    description: 'Orden (ASC o DESC)',
    enum: ['ASC', 'DESC'],
    default: 'DESC',
    example: 'DESC',
  })
  @IsOptional()
  order?: 'ASC' | 'DESC' = 'DESC';
}

export class PaginatedDto<T> extends PaginationDto {
  @ApiPropertyOptional({
    description: 'Término de búsqueda',
    example: 'proyecto nuevo',
  })
  @IsOptional()
  search?: string;
}

Archivo: apps/backend/src/common/dto/paginated-response.dto.ts

import { ApiProperty } from '@nestjs/swagger';

export class PaginationMetaDto {
  @ApiProperty({ description: 'Página actual', example: 1 })
  page: number;

  @ApiProperty({ description: 'Elementos por página', example: 20 })
  limit: number;

  @ApiProperty({ description: 'Total de elementos', example: 150 })
  totalItems: number;

  @ApiProperty({ description: 'Total de páginas', example: 8 })
  totalPages: number;

  @ApiProperty({ description: 'Tiene página siguiente', example: true })
  hasNextPage: boolean;

  @ApiProperty({ description: 'Tiene página anterior', example: false })
  hasPreviousPage: boolean;
}

export class PaginatedResponseDto<T> {
  @ApiProperty({ description: 'Lista de elementos', isArray: true })
  items: T[];

  @ApiProperty({ description: 'Metadata de paginación', type: PaginationMetaDto })
  meta: PaginationMetaDto;

  constructor(items: T[], total: number, page: number, limit: number) {
    this.items = items;

    const totalPages = Math.ceil(total / limit);

    this.meta = {
      page,
      limit,
      totalItems: total,
      totalPages,
      hasNextPage: page < totalPages,
      hasPreviousPage: page > 1,
    };
  }
}

4. Ejemplo de Controller con Todas las Convenciones

Archivo: apps/backend/src/modules/projects/projects.controller.ts

import {
  Controller,
  Get,
  Post,
  Put,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import {
  ApiTags,
  ApiOperation,
  ApiResponse,
  ApiBearerAuth,
  ApiQuery,
} from '@nestjs/swagger';
import { ProjectsService } from './projects.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { ProjectResponseDto } from './dto/project-response.dto';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { PaginatedResponseDto } from '../../common/dto/paginated-response.dto';
import { Roles } from '../../common/decorators/roles.decorator';
import { ConstructionRole } from '../../common/enums/construction-role.enum';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { CurrentConstructora } from '../../common/decorators/current-constructora.decorator';

@ApiTags('Projects')
@ApiBearerAuth('JWT-auth')
@Controller('projects')
export class ProjectsController {
  constructor(private readonly projectsService: ProjectsService) {}

  /**
   * Listar proyectos con paginación, filtros y ordenamiento
   */
  @Get()
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.ENGINEER, ConstructionRole.FINANCE)
  @ApiOperation({ summary: 'Listar proyectos con paginación' })
  @ApiResponse({
    status: 200,
    description: 'Lista de proyectos',
    type: PaginatedResponseDto,
  })
  @ApiQuery({ name: 'page', required: false, example: 1 })
  @ApiQuery({ name: 'limit', required: false, example: 20 })
  @ApiQuery({ name: 'status', required: false, enum: ['planning', 'active', 'completed'] })
  @ApiQuery({ name: 'search', required: false, example: 'proyecto nuevo' })
  async findAll(
    @Query() paginationDto: PaginationDto,
    @Query('status') status?: string,
    @Query('search') search?: string,
    @CurrentConstructora() constructoraId?: string,
  ) {
    const { items, total } = await this.projectsService.findAll({
      ...paginationDto,
      status,
      search,
      constructoraId,
    });

    return {
      statusCode: 200,
      message: 'Proyectos obtenidos exitosamente',
      data: new PaginatedResponseDto(items, total, paginationDto.page, paginationDto.limit),
    };
  }

  /**
   * Obtener proyecto por ID
   */
  @Get(':id')
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.ENGINEER, ConstructionRole.RESIDENT)
  @ApiOperation({ summary: 'Obtener proyecto por ID' })
  @ApiResponse({
    status: 200,
    description: 'Proyecto encontrado',
    type: ProjectResponseDto,
  })
  @ApiResponse({ status: 404, description: 'Proyecto no encontrado' })
  async findOne(@Param('id', ParseUUIDPipe) id: string) {
    const project = await this.projectsService.findOne(id);

    return {
      statusCode: 200,
      message: 'Proyecto obtenido exitosamente',
      data: project,
    };
  }

  /**
   * Crear nuevo proyecto
   */
  @Post()
  @Roles(ConstructionRole.DIRECTOR)
  @ApiOperation({ summary: 'Crear nuevo proyecto' })
  @ApiResponse({
    status: 201,
    description: 'Proyecto creado exitosamente',
    type: ProjectResponseDto,
  })
  @ApiResponse({ status: 400, description: 'Datos inválidos' })
  async create(
    @Body() createProjectDto: CreateProjectDto,
    @CurrentUser('id') userId: string,
    @CurrentConstructora() constructoraId: string,
  ) {
    const project = await this.projectsService.create({
      ...createProjectDto,
      constructoraId,
      createdBy: userId,
    });

    return {
      statusCode: 201,
      message: 'Proyecto creado exitosamente',
      data: project,
    };
  }

  /**
   * Actualizar proyecto completo (PUT)
   */
  @Put(':id')
  @Roles(ConstructionRole.DIRECTOR)
  @ApiOperation({ summary: 'Actualizar proyecto completo' })
  @ApiResponse({
    status: 200,
    description: 'Proyecto actualizado exitosamente',
    type: ProjectResponseDto,
  })
  @ApiResponse({ status: 404, description: 'Proyecto no encontrado' })
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateProjectDto: UpdateProjectDto,
  ) {
    const project = await this.projectsService.update(id, updateProjectDto);

    return {
      statusCode: 200,
      message: 'Proyecto actualizado exitosamente',
      data: project,
    };
  }

  /**
   * Actualizar proyecto parcial (PATCH)
   */
  @Patch(':id')
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.ENGINEER)
  @ApiOperation({ summary: 'Actualizar proyecto parcialmente' })
  @ApiResponse({
    status: 200,
    description: 'Proyecto actualizado exitosamente',
    type: ProjectResponseDto,
  })
  @ApiResponse({ status: 404, description: 'Proyecto no encontrado' })
  async partialUpdate(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateProjectDto: Partial<UpdateProjectDto>,
  ) {
    const project = await this.projectsService.update(id, updateProjectDto);

    return {
      statusCode: 200,
      message: 'Proyecto actualizado exitosamente',
      data: project,
    };
  }

  /**
   * Eliminar proyecto (soft delete)
   */
  @Delete(':id')
  @Roles(ConstructionRole.DIRECTOR)
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiOperation({ summary: 'Eliminar proyecto (soft delete)' })
  @ApiResponse({ status: 204, description: 'Proyecto eliminado exitosamente' })
  @ApiResponse({ status: 404, description: 'Proyecto no encontrado' })
  async remove(@Param('id', ParseUUIDPipe) id: string) {
    await this.projectsService.remove(id);
    // No retorna data (204 No Content)
  }
}

5. Ejemplo de DTO con Validaciones

Archivo: apps/backend/src/modules/projects/dto/create-project.dto.ts

import {
  IsString,
  IsNotEmpty,
  IsEnum,
  IsOptional,
  IsDateString,
  IsNumber,
  Min,
  Max,
  Length,
  IsUUID,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ProjectStatus } from '../enums/project-status.enum';

export class CreateProjectDto {
  @ApiProperty({
    description: 'Nombre del proyecto',
    example: 'Residencial Las Palmas',
    minLength: 3,
    maxLength: 255,
  })
  @IsString({ message: 'El nombre debe ser un texto' })
  @IsNotEmpty({ message: 'El nombre es requerido' })
  @Length(3, 255, { message: 'El nombre debe tener entre 3 y 255 caracteres' })
  name: string;

  @ApiPropertyOptional({
    description: 'Descripción del proyecto',
    example: 'Desarrollo habitacional de 150 unidades',
  })
  @IsOptional()
  @IsString({ message: 'La descripción debe ser un texto' })
  description?: string;

  @ApiProperty({
    description: 'Estado del proyecto',
    enum: ProjectStatus,
    example: ProjectStatus.PLANNING,
  })
  @IsEnum(ProjectStatus, { message: 'Estado inválido' })
  status: ProjectStatus;

  @ApiProperty({
    description: 'Fecha de inicio estimada',
    example: '2025-01-15',
  })
  @IsDateString({}, { message: 'La fecha de inicio debe ser válida (YYYY-MM-DD)' })
  startDate: string;

  @ApiProperty({
    description: 'Fecha de fin estimada',
    example: '2026-06-30',
  })
  @IsDateString({}, { message: 'La fecha de fin debe ser válida (YYYY-MM-DD)' })
  endDate: string;

  @ApiProperty({
    description: 'Presupuesto total en MXN',
    example: 45000000,
    minimum: 0,
  })
  @IsNumber({}, { message: 'El presupuesto debe ser un número' })
  @Min(0, { message: 'El presupuesto no puede ser negativo' })
  budget: number;

  @ApiPropertyOptional({
    description: 'Ubicación del proyecto',
    example: 'Av. Reforma 123, CDMX',
  })
  @IsOptional()
  @IsString({ message: 'La ubicación debe ser un texto' })
  location?: string;

  @ApiPropertyOptional({
    description: 'Ingeniero responsable (UUID)',
    example: '123e4567-e89b-12d3-a456-426614174000',
  })
  @IsOptional()
  @IsUUID('4', { message: 'El ID del ingeniero debe ser un UUID válido' })
  engineerId?: string;
}

6. Rate Limiting Configuration

Archivo: apps/backend/src/common/guards/throttle.guard.ts

import { Injectable } from '@nestjs/common';
import { ThrottlerGuard as NestThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class ThrottleGuard extends NestThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    // Rate limiting por usuario autenticado (si existe)
    if (req.user?.sub) {
      return `user-${req.user.sub}`;
    }

    // Rate limiting por IP para requests no autenticados
    return req.ip;
  }
}

Configuración en AppModule:

import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        ttl: 60000, // 1 minuto
        limit: 100, // 100 requests
      },
    ]),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

7. Custom Decorators para Documentación

Archivo: apps/backend/src/common/decorators/api-paginated-response.decorator.ts

import { applyDecorators, Type } from '@nestjs/common';
import { ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { PaginatedResponseDto } from '../dto/paginated-response.dto';

export const ApiPaginatedResponse = <TModel extends Type<any>>(model: TModel) => {
  return applyDecorators(
    ApiOkResponse({
      schema: {
        allOf: [
          {
            properties: {
              statusCode: { type: 'number', example: 200 },
              message: { type: 'string', example: 'Operación exitosa' },
              data: {
                properties: {
                  items: {
                    type: 'array',
                    items: { $ref: getSchemaPath(model) },
                  },
                  meta: {
                    properties: {
                      page: { type: 'number', example: 1 },
                      limit: { type: 'number', example: 20 },
                      totalItems: { type: 'number', example: 150 },
                      totalPages: { type: 'number', example: 8 },
                      hasNextPage: { type: 'boolean', example: true },
                      hasPreviousPage: { type: 'boolean', example: false },
                    },
                  },
                },
              },
            },
          },
        ],
      },
    }),
  );
};

Uso:

@Get()
@ApiPaginatedResponse(ProjectResponseDto)
async findAll() {
  // ...
}

🧪 Test Cases

TC-API-001: Respuesta Exitosa

Request:

GET /api/projects/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>

Resultado esperado:

{
  "statusCode": 200,
  "message": "Proyecto obtenido exitosamente",
  "data": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "name": "Residencial Las Palmas",
    "status": "active",
    ...
  }
}

TC-API-002: Error de Validación

Request:

POST /api/projects
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "AB",  // Muy corto (mínimo 3)
  "status": "invalid_status",  // Estado inválido
  "budget": -1000  // Negativo
}

Resultado esperado:

{
  "statusCode": 400,
  "message": "Error de validación",
  "error": "Bad Request",
  "timestamp": "2025-11-17T10:30:00.000Z",
  "path": "/api/projects",
  "validationErrors": [
    {
      "field": "name",
      "message": "El nombre debe tener entre 3 y 255 caracteres"
    },
    {
      "field": "status",
      "message": "Estado inválido"
    },
    {
      "field": "budget",
      "message": "El presupuesto no puede ser negativo"
    }
  ]
}

TC-API-003: Paginación

Request:

GET /api/projects?page=2&limit=10&status=active&sortBy=createdAt&order=DESC
Authorization: Bearer <token>

Resultado esperado:

{
  "statusCode": 200,
  "message": "Proyectos obtenidos exitosamente",
  "data": {
    "items": [ ... ], // 10 proyectos
    "meta": {
      "page": 2,
      "limit": 10,
      "totalItems": 45,
      "totalPages": 5,
      "hasNextPage": true,
      "hasPreviousPage": true
    }
  }
}

TC-API-004: Rate Limiting

Pasos:

  1. Realizar 101 requests en < 60 segundos
  2. Observar respuesta del request #101

Resultado esperado:

{
  "statusCode": 429,
  "message": "Too Many Requests",
  "error": "ThrottlerException"
}

Headers de respuesta:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700123456

TC-API-005: Recurso No Encontrado

Request:

GET /api/projects/00000000-0000-0000-0000-000000000000
Authorization: Bearer <token>

Resultado esperado:

{
  "statusCode": 404,
  "message": "Proyecto no encontrado",
  "error": "Not Found",
  "timestamp": "2025-11-17T10:30:00.000Z",
  "path": "/api/projects/00000000-0000-0000-0000-000000000000"
}

TC-API-006: Sin Permisos

Request:

POST /api/projects
Authorization: Bearer <token-de-residente>  // Solo DIRECTOR puede crear
Content-Type: application/json

{
  "name": "Nuevo Proyecto",
  "status": "planning",
  ...
}

Resultado esperado:

{
  "statusCode": 403,
  "message": "No tienes permisos para realizar esta acción",
  "error": "Forbidden",
  "timestamp": "2025-11-17T10:30:00.000Z",
  "path": "/api/projects"
}

📋 Tareas de Implementación

Backend

  • API-BE-001: Implementar TransformInterceptor

    • Estimado: 1h
  • API-BE-002: Implementar HttpExceptionFilter

    • Estimado: 2h
  • API-BE-003: Crear DTOs de paginación (PaginationDto, PaginatedResponseDto)

    • Estimado: 1.5h
  • API-BE-004: Configurar ThrottlerGuard globalmente

    • Estimado: 1h
  • API-BE-005: Crear decorator @ApiPaginatedResponse

    • Estimado: 1h
  • API-BE-006: Configurar ValidationPipe global con mensajes en español

    • Estimado: 1h
  • API-BE-007: Documentar todos los endpoints con Swagger decorators

    • Estimado: 3h

Testing

  • API-TEST-001: Unit tests para TransformInterceptor

    • Estimado: 1h
  • API-TEST-002: Unit tests para HttpExceptionFilter

    • Estimado: 1.5h
  • API-TEST-003: Integration tests para paginación

    • Estimado: 2h
  • API-TEST-004: E2E tests para rate limiting

    • Estimado: 1.5h

Documentation

  • API-DOC-001: Crear guía de convenciones REST para el equipo

    • Estimado: 2h
  • API-DOC-002: Documentar formato de respuestas estándar

    • Estimado: 1h

Total estimado: ~20 horas


🔗 Dependencias

Depende de

  • US-FUND-004 (Infraestructura Base)

Bloqueante para

  • Todos los módulos de negocio (Projects, Budgets, Purchases, etc.)
  • Frontend (necesita API estable y predecible)

📊 Definición de Hecho (DoD)

  • TransformInterceptor aplicado globalmente
  • HttpExceptionFilter aplicado globalmente
  • ValidationPipe configurado con mensajes en español
  • ThrottlerGuard funcionando (100 req/min)
  • Paginación implementada en al menos 3 endpoints
  • Swagger docs accesibles en /api/docs
  • Todos los test cases (TC-API-001 a TC-API-006) pasan
  • Code coverage > 80% en interceptors y filters
  • Guía de convenciones REST documentada

📝 Notas Adicionales

Versionado de API

Para futuras versiones:

@Controller({ path: 'projects', version: '1' })
export class ProjectsV1Controller {}

@Controller({ path: 'projects', version: '2' })
export class ProjectsV2Controller {}

Acceso: /api/v1/projects, /api/v2/projects

HATEOAS (Opcional)

Para nivel de madurez REST avanzado:

{
  "statusCode": 200,
  "data": {
    "id": "123",
    "name": "Proyecto X",
    "_links": {
      "self": "/api/projects/123",
      "budgets": "/api/projects/123/budgets",
      "update": "/api/projects/123",
      "delete": "/api/projects/123"
    }
  }
}

Fecha de creación: 2025-11-17 Última actualización: 2025-11-17 Versión: 1.0