Compile-time OpenAPI contract checking for Rust HTTP clients.
openapi-contract turns your OpenAPI spec into a compile-time source of
truth. Typos in paths, missing path parameters, wrong HTTP methods, and
drift between your code and the spec all become build errors — not
3 a.m. production surprises.
use openapi_contract::{api, generate_types};
generate_types!("openapi-spec.json");
let team_id = "t1";
let members = api!(GET "/api/teams/{id}/members", id = &team_id)
.fetch(&client)
.await?;If /api/teams/{id}/members doesn't exist in your spec, if you forget
the id param, or if you write POST instead of GET, the code
won't compile. The response type is derived from the spec's 200
schema, so members is already a strongly-typed Vec<TeamMember> —
no hand-written DTOs, no serde_json::Value soup, no annotation needed.
Most Rust HTTP clients against an OpenAPI-described service look like this:
// Typo? Wrong method? Missing param? You'll find out at runtime.
let url = format!("{}/api/teams/{}/memberz", base, team_id);
let res: Vec<TeamMember> = client.get(&url).send().await?.json().await?;openapi-contract closes that gap:
- Paths are checked against the spec — unknown paths are rejected at compile time, with suggestions for similar paths.
- HTTP methods are checked —
DELETEon aGET-only endpoint fails to compile, and the error tells you which methods are defined. - Path parameters are checked — missing or extra path parameters are caught before the binary is produced.
- Response types are generated from the spec —
generate_types!expands into ordinary Rust structs and enums, so the response type of everyapi!call is known at compile time. - SSE is a first-class citizen — endpoints that return
text/event-streamare exposed as aSseStreamyou can.next().await. - Transport is pluggable — bring your own
ApiClientimplementation (auth headers, retries, tracing, mock servers, etc.); the macro only owns the contract, not the wire.
Cargo.toml:
[dependencies]
openapi-contract = "0.1"
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }Drop your spec at the crate root as openapi-spec.json (or point
OPENAPI_SPEC_PATH at an absolute location), then:
use openapi_contract::{api, generate_types, ApiClient, ApiError, Method};
use openapi_contract::sse::SseStream;
// Expand the spec's `components.schemas` into plain Rust types.
generate_types!("openapi-spec.json");
// A minimal ApiClient — plug in auth, retries, tracing, etc.
struct MyClient {
base_url: String,
http: reqwest::Client,
}
impl ApiClient for MyClient {
fn request(
&self,
method: Method,
path: &str,
query: Option<&str>,
body: Option<String>,
) -> impl std::future::Future<Output = Result<reqwest::Response, ApiError>> + Send {
let mut url = format!("{}{}", self.base_url, path);
if let Some(qs) = query {
url.push('?');
url.push_str(qs);
}
let mut req = self.http.request(method.as_reqwest(), &url);
if let Some(b) = body {
req = req.header("content-type", "application/json").body(b);
}
async move { req.send().await.map_err(ApiError::from) }
}
fn request_stream(
&self,
method: Method,
path: &str,
query: Option<&str>,
) -> impl std::future::Future<Output = Result<SseStream, ApiError>> + Send {
let mut url = format!("{}{}", self.base_url, path);
if let Some(qs) = query {
url.push('?');
url.push_str(qs);
}
let req = self.http.request(method.as_reqwest(), &url);
async move {
let resp = req.send().await.map_err(ApiError::from)?;
Ok(SseStream::new(Box::pin(resp.bytes_stream())))
}
}
}
#[tokio::main]
async fn main() -> Result<(), ApiError> {
let client = MyClient {
base_url: "https://api.example.com".into(),
http: reqwest::Client::new(),
};
// GET with a path param — response type is inferred from the spec.
let team_id = "t1";
let members = api!(GET "/api/teams/{id}/members", id = &team_id)
.fetch(&client)
.await?;
// POST with a typed body.
let invite = InviteInput {
email: "new@example.com".into(),
role: Some("member".into()),
};
let result = api!(POST "/api/teams/{id}/invite", id = &team_id, body = &invite)
.fetch(&client)
.await?;
// GET with query parameters.
let users = api!(GET "/api/users", query = { limit: 10, offset: 0 })
.fetch(&client)
.await?;
println!("{} members, {} users, invite id {}", members.len(), users.len(), result.id);
Ok(())
}error: unknown API path "/api/tems/my". Similar paths: /api/teams/my, /api/teams/{id}/members
--> src/main.rs:7:25
|
7 | let _req = api!(GET "/api/tems/my");
| ^^^^^^^^^^^^^^
error: DELETE is not defined for "/api/billing/current". Available methods: GET
--> src/main.rs:9:21
|
9 | let _req = api!(DELETE "/api/billing/current");
| ^^^^^^
error: missing path parameter `id` for GET "/api/teams/{id}/members". Required: id
--> src/main.rs:11:25
|
11 | let _req = api!(GET "/api/teams/{id}/members");
| ^^^^^^^^^^^^^^^^^^^^^^^^^
No runtime behaviour involved — these are proc-macro diagnostics produced while the crate is being built.
| Feature | Description |
|---|---|
api!(METHOD "path", ...) |
Validated request builder — paths, methods, and parameters checked against the spec at compile time |
generate_types!("spec.json") |
Expands components.schemas into plain Rust structs and enums with serde derives |
ApiClient trait |
Pluggable transport — bring your own reqwest client, auth, retries, tracing |
SseStream |
First-class text/event-stream support for streaming endpoints |
Measured with cargo llvm-cov --workspace: 99.28% lines / 98.33% regions / 96.92% functions.
The suite covers four layers: in-source unit tests, trybuild
compile-pass / compile-fail tests for the proc macros, mockito-driven
runtime tests for the full request path, and a small set of live
httpbin.org integration tests (gated behind #[ignore]).
At macro expansion time, the spec is located by (in order):
- Absolute path passed to
generate_types!. $OPENAPI_SPEC_PATHenv var (absolute or relative toCARGO_MANIFEST_DIR).CARGO_MANIFEST_DIR-relative path passed to the macro.- Parent directories of
CARGO_MANIFEST_DIR, walked upward.
Only OpenAPI 3.x JSON is supported in 0.1. components.schemas is
expanded into structs and enums; request and response types are pulled
from application/json content for 200 / 201 / 202 responses.
An endpoint whose 200 response uses text/event-stream is treated as
SSE — its response type is () and it must be consumed via
fetch_stream.
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.