Cookbook / Production

OpenAPI documentation

Describe your routes once and serve a validated OpenAPI 3.1 document for clients and docs.

axeon-openapi builds an OpenAPI 3.1 document from your routes and Rust types. You attach a short description to each route, the crate collects them from app.routes(), and build checks the whole document before it is served. Use it to generate typed clients, for example with openapi-typescript, and to publish API docs.

Terminal
cargo add axeon-openapi utoipa
src/main.rs
use axeon::{Json, Server, ServerError};
use axeon_openapi::{op, RouteDocs, Spec};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};

#[derive(Deserialize, IntoParams)]
struct ProductQuery {
    category: Option<String>,
    max_price: Option<u32>,
}

#[derive(Serialize, ToSchema)]
struct ProductDto {
    id: i64,
    name: String,
    category: String,
    price: u32,
}

fn catalog() -> Vec<ProductDto> {
    vec![
        ProductDto { id: 1, name: "Desk lamp".into(), category: "lighting".into(), price: 40 },
        ProductDto { id: 2, name: "Office chair".into(), category: "furniture".into(), price: 120 },
    ]
}

fn main() {
    let mut app = Server::new();

    app.route("/v1/products")
        .doc(
            op()
                .summary("List products")
                .tag("Catalog")
                .query::<ProductQuery>()
                .response::<Vec<ProductDto>>(200, "Matching products")
                .error(400, "Bad filters"),
        )
        .get(|req| async move {
            let query: ProductQuery = req.query_as()?;
            let max = query.max_price.unwrap_or(u32::MAX);
            let items: Vec<ProductDto> = catalog()
                .into_iter()
                .filter(|p| p.price <= max)
                .filter(|p| query.category.as_ref().map_or(true, |c| &p.category == c))
                .collect();
            Ok::<_, ServerError>(Json(items))
        });

    app.route("/v1/products/:id")
        .doc(
            op()
                .summary("Get a product")
                .tag("Catalog")
                .path_param::<i64>("id", "Product ID")
                .response::<ProductDto>(200, "The product")
                .error(404, "No such product"),
        )
        .get(|req| async move {
            let id: i64 = req.param("id")?;
            Ok::<_, ServerError>(catalog().into_iter().find(|p| p.id == id).map(Json))
        });

    // After registering every route: build, validate and serve the document.
    let openapi = Spec::new("Catalog API", "1.0")
        .server("https://api.example.com", None)
        .bearer_auth("bearer")
        .build(app.routes())
        .expect("invalid OpenAPI document");
    app.mount("/", axeon_openapi::router(&openapi)); // GET /openapi.json

    app.listen("127.0.0.1:3000").expect("server failed to start");
}
Terminal
curl -s http://127.0.0.1:3000/openapi.json | head -c 400
npx openapi-typescript http://127.0.0.1:3000/openapi.json -o api.d.ts

Notes#

  • doc applies to every handler of a route builder, so give each method its own builder: app.route("/posts").doc(list).get(..) and app.route("/posts").doc(create).post(..).
  • Only routes with doc are listed. Spec::include_undocumented() lists the rest, and op().hidden() always hides a route.
  • build fails with every problem it finds: a schema or security scheme that is referenced but missing, two types sharing a schema name, a documented path parameter that is not in the path, or a duplicate operation ID. Failing at startup is cheaper than shipping a broken contract.
  • Plain named types become named schemas. Generic ones such as Page<ProductDto> are inlined, so add the type they wrap with .schema::<ProductDto>().
  • Error responses use Axeon's {"error": {...}} body, which op().error(..) documents for you.