# 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: ```typescript { "statusCode": 200, "message": "Mensaje descriptivo", "data": { ... } // o [ ... ] para listas } ``` **Para errores:** ```typescript { "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: ```typescript { "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` ```typescript import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; export interface Response { statusCode: number; message: string; data: T; } @Injectable() export class TransformInterceptor implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { 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` ```typescript 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(); const request = ctx.getRequest(); 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` ```typescript 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 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` ```typescript 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 { @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` ```typescript 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, ) { 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` ```typescript 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` ```typescript import { Injectable } from '@nestjs/common'; import { ThrottlerGuard as NestThrottlerGuard } from '@nestjs/throttler'; @Injectable() export class ThrottleGuard extends NestThrottlerGuard { protected async getTracker(req: Record): Promise { // 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:** ```typescript 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` ```typescript import { applyDecorators, Type } from '@nestjs/common'; import { ApiOkResponse, getSchemaPath } from '@nestjs/swagger'; import { PaginatedResponseDto } from '../dto/paginated-response.dto'; export const ApiPaginatedResponse = >(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:** ```typescript @Get() @ApiPaginatedResponse(ProjectResponseDto) async findAll() { // ... } ``` --- ## 🧪 Test Cases ### TC-API-001: Respuesta Exitosa **Request:** ```http GET /api/projects/123e4567-e89b-12d3-a456-426614174000 Authorization: Bearer ``` **Resultado esperado:** ```json { "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:** ```http POST /api/projects Authorization: Bearer Content-Type: application/json { "name": "AB", // Muy corto (mínimo 3) "status": "invalid_status", // Estado inválido "budget": -1000 // Negativo } ``` **Resultado esperado:** ```json { "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:** ```http GET /api/projects?page=2&limit=10&status=active&sortBy=createdAt&order=DESC Authorization: Bearer ``` **Resultado esperado:** ```json { "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:** ```json { "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:** ```http GET /api/projects/00000000-0000-0000-0000-000000000000 Authorization: Bearer ``` **Resultado esperado:** ```json { "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:** ```http POST /api/projects Authorization: Bearer // Solo DIRECTOR puede crear Content-Type: application/json { "name": "Nuevo Proyecto", "status": "planning", ... } ``` **Resultado esperado:** ```json { "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: ```typescript @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: ```typescript { "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