erp-core/orchestration/directivas/ESTANDARES-API-REST-GENERICO.md

687 lines
15 KiB
Markdown

# 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<T>(
qb: SelectQueryBuilder<T>,
filters: Record<string, any>,
allowedFields: string[]
): SelectQueryBuilder<T> {
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<any>, 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 <jwt_token>
X-Tenant-Id: <tenant_uuid>
```
### 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<T extends BaseEntity> {
constructor(
private repository: Repository<T>,
private tenantId: string,
) {}
async find(options?: FindManyOptions<T>): Promise<T[]> {
return this.repository.find({
...options,
where: {
...options?.where,
tenantId: this.tenantId,
} as FindOptionsWhere<T>,
});
}
async findOne(id: string): Promise<T | null> {
return this.repository.findOne({
where: {
id,
tenantId: this.tenantId,
} as FindOptionsWhere<T>,
});
}
}
```
---
## 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<PaginatedResponse<PartnerDto>> {
// ...
}
@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<PartnerDto> {
// ...
}
}
```
### 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: </api/v2/partners>; 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