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.
cargo add axeon-openapi utoipa
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");
}
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#
docapplies to every handler of a route builder, so give each method its own builder:app.route("/posts").doc(list).get(..)andapp.route("/posts").doc(create).post(..).- Only routes with
docare listed.Spec::include_undocumented()lists the rest, andop().hidden()always hides a route. buildfails 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, whichop().error(..)documents for you.