Skip to content

Instantly share code, notes, and snippets.

@al-maisan
Last active March 9, 2026 05:31
Show Gist options
  • Select an option

  • Save al-maisan/b905a93c60b88588064029fca38ed5e5 to your computer and use it in GitHub Desktop.

Select an option

Save al-maisan/b905a93c60b88588064029fca38ed5e5 to your computer and use it in GitHub Desktop.
future api dev approach

Proposal: API Documentation with utoipa (Hybrid Approach)

Date: 2026-03-09

Problem

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

Proposed Solution

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.

How It Works

1. Annotate request/response types

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.

2. Annotate handlers

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> {
    // ...
}

3. Register all annotated items

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;

4. Serve interactive Swagger UI

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 middleware

Result: 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.

5. CI drift check

A CI step compares the generated spec against the design YAML to catch breaking changes and undocumented drift.

Tool: oasdiff

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/oasdiff

CI 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 ERR

The --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());
}

Bidirectional checking

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 ERR

The 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.

Workflow for Adding a New Endpoint

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

Migration Path

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.

Dependencies

# Cargo.toml additions
utoipa = { version = "5", features = ["axum_extras", "uuid"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
  • utoipa is compile-time only (proc macros). Zero runtime overhead on handler execution.
  • utoipa-swagger-ui bundles 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).

Pros

  • 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 /docs replaces 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.

Cons

  • 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 UserId extractor is not known to utoipa. It won't appear in the spec unless we add a small impl for 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-ui adds ~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.

Reducing Manual Burden with AI

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.

What an LLM can generate

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, ...)),
)]

Practical workflow

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.

Ongoing use

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.

Alternatives Considered

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.

Summary

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment