roas-http-validator 0.2.0

Validates HTTP requests against an OpenAPI description, with adapters for axum, actix-web, poem, salvo and rocket
Documentation

roas-http-validator

Validates HTTP requests against an OpenAPI description — framework-agnostic, with adapters for axum, actix-web, poem, salvo and rocket.

crates.io docs.rs

roas checks that a description is well formed. This checks that a request is what the description says it should be: the path is one the description names, the method is one that path offers, every required parameter arrived, each one is the type its Schema Object declares, and the body is what the Request Body Object describes.

Quick start

use roas_http_validator::{RequestView, Validator};

# fn main() -> Result<(), Box<dyn std::error::Error>> {
let spec = serde_yaml_ng::from_str(include_str!("petstore.openapi.yaml"))?;
let validator = Validator::new(spec);

let request = RequestView::new("GET", "/pets").with_query("limit=1000");
let report = validator.validate(&request)?;

assert!(!report.is_valid());
println!("{report}");
# Ok(()) }
GET /pets (listPets): 1 error(s)
  - query parameter "limit": 1000 is above maximum 100

Examples

Three, runnable, one per way this crate gets used:

cargo run -p roas-http-validator --example validate                        # the whole shape, no framework
cargo run -p roas-http-validator --features http    --example axum_layer   # as axum middleware
cargo run -p roas-http-validator --features reqwest --example client_check # checking a call before sending it

validate judges a handful of requests against one description and turns each verdict into the response a server would send — including the difference between a 404, a 405, a 400 and a description that could not be read.

axum_layer is the same thing as middleware, and is where the body decision becomes concrete: it buffers with an explicit cap, validates, and puts the body back so the handler can still read it. It drives the router directly rather than binding a port, so it runs and finishes.

client_check asks the other question — "is the call I am about to make one the API described?" — which is what a contract test wants and wants without a server.

Which request type?

None of them, and all of them.

Rust has no single HTTP request type to validate. http::Request comes closest — it is what hyper, tower, axum, warp and tonic all speak — but it is generic over a body that is usually a stream, and the crate itself is version-split: actix-web 4 still declares http = "0.2" while hyper 1, axum 0.8 and reqwest are on 1.x, so their HeaderMaps are different types that no single signature accepts. Rocket shares nothing with any of them.

So this crate takes RequestView — the small set of things an OpenAPI description actually talks about — and each framework gets a ToRequestView impl behind its own feature:

Feature Covers
http http::Request, http::request::Parts — and so axum, warp, tonic, hyper
actix-web actix_web::HttpRequest
poem poem::Request
salvo salvo_core::http::Request
rocket rocket::Request
reqwest reqwest::Request and its blocking twin — the client's side, for checking a call you are about to make
use roas_http_validator::ToRequestView;

# fn main() -> Result<(), Box<dyn std::error::Error>> {
# let validator = roas_http_validator::Validator::new(serde_json::from_str(
#     r#"{"openapi":"3.2.0","info":{"title":"t","version":"1"},"paths":{"/pets":{"post":{}}}}"#,
# )?);
# let body = b"{}";
let request = http::Request::builder()
    .method("POST")
    .uri("/pets")
    .header("content-type", "application/json")
    .body(())?;

let report = validator.validate(&request.request_view().with_body(body.as_slice()))?;
# Ok(()) }

The body is not part of that conversion, on purpose. A framework body is a stream, and validating one means buffering it — how much, and whether at all, is the caller's decision, so the adapters convert the head and with_body takes the bytes. reqwest is the exception: a non-streaming body is already bytes in memory, so that adapter supplies it and client-side validation is a one-liner.

Routing is a different answer from validation

validate returns Err(RoutingError) when the description says nothing about the request, and Ok(report) when it does. A server usually turns the first into a 404 or a pass-through and the second into a 400, so they are not the same value:

# use roas_http_validator::{RequestView, RoutingError, Validator};
# fn main() -> Result<(), Box<dyn std::error::Error>> {
# let validator = Validator::new(serde_json::from_str(
#     r#"{"openapi":"3.2.0","info":{"title":"t","version":"1"},"paths":{"/pets":{"get":{}}}}"#,
# )?);
match validator.validate(&RequestView::new("DELETE", "/pets")) {
    Err(RoutingError::PathNotFound { .. }) => { /* 404 */ }
    Err(RoutingError::MethodNotAllowed { allowed, .. }) => { /* 405, `Allow: {allowed}` */ }
    Ok(report) if report.is_valid() => { /* on you go */ }
    Ok(report) => { /* 400, and `report.errors` says why */ }
}
# Ok(()) }

Path matching follows the specification's own rule that a concrete segment outranks a templated one, so /pets/mine wins over /pets/{petId}. A Server Object's base path is stripped when the request carries one — resolved per operation, since a Server Object may sit on the Operation Object as well as the Path Item Object and the root and the innermost one wins, so GET /pets under /v1 and POST /pets under /v2 route separately — and the unstripped path is tried too, because an application behind a proxy sees the path without the prefix its own description advertises. Options::base_path overrides all of it.

OpenAPI 3.2's additionalOperations is looked up alongside the eight standard methods. Both are matched case-sensitively, as RFC 9110 §9.1 requires of a method token: additionalOperations keys match the capitalization the description wrote, and the eight standard ones match only their uppercase spelling — get is a different method from GET, and no Path Item Object describes it. MethodNotAllowed reports the token the request carried and lists allowed as method tokens, so it drops straight into an Allow header.

A Path Item Object that is a $ref is merged with what is written beside it rather than replaced by it: only a field present in both is undefined, so a local required parameter beside a reference that carries the operations keeps applying. Local wins where both define the same field, per method. Reference chains are followed to their end, with cycle detection; one that cannot be finished yields RoutingError::Unresolved — neither a 404 nor a 405, because a Path Item Object half of which never arrived cannot be said to lack a method — or, when a local operation does match, a Location::Description error beside the rest of the verdict.

Parameters arrive as text

?limit=10 is the two characters 1 and 0, not the number ten. Before a Schema Object can judge a parameter, the text has to be turned back into the value the description says it is — and style and explode say how it was flattened on the way out. All seven styles are handled:

in Styles
path simple, label, matrix
query form, spaceDelimited, pipeDelimited, deepObject
header simple
cookie form
querystring content (OpenAPI 3.2)

Splitting happens before decoding, so a percent-encoded delimiter stays data: in a non-exploded form array, a%2Cb is one item containing a comma rather than two items. (spaceDelimited has to be the exception — a literal space cannot appear in a query string at all, so %20 is the only spelling its delimiter has.) The same style/explode machinery reads application/x-www-form-urlencoded bodies through their Encoding Object, so a repeated tags=a&tags=b field becomes the array it stands for.

A parameter whose schema this crate cannot read structurally — a composition, say — stays a string, so the schema still judges it and the verdict is at worst too strict, never too lax.

Every error, not the first

ValidationReport::errors collects everything wrong with the request, the way roas's own description validator collects diagnostics: a client that sent three bad parameters is better served by hearing about all three. Each error names where it was found, which parameter it is about, and a JSON Pointer to the value inside it — body at /user/name: … — whatever went wrong there.

violations() and unchecked() split the errors for you: the first is what the request definitely got wrong, the second is what could not be judged either way — an unresolvable $ref counts as the latter, since a schema that could not be reached judged nothing. is_valid() requires both to be empty, and which of the two warrants a 400 is the caller's call to make knowingly.

"Wrong" includes "could not be judged". A subschema that cannot be applied — a pattern that will not compile, a number whose digits floating point already lost — yields no verdict rather than a failing one, and not, anyOf and oneOf all carry that third state instead of reading it as a mismatch. Otherwise { "not": { "pattern": "(" } } would accept anything at all, on the strength of a check that never ran. The logic is properly three-valued, so a constraint the value definitely broke still settles the schema: minLength: 2 rejects "x" whether or not the pattern beside it compiles.

Numbers are compared as the decimals they were written as. serde_json is built here with arbitrary_precision, so a parsed number keeps its literal, and every numeric keyword is integer arithmetic on mantissa × 10^scale rather than a judgement about what an f64 made of it:

written the double says the literal says
1.0 against type: integer might have had a fraction it is an integer
1.0000000000000001 against type: integer same value as 1.0 it is not
9007199254740993 against maximum: 9007199254740992 equal above it
0.3 against multipleOf: 0.1 2.9999999999999996, unprovable a multiple
1.23 against multipleOf: 0.01 unprovable a multiple

Parameters go the same way — a query value is parsed as a decimal rather than through an f64 — so ?n=9007199254740993 is that number and not the nearest double.

What is left unchecked is only what will not fit: a literal past i128's range is reported rather than approximated, and so is a comparison it makes undecidable — two such literals that are not written identically cannot be told apart, which uniqueItems says rather than assumes.

The instance side is always exact: a request body is JSON and this crate parses it itself.

The schema side is exact as far as the format it was parsed from allows. roas's numeric fields are serde_json::Number and this crate turns on its exact-numbers feature, which is what makes a Number keep its literal — so a JSON description is exact throughout. A YAML one is exact for every integer this crate can hold — both stop at exactly i128's range, so nothing YAML loses would have been decidable in any format — but not for a literal written with a decimal point or an exponent: serde_yaml_ng reads those through an f64 before serde_json is involved, so maximum: 9007199254740993.5 arrives as 9007199254740994 and maximum: 9007199254740993e0 arrives as 9007199254740992. The rounded value still fits, so the verdict that follows is definite and wrong rather than reported — a request of 9007199254740994 is accepted against the first, and one of 9007199254740993 is rejected by the second. The same descriptions in JSON decide both correctly. That loss happens in the YAML parser, upstream of anything this crate or roas can reach.

In practice this needs a bound written in YAML with more significant digits than a double carries — 17-plus — and a decimal point or exponent, since a plain integer never goes near an f64. Ordinary decimals (0.1, 1.25, 2.5) round-trip YAML unchanged, and so does every integer in range — 2^53, i64::MAX, u64::MAX and both ends of i128 all survive. Past that range a number is reported as unchecked rather than compared, whatever it was parsed from.

Versions

The interpreter is v3.2. Enable v3_1, v3_0 or v2 to accept an older description: it is upconverted through roas's own migrations first, so there is one interpreter rather than four.

# use roas_http_validator::{Options, Validator};
# #[cfg(feature = "v2")]
# fn main() -> Result<(), Box<dyn std::error::Error>> {
let swagger = serde_json::from_str(r#"{"swagger":"2.0","info":{"title":"t","version":"1"}}"#)?;
let validator = Validator::from_v2(swagger, Options::new());
# Ok(()) }
# #[cfg(not(feature = "v2"))]
# fn main() {}

Media types it does not read itself

JSON, application/x-www-form-urlencoded and text/* are built in. Anything else — multipart/form-data, XML, whatever your service actually speaks — is reported as unchecked rather than guessed at, and Options::decoder is the way in:

# use roas_http_validator::Options;
let options = Options::new().decoder("application/xml", |bytes, _media_type| {
    let text = std::str::from_utf8(bytes).map_err(|error| error.to_string())?;
    Ok(serde_json::json!({ "raw": text }))  // whatever mapping your clients use
});

The bytes become a value and the Schema Object judges it like any other, JSON Pointers and all. Lookup follows Media Type Object precedence — exact, then type/*, then */* — and a registration beats the built-in for the same media type.

These two are a hook rather than more built-ins on purpose, for reasons that differ by format. Multipart would mean owning a boundary parser and buffering file uploads, which is precisely where this crate's "the caller decides what to buffer" posture earns its keep. XML has no specified mapping onto a JSON Schema instance at all — OpenAPI's XML Object is serialization metadata for code generators, so any translation is a choice and implementations make different ones. Taking yours beats inventing one and reporting violations against it.

What it does not check yet

Response validation and security requirements. Anything a check could not judge is reported as ErrorKind::Unsupported rather than passed over, so a request never looks valid because nothing looked at it.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.