openapi-contract 0.1.0

Compile-time OpenAPI contract checking for Rust HTTP clients. Validates paths, parameters, and response types against your OpenAPI spec at macro expansion.
Documentation
# openapi-contract

[![CI](https://github.com/ZacharyL2/openapi-contract/actions/workflows/ci.yml/badge.svg)](https://github.com/ZacharyL2/openapi-contract/actions/workflows/ci.yml)
[![Coverage](https://codecov.io/gh/ZacharyL2/openapi-contract/branch/main/graph/badge.svg)](https://codecov.io/gh/ZacharyL2/openapi-contract)
[![Crates.io](https://img.shields.io/crates/v/openapi-contract.svg)](https://crates.io/crates/openapi-contract)
[![Docs.rs](https://docs.rs/openapi-contract/badge.svg)](https://docs.rs/openapi-contract)
[![License](https://img.shields.io/crates/l/openapi-contract.svg)](#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
  |
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 highlights

| 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 |

## Test coverage

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]`).

## Spec resolution

At macro expansion time, the spec is located by (in order):

1. Absolute path passed to `generate_types!`.
2. `$OPENAPI_SPEC_PATH` env var (absolute or relative to
   `CARGO_MANIFEST_DIR`).
3. `CARGO_MANIFEST_DIR`-relative path passed to the macro.
4. 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`.

## License

Licensed under either of:

- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
  <https://www.apache.org/licenses/LICENSE-2.0>)
- MIT License ([LICENSE-MIT](LICENSE-MIT) or
  <https://opensource.org/licenses/MIT>)

at your option.

### Contribution

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.