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

15 KiB

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

{
  "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

// 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

// 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

{
  "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

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

Authorization: Bearer <jwt_token>
X-Tenant-Id: <tenant_uuid>

Respuesta 401

{
  "error": {
    "code": "AUTH-001",
    "message": "Token no proporcionado o invalido",
    "timestamp": "2025-12-05T10:30:00Z"
  }
}

MULTI-TENANCY

Header Obligatorio

X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000

Validacion en Middleware

@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

// 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

// 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
{
  "items": [
    { "name": "Partner 1", "email": "p1@test.com" },
    { "name": "Partner 2", "email": "p2@test.com" }
  ]
}

Response:

{
  "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
{
  "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

@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

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

Deprecation: true
Sunset: Sat, 01 Jun 2026 00:00:00 GMT
Link: </api/v2/partners>; rel="successor-version"

RATE LIMITING

Headers de Respuesta

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1701792000

Respuesta 429

{
  "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


Version: 1.0.0 Ultima actualizacion: 2025-12-05 Estado: ACTIVA Y OBLIGATORIA