Back to sh0
sh0

OpenAPI como fuente unica de verdad: documentacion, herramientas MCP y playground

Como usamos utoipa para auto-generar una especificacion OpenAPI 3.1 a partir de anotaciones de manejadores Rust, y luego usamos esa especificacion para generar documentacion de API, un playground interactivo y definiciones de herramientas MCP.

Thales & Claude | March 30, 2026 11 min sh0
EN/ FR/ ES
openapiutoiparustdocumentationapimcpdeveloper-experience

Teniamos 182 endpoints de API. Tambien teniamos un archivo TypeScript mantenido manualmente llamado api-endpoints.ts que describia esos endpoints para la pagina de documentacion de API del panel. Contenia mas de 180 entradas, cada una con un metodo, ruta, descripcion, parametros y respuestas de ejemplo. Y estaba equivocado. No dramaticamente equivocado -- la mayoria de las entradas eran aproximadamente correctas -- sino el tipo de error que se acumula silenciosamente: un parametro renombrado en el backend pero no en la documentacion, un nuevo endpoint anadido al router pero nunca a la documentacion, un campo de respuesta que cambio de tipo de string a number hace tres sesiones.

Mantener la documentacion de API separada de la implementacion de la API es un juego perdido. Puedes ser disciplinado al respecto por un tiempo, pero al ritmo al que estabamos lanzando -- 105 sesiones en 14 dias -- "un tiempo" significa unas cuatro horas antes de que algo se desincronice.

Necesitabamos una fuente unica de verdad. Un lugar donde la definicion del endpoint, los tipos de parametros, los esquemas de respuesta y la documentacion vivieran juntos. Y necesitabamos que esa verdad fluyera automaticamente hacia tres salidas: documentacion de referencia de API, un playground interactivo y definiciones de herramientas MCP para nuestro asistente de IA.

utoipa: OpenAPI a partir de anotaciones Rust

El ecosistema Rust tiene un ganador claro para la generacion de OpenAPI: utoipa. Genera una especificacion OpenAPI 3.1 a partir de macros derive y anotaciones de atributos sobre tus funciones manejadoras y tipos existentes. Sin archivos de esquema separados, sin paso de generacion de codigo, sin YAML que mantener. La especificacion se deriva del codigo.

Anadimos utoipa 5 a sh0-api y comenzamos a anotar. El trabajo se dividio en dos categorias: tipos de datos y manejadores.

69 DTOs con ToSchema

Cada tipo de solicitud y respuesta necesitaba #[derive(utoipa::ToSchema)]:

rust#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AppResponse {
    pub id: String,
    pub name: String,
    pub project_id: Option<String>,
    pub status: String,
    pub stack: Option<String>,
    pub branch: Option<String>,
    pub port: Option<i64>,
    pub replicas: i64,
    pub node_id: Option<String>,
    #[schema(format = "date-time")]
    pub created_at: String,
    #[schema(format = "date-time")]
    pub updated_at: String,
}

Anotamos 69 DTOs en types.rs mas aproximadamente 30 structs de solicitud inline dispersos en archivos de manejadores -- el CreateAlertRequest en alerts.rs, el InviteUserRequest en team.rs, el DeployComposeRequest en compose.rs. Cada uno de estos era un struct pequeno definido junto al manejador que lo consumia, y cada uno necesitaba el derive ToSchema.

Para parametros de paginacion y filtros de consulta, usamos IntoParams:

rust#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct PaginationParams {
    /// Page number (1-indexed)
    #[param(minimum = 1, default = 1)]
    pub page: Option<i64>,
    /// Items per page (max 200)
    #[param(minimum = 1, maximum = 200, default = 20)]
    pub per_page: Option<i64>,
}

El derive IntoParams genera entradas de parametros individuales en la especificacion OpenAPI, asi que el playground sabe renderizarlos como campos de formulario separados en lugar de un unico cuerpo JSON.

182 manejadores con #[utoipa::path]

Cada funcion manejadora recibio un bloque de anotacion describiendo sus metadatos OpenAPI:

rust#[utoipa::path(
    get,
    path = "/api/v1/apps",
    tag = "Apps",
    params(PaginationParams),
    security(("bearer" = [])),
    responses(
        (status = 200, description = "List of applications", body = PaginatedResponse<AppResponse>),
        (status = 401, description = "Unauthorized", body = ErrorResponse),
    )
)]
pub async fn list_apps(
    State(state): State<AppState>,
    auth: AuthUser,
    Query(params): Query<PaginationParams>,
) -> Result<Json<PaginatedResponse<AppResponse>>> {
    // ...
}

Anotamos 182 funciones manejadoras en 36 archivos de manejadores. Cada endpoint recibio una etiqueta (para agrupacion), un requisito de seguridad (Bearer JWT), esquemas de respuesta para el caso exitoso y esquemas de respuesta de error para 400, 401, 403, 404 y 429 segun correspondiera. En dos pasadas de auditoria, anadimos anotaciones de respuesta de error a los 182 endpoints -- primero los 56 manejadores mas criticos, luego los 126 restantes.

El struct ApiDoc

Todo esto se une en un unico struct registrado en router.rs:

rust#[derive(utoipa::OpenApi)]
#[openapi(
    info(
        title = "sh0 API",
        version = "1.0.0",
        description = "sh0 self-hosted PaaS API"
    ),
    tags(
        (name = "Apps", description = "Application management"),
        (name = "Deployments", description = "Deploy and rollback"),
        (name = "Domains", description = "Domain and SSL management"),
        // ... 34 tags total
    ),
    modifiers(&SecurityAddon),
    components(schemas(
        AppResponse, DeploymentResponse, DomainResponse,
        // ... 67 component schemas
    ))
)]
struct ApiDoc;

Una unica ruta GET /api/v1/openapi.json sirve la especificacion auto-generada. Sin autenticacion requerida -- la especificacion es documentacion publica, y hacerla disponible sin autenticacion significa que herramientas externas pueden consumirla directamente.

El componente compartido ApiExplorer

Generar una especificacion JSON es solo la mitad de la historia. Alguien tiene que renderizarla. Construimos un componente Svelte 5 llamado ApiExplorer que consume una especificacion OpenAPI 3.1 y renderiza dos cosas: una referencia de documentacion y un playground interactivo.

El parser OpenAPI

El parser (openapi-parser.ts) maneja el trabajo poco glamuroso de convertir una especificacion OpenAPI cruda en algo que un componente de interfaz puede renderizar:

  • Resolucion de $ref con un limite de profundidad de recursion de 20 (aumentado desde 10 despues de que algunos de nuestros esquemas profundamente anidados causaran truncamiento)
  • Agrupacion por etiquetas -- endpoints organizados por su etiqueta OpenAPI, con conteos de endpoints por etiqueta
  • Generacion de ejemplos -- cuerpos de solicitud sinteticos basados en tipos y restricciones de esquema
  • Constructor de cURL -- genera comandos cURL listos para copiar y pegar con escape de shell adecuado para cuerpos JSON que contienen comillas simples

Dos modos, un componente

El componente ApiExplorer acepta una prop mode -- ya sea "docs" o "playground":

Modo documentacion (usado en el sitio web sh0.dev en /api) renderiza una referencia de API de solo lectura. Barra lateral izquierda con navegacion por etiquetas y conteos de endpoints. Insignias coloreadas por metodo: GET en esmeralda, POST en azul, PATCH en violeta, PUT en ambar, DELETE en rojo. Detalles expandibles de endpoints con parametros, esquemas de cuerpo de solicitud, codigos de estado de respuesta y ejemplos cURL.

Modo playground (usado en el panel en /api-docs) anade interactividad sobre la documentacion. Un selector de metodo con autocompletado de rutas. Un editor de cuerpo JSON. Inyeccion automatica de autenticacion (token Bearer de la sesion, token CSRF de la cookie). Un visor de respuestas con resaltado de sintaxis. Historial de solicitudes persistido en localStorage.

El componente fue disenado como un modulo compartido independiente en shared/ApiExplorer.svelte, luego copiado tanto a dashboard/src/lib/components/shared/ como a website/src/lib/components/shared/. Intentamos symlinks primero, pero Vite resuelve node_modules desde la ruta real del archivo, no la ruta del symlink, asi que el compilador Svelte del panel intentaria usar los node_modules del sitio web. Copias de archivo con sincronizacion manual fue la solucion pragmatica.

Eliminando api-endpoints.ts

La mejor parte: eliminamos dashboard/src/lib/data/api-endpoints.ts. Ciento ochenta entradas de endpoints mantenidas manualmente, desaparecidas. Reemplazadas por un unico fetch a /api/v1/openapi.json que devuelve la especificacion auto-generada desde el codigo fuente Rust. La documentacion nunca puede desviarse de la implementacion de nuevo, porque la documentacion es la implementacion.

Fase 2 de MCP: extensiones x-mcp-*

La especificacion OpenAPI ya alimentaba dos salidas (documentacion y playground). Luego necesitamos una tercera: definiciones de herramientas MCP para el asistente de IA de sh0.

MCP (Model Context Protocol) permite que los modelos de IA llamen herramientas en un servidor. Cada herramienta tiene un nombre, descripcion y esquema de parametros. Teniamos 25 herramientas MCP, y mantener sus definiciones separadas de los manejadores de API era el mismo problema de desviacion de documentacion que acababamos de resolver para la documentacion de API.

La solucion: extensiones OpenAPI personalizadas. Anadimos campos x-mcp-* a las anotaciones de los manejadores:

rust#[utoipa::path(
    get,
    path = "/api/v1/apps",
    tag = "Apps",
    extensions(
        ("x-mcp-enabled" = true),
        ("x-mcp-name" = "list_apps"),
        ("x-mcp-description" = "List all deployed applications with their status"),
        ("x-mcp-risk" = "read"),
    ),
    // ...
)]
pub async fn list_apps(/* ... */) { /* ... */ }

Para endpoints donde el nombre del parametro OpenAPI difiere del nombre del parametro de la herramienta MCP (tipicamente id en parametros de ruta, que es ambiguo en un esquema de herramienta plano), anadimos una extension de remapeo de parametros:

rustextensions(
    ("x-mcp-enabled" = true),
    ("x-mcp-name" = "get_app"),
    ("x-mcp-param-map" = { "id": "app_id" }),
    ("x-mcp-risk" = "read"),
),

El generador

El modulo openapi.rs en el servidor MCP parsea la especificacion OpenAPI al inicio y genera definiciones McpTool de cada endpoint anotado con x-mcp-enabled = true:

  1. Leer el JSON OpenAPI (ya servido por la API)
  2. Iterar sobre todas las rutas y operaciones
  3. Filtrar las operaciones con x-mcp-enabled: true
  4. Extraer el nombre de la herramienta de x-mcp-name
  5. Construir el esquema de parametros a partir de los parametros de ruta, parametros de consulta y cuerpo de solicitud de la operacion
  6. Aplicar el remapeo de parametros de x-mcp-param-map
  7. Establecer el nivel de riesgo de x-mcp-risk (read, write o destructive)

El resultado: 18 herramientas derivadas de OpenAPI mas 2 herramientas manuales (para operaciones que van directamente a Docker en lugar de pasar por la API, como get_app_logs que transmite desde la API de log de Docker). Fases posteriores anadieron herramientas de sandbox y busqueda web, llevando el total a 27.

Una anotacion, tres salidas

La anotacion en una funcion manejadora ahora impulsa tres sistemas:

  1. Documentacion de API -- los atributos utoipa generan la especificacion OpenAPI, que el ApiExplorer renderiza como documentacion
  2. Playground interactivo -- la misma especificacion alimenta los formularios de parametros del playground, editores de cuerpo y generadores de cURL
  3. Definiciones de herramientas MCP -- las extensiones x-mcp-* generan definiciones de herramientas que el asistente de IA usa para interactuar con la plataforma

Anade un nuevo endpoint de API, anotalo una vez, y aparece en la documentacion, el playground y la lista de herramientas del asistente de IA. Cambia un tipo de parametro, y los tres se actualizan automaticamente en el siguiente build. Elimina un endpoint, y desaparece de los tres.

Las pasadas de auditoria

La integracion inicial fue una sesion maraton -- 182 anotaciones de una sola vez. Dos sesiones de auditoria de seguimiento detectaron las brechas:

Ronda de auditoria 1: Se anadieron esquemas de respuesta de error (tipos ToSchema ErrorResponse y ErrorBody) y se anadieron anotaciones de respuesta 400/401/403/404/429 a los 56 endpoints de manejadores mas criticos. Se corrigieron los fondos del modo oscuro en el ApiExplorer, el escape de shell de cURL para JSON con comillas simples, y una verificacion fragil startsWith reemplazada por una variable de estado adecuada para la pista del token de autenticacion.

Ronda de auditoria 2: Se extendieron las anotaciones de respuesta de error a los 126 endpoints de manejadores restantes en 31 archivos. Cada uno de los 182 endpoints ahora documenta sus respuestas de error. Tambien se corrigio un bloque de codigo cURL que usaba un fondo oscuro codificado en lugar de adaptarse al tema.

Despues de ambas auditorias: 478 tests pasando, builds limpios tanto en el backend Rust como en ambos frontends Svelte (panel y sitio web).

Por que esto importa

La documentacion de API no es una funcionalidad de lujo. Para un PaaS, la API es el producto. Cada comando CLI, cada accion del panel, cada integracion de webhook CI/CD habla con la API. Si la documentacion esta equivocada, los desarrolladores perderan horas depurando solicitudes que deberian funcionar, o construyendo integraciones contra endpoints que han cambiado.

El enfoque de fuente unica de verdad tiene un beneficio compuesto. No solo elimina la desviacion -- cambia la estructura de incentivos. Anadir documentacion ya no es trabajo extra que ocurre despues de que la funcionalidad esta terminada (o, realistamente, nunca). Es parte de escribir el manejador. La anotacion #[utoipa::path] esta ahi mismo, tres lineas arriba de la firma de la funcion. Omitirla se sentiria como dejar la funcion sin un tipo de retorno.

Para un equipo de dos personas -- un humano, una IA -- este tipo de disciplina estructural no es opcional. No tenemos un redactor tecnico para mantener la documentacion sincronizada. No tenemos un ingeniero de QA para notar cuando el playground muestra los parametros incorrectos. El codigo tiene que documentarse a si mismo, o no se documenta.


Esta es la Parte 32 de la serie "Como construimos sh0.dev". A continuacion: la CLI de sh0 -- 10 comandos que replican cada accion del panel, construidos con clap, reqwest y tokio-tungstenite.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles