From c76ae5f33808b5eaedaa0d5db82daf3449441d7b Mon Sep 17 00:00:00 2001 From: James Lal Date: Tue, 27 Oct 2026 04:20:22 -0600 Subject: [PATCH] fix: publish generated validation problem documentation --- .github/workflows/ci.yml | 17 +++++++ CHANGELOG.md | 7 +++ docs/examples/customize-validation.rs | 37 ++++++++++++++ docs/server-validation.md | 44 +++++++++++++++++ tests/server_validation_roundtrip_test.rs | 53 ++++++++++++++++++++ website/package.json | 3 +- website/scripts/audit-problems.mjs | 16 ++++++ website/src/data/problems.ts | 39 +++++++++++++++ website/src/pages/openapi-to-axum.astro | 12 +++-- website/src/pages/problems/[slug].astro | 59 +++++++++++++++++++++++ 10 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 docs/examples/customize-validation.rs create mode 100644 website/scripts/audit-problems.mjs create mode 100644 website/src/data/problems.ts create mode 100644 website/src/pages/problems/[slug].astro diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2297bba..db4f3409 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,23 @@ env: CARGO_TERM_COLOR: always jobs: + website: + runs-on: ubuntu-latest + defaults: + run: + working-directory: website + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: website/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm run build + - run: npm run audit:html + - run: npm run audit:seo + test: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index e764009c..94d2fe5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ when correcting output that was wrong or incomplete on the wire. ## [Unreleased] +### Fixed + +- Publish documentation at all six generated Axum Problem Details type URIs + so existing validation error links resolve (#109). Website builds now check + that every emitted problem URI has a page. Document response middleware for + customizing the existing public validation error profile. + ## [0.23.0] - 2026-10-25 ### Added diff --git a/docs/examples/customize-validation.rs b/docs/examples/customize-validation.rs new file mode 100644 index 00000000..f43f7f23 --- /dev/null +++ b/docs/examples/customize-validation.rs @@ -0,0 +1,37 @@ +use crate::generated::ProblemDetails; +use axum::{ + body::{to_bytes, Body}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; + +pub async fn customize_validation(response: Response) -> Response { + if response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + != Some("application/problem+json") + { + return response; + } + let (mut parts, body) = response.into_parts(); + // Only buffer bounded problem bodies, never normal or streaming responses. + let Ok(bytes) = to_bytes(body, 64 * 1024).await else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let mut output = bytes.to_vec(); + if let Ok(mut problem) = serde_json::from_slice::(&bytes) { + if let Some(slug) = problem + .r#type + .strip_prefix("https://openapi-to-rust.dev/problems/") + { + problem.r#type = format!("https://api.example.com/problems/{slug}"); + if let Ok(encoded) = serde_json::to_vec(&problem) { + output = encoded; + // The new JSON may have a different byte length. + parts.headers.remove(header::CONTENT_LENGTH); + } + } + } + Response::from_parts(parts, Body::from(output)) +} diff --git a/docs/server-validation.md b/docs/server-validation.md index e2d9c586..c61c2518 100644 --- a/docs/server-validation.md +++ b/docs/server-validation.md @@ -64,10 +64,54 @@ traversed, then sorted before the bounded response is returned. | 422 | `request_validation_failed` | A decoded value violates the OpenAPI schema | | 500 | `generated_contract_error` | Generated Rust types disagree with a schema that already accepted the value | +The six type URIs resolve to human-readable documentation: + +- [Malformed request](https://openapi-to-rust.dev/problems/malformed-request) +- [Malformed parameter](https://openapi-to-rust.dev/problems/malformed-parameter) +- [Validation failure](https://openapi-to-rust.dev/problems/validation) +- [Body too large](https://openapi-to-rust.dev/problems/request-body-too-large) +- [Unsupported media type](https://openapi-to-rust.dev/problems/unsupported-media-type) +- [Generated contract error](https://openapi-to-rust.dev/problems/generated-contract-error) + +These identifiers remain stable for previously generated servers. Clients do +not need to fetch the documentation while processing a rejection. + Generated clients preserve their operation-specific typed error and raw body. `ApiError::problem_details()` lazily decodes this profile only when the response declares `application/problem+json`. +## Customizing responses + +Generated `ProblemDetails` and `InvalidParameter` fields are public, as is the +`RequestValidationRejection.0` field. Generated handlers reject invalid requests +before calling your API trait and immediately convert the rejection into an +Axum response. Implementing your API trait therefore cannot intercept it, and +the generated type already has an `IntoResponse` implementation. + +Attach Axum 0.8 response middleware to the generated router instead: + +```rust,ignore +let app = build_router(api) + .layer(axum::middleware::map_response(customize_validation)); +``` + +The [tested middleware example](examples/customize-validation.rs) reads bounded +`application/problem+json` bodies, decodes the generated `ProblemDetails`, and +replaces the project type URI prefix with `https://api.example.com/problems/`. +Copy it into your application and adjust the `crate::generated` import to match +your generated module. It preserves the HTTP status, other headers, and normal +or streaming responses; malformed or unrelated JSON passes through unchanged. +A problem body that exceeds the example's 64 KiB bound or fails to read becomes +an empty HTTP 500 response. Choose an appropriate bound for your service. + +You can also change `title` or individual violations before serialization. For +a custom JSON shape, serialize your own type instead and set the response +Content-Type accordingly. Keep the status and body consistent, remove any stale +Content-Length after rewriting, and preserve the sanitized error policy below. +Custom profiles may no longer decode through generated clients' +`problem_details()` helper. Serve documentation for any replacement type URIs +you publish. There is currently no `problem_base_url` TOML option. + ## Information disclosure and logging Public errors never include submitted values, Serde or validator diagnostics, diff --git a/tests/server_validation_roundtrip_test.rs b/tests/server_validation_roundtrip_test.rs index 71743a4d..09ab2c22 100644 --- a/tests/server_validation_roundtrip_test.rs +++ b/tests/server_validation_roundtrip_test.rs @@ -139,6 +139,7 @@ serde_json = "1" serde_urlencoded = "0.7" thiserror = "2" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } +tower = { version = "0.5", features = ["util"] } url = "2" "#, ) @@ -148,6 +149,8 @@ url = "2" temp.path().join("src/lib.rs"), r####"pub mod generated; +include!("customize-validation.rs"); + #[cfg(test)] mod tests { use super::generated::*; @@ -161,6 +164,50 @@ mod tests { const SECRET: &str = "TOP_SECRET_MARKER"; + #[tokio::test] + async fn documented_response_middleware_customizes_generated_rejections() { + use axum::{body::{Body, to_bytes}, http::{Request, StatusCode, header}, response::IntoResponse}; + use tower::ServiceExt; + let calls = Arc::new(AtomicUsize::new(0)); + let app = server_api_router(Api { calls: calls.clone() }) + .layer(axum::middleware::map_response(crate::customize_validation)); + let response = app.oneshot(Request::builder() + .method("POST").uri("/widgets/acme?limit=2") + .header("x-mode", "sync").header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"name":"x","secret":"ok"}"#)).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/problem+json"); + let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let problem: ProblemDetails = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(problem.r#type, "https://api.example.com/problems/validation"); + assert_eq!(problem.code, "request_validation_failed"); + assert_eq!(problem.errors[0].location, "/body/name"); + assert_eq!(calls.load(Ordering::SeqCst), 0); + + // Normal bodies pass through, even above the problem buffer limit. + let normal = "x".repeat(70 * 1024).into_response(); + let normal = crate::customize_validation(normal).await; + assert_eq!(to_bytes(normal.into_body(), 100 * 1024).await.unwrap().len(), 70 * 1024); + + for raw in ["not json", r#"{"type":"https://other.example/problem","title":"Other","status":422,"code":"other"}"#] { + let response = (StatusCode::UNPROCESSABLE_ENTITY, [(header::CONTENT_TYPE, "application/problem+json")], raw).into_response(); + let response = crate::customize_validation(response).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(to_bytes(response.into_body(), 64 * 1024).await.unwrap(), raw); + } + + let raw = r#"{"type":"https://openapi-to-rust.dev/problems/validation","title":"Request validation failed","status":422,"code":"request_validation_failed"}"#; + let mut response = (StatusCode::UNPROCESSABLE_ENTITY, [(header::CONTENT_TYPE, "application/problem+json")], raw).into_response(); + response.headers_mut().insert(header::CONTENT_LENGTH, raw.len().into()); + response.headers_mut().insert("x-request-id", "example".parse().unwrap()); + let response = crate::customize_validation(response).await; + assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); + assert_eq!(response.headers()["x-request-id"], "example"); + + let oversized = ([(header::CONTENT_TYPE, "application/problem+json")], "x".repeat(70 * 1024)).into_response(); + assert_eq!(crate::customize_validation(oversized).await.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + #[derive(Clone)] struct Api { calls: Arc, @@ -373,6 +420,12 @@ mod tests { ) .expect("scratch lib"); + std::fs::write( + temp.path().join("src/customize-validation.rs"), + include_str!("../docs/examples/customize-validation.rs"), + ) + .expect("documented middleware example"); + let output = Command::new("cargo") .args(["test", "--quiet", "--offline"]) .current_dir(temp.path()) diff --git a/website/package.json b/website/package.json index a2030dcb..6255e33a 100644 --- a/website/package.json +++ b/website/package.json @@ -7,10 +7,11 @@ "predev": "node scripts/fetch-playground-wasm.mjs", "dev": "astro dev", "prebuild": "node scripts/fetch-playground-wasm.mjs", - "build": "astro check && astro build", + "build": "astro check && astro build && node scripts/audit-problems.mjs", "check": "astro check", "audit:html": "html-validate 'dist/**/*.html'", "audit:seo": "node scripts/audit-seo.mjs", + "audit:problems": "node scripts/audit-problems.mjs", "preview": "astro preview" }, "dependencies": { diff --git a/website/scripts/audit-problems.mjs b/website/scripts/audit-problems.mjs new file mode 100644 index 00000000..003e6aa2 --- /dev/null +++ b/website/scripts/audit-problems.mjs @@ -0,0 +1,16 @@ +// Run after Astro builds: every problem type emitted by the generator must +// resolve to its own documentation page, including transport-only helpers. +import { readFile } from "node:fs/promises"; + +const source = await readFile(new URL("../../src/server/validation.rs", import.meta.url), "utf8"); +const uris = new Set(source.match(/https:\/\/openapi-to-rust\.dev\/problems\/[a-z-]+/g)); +if (uris.size === 0) throw new Error("No generated problem type URIs found; check the audit's source path/pattern."); + +for (const uri of uris) { + const { pathname } = new URL(uri); + const html = await readFile(new URL(`../dist${pathname}/index.html`, import.meta.url), "utf8"); + if (!html.includes(`A typed response enum per operation, with variants tied to documented status codes.
  • Axum handlers and a combined build_router(...) factory.
  • Path, query, header, and request-body extraction.
  • -
  • An HTTP 400 short-circuit when required parameters are missing or invalid at the handler boundary.
  • +
  • Request validation before your implementation runs, with sanitized Problem Details responses.
  • SSE-ready response variants when streaming behavior is declared.
  • @@ -105,8 +105,14 @@ openapi-to-rust generate`} /> to documented responses. A successful JSON response, a typed 4xx body, and an empty 204 become distinct Rust variants.

    - This makes the implementation’s possible wire responses visible in its return type. It does not add general runtime schema - validation: minimums, maximums, patterns, and similar constraints are included as documentation rather than validators. + This makes the implementation’s possible wire responses visible in its return type. Request schema validation is enabled + by default; response bodies are not automatically validated against their schemas at runtime. +

    +

    + Missing required inputs and schema violations return HTTP 422 validation problems. + Malformed input returns HTTP 400; size and media-type failures use HTTP 413 and 415. + See the validation configuration and response customization guide + for limits and a tested response-middleware example.

    Generate SSE-ready Axum responses

    diff --git a/website/src/pages/problems/[slug].astro b/website/src/pages/problems/[slug].astro new file mode 100644 index 00000000..7234ae33 --- /dev/null +++ b/website/src/pages/problems/[slug].astro @@ -0,0 +1,59 @@ +--- +import DocsLayout from "../../layouts/DocsLayout.astro"; +import CodeBlock from "../../components/CodeBlock.astro"; +import { problems } from "../../data/problems"; + +export function getStaticPaths() { + return problems.map((problem) => ({ params: { slug: problem.slug }, props: { problem } })); +} + +const { problem } = Astro.props; +const uri = `https://openapi-to-rust.dev/problems/${problem.slug}`; +const example = JSON.stringify({ + type: uri, title: problem.title, status: problem.status, code: problem.code, + ...(problem.slug === "validation" ? { errors: [{ code: "required", location: "/body/name", message: "is required" }] } : {}), + ...(problem.slug === "malformed-parameter" ? { errors: [{ code: "malformed", location: "/query/limit", message: "is malformed" }] } : {}), +}, null, 2); +--- + + +

    Problem identity

    +

    HTTP status: {problem.status}. Stable code: {problem.code}.

    +

    Type URI: {uri}.

    +

    How to respond

    +

    {problem.action}

    +

    Example response

    +

    The response uses application/problem+json. This is an illustrative body; individual violations depend on the request.

    + +

    Error fields

    +

    + type identifies the problem category, title summarizes it, + status mirrors the HTTP status, and code is a stable machine-readable label. + The optional errors array contains sanitized code, location, + and message fields. Empty arrays are omitted. +

    +

    + Locations are JSON Pointers rooted at /body, /path, /query, + /header, or /cookie. Violations are deduplicated, capped (16 by default), + then sorted. Messages omit submitted values and internal diagnostics. +

    +

    + Clients can use the type URI as an identifier without fetching this page for every error. + See RFC 9457, section 3.1.1. +

    +

    Related documentation

    + +