We had 182 API endpoints. We also had a hand-maintained TypeScript file called api-endpoints.ts that described those endpoints for the dashboard's API documentation page. It contained 180+ entries, each with a method, path, description, parameters, and example responses. And it was wrong. Not dramatically wrong -- most entries were roughly correct -- but the kind of wrong that accumulates silently: a parameter renamed in the backend but not in the docs, a new endpoint added to the router but never to the documentation, a response field that changed type from string to number three sessions ago.
Maintaining API documentation separately from the API implementation is a losing game. You can be disciplined about it for a while, but at the pace we were shipping -- 105 sessions in 14 days -- "a while" means about four hours before something drifts out of sync.
We needed a single source of truth. One place where the endpoint definition, the parameter types, the response schemas, and the documentation all lived together. And we needed that truth to flow automatically into three outputs: API reference documentation, an interactive playground, and MCP tool definitions for our AI assistant.
utoipa: OpenAPI from Rust Annotations
The Rust ecosystem has a clear winner for OpenAPI generation: utoipa. It generates an OpenAPI 3.1 specification from derive macros and attribute annotations on your existing handler functions and types. No separate schema files, no code generation step, no YAML to maintain. The spec is derived from the code.
We added utoipa 5 to sh0-api and started annotating. The work broke down into two categories: data types and handlers.
69 DTOs with ToSchema
Every request and response type needed #[derive(utoipa::ToSchema)]:
#[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,
}We annotated 69 DTOs in types.rs plus approximately 30 inline request structs scattered across handler files -- the CreateAlertRequest in alerts.rs, the InviteUserRequest in team.rs, the DeployComposeRequest in compose.rs. Each of these was a small struct defined next to the handler that consumed it, and each needed the ToSchema derive.
For pagination parameters and query filters, we used IntoParams:
#[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>,
}The IntoParams derive generates individual parameter entries in the OpenAPI spec, so the playground knows to render them as separate form fields rather than a single JSON body.
182 Handlers with #[utoipa::path]
Every handler function got an annotation block describing its OpenAPI metadata:
#[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>>> {
// ...
}We annotated 182 handler functions across 36 handler files. Every endpoint got a tag (for grouping), a security requirement (Bearer JWT), response schemas for the success case, and error response schemas for 400, 401, 403, 404, and 429 as appropriate. In two audit passes, we added error response annotations to all 182 endpoints -- first the 56 most critical handlers, then the remaining 126.
The ApiDoc Struct
All of this comes together in a single struct registered in router.rs:
#[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;A single GET /api/v1/openapi.json route serves the auto-generated spec. No auth required -- the spec is public documentation, and making it available without authentication means external tools can consume it directly.
The Shared ApiExplorer Component
Generating a JSON spec is only half the story. Someone has to render it. We built a Svelte 5 component called ApiExplorer that consumes an OpenAPI 3.1 specification and renders two things: a documentation reference and an interactive playground.
The OpenAPI Parser
The parser (openapi-parser.ts) handles the unglamorous work of turning a raw OpenAPI spec into something a UI component can render:
- $ref resolution with a recursion depth limit of 20 (increased from 10 after some of our deeply nested schemas caused truncation)
- Tag grouping -- endpoints organized by their OpenAPI tag, with endpoint counts per tag
- Example generation -- synthetic request bodies based on schema types and constraints
- cURL builder -- generates copy-paste cURL commands with proper shell escaping for JSON bodies containing single quotes
Two Modes, One Component
The ApiExplorer component accepts a mode prop -- either "docs" or "playground":
Docs mode (used on the sh0.dev website at /api) renders a read-only API reference. Left sidebar with tag navigation and endpoint counts. Method-colored badges: GET in emerald, POST in blue, PATCH in violet, PUT in amber, DELETE in red. Expandable endpoint details with parameters, request body schemas, response status codes, and cURL examples.
Playground mode (used in the dashboard at /api-docs) adds interactivity on top of the documentation. A method selector with path autocomplete. A JSON body editor. Automatic auth injection (Bearer token from the session, CSRF token from the cookie). A response viewer with syntax highlighting. Request history persisted in localStorage.
The component was designed as a standalone shared module in shared/ApiExplorer.svelte, then copied into both dashboard/src/lib/components/shared/ and website/src/lib/components/shared/. We tried symlinks first, but Vite resolves node_modules from the real path of the file, not the symlink path, so the dashboard's Svelte compiler would try to use the website's node_modules. File copies with manual sync was the pragmatic solution.
Deleting api-endpoints.ts
The best part: we deleted dashboard/src/lib/data/api-endpoints.ts. One hundred and eighty manually maintained endpoint entries, gone. Replaced by a single fetch to /api/v1/openapi.json that returns the spec auto-generated from the Rust source code. The documentation can never drift from the implementation again, because the documentation is the implementation.
MCP Phase 2: x-mcp-* Extensions
The OpenAPI spec was already powering two outputs (docs and playground). Then we needed a third: MCP tool definitions for the sh0 AI assistant.
MCP (Model Context Protocol) lets AI models call tools on a server. Each tool has a name, description, and parameter schema. We had 25 MCP tools, and maintaining their definitions separately from the API handlers was the same documentation drift problem we had just solved for the API docs.
The solution: custom OpenAPI extensions. We added x-mcp-* fields to the handler annotations:
#[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(/* ... */) { /* ... */ }For endpoints where the OpenAPI parameter name differs from the MCP tool parameter name (typically id in path parameters, which is ambiguous in a flat tool schema), we added a parameter remapping extension:
extensions(
("x-mcp-enabled" = true),
("x-mcp-name" = "get_app"),
("x-mcp-param-map" = { "id": "app_id" }),
("x-mcp-risk" = "read"),
),The Generator
The openapi.rs module in the MCP server parses the OpenAPI spec at startup and generates McpTool definitions from every endpoint annotated with x-mcp-enabled = true:
1. Read the OpenAPI JSON (already served by the API)
2. Iterate over all paths and operations
3. Filter for operations with x-mcp-enabled: true
4. Extract the tool name from x-mcp-name
5. Build the parameter schema from the operation's path parameters, query parameters, and request body
6. Apply parameter remapping from x-mcp-param-map
7. Set the risk level from x-mcp-risk (read, write, or destructive)
The result: 18 OpenAPI-derived tools plus 2 manual tools (for operations that go directly to Docker rather than through the API, like get_app_logs which streams from the Docker log API). Later phases added sandbox and web search tools, bringing the total to 27.
One Annotation, Three Outputs
The annotation on a handler function now drives three systems:
1. API documentation -- the utoipa attributes generate the OpenAPI spec, which the ApiExplorer renders as docs
2. Interactive playground -- the same spec powers the playground's parameter forms, body editors, and cURL generators
3. MCP tool definitions -- the x-mcp-* extensions generate tool definitions that the AI assistant uses to interact with the platform
Add a new API endpoint, annotate it once, and it appears in the docs, the playground, and the AI assistant's tool list. Change a parameter type, and all three update automatically on the next build. Delete an endpoint, and it disappears from all three.
The Audit Passes
The initial integration was a marathon session -- 182 annotations in one sitting. Two follow-up audit sessions caught the gaps:
Audit Round 1: Added error response schemas (ErrorResponse and ErrorBody ToSchema types) and added 400/401/403/404/429 response annotations to the 56 most critical handler endpoints. Fixed dark mode backgrounds in the ApiExplorer, cURL shell escaping for JSON with single quotes, and a fragile startsWith check replaced with a proper state variable for the auth token hint.
Audit Round 2: Extended error response annotations to all remaining 126 handler endpoints across 31 files. Every one of the 182 endpoints now documents its error responses. Also fixed a cURL code block that used a hardcoded dark background instead of adapting to the theme.
After both audits: 478 tests passing, clean builds on both the Rust backend and both Svelte frontends (dashboard and website).
Why This Matters
API documentation is not a luxury feature. For a PaaS, the API is the product. Every CLI command, every dashboard action, every CI/CD webhook integration talks to the API. If the documentation is wrong, developers will waste hours debugging requests that should work, or building integrations against endpoints that have changed.
The single-source-of-truth approach has a compound benefit. It does not just eliminate drift -- it changes the incentive structure. Adding documentation is no longer extra work that happens after the feature is done (or, realistically, never). It is part of writing the handler. The #[utoipa::path] annotation is right there, three lines above the function signature. Skipping it would feel like leaving the function without a return type.
For a two-person team -- one human, one AI -- this kind of structural discipline is not optional. We do not have a technical writer to keep docs in sync. We do not have a QA engineer to notice when the playground shows the wrong parameters. The code has to document itself, or it does not get documented.
---
This is Part 32 of the "How We Built sh0.dev" series. Next up: the sh0 CLI -- 10 commands that mirror every dashboard action, built with clap, reqwest, and tokio-tungstenite.