Contratos de API REST — Plataforma Imagy
Principios
- RESTful con patrones avanzados: No solo CRUD basico, sino APIs de nivel produccion
- Idempotencia obligatoria: Toda operacion de escritura debe ser segura ante reintentos
- Concurrencia optimista: ETags para prevenir lost updates
- Merge-Patch: Actualizaciones parciales sin riesgo de sobrescribir campos
- Versionamiento en URL:
/api/v1/,/api/v2/ - Envelope estandar: Todas las respuestas siguen el mismo formato
Versionamiento
Formato
/api/v{major}/{resource}Reglas
| Cambio | Requiere nueva version? |
|---|---|
| Agregar campo a response | No |
| Agregar endpoint nuevo | No |
| Agregar query param opcional | No |
| Eliminar campo de response | Si |
| Renombrar campo | Si |
| Cambiar tipo de campo | Si |
| Cambiar semantica de endpoint | Si |
Deprecacion
Cuando una version se depreca:
- Header
Sunset: {date}en todas las respuestas de la version vieja - Header
Deprecation: true - Documentacion actualizada con fecha de fin de soporte
- Minimo 6 meses de soporte paralelo
Implementacion (.NET)
csharp
[ApiVersion("1.0")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class CreditProductsController : ControllerBase { }Idempotency Keys
Cuando es obligatorio
Todo endpoint POST que muta estado debe soportar Idempotency-Key:
| Endpoint | Obligatorio | Justificacion |
|---|---|---|
POST /credits | Si | Crear credito duplicado es catastrofico |
POST /disbursements | Si | Doble desembolso es catastrofico |
POST /payments | Si | Doble cobro es catastrofico |
POST /signatures/request | Si | Doble firma genera confusion |
POST /executions/trigger | Si | Doble ejecucion de flujo |
GET /* | No | Lecturas son naturalmente idempotentes |
PUT/PATCH /* | No | Son idempotentes por definicion (mismo resultado) |
DELETE /* | No | Es idempotente (eliminar algo ya eliminado = OK) |
Formato del Header
Idempotency-Key: {uuid-v4-generado-por-el-cliente}Comportamiento del Servidor
Implementacion
csharp
// Middleware de idempotencia
public class IdempotencyMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Method != "POST")
{
await _next(context);
return;
}
var key = context.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(key))
{
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new
{
error = new { code = "MISSING_IDEMPOTENCY_KEY",
message = "Idempotency-Key header is required for POST requests" }
});
return;
}
// Verificar si ya existe respuesta para esta key
var cached = await _idempotencyService.GetCachedResponseAsync(
key, _identity.TenantId);
if (cached is not null)
{
context.Response.StatusCode = cached.StatusCode;
context.Response.Headers.Append("X-Idempotent-Replayed", "true");
await context.Response.WriteAsync(cached.Body);
return;
}
// Procesar normalmente y cachear respuesta
// ... capture response, save with key ...
await _next(context);
}
}Almacenamiento
sql
CREATE TABLE idempotency_keys (
key VARCHAR(255) NOT NULL,
tenant_id UUID NOT NULL,
endpoint VARCHAR(255) NOT NULL,
response_status INT NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
PRIMARY KEY (key, tenant_id)
);
-- Limpieza automatica
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);- TTL: 24 horas (configurable)
- Scope: por tenant (misma key en diferentes tenants son independientes)
- Limpieza: job periodico o pg_cron
ETags y Concurrencia Optimista
Cuando es obligatorio
Todo endpoint que actualiza un recurso debe usar ETags:
| Operacion | Header requerido | Comportamiento |
|---|---|---|
GET /resource/{id} | Response: ETag: "{version}" | Servidor incluye version actual |
PATCH /resource/{id} | Request: If-Match: "{version}" | Cliente envia version que leyo |
PUT /resource/{id} | Request: If-Match: "{version}" | Cliente envia version que leyo |
DELETE /resource/{id} | Request: If-Match: "{version}" (opcional) | Previene eliminar version incorrecta |
Flujo
Implementacion en PostgreSQL
sql
-- Columna de version en cada tabla que soporte concurrencia
ALTER TABLE credit_products ADD COLUMN row_version INT NOT NULL DEFAULT 1;
-- Trigger que auto-incrementa en cada UPDATE
CREATE OR REPLACE FUNCTION increment_row_version()
RETURNS TRIGGER AS $$
BEGIN
NEW.row_version = OLD.row_version + 1;
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_credit_products_version
BEFORE UPDATE ON credit_products
FOR EACH ROW EXECUTE FUNCTION increment_row_version();Respuesta 412
json
{
"error": {
"code": "PRECONDITION_FAILED",
"message": "The resource was modified by another request. Please reload and try again.",
"details": [
{ "field": "If-Match", "message": "Version mismatch: expected 5, current is 6" }
]
}
}PATCH con Merge-Patch
Content-Type
Content-Type: application/merge-patch+jsonSemantica
- Campos presentes en el body: se actualizan
- Campos ausentes del body: no se modifican
- Campos con valor
null: se eliminan (si el schema lo permite)
Ejemplo
Estado actual del recurso:
json
{
"id": "prod-001",
"name": "Microcredito 30 dias",
"interest_rate": 0.076,
"min_amount": 100000,
"max_amount": 750000,
"is_active": true
}Request PATCH (solo cambiar tasa):
json
PATCH /api/v1/credit-products/prod-001
Content-Type: application/merge-patch+json
If-Match: "5"
{
"interest_rate": 0.082
}Resultado: solo interest_rate cambia, todo lo demas permanece intacto.
Diferencia con PUT
| Metodo | Semantica | Campos no enviados |
|---|---|---|
PUT | Reemplaza el recurso completo | Se sobrescriben con null/default |
PATCH | Actualiza solo los campos enviados | Permanecen sin cambio |
Regla: Usar PATCH para actualizaciones parciales (99% de los casos). Usar PUT solo cuando se necesita reemplazar un recurso completo (raro).
Sparse Fieldsets (Opcional - Fase Posterior)
Formato
GET /api/v1/credit-products?fields=id,name,status,interest_rateComportamiento
- Si
fieldsno se envia: retorna todos los campos - Si
fieldsse envia: retorna solo los campos solicitados +idsiempre row_versionsiempre se incluye (necesario para ETags)- Campos invalidos se ignoran silenciosamente
Implementacion con Dapper
csharp
// Whitelist de campos permitidos (previene SQL injection)
var allowedFields = new HashSet<string> { "id", "code", "name", ... };
var safe = requestedFields.Where(f => allowedFields.Contains(f));
var sql = $"SELECT {string.Join(", ", safe)} FROM credit_products ...";Envelope de Respuesta
Exito (recurso unico)
json
{
"data": {
"id": "prod-001",
"name": "Microcredito 30 dias",
"interest_rate": 0.076
},
"metadata": {
"request_id": "req-abc123",
"timestamp": "2026-05-18T10:30:00Z"
}
}Headers: ETag: "5"
Exito (coleccion paginada)
json
{
"data": [
{ "id": "prod-001", "name": "Microcredito" },
{ "id": "prod-002", "name": "Credito Personal" }
],
"metadata": {
"request_id": "req-abc123",
"timestamp": "2026-05-18T10:30:00Z",
"pagination": {
"page": 1,
"page_size": 20,
"total": 45,
"total_pages": 3
}
}
}Error
json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more validation errors occurred",
"details": [
{ "field": "interest_rate", "message": "Must be between 0 and 1" },
{ "field": "min_amount", "message": "Must be greater than 0" }
]
},
"metadata": {
"request_id": "req-abc123",
"timestamp": "2026-05-18T10:30:00Z"
}
}Codigos de Error Estandar
| Codigo | HTTP | Descripcion |
|---|---|---|
VALIDATION_ERROR | 400 | Datos de entrada invalidos |
MISSING_IDEMPOTENCY_KEY | 400 | Falta header Idempotency-Key en POST |
UNAUTHORIZED | 401 | No autenticado |
FORBIDDEN | 403 | Sin permisos |
NOT_FOUND | 404 | Recurso no existe |
CONFLICT | 409 | Conflicto de estado (ej: recurso ya existe) |
PRECONDITION_FAILED | 412 | ETag mismatch (concurrencia) |
RATE_LIMITED | 429 | Rate limit excedido |
INTERNAL_ERROR | 500 | Error interno |
SERVICE_UNAVAILABLE | 503 | Servicio no disponible |
Paginacion
Request
GET /api/v1/credits?page=2&page_size=20&sort=created_at:descParametros
| Param | Default | Max | Descripcion |
|---|---|---|---|
page | 1 | - | Numero de pagina |
page_size | 20 | 100 | Items por pagina |
sort | created_at:desc | - | Campo y direccion |
search | - | - | Busqueda full-text |
Filtros
Filtros como query params:
GET /api/v1/credits?status=active&product_code=microcredito-30d&created_after=2026-01-01Headers Estandar
Request
| Header | Obligatorio | Descripcion |
|---|---|---|
Authorization | Si (excepto publicos) | Bearer {JWT} |
Content-Type | Si (en body) | application/json o application/merge-patch+json |
Idempotency-Key | Si (en POST) | UUID v4 generado por cliente |
If-Match | Si (en PATCH/PUT) | ETag del recurso |
Accept-Language | No | Idioma preferido para mensajes de error |
Response
| Header | Siempre | Descripcion |
|---|---|---|
ETag | En GET de recurso unico | Version del recurso |
X-Request-Id | Si | ID de trazabilidad |
X-Idempotent-Replayed | Si aplica | true si es respuesta cacheada |
Sunset | Si deprecated | Fecha de fin de soporte |
Retry-After | En 429 | Segundos para reintentar |
Naming de Endpoints
Convenciones
| Regla | Ejemplo |
|---|---|
| Plural para colecciones | /api/v1/credits (no /credit) |
| Kebab-case | /api/v1/credit-products (no /creditProducts) |
| Sustantivos, no verbos | /api/v1/credits (no /api/v1/create-credit) |
| Acciones como sub-recurso | /api/v1/credits/{id}/disburse |
| Anidamiento max 2 niveles | /api/v1/organizations/{id}/members |
Metodos HTTP
| Metodo | Uso | Idempotente |
|---|---|---|
GET | Leer recurso(s) | Si |
POST | Crear recurso o ejecutar accion | No (requiere Idempotency-Key) |
PATCH | Actualizar parcialmente | Si |
PUT | Reemplazar completamente | Si |
DELETE | Eliminar (soft delete) | Si |