Many Rust web servers describe themselves: they generate an OpenAPI document from the server code. Spargen closes the loop by turning that document back into a typed Rust client. This is the round-trip:
Rust server code → (framework) → OpenAPI document → (spargen) → typed Rust client
This page gives a concrete, runnable recipe for the three most common Rust OpenAPI-emitting frameworks — utoipa, aide, and poem-openapi — plus the escape hatch for the rare construct spargen cannot represent.
Every recipe follows the same three steps:
- Export the OpenAPI document from the framework.
- Generate the client with spargen.
- Wire the generate step into your build.
Two facts frame all of it:
- Spargen requires OpenAPI 3.1.x (or 3.2.x). A 3.0.x document is rejected with
E001— no silent downgrade. utoipa and aide emit 3.1.0; poem-openapi emits 3.0.0 and must be upgraded (see its recipe). - Unsupported constructs never degrade silently. Anything spargen cannot represent is
supported, warned, or rejected with a stable code — see the support matrix
and diagnostic index. When a real spec trips a rejection, the
carveescape hatch drops just that island and generates the rest.
The specs used below are vendored under corpus/recipes/ and are
exercised by spargen/tests/recipes.rs, which asserts each framework's outcome so these recipes
stay honest.
Once you have exported and committed the spec, generate during compilation from build.rs.
Choose an OUT_DIR path for ephemeral output or a source path for output you also commit:
// build.rs
fn main() {
let build = spargen::Spec::new("openapi.json").build("src/api.rs");
let report = spargen::generate(&build);
report.emit_cargo_warnings();
}In CI, spargen check openapi.json is a fast pre-flight: it runs the full frontend (same support
audit as generate) without emitting code, so a spec that would reject fails the gate early. Add
--format json for machine-readable diagnostics.
juhaku/utoipa is code-first: #[derive(ToSchema)] on models
and #[utoipa::path(...)] on handlers, collected by a #[derive(OpenApi)] ApiDoc.
OpenAPI version: utoipa 5.x emits OpenAPI 3.1.0 (the OpenApiVersion enum defaults to
Version31, serialized as "3.1.0"). No upgrade needed — spargen consumes it directly.
Export. The derived ApiDoc gives you the document; write it to a file:
use utoipa::OpenApi;
let json = ApiDoc::openapi()
.to_pretty_json()
.expect("serialize openapi");
std::fs::write("openapi.json", json).unwrap();(to_json() is the compact form; to_yaml() exists behind utoipa's yaml feature. If you serve
the spec over HTTP via utoipa-swagger-ui/utoipa-axum, hitting that route and saving the body
works too.)
Generate. Compile the crate containing the shared build.rs above.
Idioms spargen handles. The vendored corpus/recipes/utoipa.json
mirrors a typical utoipa document and generates cleanly. It covers:
| utoipa idiom | OpenAPI shape | spargen result |
|---|---|---|
Option<String> field |
type: ["string", "null"] |
Option<String> |
Option<Category> field |
oneOf: [{ "type": "null" }, { "$ref": … }] |
Option<Category> (the null member makes the lone remaining member optional) |
| flattened / composed model | allOf: [{ "$ref": … }, { … }] |
one merged struct |
#[serde(tag = "…")] enum |
oneOf + discriminator |
a Rust enum with custom (de)serialize dispatching on the tag |
#[utoipa::path(tag = "…")] |
tags on operations |
grouped operations (no effect on typing) |
| multiple documented statuses | 200 body + 404 body |
a per-operation response enum |
SecurityScheme::Http bearer |
http / bearer |
bearer credential attached per operation |
Caveat — type: "null" validity. utoipa emits oneOf/type arrays containing
{"type":"null"} for nullable $refs. This is valid OpenAPI 3.1 and spargen accepts it, though
some third-party 3.0-era validators flag it — that is a validator limitation, not a spargen one.
tamasfe/aide documents an axum app: ApiRouter +
api_route(...), with component schemas produced by schemars
(JSON Schema 2020-12).
OpenAPI version: aide emits OpenAPI 3.1.0 (its OpenApi document serializes the version as
"3.1.0"). No upgrade needed.
Export. aide builds an OpenApi value while assembling the router; serialize it with serde:
use aide::openapi::OpenApi;
use aide::axum::ApiRouter;
let mut api = OpenApi::default();
let router = ApiRouter::new()
.api_route("/items", aide::axum::routing::get(list_items))
// …
.finish_api(&mut api); // populates `api`
std::fs::write("openapi.json", serde_json::to_string_pretty(&api).unwrap()).unwrap();(Commonly you also serve api from a route via aide::openapi::OpenApi + Json; fetching that
route and saving the body is equivalent. finish_api_with(&mut api, transform) lets you set
titles/versions during assembly.)
Generate. Compile the crate containing the shared build.rs above.
Idioms spargen handles. The vendored corpus/recipes/aide.json
mirrors a schemars-backed aide document. It generates with only validation-only warnings
(W001, for the minimum/format hints schemars emits, which spargen faithfully ignores). It
covers:
| aide/schemars idiom | OpenAPI shape | spargen result |
|---|---|---|
Option<String> field |
anyOf: [{ "type": "string" }, { "type": "null" }] |
Option<String> |
Option<i32> field |
type: ["integer", "null"] |
Option<i32> |
| externally-tagged enum with data | oneOf of closed (additionalProperties: false) objects with unique required keys |
a Rust enum, dispatched by inspecting content (no serde(untagged), no Value) |
| untagged scalar enum | oneOf: [{ "type": "string" }, { "type": "integer" }] |
a Rust enum, dispatched by JSON type |
#[serde(flatten)] |
allOf composition |
one merged struct |
| multiple documented statuses | 200 body + 400 body |
a per-operation response enum |
schemars validation keywords (
minimum,maxLength,pattern, …) are not enforced by the generated types and surface asW001. That is expected, not a problem — the recipe test assertsW001is the only diagnostic class aide's document produces.
poem-web/poem's poem-openapi is code-first over the poem
framework: #[derive(Object)]/#[derive(ApiResponse)] models and an #[OpenApi] impl block,
assembled into an OpenApiService.
OpenAPI version — action required: poem-openapi emits OpenAPI 3.0.0
(const OPENAPI_VERSION: &str = "3.0.0" in its serializer). Spargen rejects 3.0.x with
E001. You must upgrade the exported document to 3.1 before generating.
Export.
use poem_openapi::OpenApiService;
let service = OpenApiService::new(Api, "My API", "1.0");
std::fs::write("openapi-3.0.json", service.spec()).unwrap(); // JSON
// service.spec_yaml() gives YAML.Upgrade 3.0.0 → 3.1. Convert the document with an off-the-shelf converter, then feed the 3.1 result to spargen. Any of these works:
- run the spec through a 3.0→3.1 upgrade tool (for example the LinkML
oas30-to-31/openapi-3-0-3-to-3-1style converters, or an editor that round-trips to 3.1); - if the document is simple, hand-edit: set
openapi: "3.1.0", and replace 3.0nullable: trueon a schema of typeTwith the 3.1 formtype: ["T", "null"](or, for a$ref,oneOf: [{ "type": "null" }, { "$ref": … }]).
Then point the shared build.rs at openapi-3.1.json and compile.
Why the hard stop? OpenAPI 3.0 and 3.1 differ in their schema model (3.1 adopts JSON Schema
2020-12; 3.0 uses its own dialect with nullable). Spargen targets the 3.1 dialect and refuses to
guess at a 3.0 document rather than mistranslate it — the rejection is the contract, per the
support matrix. The vendored
corpus/recipes/poem-openapi.json is a 3.0.0 document; the
recipe test asserts it rejects with E001.
Real framework output occasionally contains a construct spargen cannot faithfully represent — for
example a JSON Schema $dynamicRef, which rejects with E006. By default one such
island rejects the whole document.
Spec::carve is the escape hatch: it drops only the unrepresentable constructs (each reported once with
W009), then generates everything else. It reaches a fixpoint (cascading through any components that
referenced a carved schema) and stays deterministic.
let build = spargen::Spec::new("openapi.json")
.carve(true)
.build("src/api.rs");
let report = spargen::generate(&build);
assert_eq!(report.outcome, spargen::Outcome::Generated);spargen check --carve audits the carved subset the same way. If you would rather remove specific
paths/operations/components by name (an exact or glob rule) instead of letting carve decide, use the
compatibility omit mode. The vendored
corpus/recipes/utoipa-untagged-overlap.json
demonstrates that overlapping integer | number unions no longer need this escape hatch: they
generate as typed trial-matching enums. The carve integration suite separately pins the
reject-then-carve flow for genuinely unsupported constructs.
- Support matrix — exactly what is supported, warned, or rejected.
- Diagnostic index — every stable
E###/W###code (spargen explain E001). - Compatibility omit mode — carve/omit unsupported segments by name.
corpus/recipes/README.md— provenance of the vendored specs and the verified per-framework version constants.