Dos apps, una misma forma de los datos
La API es dueña de los datos: leads, propuestas, productos, artículos. La web los lee y los escribe desde el servidor, y solo desde el servidor; el navegador nunca llama a la API. Cada petición del sitio tiene una forma en la que los dos lados deben estar de acuerdo: la ruta, los parámetros, el cuerpo y la respuesta.
Sin un contrato, ese acuerdo vive en dos lugares escritos a mano. Un modelo de Pydantic en Python dice que un campo se llama min_minor; un tipo de TypeScript en algún rincón de la web dice lo mismo, o lo decía. Nada los une. Las pruebas de cada app pasan, y la diferencia aparece como un formulario roto o una página vacía.
La regla
Un cambio en las rutas o los modelos de la API regenera el archivo OpenAPI de la API y los tipos de la web en el mismo commit. Los dos workflows de CI fallan si se desfasan.
De un modelo en Python a un tipo en TypeScript
FastAPI ya describe cada ruta que sirve en OpenAPI, con los modelos como JSON Schema (página de funcionalidades de FastAPI, en inglés). No escribimos el contrato: lo imprimimos. Un módulo pequeño arma la app con la configuración local y escribe su especificación en apps/api/openapi.json, que va en el repo:
def render() -> str:
spec = create_app(Settings(environment="local")).openapi()
return json.dumps(spec, indent=2, ensure_ascii=False) + "\n"Del lado de la web, npm run api:types corre openapi-typescript (en inglés) sobre ese archivo y escribe lib/api/schema.d.ts. El resultado son solo tipos: nada de eso llega al navegador ni corre en el servidor.
Qué atrapa el cliente tipado
Los tipos generados alimentan un solo cliente, hecho con openapi-fetch (en inglés). Recibe las rutas como parámetro de tipo, así que cada llamada nombra una ruta del contrato y recibe el tipo de la respuesta:
export const api = createClient<paths>({
baseUrl: process.env.CORE_API_URL ?? "http://localhost:8000",
});
const { data, response } = await api.GET("/v1/public/posts/{slug}", {
params: { path: { slug }, query: { locale } },
signal: AbortSignal.timeout(TIMEOUT_MS),
});TypeScript revisa entonces lo que suele fallar en silencio:
- una ruta que no existe, o un error de tipeo en ella;
- un parámetro de ruta que falta, como el
slugdel artículo; - un campo del cuerpo que la API no acepta, o uno obligatorio que se quedó afuera;
- leer de la respuesta un campo que la API no envía.
Cada uno hace fallar npm run typecheck, en la máquina de quien programa y en CI, antes de que se construya una página.
El admin usa el mismo cliente. Leads, propuestas, productos y artículos se leen y se escriben con rutas del contrato, y el token de staff que lleva cada llamada del admin es un header que el contrato declara, así que TypeScript también sabe qué llamadas lo necesitan.
Dos chequeos que fallan si hay desfase
Un archivo generado solo sirve si está al día. Por eso cada workflow regenera el suyo desde cero y le pregunta a git si algo cambió:
# api.yml
- name: openapi.json matches the code
run: |
uv run python -m app.openapi > openapi.json
git diff --exit-code -- openapi.json
# web.yml
- name: API types match apps/api/openapi.json
run: |
npm run api:types
git diff --exit-code -- lib/api/schema.d.ts
Cuando un chequeo falla, su error dice cómo arreglarlo: el comando que hay que correr y el archivo que reescribe. Quien programa lo corre, hace commit del resultado y vuelve a subir. Nadie edita a mano un archivo generado, y nadie tiene que recordar qué lado cambió primero.
El primer chequeo falla cuando alguien cambia una ruta o un modelo y olvida imprimir la especificación. El segundo falla cuando la especificación cambió y los tipos no. El workflow de la web también corre cuando cambia apps/api/openapi.json, no solo cuando cambian archivos de apps/web, así que un pull request que solo toca la API igual tiene que traer los tipos de la web.
Como los dos archivos están en el repo, el contrato aparece en la revisión de código. Un campo que cambia son unas pocas líneas de diff en openapi.json que quien revisa puede leer sin abrir el Python.
Un cambio real: el presupuesto como rango
El 7 de octubre cambiamos cómo se guarda el presupuesto de un brief. Era una opción fija de una lista; pasó a ser un rango en centavos, abierto en cualquiera de sus extremos, con una moneda. Del lado de la API es un modelo de Pydantic:
class BudgetRange(BaseModel):
"""What the visitor expects to spend, in the currency's minor unit (cents). Either end may be open."""
model_config = ConfigDict(extra="forbid")
min_minor: int | None = Field(default=None, ge=0, le=CEILING)
max_minor: int | None = Field(default=None, gt=0, le=CEILING)
currency: Literal["usd"] = "usd"Y esto es lo que recibió la web, sin que nadie lo escribiera:
BudgetRange: {
/** Min Minor */
min_minor?: number | null;
/** Max Minor */
max_minor?: number | null;
/**
* Currency
* @default usd
* @constant
*/
currency: "usd";
};El commit tocó 21 archivos entre la API, el admin y el sitio. La parte del contrato fueron 65 líneas cambiadas en openapi.json y 27 en schema.d.ts, ambas regeneradas. Con los tipos nuevos, la llamada del formulario de contacto a POST /v1/public/leads no podía compilar hasta enviar la forma nueva: un cambio de 4 líneas en lib/lead-submit.ts. La página del lead en el admin también leía el campo viejo, y cambió en el mismo commit para mostrar el rango.
- operaciones en 61 rutas del openapi.json de hoy
- 82
- esquemas que la web recibe como tipos de TypeScript
- 157
- commits que cambiaron el contrato desde el 26 de septiembre
- 29
Lo que el contrato no hace
No escribimos el contrato: lo imprimimos.
- Revisa tipos al compilar, no datos al ejecutar. La API sigue validando cada petición con sus modelos de Pydantic; la web confía en que las respuestas de la API coinciden con el archivo.
- Mantiene coherente el repo. Si una API más vieja corriera detrás de una web más nueva, TypeScript no podría saberlo.
- No vuelve seguro un cambio para los datos viejos. Cuando cambió el presupuesto, la API siguió leyendo los briefs guardados con la opción anterior como el mismo rango; el contrato solo describía la forma nueva.
Dentro de esos límites elimina toda una clase de errores por el costo de dos pasos de CI y un comando que recordar. Cuando una plataforma tiene backend y frontend en lenguajes distintos, es lo primero que armamos. Si estás planeando una, mira cómo construimos plataformas web o ve qué te sirve.


