Skip to content

Contratos de API REST — Plataforma Imagy

Principios

  1. RESTful con patrones avanzados: No solo CRUD basico, sino APIs de nivel produccion
  2. Idempotencia obligatoria: Toda operacion de escritura debe ser segura ante reintentos
  3. Concurrencia optimista: ETags para prevenir lost updates
  4. Merge-Patch: Actualizaciones parciales sin riesgo de sobrescribir campos
  5. Versionamiento en URL: /api/v1/, /api/v2/
  6. Envelope estandar: Todas las respuestas siguen el mismo formato

Versionamiento

Formato

/api/v{major}/{resource}

Reglas

CambioRequiere nueva version?
Agregar campo a responseNo
Agregar endpoint nuevoNo
Agregar query param opcionalNo
Eliminar campo de responseSi
Renombrar campoSi
Cambiar tipo de campoSi
Cambiar semantica de endpointSi

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:

EndpointObligatorioJustificacion
POST /creditsSiCrear credito duplicado es catastrofico
POST /disbursementsSiDoble desembolso es catastrofico
POST /paymentsSiDoble cobro es catastrofico
POST /signatures/requestSiDoble firma genera confusion
POST /executions/triggerSiDoble ejecucion de flujo
GET /*NoLecturas son naturalmente idempotentes
PUT/PATCH /*NoSon idempotentes por definicion (mismo resultado)
DELETE /*NoEs 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:

OperacionHeader requeridoComportamiento
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+json

Semantica

  • 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

MetodoSemanticaCampos no enviados
PUTReemplaza el recurso completoSe sobrescriben con null/default
PATCHActualiza solo los campos enviadosPermanecen 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_rate

Comportamiento

  • Si fields no se envia: retorna todos los campos
  • Si fields se envia: retorna solo los campos solicitados + id siempre
  • row_version siempre 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

CodigoHTTPDescripcion
VALIDATION_ERROR400Datos de entrada invalidos
MISSING_IDEMPOTENCY_KEY400Falta header Idempotency-Key en POST
UNAUTHORIZED401No autenticado
FORBIDDEN403Sin permisos
NOT_FOUND404Recurso no existe
CONFLICT409Conflicto de estado (ej: recurso ya existe)
PRECONDITION_FAILED412ETag mismatch (concurrencia)
RATE_LIMITED429Rate limit excedido
INTERNAL_ERROR500Error interno
SERVICE_UNAVAILABLE503Servicio no disponible

Paginacion

Request

GET /api/v1/credits?page=2&page_size=20&sort=created_at:desc

Parametros

ParamDefaultMaxDescripcion
page1-Numero de pagina
page_size20100Items por pagina
sortcreated_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-01

Headers Estandar

Request

HeaderObligatorioDescripcion
AuthorizationSi (excepto publicos)Bearer {JWT}
Content-TypeSi (en body)application/json o application/merge-patch+json
Idempotency-KeySi (en POST)UUID v4 generado por cliente
If-MatchSi (en PATCH/PUT)ETag del recurso
Accept-LanguageNoIdioma preferido para mensajes de error

Response

HeaderSiempreDescripcion
ETagEn GET de recurso unicoVersion del recurso
X-Request-IdSiID de trazabilidad
X-Idempotent-ReplayedSi aplicatrue si es respuesta cacheada
SunsetSi deprecatedFecha de fin de soporte
Retry-AfterEn 429Segundos para reintentar

Naming de Endpoints

Convenciones

ReglaEjemplo
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

MetodoUsoIdempotente
GETLeer recurso(s)Si
POSTCrear recurso o ejecutar accionNo (requiere Idempotency-Key)
PATCHActualizar parcialmenteSi
PUTReemplazar completamenteSi
DELETEEliminar (soft delete)Si

Reimagine Tech LLC — Documentacion Interna