Skip to content

ZacharyL2/openapi-contract

Repository files navigation

openapi-contract

CI Coverage Crates.io Docs.rs 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.

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:

// 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 checkedDELETE 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 specgenerate_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:

[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(())
}

What the compile errors look like

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:

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.

About

No description, website, or topics provided.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages