Semana 6 — APIs y patrones de diseño
Objetivos
- Diseñar una API HTTP orientada a recursos: rutas, métodos, códigos de estado y una forma de error estable.
- Explicar qué significa que un método sea seguro o idempotente según RFC 9110, y usar una clave de idempotencia para que un reintento no duplique una operación.
- Distinguir los cambios compatibles de los que rompen a quien llama a la API, y planificar una versión nueva y una deprecación.
- Reconocer en código real los patrones de diseño que siguen vigentes (strategy, adapter, decorator, observer, repository, dependency injection) y cuándo una función basta en lugar de un patrón.
- Documentar una API existente con OpenAPI y revisarla con Redocly.
Sesión A — Interfaces y APIs: REST, errores, versionado e idempotencia
Lecturas:
- Google, API Design Guide: Resource-oriented design, Standard methods y Errors. Las versiones actuales están en las AIP: AIP-121 (recursos), AIP-136 (métodos propios), AIP-158 (paginación) y AIP-180 (compatibilidad hacia atrás).
- Microsoft, Azure REST API Guidelines (la versión vigente de las Microsoft REST API Guidelines): Exactly Once Behavior, Handling Errors, API Versioning y Repeatability of requests.
- RFC 9110, HTTP Semantics, sec. 9.2.2, Idempotent Methods (y 9.2.1, Safe Methods).
Lecturas complementarias:
- RFC 9457, Problem Details for HTTP APIs.
- IETF, The Idempotency-Key HTTP Header Field (borrador, todavía no es un RFC).
- RFC 9745, The Deprecation HTTP Response Header Field, y RFC 8594, The Sunset HTTP Header Field.
Temas:
- Una API es un contrato con quien la llama, que no está en la sala cuando la cambian. Por la ley de Hyrum, todo lo observable (el orden de una lista, el texto de un mensaje, un campo que sobra) termina siendo parte del contrato. Por eso se diseña con cuidado y se cambia con reglas.
- Diseño orientado a recursos: la API expone sustantivos (canchas, reservas, miembros) organizados en colecciones (
/api/courts,/api/courts/1), y pocos verbos estándar. Según Google: List y Get conGET, Create conPOST, Update conPATCHy Delete conDELETE. Una acción que no encaja, como cancelar una reserva, es un método propio:POST /api/bookings/12:cancelen el estilo de Google, oPOST /api/bookings/12/cancel. Error común: verbos en las rutas para operaciones estándar (/api/getCourts) o unGETque cambia datos. Los proxies y los navegadores repiten unGETsin preguntar. - Seguro e idempotente (RFC 9110). Un método es seguro si quien llama no pide ningún cambio de estado:
GET,HEAD,OPTIONS,TRACE. Es idempotente si el efecto sobre el servidor de N solicitudes iguales es el mismo que el de una: los seguros, másPUTyDELETE. La respuesta puede cambiar: un segundoDELETEpuede dar404, pero el estado del servidor es el mismo.POSTyPATCHno son idempotentes por definición. - Por qué importa: las redes fallan. Si una reserva tarda y el celular corta la conexión, la app no sabe si la reserva se hizo. Un reintento de un método idempotente es seguro; uno de
POSTpuede duplicar la operación, o fallar sin decir que la primera vez funcionó. La solución habitual es una clave de idempotencia: quien llama manda una cabeceraIdempotency-Keycon un valor único; el servidor guarda la clave con el resultado y, ante un reintento con la misma clave, responde de forma consistente sin repetir la operación. El borrador de la IETF recomienda422si se reusa una clave con otro cuerpo y409si la primera solicitud todavía se está procesando. - Códigos de estado con significado:
200 OK;201 Createdcon la cabeceraLocationde lo creado;204 No Content;400 Bad Request(mal formado);401(no se sabe quién es) frente a403(se sabe y no puede);404;405con la cabeceraAllow;409 Conflict(choca con el estado actual);413 Content Too Large;422 Unprocessable Content(bien formado, pero no cumple una regla);429;500;503. La frontera entre400y422es una decisión de diseño: escríbanla y respétenla en todos los endpoints. - Errores: un código estable que un programa pueda comparar, un mensaje para personas y, si sirve, detalles por campo. RFC 9457 define un formato estándar (
application/problem+jsoncontype,title,status,detail); Google y Microsoft usan{ "error": { "code", "message", ... } }, como Kallpa. Importa menos cuál se elija que usar el mismo en toda la API. - Evolución y versiones. Un cambio compatible no rompe a nadie: agregar un endpoint, un campo opcional en la solicitud o un campo en la respuesta. Uno incompatible sí: quitar o renombrar un campo, cambiar su tipo o su significado, volver obligatorio algo opcional, cambiar un código de estado. Devolver un arreglo suelto en lugar de un objeto (
[...]frente a{ "bookings": [...] }) deja sin lugar para agregar metadatos de paginación (nextPageToken): pasar al objeto rompe a quien espera el arreglo. Aun con el objeto, AIP-158 considera incompatible agregar paginación después, porque quien llama solo vería la primera página: conviene paginar desde el inicio. Error común: renombrar un campo “porque se ve mejor” en una API que ya tiene clientes HTTP. - Deprecación: la versión vieja y la nueva conviven un tiempo (
/v1/y/v2/, un parámetro como?api-version=o una cabecera de versión). La vieja se anuncia con las cabecerasDeprecationySunset(la fecha en que deja de existir), se mide quién la sigue usando y recién entonces se apaga. Se retoma en la semana 16.
Sesión B — Patrones de diseño que siguen vigentes
Lecturas:
- Refactoring.Guru, Patrones de diseño: Strategy, Adapter, Decorator, Observer y Factory Method.
- Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern (2004).
Lecturas complementarias:
- Peter Norvig, Design Patterns in Dynamic Languages (1996): 16 de los 23 patrones del libro de la “pandilla de los cuatro” se vuelven invisibles o más simples, al menos en algunos usos, en lenguajes como Dylan o Lisp; las funciones de primera clase explican una parte.
- Martin Fowler, Repository, del catálogo de Patterns of Enterprise Application Architecture.
Temas:
- Un patrón de diseño es una solución con nombre a un problema que se repite, con sus trade-offs. El catálogo clásico es de 1994 (Gamma, Helm, Johnson y Vlissides), escrito para C++ y Smalltalk. Lo que sigue vigente es el problema que cada patrón resuelve y el vocabulario: decir “aquí va un adapter” ahorra un párrafo en una revisión de código.
- Strategy: cambiar un algoritmo sin tocar a quien lo usa. En TypeScript suele ser solo una función o un objeto con una función. En Kallpa,
Clockes una estrategia para obtener “ahora”:systemClocken producción,fixedClocken las pruebas. - Dependency injection: un módulo recibe sus dependencias en lugar de crearlas.
createServer({ db, clock, notifier })recibe la base de datos, el reloj y el notificador, y por eso las pruebas pueden usar:memory:, un reloj fijo y un notificador que guarda los mensajes. No hace falta un contenedor de DI: pasar parámetros al construir el objeto es DI. - Adapter: hacer que una interfaz existente encaje con la que el código espera.
Notifieres la interfaz que Kallpa espera. Cuando el complejo contrate WhatsApp Business, el adapter traducebookingConfirmed(...)a la API de ese proveedor, y la lógica de reservas no cambia. Repository: una colección de objetos del dominio que esconde cómo se guardan (src/store/). - Decorator y middleware: envolver una función con otra que agrega algo antes o después sin cambiarla. En
src/http/server.ts, cada handler corre dentro de untry/catch/finallyque convierte los errores en respuestas JSON y escribe una línea de log. Sacar eso a funcioneswithErrors(handler)owithLog(handler)es el patrón decorator. - Observer: quien produce un evento no conoce a quienes reaccionan. “Se confirmó una reserva” podría notificar por WhatsApp, actualizar la caja y registrar una métrica, sin que la reserva sepa de ninguno. En Node existe
EventEmitter. El costo es que el flujo ya no se lee de corrido en el código. - Error común: aplicar patrones porque existen. Una
BookingFactoryProviderpara crear un objeto que se crea con un literal agrega interfaces sin esconder nada: es un módulo superficial (semana 5). Un Singleton es estado global con otro nombre y complica las pruebas. La pregunta es siempre la misma: ¿qué cambio futuro abarata este patrón y qué tan probable es? - Con IA. Los modelos de lenguaje reproducen los patrones que más aparecen en el código con el que se entrenaron, a veces con más ceremonia de la necesaria (interfaces con una sola implementación, fábricas para todo). Al revisar código generado, pregunten qué cambio facilita cada abstracción. Una especificación OpenAPI también sirve con IA: es un contrato contra el que se puede comprobar un cliente HTTP generado, en lugar de confiar en que los campos que usa existen.
Laboratorio — Diseñar y documentar una API con OpenAPI
Guía: este documento. Referencias: OpenAPI Specification y Learn OpenAPI; Redocly CLI, lint y build-docs.
La especificación describe la API de Kallpa como es hoy, no como debería ser; las mejoras se proponen aparte. En parejas, sobre su copia de Kallpa, con dos terminales en el mismo contenedor (./env/run.sh en cada una).
- En una terminal,
make seedymake start. En la otra, recorrer la API concurl -s -iy anotar en una tabla, por endpoint, el código de estado y la forma del cuerpo de cada respuesta, también las de error. Para reservar, usen una fecha dentro de los 14 días y una hora libre:D=$(date -d '+8 days' +%F) BODY="{\"courtId\":2,\"memberId\":2,\"date\":\"$D\",\"startHour\":10,\"teamName\":\"Los Halcones\"}" curl -s -i -X POST localhost:3000/api/bookings -H 'content-type: application/json' -d "$BODY" - Repetir exactamente la misma reserva, como si fuera un reintento después de un timeout. Cancelarla dos veces (
ID=…; curl -s -i -X POST localhost:3000/api/bookings/$ID/cancel). Anotar qué ve quien llama en cada caso y si puede saber qué pasó la primera vez. - Copiar el esqueleto
code/week-06/openapi.yamladocs/openapi.yaml. Ya traeinfo, el esquemaErroryGET /api/courtscompleto, y declarasecurity: []: Kallpa no tiene autenticación y la especificación lo dice en voz alta (se retoma en la semana 15). Correrredocly lint docs/openapi.yaml. - Describir todos los endpoints de su copia (siete en la Kallpa publicada; más, si ya integraron su issue de P0): parámetros de ruta y de consulta, el cuerpo de
POST /api/bookings(campos obligatorios,minimum,maximumymaxLengthsegún las reglas del README) y todas las respuestas, con sus códigos de error en unenumy el esquemaErrorcon$ref. Usarcomponents/schemasparaBooking,CourtyMember. - Llevar
redocly linta cero errores. Cada advertencia que quede se corrige o se justifica con un comentario en el YAML (por ejemplo, el servidorlocalhost). Generar la documentación conredocly build-docs docs/openapi.yaml -o docs/api.htmly abrirla en el navegador de su computadora. - Revisión de diseño en
docs/api-revision.md: comparar su tabla y su especificación con las guías de Google y de Microsoft. Encuentren al menos cuatro inconsistencias (en códigos de estado, en la forma de las respuestas, en nombres de campos, en la frontera entre400y422, en lo que pasa al reintentar). Para cada una: ¿corregirla rompe a quien ya llama a la API? ¿Cómo la harían convivir con la versión actual? - Diseñar en el mismo documento una clave de idempotencia para
POST /api/bookings: qué se guarda, por cuánto tiempo, qué se responde ante un reintento con la misma clave y ante la misma clave con otro cuerpo. Opcional: implementarla, con una prueba nueva que haga el reintento.
Entregas
- No hay entregas esta semana. P2 (diseño y primer sprint) pide una especificación de la API como la de hoy (
projects/p2-diseno/); se entrega en la semana 8.
Preguntas de repaso
- ¿Es idempotente
POST /api/bookings/12/cancel? ¿YPATCH /api/bookings/12con{ "status": "cancelled" }? Justifiquen con la definición de RFC 9110, no con el método. - Una app pierde la conexión justo después de enviar una reserva. Describan qué pasa al reintentar con y sin una clave de idempotencia.
- Clasifiquen como compatible o incompatible: agregar
pricea la respuesta de la disponibilidad; cambiarhourporstartHour; pasar un error de400a422; agregar un parámetro opcional?sport=. - ¿Qué patrón de diseño es
fixedClocken las pruebas de Kallpa? ¿Qué se perdería si cada función llamara anew Date()? - Un compañero propone una interfaz
NotifierFactorycon una sola implementación que devuelveconsoleNotifier. ¿Qué le responderían en la revisión de código?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (20)Pruebas (7)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
// Un servidor de pagos mínimo: POST /charges no tiene una regla que frene un
// duplicado, como sí la tiene una cancha ocupada. Sirve para ver el reintento
// que cobra dos veces y lo que cambia con una clave de idempotencia.
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { withIdempotency, type IdempotencyEntry, type OperationResponse } from './idempotency.ts';
export type Charge = { id: number; memberId: number; cents: number };
export type ChargesServer = { url: string; charges: Charge[]; close(): Promise<void> };
const DAY_MS = 24 * 60 * 60 * 1000;
export async function startChargesServer(options: { requireKey: boolean }): Promise<ChargesServer> {
const charges: Charge[] = [];
const operation = async (request: Omit<Charge, 'id'>): Promise<OperationResponse> => {
const charge = { id: charges.length + 1, ...request };
charges.push(charge);
return { status: 201, body: { charge } };
};
const store = new Map<string, IdempotencyEntry>();
const idempotent = withIdempotency(store, operation, { now: Date.now, ttlMs: DAY_MS });
const server = http.createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const request = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Omit<Charge, 'id'>;
const key = req.headers['idempotency-key'];
const result = options.requireKey
? await idempotent(typeof key === 'string' ? key : undefined, request)
: await operation(request);
res.writeHead(result.status, { 'content-type': 'application/json' });
res.end(JSON.stringify(result.body));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
charges,
close: () =>
new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => (error ? reject(error) : resolve()));
}),
};
}// Ilustración escrita para la clase: un cliente HTTP como los que suele
// proponer un agente cuando no tiene a la vista la especificación. Compila,
// corre y no lanza ningún error, pero dos de sus campos no existen.
export const FIELDS_USED = ['courts[].courtName', 'courts[].sportType'];
export async function courtLabels(baseUrl: string): Promise<string[]> {
const response = await fetch(`${baseUrl}/api/courts`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = await response.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return data.courts.map((court: any) => `${court.courtName} (${court.sportType})`);
}// Un validador mínimo de JSON Schema, solo con lo que usa la especificación de
// Kallpa, para comparar respuestas reales con openapi.yaml ya empaquetado como
// JSON (make: redocly bundle). No reemplaza a una biblioteca de validación.
export type Schema = {
$ref?: string;
type?: string | string[];
required?: string[];
properties?: Record<string, Schema>;
items?: Schema;
enum?: unknown[];
minimum?: number;
maximum?: number;
maxLength?: number;
minLength?: number;
};
export type OpenApi = {
paths: Record<string, Record<string, Operation>>;
components?: { schemas?: Record<string, Schema>; responses?: Record<string, ResponseSpec> };
};
type ResponseSpec = { $ref?: string; content?: Record<string, { schema: Schema }> };
type Operation = { responses: Record<string, ResponseSpec> };
function resolve(spec: OpenApi, schema: Schema): Schema {
if (schema.$ref === undefined) return schema;
const name = schema.$ref.replace('#/components/schemas/', '');
const target = spec.components?.schemas?.[name];
if (!target) throw new Error(`$ref sin destino: ${schema.$ref}`);
return target;
}
function typeOf(value: unknown): string {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
if (Number.isInteger(value)) return 'integer';
return typeof value;
}
// Devuelve la lista de discrepancias: vacía si el valor cumple el esquema.
export function validate(spec: OpenApi, value: unknown, rawSchema: Schema, path = '$'): string[] {
const schema = resolve(spec, rawSchema);
const problems: string[] = [];
if (schema.type !== undefined) {
const allowed = Array.isArray(schema.type) ? schema.type : [schema.type];
const actual = typeOf(value);
if (!allowed.includes(actual) && !(actual === 'integer' && allowed.includes('number'))) {
return [`${path}: se esperaba ${allowed.join(' o ')} y llegó ${actual}`];
}
}
if (schema.enum && !schema.enum.includes(value)) problems.push(`${path}: ${JSON.stringify(value)} no está en el enum`);
if (typeof value === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) problems.push(`${path}: menor que ${schema.minimum}`);
if (schema.maximum !== undefined && value > schema.maximum) problems.push(`${path}: mayor que ${schema.maximum}`);
}
if (typeof value === 'string') {
if (schema.maxLength !== undefined && value.length > schema.maxLength) problems.push(`${path}: más largo que ${schema.maxLength}`);
if (schema.minLength !== undefined && value.length < schema.minLength) problems.push(`${path}: más corto que ${schema.minLength}`);
}
if (Array.isArray(value) && schema.items) {
value.forEach((item, i) => problems.push(...validate(spec, item, schema.items!, `${path}[${i}]`)));
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const fields = value as Record<string, unknown>;
for (const name of schema.required ?? []) {
if (!(name in fields)) problems.push(`${path}.${name}: falta`);
}
for (const [name, property] of Object.entries(schema.properties ?? {})) {
if (name in fields) problems.push(...validate(spec, fields[name], property, `${path}.${name}`));
}
}
return problems;
}
// El esquema de una respuesta: responseSchema(spec, '/api/courts', 'get', '200').
export function responseSchema(spec: OpenApi, path: string, method: string, status: string): Schema {
const declared = spec.paths[path]?.[method]?.responses?.[status];
if (!declared) throw new Error(`la especificación no declara ${method} ${path} ${status}`);
const response = declared.$ref
? spec.components?.responses?.[declared.$ref.replace('#/components/responses/', '')]
: declared;
const schema = response?.content?.['application/json']?.schema;
if (!schema) throw new Error(`${method} ${path} ${status} no declara un cuerpo JSON`);
return schema;
}
// ¿Existe este campo en el esquema? 'courts[].name' recorre objetos y arreglos.
export function fieldExists(spec: OpenApi, rawSchema: Schema, fieldPath: string): boolean {
let schema = resolve(spec, rawSchema);
for (const part of fieldPath.split('.')) {
const isArray = part.endsWith('[]');
const name = isArray ? part.slice(0, -2) : part;
const next = schema.properties?.[name];
if (!next) return false;
schema = resolve(spec, next);
if (isArray) {
if (!schema.items) return false;
schema = resolve(spec, schema.items);
}
}
return true;
}diff --git a/src/http/server.ts b/src/http/server.ts
index 251064b..09ac4b5 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -1,4 +1,4 @@
-import http from 'node:http';
+import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import type { DatabaseSync } from 'node:sqlite';
import { systemClock, type Clock } from '../domain/clock.ts';
import { consoleNotifier, type Notifier } from '../notifications/notifier.ts';
@@ -36,27 +36,13 @@ export function buildRouter(deps: AppDependencies): Router {
return router;
}
-export function createServer(options: ServerOptions): http.Server {
- const deps: AppDependencies = {
- service: new BookingService(options.db),
- clock: options.clock ?? systemClock,
- notifier: options.notifier ?? consoleNotifier,
- };
- const log = options.log ?? console.log;
- const router = buildRouter(deps);
+type RawHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
- return http.createServer(async (req, res) => {
- const url = new URL(req.url ?? '/', 'http://localhost');
+// Convierte cualquier error en una respuesta JSON.
+function withErrors(next: RawHandler): RawHandler {
+ return async (req, res) => {
try {
- if (req.method === 'GET' && (await serveStatic(url.pathname, res))) return;
- const match = router.match(req.method ?? 'GET', url.pathname);
- if (match.kind === 'not-found') {
- throw new HttpError(404, 'NOT_FOUND', `No existe la ruta ${url.pathname}.`);
- }
- if (match.kind === 'method-not-allowed') {
- throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${req.method} no está permitido en ${url.pathname}.`);
- }
- await match.handler({ req, res, url, params: match.params });
+ await next(req, res);
} catch (error) {
if (error instanceof HttpError) {
sendError(res, error);
@@ -64,8 +50,45 @@ export function createServer(options: ServerOptions): http.Server {
console.error(error);
sendError(res, new HttpError(500, 'INTERNAL_ERROR', 'Ocurrió un error inesperado.'));
}
+ }
+ };
+}
+
+// Escribe una línea de log al terminar, pase lo que pase. Va por fuera de
+// withErrors para ver el código de estado que de verdad se respondió.
+function withLog(log: (line: string) => void, next: RawHandler): RawHandler {
+ return async (req, res) => {
+ try {
+ await next(req, res);
} finally {
log(`${req.method} ${req.url} ${res.statusCode}`);
}
- });
+ };
+}
+
+function dispatcher(router: Router): RawHandler {
+ return async (req, res) => {
+ const url = new URL(req.url ?? '/', 'http://localhost');
+ if (req.method === 'GET' && (await serveStatic(url.pathname, res))) return;
+ const match = router.match(req.method ?? 'GET', url.pathname);
+ if (match.kind === 'not-found') {
+ throw new HttpError(404, 'NOT_FOUND', `No existe la ruta ${url.pathname}.`);
+ }
+ if (match.kind === 'method-not-allowed') {
+ throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${req.method} no está permitido en ${url.pathname}.`);
+ }
+ await match.handler({ req, res, url, params: match.params });
+ };
+}
+
+export function createServer(options: ServerOptions): http.Server {
+ const deps: AppDependencies = {
+ service: new BookingService(options.db),
+ clock: options.clock ?? systemClock,
+ notifier: options.notifier ?? consoleNotifier,
+ };
+ const log = options.log ?? console.log;
+ const router = buildRouter(deps);
+
+ return http.createServer(withLog(log, withErrors(dispatcher(router))));
}// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig(
{ ignores: ['build/', 'solution/'] },
js.configs.recommended,
tseslint.configs.recommended,
);// Tres clientes HTTP de la misma respuesta de disponibilidad, y los cambios de
// la API que cada uno aguanta. Los cambios se simulan sobre la respuesta real.
export type Slot = { hour: number; available: boolean; teamName?: string };
export type Availability = { courtId: number; date: string; slots: Slot[] };
// Lee solo lo que necesita e ignora lo demás.
export function freeHoursTolerant(body: Availability): number[] {
return body.slots.filter((slot) => slot.available).map((slot) => slot.hour);
}
// Exige exactamente los campos que vio el día que lo escribieron.
export function freeHoursStrict(body: Availability): number[] {
for (const slot of body.slots) {
const allowed = ['hour', 'available', 'teamName'];
const unknown = Object.keys(slot).filter((key) => !allowed.includes(key));
if (unknown.length > 0) throw new Error(`campo desconocido: ${unknown.join(', ')}`);
}
return freeHoursTolerant(body);
}
// Lee el arreglo suelto de GET /api/members/:id/bookings.
export function countConfirmed(body: { status: string }[]): number {
return body.filter((booking) => booking.status === 'confirmed').length;
}
// Decide qué mostrar según el código de estado.
export function errorKind(status: number): 'revise-el-formulario' | 'no-se-puede' | 'desconocido' {
if (status === 400) return 'revise-el-formulario';
if (status === 409) return 'no-se-puede';
return 'desconocido';
}
// Los cambios que clasifica el capítulo.
export function addPriceField(body: Availability): unknown {
return { ...body, slots: body.slots.map((slot) => ({ ...slot, priceCents: 9000 })) };
}
export function renameHourField(body: Availability): unknown {
return {
...body,
slots: body.slots.map(({ hour, ...rest }) => ({ startHour: hour, ...rest })),
};
}// Una clave de idempotencia como decorator de una operación: la operación no
// sabe nada de claves. La memoria es un Map para mostrar la lógica; en un
// sistema real la clave se guarda en la misma transacción que el efecto.
import { createHash } from 'node:crypto';
export type OperationResponse = { status: number; body: unknown };
export type IdempotencyEntry =
| { state: 'in-progress'; fingerprint: string; storedAt: number }
| { state: 'done'; fingerprint: string; storedAt: number; response: OperationResponse };
export type IdempotencyOptions = {
now: () => number; // milisegundos; se inyecta para probar el vencimiento
ttlMs: number;
};
export type Replayable = OperationResponse & { replayed: boolean };
// El mismo contenido da la misma huella, en cualquier orden de campos.
export function fingerprint(request: unknown): string {
return createHash('sha256').update(canonicalJson(request)).digest('hex');
}
export function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (typeof value === 'object' && value !== null) {
const fields = Object.entries(value)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([key, field]) => `${JSON.stringify(key)}:${canonicalJson(field)}`);
return `{${fields.join(',')}}`;
}
return JSON.stringify(value);
}
function errorResponse(status: number, code: string, message: string): Replayable {
return { status, body: { error: { code, message } }, replayed: false };
}
export function withIdempotency<Request>(
store: Map<string, IdempotencyEntry>,
operation: (request: Request) => Promise<OperationResponse>,
options: IdempotencyOptions,
) {
return async (key: string | undefined, request: Request): Promise<Replayable> => {
if (key === undefined) {
return errorResponse(400, 'IDEMPOTENCY_KEY_MISSING', 'Esta operación pide la cabecera Idempotency-Key.');
}
const print = fingerprint(request);
const now = options.now();
const existing = store.get(key);
if (existing && now - existing.storedAt < options.ttlMs) {
if (existing.fingerprint !== print) {
return errorResponse(422, 'IDEMPOTENCY_KEY_REUSED', 'Esa clave ya se usó con otro cuerpo.');
}
if (existing.state === 'in-progress') {
return errorResponse(409, 'REQUEST_IN_PROGRESS', 'La primera solicitud con esa clave todavía se procesa.');
}
return { ...existing.response, replayed: true };
}
// Se marca antes del primer await: un reintento concurrente ya ve la clave.
store.set(key, { state: 'in-progress', fingerprint: print, storedAt: now });
try {
const response = await operation(request);
store.set(key, { state: 'done', fingerprint: print, storedAt: now, response });
return { ...response, replayed: false };
} catch (error) {
// No se sabe qué pasó: se libera la clave para que un reintento pueda
// volver a correr la operación.
store.delete(key);
throw error;
}
};
}// Una red que falla en el peor momento: el servidor recibe la solicitud y la
// procesa, pero quien llama nunca ve la respuesta porque la conexión se corta.
import http from 'node:http';
import type { AddressInfo } from 'node:net';
export type FlakyProxy = { url: string; close(): Promise<void> };
// Reenvía todo al servidor real. Las primeras `drops` respuestas no llegan.
export async function startFlakyProxy(target: string, drops = 1): Promise<FlakyProxy> {
let remaining = drops;
const server = http.createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (typeof value === 'string' && name !== 'host' && name !== 'connection') headers.set(name, value);
}
const upstream = await fetch(`${target}${req.url}`, {
method: req.method,
headers,
body: chunks.length > 0 ? Buffer.concat(chunks) : undefined,
});
const text = await upstream.text();
if (remaining > 0) {
remaining--;
req.socket.destroy(); // el servidor ya hizo su trabajo; la respuesta se pierde
return;
}
res.writeHead(upstream.status, { 'content-type': 'application/json' });
res.end(text);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
close: () =>
new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => (error ? reject(error) : resolve()));
}),
};
}
export type Attempt =
| { kind: 'response'; status: number; body: unknown }
| { kind: 'connection-lost'; message: string };
// Lo que hace una app al reintentar: POST, y si se corta, otra vez lo mismo.
export async function postWithRetry(
url: string,
body: unknown,
headers: Record<string, string> = {},
maxAttempts = 2,
): Promise<Attempt[]> {
const attempts: Attempt[] = [];
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
});
attempts.push({ kind: 'response', status: response.status, body: await response.json() });
return attempts;
} catch (error) {
attempts.push({ kind: 'connection-lost', message: (error as Error).message });
}
}
return attempts;
}openapi: 3.1.0
info:
title: Kallpa API
version: 1.0.0
description: Reservas de canchas.
security: []
servers:
- url: http://localhost:3000
paths:
/api/courts:
get:
operationId: listCourts
summary: Lista las canchas
responses:
'200':
description: Las canchas
content:
application/json:
schema:
type: object
required: [courts]
properties:
courts:
type: array
items:
$ref: '#/components/schemas/Court'
components:
schemas:
Court:
type: object
required: [id, name, sport]
properties:
id: { type: integer }
name: { type: string }
sport: { type: string, enum: [fulbito, voley, basquet] }
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }#!/usr/bin/env bash
# Las dos versiones conviviendo, en la copia de Kallpa que deja make v2.
set -euo pipefail
export PORT="${PORT:-3101}"
cd build/v2
rm -f data/kallpa.db data/kallpa.db-*
make --no-print-directory seed > /dev/null
node src/main.ts > /tmp/kallpa-v2.log 2>&1 &
SERVER=$!
trap 'kill "$SERVER"' EXIT
sleep 1
for path in members/5/bookings v2/members/5/bookings; do
echo "\$ curl -s -i localhost:$PORT/api/$path"
curl -s -i "localhost:$PORT/api/$path" | tr -d '\r' \
| grep -v -i -E '^(date|connection|keep-alive|content-length|content-type):' \
| grep -v '^$' | cut -c1-90
echo
done
tail -2 /tmp/kallpa-v2.log#!/usr/bin/env bash
# Reserva, reintenta y cancela dos veces en una copia de Kallpa, como el paso 2
# del laboratorio. Las fechas dependen del día en que se corre.
set -euo pipefail
export PORT="${PORT:-3100}"
cd build/kallpa
rm -f data/kallpa.db data/kallpa.db-*
make --no-print-directory seed
node src/main.ts > /tmp/kallpa-recorrido.log 2>&1 &
SERVER=$!
trap 'kill "$SERVER"' EXIT
sleep 1
API="localhost:$PORT"
D=$(date -d '+8 days' +%F)
BODY="{\"courtId\":2,\"memberId\":2,\"date\":\"$D\",\"startHour\":10,\"teamName\":\"Los Halcones\"}"
JSON='content-type: application/json'
# Muestra la línea de estado, las cabeceras que importan aquí y el cuerpo.
show() {
echo "\$ curl $*"
curl -s -i "$@" | tr -d '\r' \
| grep -v -i -E '^(date|connection|keep-alive|content-length|content-type):' \
| grep -v '^$' | cut -c1-200
echo
}
echo "--- reservar y reintentar"
show -X POST "$API/api/bookings" -H "$JSON" -d "$BODY"
show -X POST "$API/api/bookings" -H "$JSON" -d "$BODY"
ID=$(curl -s "$API/api/members/2/bookings" | jq 'map(select(.teamName == "Los Halcones"))[0].id')
echo "--- cancelar dos veces"
show -X POST "$API/api/bookings/$ID/cancel"
show -X POST "$API/api/bookings/$ID/cancel"
echo "--- dos errores: la frase de estado la pone Node"
show -X POST "$API/api/bookings" -H "$JSON" -d "{\"courtId\":2,\"memberId\":2,\"date\":\"$D\",\"startHour\":10,\"hours\":4,\"teamName\":\"X\"}"
node -e 'console.log(JSON.stringify({ teamName: "x".repeat(17000) }))' > /tmp/kallpa-grande.json
show -X POST "$API/api/bookings" -H "$JSON" --data @/tmp/kallpa-grande.json// make retry: lo que ve quien llama cuando la primera respuesta se pierde.
import { startTestServer } from './build/kallpa/test/helpers.ts';
import { startChargesServer } from './charges-server.ts';
import { postWithRetry, startFlakyProxy, type Attempt } from './lost-response.ts';
function print(title: string, attempts: Attempt[]): void {
console.log(title);
attempts.forEach((attempt, i) => {
const seen =
attempt.kind === 'response'
? `${attempt.status} ${JSON.stringify(attempt.body)}`
: `se cortó la conexión (${attempt.message})`;
console.log(` intento ${i + 1}: ${seen}`);
});
}
const kallpa = await startTestServer();
const proxyK = await startFlakyProxy(kallpa.url);
const booking = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
print('Kallpa, POST /api/bookings', await postWithRetry(`${proxyK.url}/api/bookings`, booking));
const list = await (await fetch(`${kallpa.url}/api/members/2/bookings`)).json();
console.log(` reservas del vecino 2 en la base de datos: ${list.length}\n`);
await proxyK.close();
await kallpa.close();
const plain = await startChargesServer({ requireKey: false });
const proxyP = await startFlakyProxy(plain.url);
print('Pagos sin clave, POST /charges', await postWithRetry(`${proxyP.url}/charges`, { memberId: 2, cents: 8100 }));
console.log(` cobros en la base de datos: ${plain.charges.length}\n`);
await proxyP.close();
await plain.close();
const keyed = await startChargesServer({ requireKey: true });
const proxyI = await startFlakyProxy(keyed.url);
const key = { 'idempotency-key': '"8e03978e-40d5-43e8-bc93-6894a57f9324"' };
print('Pagos con clave, POST /charges', await postWithRetry(`${proxyI.url}/charges`, { memberId: 2, cents: 8100 }, key));
console.log(` cobros en la base de datos: ${keyed.charges.length}`);
await proxyI.close();
await keyed.close();diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..ec3043e 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -143,12 +143,30 @@ export function cancelBooking(deps: AppDependencies): Handler {
};
}
-// GET /api/members/:id/bookings
+// La v1 sigue respondiendo igual: solo se anuncia su fin (RFC 9745 y RFC 8594).
+export const MEMBER_BOOKINGS_V1_DEPRECATED_AT = 1798761600; // 1 de enero de 2027, 00:00 UTC
+export const MEMBER_BOOKINGS_V1_SUNSET = 'Sat, 01 Jan 2028 00:00:00 GMT';
+
+function bookingsOfMember(deps: AppDependencies, id: string): Booking[] {
+ const member = deps.service.findMember(Number(id));
+ if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${id}.`);
+ return deps.service.bookingsForMember(member.id).map(toBookingJson);
+}
+
+// GET /api/members/:id/bookings: arreglo suelto, deprecado.
export function memberBookings(deps: AppDependencies): Handler {
return ({ res, params }) => {
- const member = deps.service.findMember(Number(params.id));
- if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${params.id}.`);
- sendJson(res, 200, deps.service.bookingsForMember(member.id).map(toBookingJson));
+ res.setHeader('Deprecation', `@${MEMBER_BOOKINGS_V1_DEPRECATED_AT}`);
+ res.setHeader('Sunset', MEMBER_BOOKINGS_V1_SUNSET);
+ res.setHeader('Link', `</api/v2/members/${params.id}/bookings>; rel="successor-version"`);
+ sendJson(res, 200, bookingsOfMember(deps, params.id));
+ };
+}
+
+// GET /api/v2/members/:id/bookings: un objeto, que puede crecer.
+export function memberBookingsV2(deps: AppDependencies): Handler {
+ return ({ res, params }) => {
+ sendJson(res, 200, { bookings: bookingsOfMember(deps, params.id) });
};
}
diff --git a/src/http/server.ts b/src/http/server.ts
index 251064b..5820ff3 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -11,6 +11,7 @@ import {
listCourts,
listMembers,
memberBookings,
+ memberBookingsV2,
type AppDependencies,
} from './handlers.ts';
import { HttpError, sendError } from './json.ts';
@@ -30,6 +31,7 @@ export function buildRouter(deps: AppDependencies): Router {
router.get('/api/courts/:id/availability', courtAvailability(deps));
router.get('/api/members', listMembers(deps));
router.get('/api/members/:id/bookings', memberBookings(deps));
+ router.get('/api/v2/members/:id/bookings', memberBookingsV2(deps));
router.post('/api/bookings', createBooking(deps));
router.post('/api/bookings/:id/cancel', cancelBooking(deps));
router.get('/api/reports/daily', dailyReport(deps));
diff --git a/test/v2.test.ts b/test/v2.test.ts
new file mode 100644
index 0000000..e4f764a
--- /dev/null
+++ b/test/v2.test.ts
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, test } from 'node:test';
+import { postJson, startTestServer, type TestServer } from './helpers.ts';
+
+// El reloj de las pruebas marca el lunes 9 de marzo de 2026 a las 10:00.
+describe('las reservas de un vecino, en v1 y en v2', () => {
+ let server: TestServer;
+
+ before(async () => {
+ server = await startTestServer();
+ await postJson(`${server.url}/api/bookings`, { courtId: 1, memberId: 2, date: '2026-03-11', startHour: 10, teamName: 'Real Barranco' });
+ });
+
+ after(async () => {
+ await server.close();
+ });
+
+ test('v1 responde lo mismo que antes y anuncia su fin', async () => {
+ const response = await fetch(`${server.url}/api/members/2/bookings`);
+ const body = await response.json();
+ assert.equal(response.status, 200);
+ assert.ok(Array.isArray(body));
+ assert.equal(response.headers.get('deprecation'), '@1798761600');
+ assert.equal(response.headers.get('sunset'), 'Sat, 01 Jan 2028 00:00:00 GMT');
+ assert.equal(response.headers.get('link'), '</api/v2/members/2/bookings>; rel="successor-version"');
+ });
+
+ test('el fin no es anterior a la deprecación', async () => {
+ const response = await fetch(`${server.url}/api/members/2/bookings`);
+ const deprecation = Number(response.headers.get('deprecation')!.slice(1)) * 1000;
+ assert.ok(Date.parse(response.headers.get('sunset')!) >= deprecation);
+ });
+
+ test('v2 devuelve las mismas reservas dentro de un objeto y sin aviso', async () => {
+ const v1 = await (await fetch(`${server.url}/api/members/2/bookings`)).json();
+ const response = await fetch(`${server.url}/api/v2/members/2/bookings`);
+ assert.deepEqual(await response.json(), { bookings: v1 });
+ assert.equal(response.headers.get('deprecation'), null);
+ });
+
+ test('los errores no cambian entre versiones', async () => {
+ const response = await fetch(`${server.url}/api/v2/members/99/bookings`);
+ assert.equal(response.status, 404);
+ assert.equal(((await response.json()) as { error: { code: string } }).error.code, 'MEMBER_NOT_FOUND');
+ });
+});# Un error que Redocly sí encuentra: el $ref apunta a un esquema que no existe.
openapi: 3.1.0
info:
title: Kallpa API
version: 1.0.0
security: []
servers:
- url: http://localhost:3000
paths:
/api/courts:
get:
operationId: listCourts
summary: Lista las canchas
responses:
'200':
description: Las canchas
content:
application/json:
schema:
$ref: '#/components/schemas/Courts'
components:
schemas:
Court:
type: object
properties:
id: { type: integer }# Un error que Redocly no puede encontrar: el documento es válido, pero Kallpa
# responde 200 con { booking }, no 201 con { id }. Lint compara la especificación
# con las reglas de OpenAPI, no con el servidor.
openapi: 3.1.0
info:
title: Kallpa API
version: 1.0.0
security: []
servers:
- url: http://localhost:3000
paths:
/api/bookings:
post:
operationId: createBooking
summary: Reserva una cancha
responses:
'201':
description: La reserva creada
content:
application/json:
schema:
type: object
required: [id]
properties:
id: { type: integer }
'409':
description: La cancha está ocupada// Adapter: el proveedor tiene su interfaz y Kallpa espera otra. El proveedor de
// este archivo es inventado para la clase: la forma de sus mensajes no copia la
// API de ninguna empresa.
import type { BookingConfirmation, Notifier } from '../build/kallpa/src/notifications/notifier.ts';
export type TemplateMessage = {
to: string; // con código de país: +51987654321
template: string;
params: string[];
};
export type MessagingProvider = {
sendTemplate(message: TemplateMessage): void;
};
// Lo único que sabe de los dos lados: traduce una llamada en la otra.
export function messagingNotifier(provider: MessagingProvider): Notifier {
return {
bookingConfirmed(c: BookingConfirmation) {
provider.sendTemplate({
to: `+51${c.phone}`,
template: 'reserva_confirmada',
params: [c.memberName, c.courtName, c.date, `${c.startHour}:00`, String(c.hours), String(c.priceCents)],
});
},
};
}// Decorator: una función envuelve a otra de la misma forma y agrega algo antes
// o después, sin que la de adentro se entere. Es lo que hace decorator-server.diff
// con el try/catch/finally de src/http/server.ts.
import type { IncomingMessage, ServerResponse } from 'node:http';
import { HttpError, sendError } from '../build/kallpa/src/http/json.ts';
export type RawHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
export function withErrors(next: RawHandler): RawHandler {
return async (req, res) => {
try {
await next(req, res);
} catch (error) {
if (error instanceof HttpError) {
sendError(res, error);
} else {
sendError(res, new HttpError(500, 'INTERNAL_ERROR', 'Ocurrió un error inesperado.'));
}
}
};
}
export function withLog(log: (line: string) => void, next: RawHandler): RawHandler {
return async (req, res) => {
try {
await next(req, res);
} finally {
log(`${req.method} ${req.url} ${res.statusCode}`);
}
};
}// Observer con EventEmitter: la reserva avisa que se confirmó y no sabe quién
// escucha. Los eventos están tipados para que tsc revise el nombre y el dato.
import { EventEmitter } from 'node:events';
export type BookingConfirmedEvent = { bookingId: number; courtId: number; priceCents: number };
type BookingEvents = {
'booking-confirmed': [BookingConfirmedEvent];
};
export function createBookingEvents(): EventEmitter<BookingEvents> {
return new EventEmitter<BookingEvents>();
}
// Quien produce el evento: guarda y emite. No importa a nadie de los que reaccionan.
export function confirmBooking(
events: EventEmitter<BookingEvents>,
saved: BookingConfirmedEvent[],
booking: BookingConfirmedEvent,
): void {
saved.push(booking);
events.emit('booking-confirmed', booking);
}// Lo que un patrón no debería hacer: dos ejemplos escritos para discutir en una
// revisión de código.
import { consoleNotifier, type Notifier } from '../build/kallpa/src/notifications/notifier.ts';
import type { Clock } from '../build/kallpa/src/domain/clock.ts';
// Una fábrica con una sola implementación. Ilustración de la ceremonia que a
// veces proponen un agente o un programador con el libro recién leído.
export interface NotifierFactory {
create(): Notifier;
}
export class ConsoleNotifierFactory implements NotifierFactory {
create(): Notifier {
return consoleNotifier;
}
}
export class NotifierFactoryProvider {
getFactory(): NotifierFactory {
return new ConsoleNotifierFactory();
}
}
// Una factory function que sí hace algo: elige la implementación según un dato.
export function notifierFor(channel: 'console' | 'silent'): Notifier {
return channel === 'console' ? consoleNotifier : { bookingConfirmed() {} };
}
// Un Singleton: estado global con otro nombre. Quien lo cambia afecta a todos.
export class GlobalClock implements Clock {
static #instance: GlobalClock | undefined;
#fixed: Date | undefined;
static getInstance(): GlobalClock {
GlobalClock.#instance ??= new GlobalClock();
return GlobalClock.#instance;
}
fix(instant: string): void {
this.#fixed = new Date(instant);
}
now(): Date {
return this.#fixed ?? new Date();
}
}
// El código que depende del Singleton no lo dice en su firma.
export function isMorningWithSingleton(): boolean {
return GlobalClock.getInstance().now().getUTCHours() < 17; // 12:00 en Lima
}
// El mismo código con el reloj como parámetro.
export function isMorning(clock: Clock): boolean {
return clock.now().getUTCHours() < 17;
}// Strategy: el algoritmo que ordena las horas libres se cambia sin tocar a
// quien lo usa. Primero como lo escribiría el libro, con una interfaz y clases;
// después como función, que en TypeScript hace lo mismo.
export type Slot = { hour: number; available: boolean };
// Con interfaz y clases.
export interface HourRanker {
compare(a: number, b: number): number;
}
export class EarliestFirst implements HourRanker {
compare(a: number, b: number): number {
return a - b;
}
}
export class ClosestTo implements HourRanker {
#target: number;
constructor(target: number) {
this.#target = target;
}
compare(a: number, b: number): number {
return Math.abs(a - this.#target) - Math.abs(b - this.#target) || a - b;
}
}
export function suggestWithRanker(slots: Slot[], ranker: HourRanker): number[] {
return slots
.filter((slot) => slot.available)
.map((slot) => slot.hour)
.sort((a, b) => ranker.compare(a, b));
}
// Con una función: el tipo de la estrategia es el de un comparador.
export type Rank = (a: number, b: number) => number;
export const earliestFirst: Rank = (a, b) => a - b;
export const closestTo =
(target: number): Rank =>
(a, b) =>
Math.abs(a - target) - Math.abs(b - target) || a - b;
export function suggest(slots: Slot[], rank: Rank): number[] {
return slots
.filter((slot) => slot.available)
.map((slot) => slot.hour)
.sort(rank);
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// Lo que el apunte dice de la API publicada de Kallpa, comprobado contra un
// servidor de prueba: base de datos en memoria y reloj fijo (lunes 9 de marzo
// de 2026, 10:00 en Lima).
import assert from 'node:assert/strict';
import { after, before, describe, test } from 'node:test';
import { startTestServer, type TestServer } from './build/kallpa/test/helpers.ts';
const request = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
async function call(url: string, method = 'GET', body?: unknown) {
const response = await fetch(url, {
method,
headers: body === undefined ? {} : { 'content-type': 'application/json' },
body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
});
return { status: response.status, body: await response.json() };
}
describe('la API de Kallpa como es hoy', () => {
let server: TestServer;
before(async () => {
server = await startTestServer();
});
after(async () => {
await server.close();
});
test('la primera reserva se confirma', async () => {
const { status, body } = await call(`${server.url}/api/bookings`, 'POST', request);
assert.equal(status, 200);
assert.equal(body.booking.status, 'confirmed');
});
test('un reintento de la misma reserva responde 409 SLOT_TAKEN, sin decir de quién es', async () => {
const { status, body } = await call(`${server.url}/api/bookings`, 'POST', request);
assert.equal(status, 409);
assert.equal(body.error.code, 'SLOT_TAKEN');
assert.equal(JSON.stringify(body).includes('booking"'), false);
});
test('el reintento no crea una segunda reserva', async () => {
const { body } = await call(`${server.url}/api/members/2/bookings`);
assert.equal(body.length, 1);
assert.equal(server.sent.length, 1);
});
test('cancelar dos veces: el estado queda igual y la respuesta cambia', async () => {
const list = await call(`${server.url}/api/members/2/bookings`);
const id = list.body[0].id;
const first = await call(`${server.url}/api/bookings/${id}/cancel`, 'POST');
assert.equal(first.status, 200);
assert.equal(first.body.booking.status, 'cancelled');
const second = await call(`${server.url}/api/bookings/${id}/cancel`, 'POST');
assert.equal(second.status, 409);
assert.equal(second.body.error.code, 'ALREADY_CANCELLED');
const after = await call(`${server.url}/api/members/2/bookings`);
assert.deepEqual(after.body[0], first.body.booking);
});
test('reservar no es idempotente: si la reserva se canceló en el medio, el reintento crea otra', async () => {
const date = '2026-03-18';
const slot = { ...request, date };
const first = await call(`${server.url}/api/bookings`, 'POST', slot);
await call(`${server.url}/api/bookings/${first.body.booking.id}/cancel`, 'POST');
const retry = await call(`${server.url}/api/bookings`, 'POST', slot);
assert.equal(retry.status, 200);
assert.notEqual(retry.body.booking.id, first.body.booking.id);
const list = await call(`${server.url}/api/members/2/bookings`);
const sameSlot = list.body.filter((b: { date: string }) => b.date === date);
assert.equal(sameSlot.length, 2);
});
test('las listas tienen dos formas: objeto en canchas y miembros, arreglo suelto en las reservas de un vecino', async () => {
assert.ok(Array.isArray((await call(`${server.url}/api/courts`)).body.courts));
assert.ok(Array.isArray((await call(`${server.url}/api/members`)).body.members));
assert.ok(Array.isArray((await call(`${server.url}/api/members/2/bookings`)).body));
});
test('todos los errores tienen la misma forma: error.code y error.message', async () => {
const responses = [
await call(`${server.url}/api/getCourts`),
await call(`${server.url}/api/courts`, 'DELETE'),
await call(`${server.url}/api/bookings`, 'POST', '{mal'),
await call(`${server.url}/api/bookings/9999/cancel`, 'POST'),
await call(`${server.url}/api/members/99/bookings`),
await call(`${server.url}/api/reports/daily`),
];
for (const { body } of responses) {
assert.deepEqual(Object.keys(body), ['error']);
assert.deepEqual(Object.keys(body.error), ['code', 'message']);
}
assert.deepEqual(
responses.map((r) => r.status),
[404, 405, 400, 404, 404, 400],
);
});
test('un cuerpo de más de 16 KiB responde 413 BODY_TOO_LARGE', async () => {
const big = JSON.stringify({ teamName: 'x'.repeat(17 * 1024) });
const { status, body } = await call(`${server.url}/api/bookings`, 'POST', big);
assert.equal(status, 413);
assert.equal(body.error.code, 'BODY_TOO_LARGE');
});
});import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { after, before, describe, test } from 'node:test';
import { startTestServer, type TestServer } from './build/kallpa/test/helpers.ts';
import { courtLabels, FIELDS_USED } from './client-example.ts';
import { fieldExists, responseSchema, validate, type OpenApi } from './contract.ts';
const spec = JSON.parse(readFileSync('build/openapi.json', 'utf8')) as OpenApi;
const courts = responseSchema(spec, '/api/courts', 'get', '200');
describe('el esqueleto openapi.yaml contra Kallpa', () => {
let server: TestServer;
before(async () => {
server = await startTestServer();
});
after(async () => {
await server.close();
});
test('GET /api/courts responde lo que dice la especificación', async () => {
const body = await (await fetch(`${server.url}/api/courts`)).json();
assert.deepEqual(validate(spec, body, courts), []);
});
test('el validador encuentra un campo que falta y un valor fuera del enum', () => {
const broken = { courts: [{ id: 1, name: 'Fulbito 1', sport: 'tenis' }, { id: 2, sport: 'voley' }] };
assert.deepEqual(validate(spec, broken, courts), [
'$.courts[0].sport: "tenis" no está en el enum',
'$.courts[1].name: falta',
]);
});
test('el cliente de ejemplo corre sin errores y muestra basura', async () => {
const labels = await courtLabels(server.url);
assert.equal(labels.length, 4);
assert.ok(labels.every((label) => label === 'undefined (undefined)'));
});
test('la especificación detecta los campos inventados sin correr el cliente', () => {
assert.deepEqual(
FIELDS_USED.filter((field) => !fieldExists(spec, courts, field)),
['courts[].courtName', 'courts[].sportType'],
);
assert.equal(fieldExists(spec, courts, 'courts[].name'), true);
assert.equal(fieldExists(spec, courts, 'courts[].sport'), true);
});
test('el esqueleto declara security: [] y un esquema Error con code y message', () => {
const raw = spec as unknown as { security: unknown[]; components: { schemas: Record<string, unknown> } };
assert.deepEqual(raw.security, []);
assert.ok(raw.components.schemas.Error);
});
});import assert from 'node:assert/strict';
import { after, before, describe, test } from 'node:test';
import { startTestServer, type TestServer } from './build/kallpa/test/helpers.ts';
import {
addPriceField,
countConfirmed,
errorKind,
freeHoursStrict,
freeHoursTolerant,
renameHourField,
type Availability,
} from './evolution.ts';
describe('qué rompe cada cambio', () => {
let server: TestServer;
let body: Availability;
before(async () => {
server = await startTestServer();
body = (await (await fetch(`${server.url}/api/courts/1/availability?date=2026-03-17`)).json()) as Availability;
});
after(async () => {
await server.close();
});
test('agregar un campo en la respuesta: el cliente tolerante sigue igual', () => {
const changed = addPriceField(body) as Availability;
assert.deepEqual(freeHoursTolerant(changed), freeHoursTolerant(body));
});
test('agregar un campo en la respuesta: el cliente que exige campos exactos se cae', () => {
assert.throws(() => freeHoursStrict(addPriceField(body) as Availability), /campo desconocido: priceCents/);
});
test('renombrar hour a startHour: el cliente tolerante no se cae, pero devuelve basura', () => {
const hours = freeHoursTolerant(renameHourField(body) as Availability);
assert.equal(hours.length, 15);
assert.ok(hours.every((hour) => hour === undefined));
});
test('envolver el arreglo suelto en un objeto: el cliente se cae', () => {
const wrapped = { bookings: [{ status: 'confirmed' }] };
assert.equal(countConfirmed(wrapped.bookings), 1);
assert.throws(() => countConfirmed(wrapped as unknown as { status: string }[]), TypeError);
});
test('pasar un error de 400 a 422: el cliente que comparaba el código ya no lo reconoce', () => {
assert.equal(errorKind(400), 'revise-el-formulario');
assert.equal(errorKind(422), 'desconocido');
});
});import assert from 'node:assert/strict';
import { test } from 'node:test';
import { canonicalJson, fingerprint, withIdempotency, type IdempotencyEntry } from './idempotency.ts';
type Charge = { memberId: number; cents: number };
const charge: Charge = { memberId: 2, cents: 8100 };
const TTL = 24 * 60 * 60 * 1000;
function setup() {
const charges: Charge[] = [];
let now = 0;
const store = new Map<string, IdempotencyEntry>();
const operation = async (request: Charge) => {
charges.push(request);
return { status: 201, body: { id: charges.length, ...request } };
};
const send = withIdempotency(store, operation, { now: () => now, ttlMs: TTL });
return { charges, store, send, advance: (ms: number) => (now += ms) };
}
test('sin clave no se ejecuta nada: 400', async () => {
const { charges, send } = setup();
const response = await send(undefined, charge);
assert.equal(response.status, 400);
assert.equal(charges.length, 0);
});
test('un reintento con la misma clave y el mismo cuerpo devuelve la misma respuesta sin repetir el efecto', async () => {
const { charges, send } = setup();
const first = await send('k1', charge);
const retry = await send('k1', charge);
assert.equal(charges.length, 1);
assert.equal(first.replayed, false);
assert.equal(retry.replayed, true);
assert.equal(retry.status, first.status);
assert.deepEqual(retry.body, first.body);
});
test('el orden de los campos no cambia la huella', async () => {
assert.equal(fingerprint({ a: 1, b: { c: 2, d: 3 } }), fingerprint({ b: { d: 3, c: 2 }, a: 1 }));
assert.equal(canonicalJson({ b: [1, { z: 1, y: 2 }], a: null }), '{"a":null,"b":[1,{"y":2,"z":1}]}');
const { charges, send } = setup();
await send('k1', { memberId: 2, cents: 8100 });
const retry = await send('k1', { cents: 8100, memberId: 2 });
assert.equal(retry.replayed, true);
assert.equal(charges.length, 1);
});
test('la misma clave con otro cuerpo: 422 y sin efecto', async () => {
const { charges, send } = setup();
await send('k1', charge);
const other = await send('k1', { ...charge, cents: 9000 });
assert.equal(other.status, 422);
assert.equal(charges.length, 1);
});
test('un reintento mientras la primera todavía corre: 409', async () => {
const store = new Map<string, IdempotencyEntry>();
let finish: (value: { status: number; body: unknown }) => void = () => {};
const slow = () => new Promise<{ status: number; body: unknown }>((resolve) => (finish = resolve));
const send = withIdempotency(store, slow, { now: () => 0, ttlMs: TTL });
const first = send('k1', charge);
const concurrent = await send('k1', charge);
assert.equal(concurrent.status, 409);
finish({ status: 201, body: { id: 1 } });
assert.equal((await first).status, 201);
assert.equal((await send('k1', charge)).replayed, true);
});
test('cuando vence la clave, el mismo pedido vuelve a ejecutarse', async () => {
const { charges, send, advance } = setup();
await send('k1', charge);
advance(TTL - 1);
assert.equal((await send('k1', charge)).replayed, true);
advance(1);
assert.equal((await send('k1', charge)).replayed, false);
assert.equal(charges.length, 2);
});
test('las respuestas de error de la operación también se guardan y se repiten', async () => {
const store = new Map<string, IdempotencyEntry>();
let calls = 0;
const rejected = async () => {
calls++;
return { status: 409, body: { error: { code: 'SLOT_TAKEN' } } };
};
const send = withIdempotency(store, rejected, { now: () => 0, ttlMs: TTL });
await send('k1', charge);
const retry = await send('k1', charge);
assert.equal(calls, 1);
assert.equal(retry.status, 409);
assert.equal(retry.replayed, true);
});
test('el límite de este diseño: si el proceso falla después del efecto, el reintento lo repite', async () => {
const store = new Map<string, IdempotencyEntry>();
const charges: Charge[] = [];
let fail = true;
const operation = async (request: Charge) => {
charges.push(request); // el efecto ya ocurrió...
if (fail) throw new Error('se cayó antes de guardar la respuesta'); // ...y la respuesta se pierde
return { status: 201, body: { id: charges.length } };
};
const send = withIdempotency(store, operation, { now: () => 0, ttlMs: TTL });
await assert.rejects(send('k1', charge));
fail = false;
await send('k1', charge);
assert.equal(charges.length, 2); // por eso la clave y el efecto van en la misma transacción
});// Lo que redocly lint revisa y lo que no.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
function lint(file: string) {
const result = spawnSync('redocly', ['lint', file, '--format=summary'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
return { status: result.status, output: `${result.stdout}${result.stderr}` };
}
test('el esqueleto no tiene errores: cuatro advertencias', () => {
const { status, output } = lint('openapi.yaml');
assert.equal(status, 0);
assert.match(output, /You have 4 warnings/);
});
test('un $ref a un esquema que no existe es un error', () => {
const { status, output } = lint('lint-examples/broken-ref.yaml');
assert.equal(status, 1);
assert.match(output, /no-unresolved-refs/);
});
test('una especificación que no describe a Kallpa puede pasar lint sin errores', () => {
const { status } = lint('lint-examples/wrong-but-valid.yaml');
assert.equal(status, 0);
});import assert from 'node:assert/strict';
import { test } from 'node:test';
import { startTestServer } from './build/kallpa/test/helpers.ts';
import { startChargesServer } from './charges-server.ts';
import { postWithRetry, startFlakyProxy } from './lost-response.ts';
test('Kallpa: se pierde la respuesta, la reserva quedó hecha y el reintento dice 409', async () => {
const kallpa = await startTestServer();
const proxy = await startFlakyProxy(kallpa.url);
const booking = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
const attempts = await postWithRetry(`${proxy.url}/api/bookings`, booking);
assert.equal(attempts[0].kind, 'connection-lost');
assert.ok(attempts[1].kind === 'response');
assert.equal(attempts[1].status, 409);
// La reserva existe y es de quien reintentó, aunque lo último que vio fue un error.
const list = await (await fetch(`${kallpa.url}/api/members/2/bookings`)).json();
assert.equal(list.length, 1);
await proxy.close();
await kallpa.close();
});
test('un POST sin regla que lo frene: el reintento cobra dos veces', async () => {
const charges = await startChargesServer({ requireKey: false });
const proxy = await startFlakyProxy(charges.url);
const attempts = await postWithRetry(`${proxy.url}/charges`, { memberId: 2, cents: 8100 });
assert.equal(attempts[0].kind, 'connection-lost');
assert.ok(attempts[1].kind === 'response');
assert.equal(attempts[1].status, 201);
assert.equal(charges.charges.length, 2);
await proxy.close();
await charges.close();
});
test('con clave de idempotencia: un solo cobro y el reintento recibe la respuesta original', async () => {
const charges = await startChargesServer({ requireKey: true });
const proxy = await startFlakyProxy(charges.url);
const attempts = await postWithRetry(
`${proxy.url}/charges`,
{ memberId: 2, cents: 8100 },
{ 'idempotency-key': '"8e03978e-40d5-43e8-bc93-6894a57f9324"' },
);
assert.equal(attempts[0].kind, 'connection-lost');
assert.ok(attempts[1].kind === 'response');
assert.equal(attempts[1].status, 201);
assert.deepEqual(attempts[1].body, { charge: { id: 1, memberId: 2, cents: 8100 } });
assert.equal(charges.charges.length, 1);
await proxy.close();
await charges.close();
});// Los patrones del capítulo, cada uno con lo que promete y lo que cuesta.
import assert from 'node:assert/strict';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { test } from 'node:test';
import { fixedClock, systemClock } from './build/kallpa/src/domain/clock.ts';
import { HttpError, sendJson } from './build/kallpa/src/http/json.ts';
import { createServer } from './build/kallpa/src/http/server.ts';
import { consoleNotifier, type BookingConfirmation } from './build/kallpa/src/notifications/notifier.ts';
import { CourtRepository } from './build/kallpa/src/store/court-repository.ts';
import { openDatabase } from './build/kallpa/src/store/database.ts';
import { seedCatalog } from './build/kallpa/src/store/seed.ts';
import { messagingNotifier, type TemplateMessage } from './patterns/adapter.ts';
import { withErrors, withLog, type RawHandler } from './patterns/decorator.ts';
import { confirmBooking, createBookingEvents, type BookingConfirmedEvent } from './patterns/observer.ts';
import {
GlobalClock,
isMorning,
isMorningWithSingleton,
notifierFor,
NotifierFactoryProvider,
} from './patterns/overkill.ts';
import { closestTo, ClosestTo, earliestFirst, EarliestFirst, suggest, suggestWithRanker } from './patterns/strategy.ts';
const slots = [8, 10, 15, 19, 20].map((hour) => ({ hour, available: true }));
async function serve(handler: RawHandler, path = '/x') {
const server = http.createServer(handler);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}${path}`);
server.closeAllConnections();
server.close();
return response;
}
const failing: RawHandler = async () => {
throw new HttpError(404, 'NOT_FOUND', 'No existe.');
};
test('strategy: la clase y la función ordenan igual, y quien las usa no cambia', () => {
assert.deepEqual(suggest(slots, earliestFirst), suggestWithRanker(slots, new EarliestFirst()));
assert.deepEqual(suggest(slots, closestTo(18)), suggestWithRanker(slots, new ClosestTo(18)));
assert.deepEqual(suggest(slots, closestTo(18)), [19, 20, 15, 10, 8]);
assert.deepEqual(suggest(slots, earliestFirst), [8, 10, 15, 19, 20]);
});
test('strategy: el reloj de Kallpa es una estrategia; fijo en las pruebas, del sistema en producción', () => {
const clock = fixedClock('2026-03-09T15:00:00Z');
assert.equal(clock.now().toISOString(), '2026-03-09T15:00:00.000Z');
assert.equal(clock.now().toISOString(), '2026-03-09T15:00:00.000Z');
assert.ok(systemClock.now() instanceof Date);
});
test('dependency injection: createServer usa el reloj, la base de datos y el notificador que recibe', async () => {
const db = openDatabase(':memory:');
seedCatalog(db);
const sent: BookingConfirmation[] = [];
const server = createServer({
db,
clock: fixedClock('2026-03-09T15:00:00Z'),
notifier: { bookingConfirmed: (c) => sent.push(c) },
log: () => {},
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const body = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
const response = await fetch(`http://127.0.0.1:${port}/api/bookings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const { booking } = (await response.json()) as { booking: { createdAt: string } };
assert.equal(booking.createdAt, '2026-03-09T15:00:00.000Z'); // el reloj que recibió
assert.equal(sent.length, 1); // el notificador que recibió
server.closeAllConnections();
server.close();
db.close();
});
test('adapter: una reserva real llega al proveedor con su formato, y las reservas no cambiaron', async () => {
const db = openDatabase(':memory:');
seedCatalog(db);
const messages: TemplateMessage[] = [];
const server = createServer({
db,
clock: fixedClock('2026-03-09T15:00:00Z'),
notifier: messagingNotifier({ sendTemplate: (m) => messages.push(m) }),
log: () => {},
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const body = { courtId: 2, memberId: 1, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
await fetch(`http://127.0.0.1:${port}/api/bookings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
assert.equal(messages.length, 1);
assert.equal(messages[0].to, '+51987654321');
assert.equal(messages[0].template, 'reserva_confirmada');
server.closeAllConnections();
server.close();
db.close();
});
test('repository: CourtRepository devuelve canchas; quien lo usa no ve SQL', () => {
const db = openDatabase(':memory:');
seedCatalog(db);
const courts = new CourtRepository(db);
assert.deepEqual({ ...courts.findById(3) }, { id: 3, name: 'Vóley', sport: 'voley' });
db.close();
});
test('decorator: withErrors convierte la excepción en una respuesta JSON', async () => {
const response = await serve(withErrors(failing));
assert.equal(response.status, 404);
assert.deepEqual(await response.json(), { error: { code: 'NOT_FOUND', message: 'No existe.' } });
});
test('decorator: withLog(withErrors(h)) registra el estado que recibió el cliente HTTP', async () => {
const lines: string[] = [];
const response = await serve(withLog((line) => lines.push(line), withErrors(failing)));
assert.equal(response.status, 404);
assert.deepEqual(lines, ['GET /x 404']);
});
test('decorator: al revés, withErrors(withLog(h)) registra un 200 que nunca existió', async () => {
const lines: string[] = [];
const response = await serve(withErrors(withLog((line) => lines.push(line), failing)));
assert.equal(response.status, 404);
assert.deepEqual(lines, ['GET /x 200']);
});
test('decorator: la función de adentro no cambia', async () => {
const ok: RawHandler = async (_req, res) => sendJson(res, 200, { ok: true });
const lines: string[] = [];
const response = await serve(withLog((line) => lines.push(line), withErrors(ok)));
assert.deepEqual(await response.json(), { ok: true });
assert.deepEqual(lines, ['GET /x 200']);
});
const event: BookingConfirmedEvent = { bookingId: 1, courtId: 2, priceCents: 8100 };
test('observer: quien emite no conoce a quienes reaccionan, y se agregan sin tocarlo', () => {
const events = createBookingEvents();
const saved: BookingConfirmedEvent[] = [];
const log: string[] = [];
events.on('booking-confirmed', (e) => log.push(`whatsapp ${e.bookingId}`));
events.on('booking-confirmed', (e) => log.push(`caja ${e.priceCents}`));
confirmBooking(events, saved, event);
assert.deepEqual(log, ['whatsapp 1', 'caja 8100']);
});
test('observer: emit es síncrono; los listeners corren antes de que confirmBooking vuelva', () => {
const events = createBookingEvents();
const order: string[] = [];
events.on('booking-confirmed', () => order.push('listener'));
confirmBooking(events, [], event);
order.push('después de emitir');
assert.deepEqual(order, ['listener', 'después de emitir']);
});
test('observer: un listener que lanza corta a los siguientes y le llega a quien emitió', () => {
const events = createBookingEvents();
const saved: BookingConfirmedEvent[] = [];
const log: string[] = [];
events.on('booking-confirmed', () => {
throw new Error('WhatsApp no responde');
});
events.on('booking-confirmed', () => log.push('caja'));
assert.throws(() => confirmBooking(events, saved, event), /WhatsApp no responde/);
assert.equal(saved.length, 1); // la reserva ya está guardada...
assert.deepEqual(log, []); // ...y la caja nunca se enteró
});
test('sin patrón: la fábrica con una sola implementación devuelve lo mismo que un nombre', () => {
assert.equal(new NotifierFactoryProvider().getFactory().create(), consoleNotifier);
assert.equal(notifierFor('console'), consoleNotifier);
assert.notEqual(notifierFor('silent'), consoleNotifier);
});
test('singleton: lo que una prueba fija en el reloj global le llega a la siguiente', () => {
GlobalClock.getInstance().fix('2026-03-09T15:00:00Z'); // 10:00 en Lima
assert.equal(isMorningWithSingleton(), true);
GlobalClock.getInstance().fix('2026-03-09T20:00:00Z'); // 15:00 en Lima
// Otra prueba, en otro archivo, leería este valor sin saberlo.
assert.equal(isMorningWithSingleton(), false);
});
test('con el reloj como parámetro, cada llamada dice qué hora usa y nada se comparte', () => {
assert.equal(isMorning(fixedClock('2026-03-09T15:00:00Z')), true);
assert.equal(isMorning(fixedClock('2026-03-09T20:00:00Z')), false);
assert.equal(isMorning(fixedClock('2026-03-09T15:00:00Z')), true);
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 6: APIs y patrones de diseño. Se usa dentro del contenedor del curso.
#
# make copia Kallpa en build/kallpa y empaqueta openapi.yaml como
# JSON en build/openapi.json
# make test revisa los tipos (tsc), corre ESLint, redocly lint sobre
# el esqueleto, las pruebas (*.test.ts), que comprueban lo
# que dice el apunte, y los dos cambios de ejemplo sobre
# copias de Kallpa (make decorator y make v2)
# make recorrido levanta una copia de Kallpa, reserva, reintenta y cancela
# dos veces con curl (el paso 2 del laboratorio)
# make retry la respuesta perdida, con reintento: en Kallpa y en un
# servidor de pagos con y sin clave de idempotencia
# make decorator aplica decorator-server.diff a una copia de Kallpa y corre
# su make check
# make v2 aplica v2-bookings.diff a una copia de Kallpa y corre su
# make check
# make recorrido-v2 las dos versiones de make v2, con curl
# make docs genera build/api.html con redocly build-docs
# make clean borra build/
#
# El esqueleto del laboratorio es openapi.yaml. Los ejemplos leen la Kallpa del
# curso (../../kallpa) sin modificarla.
KALLPA ?= ../../kallpa
GIT = git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main
.PHONY: all test recorrido recorrido-v2 retry decorator v2 docs clean
all: build/kallpa/package.json build/openapi.json
build/kallpa/package.json: $(wildcard $(KALLPA)/src/*.ts $(KALLPA)/src/*/*.ts $(KALLPA)/test/*.ts $(KALLPA)/*.json $(KALLPA)/*.js $(KALLPA)/Makefile)
rm -rf build/kallpa
mkdir -p build
cp -r $(KALLPA) build/kallpa
rm -rf build/kallpa/data build/kallpa/node_modules
mkdir -p build/kallpa/data
build/openapi.json: openapi.yaml
mkdir -p build
redocly bundle openapi.yaml --ext json -o build/openapi.json
test: all
tsc -p .
eslint .
redocly lint openapi.yaml
node --test "*.test.ts"
$(MAKE) --no-print-directory decorator
$(MAKE) --no-print-directory v2
@if [ -f solution/openapi.yaml ]; then \
redocly lint solution/openapi.yaml --format=summary; \
redocly bundle solution/openapi.yaml --ext json -o build/solution-openapi.json; \
node --test "solution/*.test.ts"; \
else \
echo "(sin solution/: la especificación completa se publica al final del laboratorio)"; \
fi
recorrido: all
bash recorrido.sh
retry: all
node retry-demo.ts
# Un cambio de estructura: el try/catch/finally de server.ts pasa a dos
# decorators. Las pruebas de Kallpa no cambian y tienen que seguir pasando.
decorator: all
rm -rf build/decorator
cp -r build/kallpa build/decorator
cd build/decorator && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
cd build/decorator && $(GIT) apply ../../decorator-server.diff
cd build/decorator && $(GIT) diff --stat
$(MAKE) --no-print-directory -C build/decorator check
cd build/decorator && test -z "$$($(GIT) status --porcelain -- test)" && echo "test/ sin cambios"
# Un cambio compatible: la ruta nueva convive con la vieja, que se anuncia con
# Deprecation y Sunset. Las pruebas viejas siguen igual; se agrega test/v2.test.ts.
v2: all
rm -rf build/v2
cp -r build/kallpa build/v2
cd build/v2 && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
cd build/v2 && $(GIT) apply ../../v2-bookings.diff
cd build/v2 && $(GIT) add -A && $(GIT) diff --cached --stat
$(MAKE) --no-print-directory -C build/v2 check
recorrido-v2: v2
bash recorrido-v2.sh
docs: all
redocly build-docs openapi.yaml -o build/api.html
clean:
rm -rf build{
"name": "week-06",
"private": true,
"type": "module"
}{
"compilerOptions": {
"target": "es2024",
"lib": [
"es2024",
"dom"
],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": [
"node"
],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"*.ts",
"patterns"
],
"exclude": [
"build",
"solution"
]
}