# roas-http-validator
Validates HTTP requests against an [OpenAPI](https://spec.openapis.org/oas/latest.html) description — framework-agnostic, with adapters for axum, actix-web, poem, salvo and rocket.
[](https://crates.io/crates/roas-http-validator)
[](https://docs.rs/roas-http-validator)
[`roas`](https://crates.io/crates/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
```rust
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(()) }
```
```text
GET /pets (listPets): 1 error(s)
- query parameter "limit": 1000 is above maximum 100
```
## Examples
Three, runnable, one per way this crate gets used:
```shell
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`](examples/validate.rs) 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`](examples/axum_layer.rs) 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`](examples/client_check.rs) 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 `HeaderMap`s 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 |
```rust
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:
```rust
# 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](https://www.rfc-editor.org/rfc/rfc9110#section-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](https://spec.openapis.org/oas/v3.2.0#path-item-object), 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.
```rust
# 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:
```rust
# 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](../../LICENSE-APACHE) or [MIT license](../../LICENSE-MIT) at your option.