Skip to main content

Crate typed_openapi

Crate typed_openapi 

Source
Expand description

§typed-openapi

Typed Rust calls and a clap command tree, both built from one OpenAPI document, with every write behind a dry-run gate.

What comes out is api.get_voucher(5)?.send(&client)? returning your own Voucher, and toy vouchers get --id 5 on a command line nobody wrote. What goes in is the vendor’s document plus OpenAPI Overlay 1.1 documents holding your corrections to it. This is not a typed model of an OpenAPI document — for that, use openapiv3.

$ toy vouchers create --total 12.50 --currency EUR --status open  # operations at the root
POST /vouchers HTTP/1.1
host: localhost:9999
content-type: application/json

{"total":"12.50","currency":"EUR","status":"open"}

dry run: nothing was sent. Add --commit to send it.

§Two consumers, one document

The Rust caller gets owned types and one method per operation, with a newtype wherever the document names a rule — Voucher.currency is a Currency and cannot be built out of something that is not an ISO 4217 code — and a type of your own wherever it cannot: Voucher.total is a fixed-point Money, which no OpenAPI document has a way to describe.

The CLI consumer gets a two-level tree — one subcommand per resource the document’s paths name, one per operation under it, so PUT /vouchers/{id} is vouchers update whatever the vendor called it — with flags from the parameters and the request body, values held to every rule the document states about them, and dynamic shell completion. Mount the tree under raw, under any other name, or as the whole CLI:

let matches = Command::new("toy")
    .subcommands(tree::commands(api.document()))
    .get_matches();

match tree::dispatch(api.document(), api.base(), &client, &matches)? {
    Outcome::Sent(response) => ...,
    Outcome::DryRun(request) => ...,   // a write nobody confirmed
}

Both go through the same request builder, so the CLI and the typed caller cannot disagree about what an operation is — and both run the document’s pattern on the same regex engine, so they cannot disagree about what a value is either:

$ toy vouchers create --total 1,50 --currency EUR --status open
error: invalid value '1,50' for '--total <STRING>': `1,50` does not match ^-?[0-9]+(\.[0-9]{1,2})?$

pattern, minLength, maxLength, minimum, maximum, the two exclusive flags and multipleOf are all enforced, in the document’s own numbers. What that costs is a fifth of the binary — see docs/validation.md.

§The gate

A read runs on sight. A write prints the exact bytes it would have sent and stops, until --commit. Which operations write is the document’s answer rather than a guess from the HTTP method: a GET that stores a PDF is marked x-cli-writes in your Overlay and is gated like any POST. The gate is default-closed, and an operation the document does not describe has no subcommand at all.

This matters most when the CLI’s user is an agent, which has to learn caution from the tool rather than bring it.

§The bless step

One command turns the vendor’s document and your Overlays into four committed files: the corrected document, the schemas as Rust types, one typed wrapper per operation, and the document already reduced to what a command line needs. A shipped binary reads that reduction — it parses no YAML and links no OpenAPI object model.

Corrections come in layers, applied in the order you name them, so the one that repairs the vendor’s mistakes can stay a document worth handing back to the vendor while the one that marks operations for a command line sits above it:

Settings::new("spec/vendor.yaml")
    .overlay("spec/corrections.yaml")   // what the vendor got wrong
    .overlay("spec/cli.yaml")           // what only a command line needs
    .write_to("api-generated")?;

The generator ships inside this crate behind the generate feature, so your xtask is about twenty lines. See docs/generating.md.

A vendor revision that moves something you corrected fails the bless step — naming the layer it is in — instead of silently overwriting the correction, and one that moves or withdraws an operation or a field your code names fails the compiler. What is not caught — an operation the vendor adds, a field nobody destructures — is listed in docs/drift.md beside what is.

§Features

featuredefaultadds
clapyestree: the command tree, and ArgMatches back to a sent request
documentnoDocument::load, the Overlay engine, the $ref resolver
generatenothe code generator a bless step calls. Implies document
buildernoa named-argument builder beside each generated wrapper

Each feature adds and removes whole items and never changes one, so a match that is exhaustive in one build is exhaustive in all of them.

No HTTP client, in any combination. The seam is a two-method trait over http::Request<Vec<u8>>. Adapters for ureq 3 and reqwest::Client are about ten lines each and live in examples/toy/cli/src/client.rs, written to be copied rather than depended on. The default feature set is 30 crates; 21 without clap.

§What it does not do

  • No authentication. Sign the http::Request in your own adapter.
  • No oneOf / allOf / anyOf request bodies. A nested body is --json-body FILE; per-field flags exist only for flat ones.
  • No array or object query parameters, and no style / explode.
  • No async CLI. The command tree is sync; AsyncClient is for the typed caller.
  • No check on a whole-body file. --json-body FILE is held to being JSON and no further; holding its content to a schema needs the generated struct, which only your crate can name. tree::select is the seam for it.
  • A regex engine in every binary. Enforcing pattern costs one, there is no feature that removes it, and on the example it is 810 KB of a 4.6 MB stripped binary.
  • The reduced model is a binary blob. It is diffable only by regenerating it, not by reading it.
  • Rust 1.88, in every feature set. The floor is the regex engine a pattern is checked with: regress uses let-chains. Stable throughout — only this repository’s own formatter and coverage recipes want nightly.

§This repository

typed-openapi/the published crate, and the only thing on crates.io
examples/toy/one adoption end to end: an Overlay, a bless step, the generated types and wrappers, and two CLIs over them
docs/reference pages

just gate is the whole check: formatting, clippy, rustdoc, every feature combination, the tests, and the crate built from its own tarball. CI runs those recipes rather than a copy of them.

§Where to go next

docs/generating.mdthe bless step, the Settings interface, wiring your own xtask
docs/overlay.mdwriting corrections as Overlay actions, and the tripwire form
docs/cli.mdmounting the tree, the gate, flag naming, completion
docs/validation.mdevery rule that is enforced, where it runs, and what the engine costs
docs/builders.mdthe builder feature and what it costs
docs/drift.mdevery way a vendor revision is caught, and where
examples/toyone adoption end to end, built and tested by CI

Licensed under either of Apache-2.0 or MIT, at your option.

§The API

The six things a caller learns:

  • Document — the document, corrected and resolved. Document::from_blob takes the reduction back off the bytes Document::to_blob wrote.
  • Values — arguments for one operation, under the document’s own names. A CLI builds one from ArgMatches; a generated wrapper builds one from typed arguments.
  • Invocation — an operation and values that satisfy it. Making one is the validation; Invocation::request is then a rendering.
  • Plan — the gate. A read runs on sight; a write runs only once confirmed, and until then a dry run prints the exact bytes a confirmed run would send.
  • tree::commands and tree::dispatch — the clap tree, and the trip back.
  • SyncClient / AsyncClient — where the request meets the network.

The document is data: an operation is a value in a list, not a branch someone wrote, so there is no chance of the CLI disagreeing with the document it shipped with, and the tree, the completion and the request builder all read the same list.

Reading that list out of YAML is not, however, something a shipped binary should do on every invocation, and under the default feature set it is not something a shipped binary compiles. Document::load is the expensive door and document is what opens it; Document::from_blob is the door a binary uses, and it takes the reduction a bless step already wrote down.

§What a feature may do

Every feature adds and removes whole items and never changes one. No type on this page gains a variant or a field with one, so a caller who matches an error of this crate exhaustively writes the same match in every build, and what the docs say about a type they can see is true of every build that has it.

clap is on by default because most adopters want the command tree; a crate that only wants typed calls turns it off and links no argument parser. document and generate belong to the bless step, and a shipping binary that enabled either would compile a YAML parser, an OpenAPI object model and a code generator it can never reach.

Re-exports§

pub use client::Call;
pub use client::Client;
pub use client::NoContent;
pub use model::LoadError;
pub use model::Body;
pub use model::COMMIT;
pub use model::Document;
pub use model::DocumentError;
pub use model::Effect;
pub use model::Field;
pub use model::JSON_BODY;
pub use model::Location;
pub use model::Operation;
pub use model::Param;
pub use model::RAW_BODY;
pub use names::CommandName;
pub use names::kebab;
pub use plan::Plan;
pub use plan::PlanError;
pub use request::Invocation;
pub use request::ValueError;
pub use request::render;
pub use scalar::Scalar;
pub use transport::AsyncClient;
pub use transport::HttpRequest;
pub use transport::HttpResponse;
pub use transport::Recorder;
pub use transport::SyncClient;
pub use values::Part;
pub use values::Payload;
pub use values::Values;
pub use bon;
pub use regress;

Modules§

client
A typed client over a document, and the two steps every call is made of.
generate
The bless step: a vendor’s OpenAPI document and an adopter’s Overlay in, one corrected document and the Rust an adopter compiles against out.
model
The document, reduced to the facts a CLI and a typed caller both need.
multipart
Encoding a multipart/form-data body (RFC 7578).
names
The naming rules a command line forces on a document: where an operation sits in the two-level command tree, and what happens when two things in one operation want the same flag.
overlay
Applying the adopter’s corrections to the vendor’s document.
plan
The gate: whether a built request may be sent, decided once.
request
One operation plus values that satisfy it, and the request that falls out.
scalar
The value kinds a command-line flag can carry, the rules the document states about them, and how a raw argument string becomes the JSON the wire wants.
schema
Following $refs, and deciding what one schema is worth on a command line.
transport
The one seam between a decided request and the network.
tree
The clap tree, and the trip back from ArgMatches to a sent request.
values
The arguments for one operation, keyed on the names the document uses.