Date: 2026-03-09
The backend API has 46 endpoints with no machine-readable documentation. API behavior is defined implicitly by Rust code. This means:
- Frontend developers must read backend source or ask questions to understand request/response formats
- No interactive way to test endpoints during development
- No compile-time guarantee that documentation matches implementation
- API changes can silently break clients
Adopt a hybrid design-first / code-first workflow using utoipa, a Rust OpenAPI documentation library.
Design-first: New endpoints are designed in a shared YAML spec before implementation. This spec is reviewed alongside the feature design — it defines the contract before code is written.
Code-first: The Rust code is annotated with lightweight macros that auto-generate an OpenAPI spec at compile time. The generated spec is the source of truth. The YAML design document is checked against it in CI to prevent drift.
The key insight is that we are not generating Rust code from the spec. We are generating the spec from annotated Rust code. This avoids the well-known problems of code generators producing output that doesn't fit the project's patterns.
Add a single derive macro to types that cross the HTTP boundary:
// Before
#[derive(Serialize, Clone)]
pub struct LatestQuery {
pub games: Vec<GameInfo>,
pub gapped: bool,
}
// After
#[derive(Serialize, Clone, utoipa::ToSchema)]
pub struct LatestQuery {
pub games: Vec<GameInfo>,
pub gapped: bool,
}Enums work the same way. utoipa reads the existing serde attributes to produce the correct JSON schema representation:
#[derive(Serialize, Clone, utoipa::ToSchema)]
pub enum ProgressionResponse {
Success(GameInfo),
TryRefreshGame,
}Only HTTP-facing types need this (~40 types). Internal DB types, domain types, and anything not serialized in a response is left untouched.
Add a #[utoipa::path] attribute above each handler function:
#[utoipa::path(
get,
path = "/prot/async-pvp/newer-than/{timestamp}",
tag = "async-pvp",
params(
("timestamp" = i64, Path, description = "Unix epoch; returns games newer than this")
),
responses(
(status = 200, body = LatestQuery, description = "Active + recent historic games"),
(status = 400, description = "Error"),
),
security(("session_token" = []))
)]
pub async fn get_newer_than(
ctx: State<ApiContext>,
UserId(user_id): UserId,
Path(timestamp): Path<i64>,
) -> Result<Json<LatestQuery>, StatusCode> {
// handler body is completely unchanged
// ...
}The attribute is purely declarative — it does not modify the function signature or body. It's a structured comment that the compiler can verify. If LatestQuery does not derive ToSchema, the code fails to compile.
A POST endpoint with a JSON body looks like this:
#[utoipa::path(
post,
path = "/prot/async-pvp/submit-leg",
tag = "async-pvp",
request_body = PvpGameReport,
responses(
(status = 200, body = ProgressionResponse),
(status = 400, description = "Error"),
),
security(("session_token" = []))
)]
pub async fn submit_leg(
ctx: State<ApiContext>,
UserId(user_id): UserId,
Json(game_report): Json<GameReport>,
) -> Result<Json<ProgressionResponse>, StatusCode> {
// ...
}A single struct collects everything and generates the full OpenAPI document:
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
info(title = "QiblaQuiz API", version = "1.0.0"),
paths(
api::public::health_check,
api::public::authentication,
api::public::registration,
api::protected::async_pvp::get_newer_than,
api::protected::async_pvp::submit_leg,
// ... remaining handlers
),
components(schemas(
LatestQuery, PastQuery, GameInfo, ProgressionResponse,
PvpGameReport, CategoryPick, UserData,
// ... remaining types
)),
modifiers(&SecurityAddon),
tags(
(name = "public", description = "Registration and authentication"),
(name = "async-pvp", description = "Multiplayer game endpoints"),
(name = "user", description = "User profile management"),
)
)]
pub struct ApiDoc;Add a single route that serves an interactive API explorer:
// In the router setup
let app = Router::new()
.merge(api::public::router())
.merge(api::protected::router(ctx.clone()))
.merge(
utoipa_swagger_ui::SwaggerUi::new("/docs")
.url("/api-docs/openapi.json", ApiDoc::openapi())
)
// ... existing middlewareResult: http://localhost:3000/docs serves a Swagger UI where developers can browse every endpoint, see request/response schemas, and make test requests directly from the browser.
This can be gated behind a feature flag or #[cfg(debug_assertions)] so it's excluded from production builds.
A CI step compares the generated spec against the design YAML to catch breaking changes and undocumented drift.
oasdiff is a purpose-built OpenAPI diff tool that understands the semantics of API changes. Unlike a text diff, it resolves $ref pointers, flattens oneOf/allOf, and classifies every difference as breaking or non-breaking.
What it detects:
| Change | Classification | Example |
|---|---|---|
| Removed endpoint | Breaking | DELETE /prot/ranking disappeared |
| Removed response field | Breaking | GameInfo.opponent field removed |
| New required request field | Breaking | submit-leg now requires session_id |
| Changed field type | Breaking | score changed from integer to string |
| Narrowed response enum | Breaking | ProgressionResponse lost a variant |
| Changed parameter location | Breaking | timestamp moved from path to query |
| Added optional response field | Non-breaking | GameInfo gained round_score |
| Added optional request field | Non-breaking | GameReport accepts optional notes |
| Added new endpoint | Non-breaking | New GET /prot/leaderboard |
| Description/example changes | Ignored | Editorial, not contractual |
Setup:
# Install (single binary, no runtime dependencies)
go install github.com/tufin/oasdiff@latest
# or
brew install oasdiff
# or use the Docker image
docker pull tufin/oasdiffCI integration:
# .github/workflows/api-check.yml
jobs:
api-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install oasdiff
run: go install github.com/tufin/oasdiff@latest
- name: Build and generate spec from code
run: cargo run --bin dump-openapi > /tmp/generated.yaml
- name: Check for breaking changes
run: oasdiff breaking ai/api-spec.yaml /tmp/generated.yaml --fail-on ERRThe --fail-on ERR flag means CI fails only on breaking changes. Non-breaking additions (new optional fields, new endpoints) are logged but don't block the build.
Example output when drift is detected:
GET /prot/ranking
response 200, schema:
removed property 'season_state' [error]
POST /prot/async-pvp/submit-leg
request body, schema:
added required property 'session_id' [error]
response 200, schema:
added property 'round_score' [info]
POST /prot/friends/add-with-code
endpoint added to generated spec but missing from design [warn]
Errors block the PR. Warnings and info are visible in the CI log for the reviewer.
The dump-openapi binary is a small Rust binary (~10 lines) that prints the generated spec:
// src/bin/dump-openapi.rs
use qiblaquiz::ApiDoc;
use utoipa::OpenApi;
fn main() {
println!("{}", ApiDoc::openapi().to_yaml().unwrap());
}oasdiff runs in one direction (base → revision). Running it both ways catches both categories of drift:
# Design has endpoints that code doesn't implement yet
- name: Check design → code (missing implementations)
run: oasdiff breaking ai/api-spec.yaml /tmp/generated.yaml --fail-on ERR
# Code has endpoints that design doesn't document
- name: Check code → design (undocumented endpoints)
run: oasdiff breaking /tmp/generated.yaml ai/api-spec.yaml --fail-on ERRThe first check catches "you designed it but didn't build it." The second catches "you built it but didn't document it." Together they enforce that the YAML and the code stay in sync on every PR.
1. Developer adds endpoint to the YAML spec
└── PR reviewed for API design (naming, types, consistency)
2. Developer implements the handler in Rust
└── Normal Rust code, same patterns as existing handlers
└── Adds #[utoipa::path] annotation and ToSchema derives
3. CI verifies:
└── Code compiles (utoipa catches type mismatches at compile time)
└── Generated spec matches YAML design (drift check)
4. After merge, /docs is automatically up to date
The migration is incremental. Unannotated handlers work exactly as before — they just don't appear in the generated spec.
| Phase | Scope | Effort |
|---|---|---|
| Phase 1 | Add dependencies, create ApiDoc struct, serve Swagger UI with zero endpoints |
30 min |
| Phase 2 | Annotate new endpoints as they are built | 5-10 min per endpoint |
| Phase 3 | Backfill existing endpoints as they are modified | 5-10 min per endpoint |
| Phase 4 | Add CI drift check | 1-2 hours |
Phase 1 can be done in a single commit. Phase 3 is opportunistic — there is no need to annotate all 46 endpoints at once.
# Cargo.toml additions
utoipa = { version = "5", features = ["axum_extras", "uuid"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }utoipais compile-time only (proc macros). Zero runtime overhead on handler execution.utoipa-swagger-uibundles the Swagger UI static assets (~3 MB in the binary). Can be feature-gated for production.- Both crates are well-maintained (utoipa: 4.7k GitHub stars, active releases).
- No change to existing code patterns. Handlers, DB access, middleware, error handling — all unchanged. The annotations are additive.
- Compile-time safety. If a response type changes, the annotation fails to compile. Documentation cannot silently go stale.
- Incremental adoption. Annotate one endpoint or all 46. Mix annotated and unannotated freely.
- Interactive documentation. Swagger UI at
/docsreplaces ad-hoc "what does this endpoint expect?" questions. - Design-first discipline. New endpoints start as YAML (reviewed for naming/consistency) before implementation.
- No code generation headaches. We are not fighting a generator that produces code incompatible with our axum setup, middleware, extractors, or DB patterns.
- Low overhead per endpoint. ~8 lines of annotation, one derive on types. The types already exist — we just tag them.
- Industry-standard output. The generated OpenAPI 3.0 spec can be consumed by any tool: client SDK generators, Postman, API gateways, contract testing frameworks.
- Manual annotation burden. Every handler needs a
#[utoipa::path]attribute. For 46 existing endpoints, the backfill is ~2-3 hours of mechanical work. However, this is highly automatable with an LLM — see Reducing manual burden with AI below. - Annotation duplication. The path, method, and types are stated in both the route registration and the annotation. utoipa cannot infer these from axum's router automatically (this is a known limitation). An LLM can cross-reference the router and handler to generate consistent annotations, eliminating copy errors.
- Custom extractors need glue. Our
UserIdextractor is not known to utoipa. It won't appear in the spec unless we add a smallimplfor it. This is a one-time effort. - YAML spec can drift. The CI check (oasdiff) catches breaking changes and undocumented endpoints, but the YAML design spec could still lag behind on non-breaking additions if developers skip the YAML update step.
- Binary size increase.
utoipa-swagger-uiadds ~3 MB to the binary (Swagger UI static assets). Mitigated by feature-gating it out of production builds. - Not truly spec-first. Despite the design YAML, the Rust code is the real source of truth. If someone skips the YAML step, the process degrades to pure code-first — which still works, but loses the design review step.
The two most mechanical parts of utoipa adoption — annotating handlers and tagging types — are ideal LLM tasks. The inputs are structured (Rust source), the outputs are formulaic (proc-macro attributes), and correctness is verified by the compiler.
1. #[utoipa::path] annotations from handler signatures + router
Given:
// Router
.route("/prot/async-pvp/newer-than/{timestamp}", get(async_pvp::get_newer_than))
// Handler
pub async fn get_newer_than(
ctx: State<ApiContext>,
UserId(user_id): UserId,
Path(timestamp): Path<i64>,
) -> Result<Json<LatestQuery>, StatusCode> { ... }An LLM can cross-reference the route path, HTTP method, path parameters, and return type to produce the complete annotation:
#[utoipa::path(
get,
path = "/prot/async-pvp/newer-than/{timestamp}",
tag = "async-pvp",
params(("timestamp" = i64, Path)),
responses((status = 200, body = LatestQuery), (status = 400)),
security(("session_token" = []))
)]This is deterministic: the path and method come from the router, the params and body come from the function signature, and security is applied to all /prot/* routes.
2. #[derive(ToSchema)] additions to types
The LLM identifies which types cross the HTTP boundary (appear in Json<T> or request_body), then adds the derive. It preserves existing derives and serde attributes:
// Before
#[derive(Serialize, Clone)]
pub struct LatestQuery { ... }
// After
#[derive(Serialize, Clone, utoipa::ToSchema)]
pub struct LatestQuery { ... }It also follows type references — if LatestQuery contains Vec<GameInfo>, then GameInfo also needs ToSchema.
3. ApiDoc struct registration
After annotating handlers and types, the LLM generates the central #[openapi] block by collecting all annotated items:
#[openapi(
paths(get_newer_than, get_older_than, submit_leg, ...),
components(schemas(LatestQuery, GameInfo, PvpGameReport, ...)),
)]1. Point the LLM at the router file + handler modules
2. LLM generates all annotations in one pass
3. Run `cargo check` — compiler catches any type mismatches
4. Developer reviews the generated annotations (tags, descriptions)
5. Commit
The backfill of all 46 endpoints becomes a single prompted session (~15 min) instead of 2-3 hours of manual work. The compiler acts as the safety net: if the LLM gets a type wrong, the build fails.
For new endpoints, the LLM generates the annotation as part of the normal implementation workflow. The developer writes the handler, the LLM adds the utoipa annotation, and cargo check verifies it. This keeps the per-endpoint overhead near zero.
| Approach | Why not |
|---|---|
| Full spec-first codegen (openapi-generator rust-axum) | Generated code doesn't fit our patterns (custom extractors, ApiContext, middleware, #[serde(flatten)] types). We'd spend more time adapting generated output than writing handlers. |
| aide (runtime spec generation) | Similar to utoipa but generates spec at runtime, not compile time. Less mature axum integration. utoipa's compile-time approach catches more errors earlier. |
| Manual YAML only (no code integration) | The YAML spec we generated today is already 900+ lines. Without code integration, it will drift within weeks. |
| No spec at all (status quo) | Works for a single developer. Becomes a bottleneck when frontend developers need to integrate independently. |
utoipa gives us machine-readable, compile-time-verified API documentation with minimal changes to the existing codebase. The hybrid workflow preserves design-first discipline for new endpoints while letting the code remain the source of truth. The migration is incremental, the tooling is mature, and the per-endpoint cost is low.