# ESTANDARES: API REST Generico ERP **Proyecto:** ERP Core - Base Generica Reutilizable **Version:** 1.0.0 **Fecha:** 2025-12-05 **Aplicable a:** ERP Core y todas las verticales **Estado:** OBLIGATORIO --- ## PRINCIPIOS FUNDAMENTALES > **APIs consistentes, predecibles y autodocumentadas** Todas las APIs del ERP siguen patrones RESTful estandar para facilitar integracion y mantenimiento. --- ## ESTRUCTURA DE URLs ### Patron Base ``` /api/{version}/{recurso} /api/{version}/{recurso}/{id} /api/{version}/{recurso}/{id}/{sub-recurso} ``` ### Ejemplos ``` # Core GET /api/v1/partners # Lista partners POST /api/v1/partners # Crear partner GET /api/v1/partners/:id # Obtener partner PATCH /api/v1/partners/:id # Actualizar parcial PUT /api/v1/partners/:id # Actualizar completo DELETE /api/v1/partners/:id # Eliminar (soft delete) # Sub-recursos GET /api/v1/partners/:id/contacts # Contactos de partner POST /api/v1/partners/:id/contacts # Crear contacto GET /api/v1/sale-orders/:id/lines # Lineas de orden POST /api/v1/sale-orders/:id/lines # Agregar linea # Acciones POST /api/v1/sale-orders/:id/confirm # Confirmar orden POST /api/v1/sale-orders/:id/cancel # Cancelar orden POST /api/v1/invoices/:id/validate # Validar factura # Verticales (namespace propio) GET /api/v1/construccion/proyectos-obra POST /api/v1/construccion/avances GET /api/v1/vidrio/ordenes-produccion ``` --- ## CONVENCIONES DE NOMBRES ### URLs - **Sustantivos en plural:** `/partners`, `/products`, `/sale-orders` - **Kebab-case:** `/sale-orders`, `/product-categories` - **Sin verbos en URL:** `/partners` no `/getPartners` - **Jerarquia logica:** `/partners/:id/contacts` ### Query Parameters - **snake_case:** `?page=1&per_page=20&sort_by=name` - **Filtros con prefijo:** `?filter[status]=active&filter[type]=customer` - **Busqueda:** `?search=texto` - **Ordenamiento:** `?sort_by=created_at&sort_order=desc` ### Body (Request/Response) - **camelCase:** `{ "partnerId": "uuid", "taxId": "RFC123" }` --- ## PAGINACION ### Request ``` GET /api/v1/partners?page=2&per_page=20&sort_by=name&sort_order=asc ``` ### Response ```json { "data": [ { "id": "uuid-1", "name": "Partner 1", ... }, { "id": "uuid-2", "name": "Partner 2", ... } ], "meta": { "currentPage": 2, "perPage": 20, "totalPages": 15, "totalCount": 287, "hasNextPage": true, "hasPrevPage": true }, "links": { "self": "/api/v1/partners?page=2&per_page=20", "first": "/api/v1/partners?page=1&per_page=20", "prev": "/api/v1/partners?page=1&per_page=20", "next": "/api/v1/partners?page=3&per_page=20", "last": "/api/v1/partners?page=15&per_page=20" } } ``` ### Limites | Parametro | Default | Maximo | |-----------|---------|--------| | per_page | 20 | 100 | | offset | 0 | - | --- ## FILTRADO ### Sintaxis ``` GET /api/v1/partners?filter[status]=active&filter[type]=customer GET /api/v1/partners?filter[created_at][gte]=2025-01-01 GET /api/v1/partners?filter[name][like]=Constructora ``` ### Operadores | Operador | Descripcion | Ejemplo | |----------|-------------|---------| | (default) | Igual | `filter[status]=active` | | `[ne]` | No igual | `filter[status][ne]=cancelled` | | `[gt]` | Mayor que | `filter[amount][gt]=1000` | | `[gte]` | Mayor o igual | `filter[date][gte]=2025-01-01` | | `[lt]` | Menor que | `filter[amount][lt]=5000` | | `[lte]` | Menor o igual | `filter[date][lte]=2025-12-31` | | `[like]` | Contiene | `filter[name][like]=Corp` | | `[in]` | En lista | `filter[status][in]=draft,confirmed` | | `[null]` | Es nulo | `filter[deleted_at][null]=true` | ### Ejemplo de Implementacion ```typescript // Query builder con filtros dinamicos @Injectable() export class FilterService { applyFilters( qb: SelectQueryBuilder, filters: Record, allowedFields: string[] ): SelectQueryBuilder { for (const [key, value] of Object.entries(filters)) { if (!allowedFields.includes(key.split('[')[0])) continue; if (typeof value === 'object') { // Operador especificado const [field, operator] = key.split('['); const op = operator?.replace(']', ''); this.applyOperator(qb, field, op, value); } else { // Igualdad simple qb.andWhere(`entity.${key} = :${key}`, { [key]: value }); } } return qb; } private applyOperator(qb: SelectQueryBuilder, field: string, op: string, value: any) { switch (op) { case 'ne': qb.andWhere(`entity.${field} != :${field}`, { [field]: value }); break; case 'gt': qb.andWhere(`entity.${field} > :${field}`, { [field]: value }); break; case 'gte': qb.andWhere(`entity.${field} >= :${field}`, { [field]: value }); break; case 'lt': qb.andWhere(`entity.${field} < :${field}`, { [field]: value }); break; case 'lte': qb.andWhere(`entity.${field} <= :${field}`, { [field]: value }); break; case 'like': qb.andWhere(`entity.${field} ILIKE :${field}`, { [field]: `%${value}%` }); break; case 'in': const values = value.split(','); qb.andWhere(`entity.${field} IN (:...${field})`, { [field]: values }); break; case 'null': if (value === 'true') { qb.andWhere(`entity.${field} IS NULL`); } else { qb.andWhere(`entity.${field} IS NOT NULL`); } break; } } } ``` --- ## ORDENAMIENTO ### Request ``` GET /api/v1/partners?sort_by=name&sort_order=asc GET /api/v1/partners?sort=-created_at,name # Alternativa: - para desc ``` ### Campos Ordenables por Defecto - `id` - `name` - `created_at` - `updated_at` Cada endpoint debe documentar campos ordenables adicionales. --- ## RESPUESTAS ### Estructura Exitosa ```json // GET /api/v1/partners/:id (Recurso individual) { "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Constructora ABC", "taxId": "CAB123456AB1", "isCompany": true, "email": "contacto@constructoraabc.com", "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-06-20T14:45:00Z" } } // POST /api/v1/partners (Creacion) { "data": { "id": "550e8400-e29b-41d4-a716-446655440001", "name": "Nuevo Partner", ... }, "message": "Partner creado exitosamente" } // DELETE /api/v1/partners/:id { "data": null, "message": "Partner eliminado exitosamente" } ``` ### Estructura de Error ```json { "error": { "code": "VALIDATION_ERROR", "message": "Los datos proporcionados no son validos", "details": [ { "field": "email", "message": "Formato de email invalido", "code": "INVALID_FORMAT" }, { "field": "taxId", "message": "RFC ya registrado", "code": "DUPLICATE_VALUE" } ], "timestamp": "2025-12-05T10:30:00Z", "path": "/api/v1/partners", "requestId": "req-123-456-789" } } ``` --- ## CODIGOS HTTP ### Exito (2xx) | Codigo | Uso | |--------|-----| | 200 OK | GET exitoso, PATCH/PUT exitoso | | 201 Created | POST exitoso (recurso creado) | | 204 No Content | DELETE exitoso | ### Error Cliente (4xx) | Codigo | Uso | |--------|-----| | 400 Bad Request | Validacion fallida, formato invalido | | 401 Unauthorized | No autenticado | | 403 Forbidden | Sin permisos | | 404 Not Found | Recurso no existe | | 409 Conflict | Conflicto (duplicado, estado invalido) | | 422 Unprocessable Entity | Logica de negocio fallida | | 429 Too Many Requests | Rate limit excedido | ### Error Servidor (5xx) | Codigo | Uso | |--------|-----| | 500 Internal Server Error | Error no manejado | | 502 Bad Gateway | Servicio externo fallo | | 503 Service Unavailable | Servicio temporalmente no disponible | --- ## CODIGOS DE ERROR ### Catalogo de Errores ```typescript export enum ErrorCode { // Validacion (VAL-*) VALIDATION_ERROR = 'VAL-001', INVALID_FORMAT = 'VAL-002', REQUIRED_FIELD = 'VAL-003', INVALID_LENGTH = 'VAL-004', // Autenticacion (AUTH-*) UNAUTHORIZED = 'AUTH-001', TOKEN_EXPIRED = 'AUTH-002', INVALID_TOKEN = 'AUTH-003', SESSION_EXPIRED = 'AUTH-004', // Autorizacion (AUTHZ-*) FORBIDDEN = 'AUTHZ-001', INSUFFICIENT_PERMISSIONS = 'AUTHZ-002', TENANT_MISMATCH = 'AUTHZ-003', // Recursos (RES-*) NOT_FOUND = 'RES-001', ALREADY_EXISTS = 'RES-002', CONFLICT = 'RES-003', DELETED = 'RES-004', // Negocio (BIZ-*) BUSINESS_RULE_VIOLATION = 'BIZ-001', INVALID_STATE_TRANSITION = 'BIZ-002', BUDGET_EXCEEDED = 'BIZ-003', STOCK_INSUFFICIENT = 'BIZ-004', // Sistema (SYS-*) INTERNAL_ERROR = 'SYS-001', DATABASE_ERROR = 'SYS-002', EXTERNAL_SERVICE_ERROR = 'SYS-003', } ``` --- ## AUTENTICACION ### Headers Requeridos ```http Authorization: Bearer X-Tenant-Id: ``` ### Respuesta 401 ```json { "error": { "code": "AUTH-001", "message": "Token no proporcionado o invalido", "timestamp": "2025-12-05T10:30:00Z" } } ``` --- ## MULTI-TENANCY ### Header Obligatorio ```http X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000 ``` ### Validacion en Middleware ```typescript @Injectable() export class TenantMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { throw new BadRequestException({ code: 'AUTHZ-003', message: 'Header X-Tenant-Id requerido', }); } // Validar que tenant existe y usuario tiene acceso // ... req['tenantId'] = tenantId; next(); } } ``` ### Filtrado Automatico ```typescript // Base repository con filtro de tenant @Injectable() export class TenantAwareRepository { constructor( private repository: Repository, private tenantId: string, ) {} async find(options?: FindManyOptions): Promise { return this.repository.find({ ...options, where: { ...options?.where, tenantId: this.tenantId, } as FindOptionsWhere, }); } async findOne(id: string): Promise { return this.repository.findOne({ where: { id, tenantId: this.tenantId, } as FindOptionsWhere, }); } } ``` --- ## ACCIONES (RPC-style) Para operaciones que no son CRUD, usar POST con accion en URL: ``` POST /api/v1/sale-orders/:id/confirm POST /api/v1/sale-orders/:id/cancel POST /api/v1/invoices/:id/validate POST /api/v1/invoices/:id/send-email POST /api/v1/stock-moves/:id/process ``` ### Request/Response ```json // POST /api/v1/sale-orders/:id/confirm // Request body (opcional) { "skipValidation": false, "notifyCustomer": true } // Response { "data": { "id": "uuid", "state": "confirmed", "confirmedAt": "2025-12-05T10:30:00Z", "confirmedBy": "user-uuid" }, "message": "Orden confirmada exitosamente" } ``` --- ## BULK OPERATIONS ### Crear Multiples ``` POST /api/v1/partners/bulk ``` ```json { "items": [ { "name": "Partner 1", "email": "p1@test.com" }, { "name": "Partner 2", "email": "p2@test.com" } ] } ``` Response: ```json { "data": { "created": 2, "failed": 0, "items": [ { "id": "uuid-1", "name": "Partner 1", "status": "created" }, { "id": "uuid-2", "name": "Partner 2", "status": "created" } ] } } ``` ### Actualizar Multiples ``` PATCH /api/v1/partners/bulk ``` ```json { "ids": ["uuid-1", "uuid-2", "uuid-3"], "update": { "isActive": false } } ``` ### Eliminar Multiples ``` DELETE /api/v1/partners/bulk?ids=uuid-1,uuid-2,uuid-3 ``` --- ## DOCUMENTACION SWAGGER ### Decoradores Obligatorios ```typescript @Controller('partners') @ApiTags('Partners') @ApiBearerAuth() export class PartnersController { @Get() @ApiOperation({ summary: 'Lista paginada de partners' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'per_page', required: false, type: Number }) @ApiQuery({ name: 'filter[status]', required: false, type: String }) @ApiResponse({ status: 200, description: 'Lista de partners', type: PartnerListResponseDto }) @ApiResponse({ status: 401, description: 'No autenticado' }) async findAll(@Query() query: PartnerQueryDto): Promise> { // ... } @Post() @ApiOperation({ summary: 'Crear nuevo partner' }) @ApiBody({ type: CreatePartnerDto }) @ApiResponse({ status: 201, description: 'Partner creado', type: PartnerResponseDto }) @ApiResponse({ status: 400, description: 'Datos invalidos' }) @ApiResponse({ status: 409, description: 'Partner ya existe' }) async create(@Body() dto: CreatePartnerDto): Promise { // ... } } ``` ### DTOs Documentados ```typescript export class CreatePartnerDto { @ApiProperty({ description: 'Nombre o razon social del partner', example: 'Constructora ABC S.A. de C.V.', minLength: 3, maxLength: 200, }) @IsString() @Length(3, 200) name: string; @ApiProperty({ description: 'RFC del partner (Mexico)', example: 'CAB123456AB1', required: false, }) @IsOptional() @IsString() @Matches(/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/, { message: 'RFC con formato invalido', }) taxId?: string; @ApiProperty({ description: 'Indica si es persona moral', example: true, default: false, }) @IsBoolean() @IsOptional() isCompany?: boolean = false; } ``` --- ## VERSIONAMIENTO ### Estrategia: URL Path ``` /api/v1/partners # Version actual /api/v2/partners # Nueva version (cambios breaking) ``` ### Cuando Crear Nueva Version - Cambios en estructura de respuesta - Eliminar campos existentes - Cambiar tipos de datos - Cambiar comportamiento de endpoints ### Header de Deprecacion ```http Deprecation: true Sunset: Sat, 01 Jun 2026 00:00:00 GMT Link: ; rel="successor-version" ``` --- ## RATE LIMITING ### Headers de Respuesta ```http X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 987 X-RateLimit-Reset: 1701792000 ``` ### Respuesta 429 ```json { "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Demasiadas solicitudes. Intente nuevamente en 60 segundos.", "retryAfter": 60 } } ``` --- ## REFERENCIAS ### Directivas Relacionadas - `DIRECTIVA-PATRONES-ODOO.md` - Patrones de datos - `DIRECTIVA-MULTI-TENANT.md` - Aislamiento de datos - `core/orchestration/directivas/DIRECTIVA-CALIDAD-CODIGO.md` ### Estandares Externos - [JSON:API Specification](https://jsonapi.org/) - [REST API Guidelines (Microsoft)](https://github.com/microsoft/api-guidelines) - [OpenAPI 3.0](https://spec.openapis.org/oas/v3.0.3) --- **Version:** 1.0.0 **Ultima actualizacion:** 2025-12-05 **Estado:** ACTIVA Y OBLIGATORIA