Saltar al contenido

Diseño de APIs REST: más allá de lo básico

Cuando ya dominas los verbos HTTP empieza el reto real: paginación, filtrado, formatos de error y versionado que hacen una API agradable de consumir.

4 min de lectura
Diagrama de flujo de solicitud y respuesta HTTP que muestra patrones de diseño de APIs REST

Todo tutorial de REST cubre lo mismo: usar sustantivos para los recursos, verbos HTTP para las acciones y devolver los códigos de estado correctos. Eso es lo mínimo indispensable. Lo difícil empieza cuando los clientes reales necesitan paginar miles de registros, filtrar por criterios complejos y manejar errores con elegancia entre distintas versiones de la API.

Nombrado de recursos que escala

Las URLs deben describir recursos, no operaciones. Pero el matiz real está en decidir dónde termina un recurso y dónde empieza otro.

tstypescript
// ❌ Verbs in URLs — REST anti-pattern
app.post("/api/createUser", handler);
app.get("/api/getUserOrders", handler);
app.put("/api/updateOrderStatus", handler);
 
// ✅ Resources and sub-resources
app.post("/api/users", createUser);
app.get("/api/users/:userId/orders", getUserOrders);
app.patch("/api/orders/:orderId", updateOrder);

Anida sub-recursos solo un nivel de profundidad. Más allá de eso, promueve el recurso anidado a un recurso de nivel superior con un parámetro de filtro. /api/users/:userId/orders/:orderId/items/:itemId es demasiado profundo — usa /api/order-items?orderId=abc en su lugar.

Paginación bien hecha

La paginación basada en offset es simple, pero falla ante escrituras concurrentes. La paginación basada en cursor es estable y performante.

tstypescript
// ❌ Offset pagination — skips or duplicates records when data changes
// GET /api/orders?page=3&limit=20
// If an order is inserted while user is on page 2, page 3 shows a duplicate
 
// ✅ Cursor-based pagination — stable regardless of mutations
// GET /api/orders?cursor=eyJpZCI6MTAwfQ&limit=20
 
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    nextCursor: string | null;
    hasMore: boolean;
    limit: number;
  };
}
 
function paginateOrders(cursor: string | null, limit: number) {
  const decodedCursor = cursor
    ? JSON.parse(Buffer.from(cursor, "base64url").toString())
    : null;
 
  const orders = db.orders.findMany({
    where: decodedCursor ? { id: { gt: decodedCursor.id } } : undefined,
    take: limit + 1,
    orderBy: { id: "asc" },
  });
 
  const hasMore = orders.length > limit;
  const data = hasMore ? orders.slice(0, -1) : orders;
  const nextCursor = hasMore
    ? Buffer.from(JSON.stringify({ id: data.at(-1)!.id })).toString("base64url")
    : null;
 
  return { data, pagination: { nextCursor, hasMore, limit } };
}

Siempre devuelve un booleano hasMore. Los clientes nunca deberían tener que adivinar si existe una siguiente página.

Filtrado y ordenamiento

Da soporte al filtrado mediante query parameters con un patrón consistente. No inventes lenguajes de consulta personalizados.

tstypescript
// GET /api/products?category=electronics&minPrice=100&maxPrice=500&sort=-createdAt
 
interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  search?: string;
  sort?: string;
}
 
function parseSort(sort: string): { field: string; direction: "asc" | "desc" } {
  if (sort.startsWith("-")) {
    return { field: sort.slice(1), direction: "desc" };
  }
  return { field: sort, direction: "asc" };
}
 
function buildProductQuery(filters: ProductFilters) {
  const where: Record<string, unknown> = {};
 
  if (filters.category) where.category = filters.category;
  if (filters.minPrice) where.price = { gte: filters.minPrice };
  if (filters.maxPrice) where.price = { ...where.price, lte: filters.maxPrice };
  if (filters.search)
    where.name = { contains: filters.search, mode: "insensitive" };
 
  const orderBy = filters.sort
    ? { [parseSort(filters.sort).field]: parseSort(filters.sort).direction }
    : { createdAt: "desc" };
 
  return { where, orderBy };
}

Antepone - a los campos de ordenamiento para orden descendente. Permite valores separados por comas para ordenar por múltiples campos: ?sort=-price,name.

Respuestas de error consistentes

Todo error de tu API debe seguir la misma estructura. Los clientes deberían poder manejar los errores con un único parser, no con lógica específica por endpoint.

tstypescript
// ❌ Inconsistent errors — different shapes per endpoint
// { error: "Not found" }
// { message: "Validation failed", errors: [...] }
// { status: "error", reason: "Unauthorized" }
 
// ✅ Single error format across the entire API
interface ApiError {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
}
 
function errorResponse(
  status: number,
  code: string,
  message: string,
  details?: Record<string, string[]>,
): Response {
  return Response.json({ error: { code, message, details } }, { status });
}
 
// Usage
errorResponse(404, "ORDER_NOT_FOUND", "Order abc-123 does not exist");
errorResponse(422, "VALIDATION_ERROR", "Request body is invalid", {
  email: ["must be a valid email address"],
  quantity: ["must be greater than 0"],
});

Usa códigos de error legibles por máquina (ORDER_NOT_FOUND), no solo mensajes. Los códigos permiten que los clientes ramifiquen su lógica; los mensajes son para depuración humana.

Estrategia de versionado de la API

Versiona tu API desde el primer día. Los dos enfoques prácticos:

EstrategiaEjemplo de URLVentajasDesventajas
Ruta en la URL/api/v2/usersObvio, fácil de enrutarDuplica rutas
HeaderAccept: application/vnd.api+json;version=2URLs limpiasOculto, más difícil de testear
tstypescript
// URL-based versioning — explicit and simple
import { v1Router } from "./routes/v1";
import { v2Router } from "./routes/v2";
 
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
 
// Shared logic lives in services, not route handlers
// v1 and v2 route handlers call the same service layer
// but shape the response differently

El versionado basado en URL gana para la mayoría de los equipos. Es inmediatamente visible, cacheable por CDNs y testeable con curl.

Headers de rate limiting

Comunica los límites de tasa mediante headers estándar para que los clientes puedan autolimitarse antes de chocar contra el límite.

tstypescript
function rateLimitHeaders(
  limit: number,
  remaining: number,
  resetAt: Date,
): Record<string, string> {
  return {
    "X-RateLimit-Limit": String(limit),
    "X-RateLimit-Remaining": String(remaining),
    "X-RateLimit-Reset": String(Math.floor(resetAt.getTime() / 1000)),
    "Retry-After": String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)),
  };
}

Cuando un cliente supera el límite de tasa, devuelve 429 Too Many Requests con el header Retry-After. Los clientes bien comportados usan esto para replegarse automáticamente.

Puntos clave

  1. Anida sub-recursos solo un nivel de profundidad — promueve el anidamiento más profundo a nivel superior con filtros
  2. Usa paginación basada en cursor para resultados estables ante escrituras concurrentes
  3. Estandariza las respuestas de error con códigos legibles por máquina, no solo mensajes
  4. Versiona desde el primer día — el versionado basado en URL es el más simple para la mayoría de los equipos
  5. Comunica los límites de tasa mediante headers para que los clientes puedan autolimitarse
  6. Filtra y ordena mediante query parameters con un patrón consistente y predecible
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX