# openapi-contract
[](https://github.com/ZacharyL2/openapi-contract/actions/workflows/ci.yml)
[](https://codecov.io/gh/ZacharyL2/openapi-contract)
[](https://crates.io/crates/openapi-contract)
[](https://docs.rs/openapi-contract)
[](#license)
**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.
```rust
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.
## Why
Most Rust HTTP clients against an OpenAPI-described service look like this:
```rust
// 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** — `DELETE` on a `GET`-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
every `api!` call is known at compile time.
- **SSE is a first-class citizen** — endpoints that return
`text/event-stream` are exposed as a `SseStream` you can `.next().await`.
- **Transport is pluggable** — bring your own `ApiClient`
implementation (auth headers, retries, tracing, mock servers, etc.);
the macro only owns the contract, not the wire.
## Quick start
`Cargo.toml`:
```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:
```rust
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(())
}
```
## What the compile errors look like
```text
error: unknown API path "/api/tems/my". Similar paths: /api/teams/my, /api/teams/{id}/members
--> src/main.rs:7:25
|
--> 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
|