API REST para CQRS
Capítulo 19: API REST para CQRS
REST (Representational State Transfer) es un estilo arquitectónico para diseñar APIs web. Cuando se combina con CQRS, el diseño de la API puede reflejar la separación entre operaciones de escritura (comandos) y lectura (queries).
Principios de Diseño
En CQRS, el método HTTP indica claramente si una operación es comando o query:
- POST para comandos que crean o modifican estado
- GET para queries que solo leen datos
Esta tabla muestra el mapeo típico:
| Operación | HTTP Method | Endpoint | Tipo CQRS |
|---|---|---|---|
| Crear pedido | POST | /orders | Command |
| Agregar item | POST | /orders/:id/items | Command |
| Confirmar pedido | POST | /orders/:id/confirm | Command |
| Obtener pedido | GET | /orders/:id | Query |
| Listar pedidos | GET | /orders | Query |
| Buscar pedidos | GET | /orders/search | Query |
Router Principal
Express Router organiza endpoints en grupos. Podemos separar físicamente comandos y queries en routers diferentes, o unificarlos bajo un mismo recurso.
La separación física (/commands, /queries) es explícita pero poco REST. La unificación bajo /orders es más RESTful pero menos evidente.
// src/api/router.ts
import { Router } from 'express';
import { commandRouter } from './routes/commands';
import { queryRouter } from './routes/queries';
export const apiRouter = Router();
apiRouter.use('/commands', commandRouter);
apiRouter.use('/queries', queryRouter);
apiRouter.use('/orders', unifiedRouter); // Alternativa unificada
Endpoints de Comandos
Los comandos retornan 202 Accepted en lugar de 200/201 cuando el procesamiento es asíncrono. Esto indica que el servidor aceptó el comando pero el procesamiento puede continuar en background.
_links implementa HATEOAS (Hypermedia as the Engine of Application State): la respuesta incluye URLs para operaciones relacionadas, permitiendo navegación sin conocer la estructura de URLs.
// src/api/routes/commands.ts
import { Router, Request, Response } from 'express';
import { CommandBus } from '../../command/bus';
import { CreateOrderCommand } from '../../command/orders/create-order';
import { AddItemCommand } from '../../command/orders/add-item';
import { v4 as uuid } from 'uuid';
export const createCommandRouter = (commandBus: CommandBus): Router => {
const router = Router();
router.post('/orders', async (req: Request, res: Response) => {
const orderId = uuid();
const command = new CreateOrderCommand(orderId, req.body.customerId);
await commandBus.dispatch(command);
res.status(202).json({
orderId,
message: 'Order creation accepted',
_links: { self: `/orders/${orderId}` }
});
});
router.post('/orders/:id/items', async (req: Request, res: Response) => {
const command = new AddItemCommand(
req.params.id,
req.body.productId,
req.body.name,
req.body.price,
req.body.quantity
);
await commandBus.dispatch(command);
res.status(202).json({ message: 'Item addition accepted' });
});
router.post('/orders/:id/confirm', async (req: Request, res: Response) => {
const command = new ConfirmOrderCommand(req.params.id);
await commandBus.dispatch(command);
res.status(202).json({ message: 'Order confirmation accepted' });
});
return router;
};
Endpoints de Queries
Las queries usan GET con parámetros en query string para filtros y paginación. req.query contiene estos parámetros parseados automáticamente por Express.
Los parámetros se convierten explícitamente a tipos correctos: Number(req.query.page) porque query strings son siempre strings.
// src/api/routes/queries.ts
import { Router, Request, Response } from 'express';
import { QueryBus } from '../../query/bus';
import { GetOrderQuery } from '../../query/orders/get-order';
import { ListOrdersQuery } from '../../query/orders/list-orders';
import { SearchOrdersQuery } from '../../query/orders/search-orders';
export const createQueryRouter = (queryBus: QueryBus): Router => {
const router = Router();
router.get('/orders/:id', async (req: Request, res: Response) => {
const query = new GetOrderQuery(req.params.id);
const order = await queryBus.ask(query);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
router.get('/orders', async (req: Request, res: Response) => {
const query = new ListOrdersQuery(
req.query.customerId as string,
Number(req.query.page) || 0,
Number(req.query.limit) || 20
);
const result = await queryBus.ask(query);
res.json(result);
});
router.get('/orders/search', async (req: Request, res: Response) => {
const query = new SearchOrdersQuery({
status: req.query.status as string,
minTotal: Number(req.query.minTotal),
maxTotal: Number(req.query.maxTotal),
from: req.query.from as string,
to: req.query.to as string
});
const results = await queryBus.ask(query);
res.json(results);
});
return router;
};
Validación con Zod
Zod es una librería de validación de schemas con excelente soporte de TypeScript. Define schemas declarativos que validan y transforman datos.
safeParse retorna un objeto con success y data/error, permitiendo manejar errores sin excepciones.
Los middlewares de validación interceptan el request antes del handler, rechazando datos inválidos tempranamente.
// src/api/validators/order.validator.ts
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
const createOrderSchema = z.object({
customerId: z.string().uuid()
});
const addItemSchema = z.object({
productId: z.string().uuid(),
name: z.string().min(1),
price: z.number().positive(),
quantity: z.number().int().positive()
});
export const validateCreateOrder = (req: Request, res: Response, next: NextFunction) => {
const result = createOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
next();
};
export const validateAddItem = (req: Request, res: Response, next: NextFunction) => {
const result = addItemSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
next();
};
Respuestas Asíncronas
En CQRS, los comandos pueden procesarse asíncronamente. Este middleware enriquece las respuestas de comandos con información sobre el procesamiento asíncrono.
pollUrl indica dónde consultar el estado de la operación. estimatedCompletion da una estimación del tiempo de procesamiento.
// src/api/middleware/async-response.ts
import { Request, Response, NextFunction } from 'express';
export const asyncCommandResponse = (req: Request, res: Response, next: NextFunction) => {
const originalJson = res.json.bind(res);
res.json = (data: any) => {
if (req.method === 'POST' && res.statusCode === 202) {
return originalJson({
...data,
_async: true,
pollUrl: data._links?.self,
estimatedCompletion: '5s'
});
}
return originalJson(data);
};
next();
};
Endpoint de Estado
Para operaciones asíncronas, es util tener un endpoint que muestre el estado actual y el historial de estados de un recurso.
// src/api/routes/status.ts
router.get('/orders/:id/status', async (req: Request, res: Response) => {
const query = new GetOrderStatusQuery(req.params.id);
const status = await queryBus.ask(query);
res.json({
orderId: req.params.id,
status: status.current,
lastUpdated: status.updatedAt,
history: status.history
});
});
Resumen
API REST para CQRS:
- Comandos retornan 202 Accepted para operaciones asíncronas
- Queries son GET requests con filtros via query params
- Validación estricta antes de despachar comandos
- Links HATEOAS para navegación
Glosario
REST (Representational State Transfer)
Definición: Estilo arquitectónico para APIs web que usa recursos identificados por URLs, métodos HTTP estándar, y representaciones de datos (JSON, XML).
Por qué es importante: Proporciona un estándar bien entendido para diseñar APIs. Los clientes pueden predecir el comportamiento basándose en métodos HTTP.
Ejemplo práctico: GET /orders/123 retorna el pedido, POST /orders crea uno nuevo, DELETE /orders/123 lo elimina. El método indica la intención.
HTTP Status 202 Accepted
Definición: Código de respuesta HTTP que indica que el request fue aceptado para procesamiento, pero el procesamiento no ha completado.
Por qué es importante: Comunica claramente que la operación es asíncrona. El cliente sabe que debe consultar después para ver el resultado.
Ejemplo práctico: POST /orders retorna 202 con {"orderId": "123", "pollUrl": "/orders/123"}. El cliente puede hacer GET al pollUrl para ver el estado.
HATEOAS
Definición: Hypermedia as the Engine of Application State. Principio REST donde las respuestas incluyen links a acciones relacionadas.
Por qué es importante: Los clientes pueden navegar la API sin conocer todas las URLs de antemano. La API se autodescribe.
Ejemplo práctico: GET /orders/123 retorna {"_links": {"items": "/orders/123/items", "confirm": "/orders/123/confirm"}}. El cliente descubre las acciones disponibles.
Express Router
Definición: Objeto de Express.js que agrupa rutas relacionadas. Permite modularizar la definición de endpoints.
Por qué es importante: Organiza el código en módulos por recurso o funcionalidad. Facilita testing y mantenimiento.
Ejemplo práctico: Router().get('/orders', handler) define rutas que luego se montan en el app principal con app.use('/api', router).
Zod
Definición: Librería de TypeScript para validación de schemas con inferencia de tipos. Define schemas declarativos que validan y transforman datos.
Por qué es importante: Valida datos en runtime mientras mantiene type-safety en compilación. Los tipos se infieren del schema, evitando duplicación.
Ejemplo práctico: z.object({price: z.number().positive()}) valida que price exista, sea número y sea positivo. TypeScript conoce el tipo del resultado.
Middleware
Definición: Función que se ejecuta entre la recepción del request y el handler final. Puede modificar request/response, validar, loguear, o terminar el request.
Por qué es importante: Permite reutilizar lógica transversal (autenticación, validación, logging) sin duplicarla en cada handler.
Ejemplo práctico: Un middleware de validación verifica el body antes del handler. Si es inválido, retorna 400 sin llegar al handler.
Query String
Definición: Parte de la URL después del ? que contiene pares clave=valor para pasar parámetros al servidor.
Por qué es importante: Permite pasar filtros, paginación y opciones a endpoints GET sin modificar la URL base del recurso.
Ejemplo práctico: GET /orders?customerId=123&page=2&limit=20 pasa tres parámetros que Express parsea en req.query.
Polling
Definición: Técnica donde el cliente consulta periódicamente un endpoint para verificar el estado de una operación asíncrona.
Por qué es importante: Permite manejar operaciones largas sin mantener conexiones abiertas. Simple de implementar tanto en cliente como servidor.
Ejemplo práctico: Después de POST /orders (202), el cliente hace GET /orders/123 cada 2 segundos hasta que status sea “completed”.
safeParse
Definición: Método de Zod que valida datos sin lanzar excepciones. Retorna un objeto con success y data/error.
Por qué es importante: Permite manejar errores de validación como flujo normal en lugar de excepciones, facilitando respuestas estructuradas.
Ejemplo práctico: const result = schema.safeParse(data); if (!result.success) return res.status(400).json(result.error) maneja el error sin try/catch.