From d779dd700e3951fed3d61bf07dfba033df9d5ff3 Mon Sep 17 00:00:00 2001 From: Kilerd Date: Mon, 14 Sep 2026 00:09:43 +0900 Subject: [PATCH] fix(openapi)!: describe HTTP response contracts independently of schemas --- .github/workflows/rust.yml | 5 + MIGRATION.md | 79 +++- README.md | 20 + gotcha/src/lib.rs | 2 + gotcha/src/prelude.rs | 1 + gotcha/src/response.rs | 71 ++++ gotcha/tests/pass/openapi/result_responses.rs | 6 + gotcha/tests/response_contracts.rs | 182 +++++++++ gotcha_core/src/lib.rs | 10 - gotcha_core/src/parameter.rs | 2 +- gotcha_core/src/responsible.rs | 366 ++++++++++++------ gotcha_macro/src/lib.rs | 6 + gotcha_macro/src/route.rs | 111 +++++- 13 files changed, 726 insertions(+), 135 deletions(-) create mode 100644 gotcha/src/response.rs create mode 100644 gotcha/tests/response_contracts.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1f314fb..ba77b6e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -79,6 +79,11 @@ jobs: run: | cargo test -p gotcha_macro --no-default-features + - name: Run tests (gotcha_core) + run: | + cargo test -p gotcha_core --all-features + cargo test -p gotcha_core --no-default-features + clippy: name: Clippy runs-on: ubuntu-latest diff --git a/MIGRATION.md b/MIGRATION.md index 6ed3020..ebe7704 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,6 @@ # Migration Guide +- [Unreleased: HTTP response contracts](#unreleased-http-response-contracts) - [Unreleased: application and route composition](#unreleased-application-and-route-composition) - [Unreleased: documented method routers](#unreleased-documented-method-routers) - [Unreleased: OpenAPI transform composition](#unreleased-openapi-transform-composition) @@ -11,6 +12,82 @@ --- +# Unreleased: HTTP response contracts + +**`Schematic` no longer implies `Responsible`.** A data schema does not determine how a value is +sent over HTTP. Custom return types implement `Responsible` separately, alongside `IntoResponse`; +they can still derive `Schematic`. Ordinary JSON data should be returned as `Json`. +The HTTP-specific `Schematic::empty_body` hook has been removed; `()` has its own response impl. + +Built-in contracts now match the response representation: + +| Return type | Documented response | +| --- | --- | +| `String`, `&str`, boxed/Cow strings | `200 text/plain` | +| `Json` | `200 application/json`, using `T`'s schema | +| `Html` | `200 text/html` | +| `Bytes`, `Vec`, byte slices, boxed/Cow bytes | `200 application/octet-stream`, binary string schema | +| `()` | `200`, no body; `Json<()>` remains JSON | +| `(StatusCode, T)` | `default`, with `T`'s response bodies; the status is a runtime value | +| `StatusCode` / raw `Response` | `default`, with no body schema inferred | + +Media keys omit parameters such as `charset=utf-8`. Regenerate client/spec snapshots that +previously described text or binary responses as JSON. + +Use `WithStatus` to keep a fixed status in the return type and the actual response: + +```rust +use gotcha::{Json, WithStatus}; + +let created: WithStatus<_, 201> = WithStatus::new(Json("created")); +let no_content: WithStatus<_, 204> = WithStatus::new(()); +``` + +This wrapper is available without the `openapi` feature. Statuses outside 100..=599 fail during +compilation when constructed. Informational statuses, 204, 205, and 304 discard bodies and body +headers. As with Axum's status tuple, the outer status overrides an inner error status too; +prefer `Result, 201>, E>` to keep `E`'s status independent. + +**`Result` now requires `Responsible` on both sides.** `ErrorResponsible` and its schema +blanket impl have been removed. Migrate custom errors to `Responsible`, or use an explicit HTTP +type such as `WithStatus, 409>`. A schema-only error no longer automatically adds +`default`. To retain a dynamic JSON error contract, implement `Responsible` using +`gotcha::response::default_response::("application/json", "Error")`. + +For runtime status tuples or additional responses, declare the contract on the handler: + +```rust,ignore +#[api( + responses( + response(status = 404, body = "ApiError", description = "Not found"), + response(status = 409, body = "ApiError", description = "Duplicate") + ), + drop_default +)] +async fn lookup() -> Result, (StatusCode, Json)> { + // Return the documented statuses here. +} +``` + +`body` names a **data type implementing `Schematic`**, not `Json` or another HTTP wrapper. +`content_type` defaults to `application/json` when a body is present; set it explicitly for CSV +or other media. Omit `body` for an empty response. Duplicate statuses, invalid body types and +body declarations on bodyless statuses are rejected by the macro. + +Explicit entries replace inference for their status only. `drop_default` removes the inferred +default, and at least one response must remain. These declarations change documentation only; +the handler remains responsible for sending the declared status and content type. Declared body +schemas participate in the same component collection and reference rewriting as inferred bodies. + +Public helpers in `gotcha::response` support custom contracts: `response::(status, media, +description)`, `empty_response(status, description)`, `default_response::(media, description)`, +and `merge_responses(&mut target, other)`. The latter is also used by `Result`: duplicate statuses +retain media alternatives, differing schemas for one media type form `anyOf`, and identical +definitions are reused. Later header/link/example keys win. Distinct response-level `$ref`s +cannot be unioned and fail at assembly; use inline responses with schema-level references instead. + +--- + # Unreleased: application and route composition **`Gotcha::nest` and `Gotcha::merge` now accept `GotchaRouter>`, not another @@ -332,7 +409,7 @@ gotcha = { version = "0.4", features = ["openapi"] } ## 6. Smaller changes - **Validation rejections return `422`**, not `400`. `400` is still used for a malformed body. Every error now carries a readable `message`. -- **`Result` handlers** need `E: ErrorResponsible`. This is implemented for any `E: Schematic` and for axum's `(StatusCode, Json)` idiom, so most code needs no change. +- **`Result` handlers** now require `Responsible` on both sides; see [HTTP response contracts](#unreleased-http-response-contracts) for migrating schema-only errors and the former `ErrorResponsible` trait. - **Handlers returning nothing** now compile (they previously failed with `E0782`) and document an empty body. - **`Operable`** gained `summary` and `security` fields; only relevant if you construct it by hand rather than through `#[api]`. - **axum 0.8** also removed `#[async_trait]` from its extractor traits. A hand-written `FromRequest` / `FromRequestParts` impl should drop the attribute and use a plain `async fn`. diff --git a/README.md b/README.md index 469c3f3..4a54134 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,26 @@ complete document: top-level `security` is global even when set by a child. Use security for individual routes. Later writes win; maps and lists are not implicitly merged. See [the migration guide](MIGRATION.md#unreleased-openapi-transform-composition) for details. +Response contracts come from HTTP return types: `Json` documents JSON, `String` documents +plain text, `Html` documents HTML, and byte bodies document binary content. `Schematic` only +describes data. Use `WithStatus` for a status shared by the response and its document: + +```rust +use gotcha::prelude::*; + +#[cfg_attr(feature = "openapi", gotcha::api)] +async fn create() -> WithStatus, 201> { + WithStatus::new(Json("created".into())) +} +``` + +`Result` combines both HTTP response contracts. Custom response/error types can implement +`Responsible` alongside `Schematic`, using the helpers in `gotcha::response`. For dynamic status +codes, `#[api(responses(response(status = 404, body = "ApiError")), drop_default)]` declares the +additional response and removes the inferred default. Declarations describe the contract; they +do not change the handler's HTTP behavior. See [the migration guide](MIGRATION.md#unreleased-http-response-contracts) +for custom errors, media types, and inference/override rules. + ### Configuration System Create a `configurations/application.toml` file. Your application's own settings live at the top diff --git a/gotcha/src/lib.rs b/gotcha/src/lib.rs index 11707d8..e545b14 100644 --- a/gotcha/src/lib.rs +++ b/gotcha/src/lib.rs @@ -78,6 +78,8 @@ pub mod error; pub mod openapi; pub mod params; pub mod prelude; +pub mod response; +pub use response::WithStatus; /// The router that tracks OpenAPI operations alongside axum routes. pub mod router; pub mod routing; diff --git a/gotcha/src/prelude.rs b/gotcha/src/prelude.rs index fe7f80a..6d30124 100644 --- a/gotcha/src/prelude.rs +++ b/gotcha/src/prelude.rs @@ -27,6 +27,7 @@ pub use crate::builder::{EmptyConfig, EmptyState, Gotcha}; // Essential traits and types pub use crate::config::{ConfigWrapper, GotchaConfigLoader, ServerConfig}; pub use crate::error::{GotchaError, GotchaResult}; +pub use crate::response::WithStatus; pub use crate::router::Responder; pub use crate::{config, state, GotchaApp, GotchaConfig, GotchaContext, GotchaRouter}; diff --git a/gotcha/src/response.rs b/gotcha/src/response.rs new file mode 100644 index 0000000..b161d52 --- /dev/null +++ b/gotcha/src/response.rs @@ -0,0 +1,71 @@ +//! Response contracts and wrappers that keep runtime behavior and documentation together. +use axum::{ + body::Body, + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; + +#[cfg(feature = "openapi")] +pub use gotcha_core::responsible::{default_response, empty_response, merge_responses, response, Responsible}; + +/// Override a response's status both at runtime and in OpenAPI. +/// +/// ```rust +/// use gotcha::{Json, WithStatus}; +/// let created: WithStatus<_, 201> = WithStatus::new(Json("created")); +/// let no_content: WithStatus<_, 204> = WithStatus::new(()); +/// ``` +/// +/// Statuses must be in 100..=599. Informational responses, 204, 205, and 304 discard the body. +/// Like Axum's `(StatusCode, T)`, this overrides the inner status, including an inner error. +/// Prefer `Result, E>` to keep error statuses independent. +/// +/// ```compile_fail +/// use gotcha::WithStatus; +/// let invalid: WithStatus<_, 999> = WithStatus::new(()); +/// ``` +#[derive(Clone, Debug)] +pub struct WithStatus(T); + +impl WithStatus { + const VALID: () = assert!(STATUS >= 100 && STATUS <= 599, "status must be in 100..=599"); + /// Wrap a response with a fixed status. + pub const fn new(inner: T) -> Self { + let () = Self::VALID; + Self(inner) + } + /// Recover the wrapped value. + pub fn into_inner(self) -> T { + self.0 + } + fn has_body() -> bool { + STATUS >= 200 && !matches!(STATUS, 204 | 205 | 304) + } +} + +impl IntoResponse for WithStatus { + fn into_response(self) -> Response { + let () = Self::VALID; + let mut response = self.0.into_response(); + *response.status_mut() = StatusCode::from_u16(STATUS).expect("validated status"); + if !Self::has_body() { + *response.body_mut() = Body::empty(); + response.headers_mut().remove(header::CONTENT_TYPE); + response.headers_mut().remove(header::CONTENT_LENGTH); + response.headers_mut().remove(header::TRANSFER_ENCODING); + } + response + } +} + +#[cfg(feature = "openapi")] +impl Responsible for WithStatus { + fn response() -> oas::Responses { + let () = Self::VALID; + if Self::has_body() { + gotcha_core::responsible::with_status(T::response(), STATUS) + } else { + empty_response(STATUS, "no content") + } + } +} diff --git a/gotcha/tests/pass/openapi/result_responses.rs b/gotcha/tests/pass/openapi/result_responses.rs index 4f6c941..c300254 100644 --- a/gotcha/tests/pass/openapi/result_responses.rs +++ b/gotcha/tests/pass/openapi/result_responses.rs @@ -16,6 +16,12 @@ struct ApiError { message: String, } +impl Responsible for ApiError { + fn response() -> gotcha::oas::Responses { + gotcha::response::default_response::("application/json", Self::doc().unwrap()) + } +} + fn main() { let responses = , ApiError> as Responsible>::response(); diff --git a/gotcha/tests/response_contracts.rs b/gotcha/tests/response_contracts.rs new file mode 100644 index 0000000..bb071b7 --- /dev/null +++ b/gotcha/tests/response_contracts.rs @@ -0,0 +1,182 @@ +#![cfg(feature = "openapi")] + +use gotcha::axum::{ + body::{to_bytes, Body, Bytes}, + http::{Request, StatusCode}, + response::{Html, IntoResponse, Response}, +}; +use gotcha::{api, ConfigWrapper, EmptyConfig, GotchaApp, GotchaContext, GotchaResult, GotchaRouter, Json, Path, Responsible, Schematic, WithStatus}; +use serde::Serialize; +use serde_json::{json, Value}; +use tower::ServiceExt; + +#[derive(Serialize, Schematic)] +struct Record { + id: u32, +} + +// A type may now describe both its data and its own HTTP response without coherence conflicts. +impl IntoResponse for Record { + fn into_response(self) -> Response { + Json(self).into_response() + } +} +impl Responsible for Record { + fn response() -> gotcha::oas::Responses { + gotcha::response::response::(200, "application/json", "record") + } +} + +#[derive(Serialize, Schematic)] +struct Problem { + message: String, +} + +#[derive(Serialize, Schematic)] +struct DeclaredProblem { + code: u32, +} + +#[api(responses(response(status = 409, body = "DeclaredProblem")), drop_default)] +async fn declared() -> Response { + (StatusCode::CONFLICT, Json(DeclaredProblem { code: 42 })).into_response() +} + +#[api] +async fn create() -> WithStatus { + WithStatus::new(Record { id: 7 }) +} + +#[api] +async fn missing() -> Result, WithStatus, 404>> { + Err(WithStatus::new(Json(Problem { message: "missing".into() }))) +} + +#[api( + responses( + response(status = 404, body = "Problem", description = "Missing record"), + response(status = 409, body = "Problem", description = "Duplicate record") + ), + drop_default +)] +async fn lookup(Path(id): Path) -> Result, (StatusCode, Json)> { + match id { + 7 => Ok(Json(Record { id })), + 0 => Err((StatusCode::CONFLICT, Json(Problem { message: "duplicate".into() }))), + _ => Err((StatusCode::NOT_FOUND, Json(Problem { message: "missing".into() }))), + } +} + +#[api(responses(response(status = 200, body = "String", content_type = "text/plain", description = "Plain greeting")))] +async fn text() -> String { + "hello".into() +} +#[api] +async fn binary() -> Bytes { + Bytes::from_static(b"\x00\x01\x02") +} +#[api] +async fn html() -> Html<&'static str> { + Html("

hello

") +} +#[api] +async fn empty() -> WithStatus, 204> { + WithStatus::new(Json(Record { id: 9 })) +} + +#[api(responses(response(status = 200, body = "String", content_type = "text/csv")), drop_default)] +async fn csv() -> Response { + Response::builder().header("content-type", "text/csv").body(Body::from("id\n7\n")).unwrap() +} + +struct App; +impl GotchaApp for App { + type State = (); + type Config = EmptyConfig; + async fn state(&self, _: &ConfigWrapper) -> GotchaResult<()> { + Ok(()) + } + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { + router + .get("/declared", declared) + .get("/created", create) + .get("/missing", missing) + .get("/lookup/{id}", lookup) + .get("/text", text) + .get("/binary", binary) + .get("/html", html) + .get("/empty", empty) + .get("/csv", csv) + } +} + +async fn request(router: &gotcha::axum::Router, path: &str) -> (u16, String, Vec) { + let response = router.clone().oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()).await.unwrap(); + let status = response.status().as_u16(); + let media = response + .headers() + .get("content-type") + .map(|v| v.to_str().unwrap().split(';').next().unwrap().to_string()) + .unwrap_or_default(); + (status, media, to_bytes(response.into_body(), 1024 * 1024).await.unwrap().to_vec()) +} + +#[tokio::test] +async fn response_contracts_match_http_and_collect_explicit_components() { + let router = App + .build_router(GotchaContext { + state: (), + config: ConfigWrapper::default(), + }) + .await + .unwrap(); + let (_, _, body) = request(&router, "/openapi.json").await; + let spec: Value = serde_json::from_slice(&body).unwrap(); + for (path, documented_path, expected_status, expected_media, expected_body) in [ + ("/declared", "/declared", 409, "application/json", br#"{"code":42}"#.as_slice()), + ("/created", "/created", 201, "application/json", br#"{"id":7}"#.as_slice()), + ("/missing", "/missing", 404, "application/json", br#"{"message":"missing"}"#.as_slice()), + ("/lookup/7", "/lookup/{id}", 200, "application/json", br#"{"id":7}"#.as_slice()), + ("/lookup/0", "/lookup/{id}", 409, "application/json", br#"{"message":"duplicate"}"#.as_slice()), + ("/lookup/1", "/lookup/{id}", 404, "application/json", br#"{"message":"missing"}"#.as_slice()), + ("/text", "/text", 200, "text/plain", b"hello".as_slice()), + ("/binary", "/binary", 200, "application/octet-stream", b"\x00\x01\x02".as_slice()), + ("/html", "/html", 200, "text/html", b"

hello

".as_slice()), + ("/empty", "/empty", 204, "", b"".as_slice()), + ("/csv", "/csv", 200, "text/csv", b"id\n7\n".as_slice()), + ] { + let (status, media, body) = request(&router, path).await; + assert_eq!( + (status, media.as_str(), body.as_slice()), + (expected_status, expected_media, expected_body), + "{path}" + ); + let response = &spec["paths"][documented_path]["get"]["responses"][status.to_string()]; + assert!(response.is_object(), "documented status for {path}"); + if media.is_empty() { + assert!(response["content"].is_null()); + } else { + assert!(response["content"][media].is_object()); + } + } + assert!(spec["paths"]["/created"]["get"]["responses"]["200"].is_null()); + assert!(spec["paths"]["/missing"]["get"]["responses"]["default"].is_null()); + let lookup = &spec["paths"]["/lookup/{id}"]["get"]["responses"]; + assert!(lookup["default"].is_null()); + assert_eq!(lookup["409"]["content"]["application/json"]["schema"]["$ref"], "#/components/schemas/Problem"); + assert_eq!(spec["paths"]["/text"]["get"]["responses"]["200"]["description"], "Plain greeting"); + assert_eq!( + spec["paths"]["/binary"]["get"]["responses"]["200"]["content"]["application/octet-stream"]["schema"], + json!({"type":"string","format":"binary"}) + ); + assert!(spec["components"]["schemas"]["Record"].is_object()); + assert!(spec["components"]["schemas"]["Problem"].is_object()); + assert_eq!( + spec["paths"]["/declared"]["get"]["responses"]["409"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/DeclaredProblem" + ); + assert!( + spec["components"]["schemas"]["DeclaredProblem"].is_object(), + "raw Response cannot infer this component" + ); +} diff --git a/gotcha_core/src/lib.rs b/gotcha_core/src/lib.rs index 14f0959..77a6c99 100644 --- a/gotcha_core/src/lib.rs +++ b/gotcha_core/src/lib.rs @@ -81,12 +81,6 @@ pub trait Schematic { fn fields() -> Vec<(&'static str, EnhancedSchema)> { vec![] } - /// Whether a value of this type is sent as an empty body. Only the unit type is, and - /// [`Responsible`](crate::Responsible) documents it as a response with no content rather than - /// as a JSON body. - fn empty_body() -> bool { - false - } /// Generate the schema of the type. fn generate_schema() -> EnhancedSchema { EnhancedSchema { @@ -156,10 +150,6 @@ impl Schematic for () { "void" } - fn empty_body() -> bool { - true - } - fn generate_schema() -> EnhancedSchema { EnhancedSchema { schema: Schema { diff --git a/gotcha_core/src/parameter.rs b/gotcha_core/src/parameter.rs index 05d23a9..31fb08e 100644 --- a/gotcha_core/src/parameter.rs +++ b/gotcha_core/src/parameter.rs @@ -1,7 +1,7 @@ //! The [`ParameterProvider`] trait maps an axum extractor type to the OpenAPI parameters / //! request body it contributes to an operation. //! -//! Like [`crate::Responsible`], it lives in this crate (behind the `axum` feature) rather +//! It lives in this crate (behind the `axum` feature) rather //! than in `gotcha` because the generic `Path` impl overlaps the tuple impls //! `Path<(T1,)>` / `Path<(T1, T2)>`. The compiler can only prove those do not overlap — i.e. //! that `(T1,)` does not implement [`Schematic`] — in the crate where `Schematic` is defined. diff --git a/gotcha_core/src/responsible.rs b/gotcha_core/src/responsible.rs index aece36c..30fd284 100644 --- a/gotcha_core/src/responsible.rs +++ b/gotcha_core/src/responsible.rs @@ -1,155 +1,283 @@ -//! The [`Responsible`] trait maps a handler return type to its OpenAPI `Responses`. -//! -//! It lives here (rather than in the `gotcha` crate) because it carries a blanket -//! `impl Responsible for T`. For that blanket to coexist with the specific -//! `Json` / `Result` impls, the compiler must be able to prove that `Json<_>` and -//! `Result<_, _>` do not implement [`Schematic`] — which it can only do in the crate where -//! `Schematic` is defined. The axum-specific `Json` impl is gated behind the optional -//! `axum` feature so this crate stays dependency-light by default. - -use std::collections::BTreeMap; - -use oas::{MediaType, Referenceable, Response, Responses}; +//! HTTP response contracts, independent of data schemas. +//! `Schematic` alone does not imply a status or media type. Implement [`Responsible`] for +//! custom HTTP types using [`response`], [`default_response`], and [`empty_response`]. use crate::Schematic; +use oas::{MediaType, Referenceable, Response, Responses, Schema}; +use std::collections::{btree_map::Entry, BTreeMap}; +use std::convert::Infallible; -/// Maps a handler's return type to the operation's OpenAPI responses. +/// Maps a handler's HTTP return type to its OpenAPI responses. pub trait Responsible { - /// The responses this return type produces. + /// The statuses, media types, and bodies this type can produce. fn response() -> Responses; } -/// Build a `200 application/json` response whose body schema is `T`'s schema. -fn json_response() -> Responses { - let response_schema = T::generate_schema(); - let mut response = Responses { - default: None, - data: BTreeMap::default(), - }; - response.data.insert( - "200".to_string(), - Referenceable::Data(Response { - description: "default return".to_string(), - headers: None, - content: Some(BTreeMap::from([( - "application/json".to_string(), - MediaType { - schema: Some(Referenceable::Data(response_schema.schema)), - example: None, - examples: None, - encoding: None, - }, - )])), - links: None, - }), - ); - response -} - -/// A response with no body. -/// -/// axum sends `200` with an empty body for a handler that returns `()` — `impl IntoResponse for ()` -/// defers to `Body::empty()`, and `http::Response::new` defaults to `StatusCode::OK`. It is *not* -/// a `204`, so this documents `200` with no content to match what the server actually sends. A -/// handler that wants `204` returns `StatusCode::NO_CONTENT` explicitly. -fn empty_body_response() -> Responses { - let mut response = Responses { - default: None, - data: BTreeMap::default(), - }; - response.data.insert( - "200".to_string(), - Referenceable::Data(Response { - description: "no content".to_string(), +fn body_response(schema: Schema, media_type: &str, description: impl Into) -> Response { + Response { + description: description.into(), + headers: None, + content: Some(BTreeMap::from([( + media_type.into(), + MediaType { + schema: Some(Referenceable::Data(schema)), + example: None, + examples: None, + encoding: None, + }, + )])), + links: None, + } +} + +/// Describe a body with a fixed HTTP status. The caller's `IntoResponse` must match this contract. +/// Panics for statuses outside the OpenAPI range 100..=599 or statuses that cannot carry a body; +/// use [`empty_response`] for those statuses. +pub fn response(status: u16, media_type: &str, description: impl Into) -> Responses { + assert!(status >= 200 && !matches!(status, 204 | 205 | 304), "use empty_response for a bodyless status"); + single_response(status, body_response(T::generate_schema().schema, media_type, description)) +} + +/// Describe a body whose status is only known at runtime, using OpenAPI's `default` entry. +pub fn default_response(media_type: &str, description: impl Into) -> Responses { + Responses { + default: Some(Referenceable::Data(body_response(T::generate_schema().schema, media_type, description))), + data: BTreeMap::new(), + } +} + +/// Describe a fixed-status response without a body. +pub fn empty_response(status: u16, description: impl Into) -> Responses { + single_response( + status, + Response { + description: description.into(), headers: None, content: None, links: None, - }), - ); - response + }, + ) } -#[cfg(feature = "axum")] -impl Responsible for axum::Json -where - T: Schematic, -{ - fn response() -> Responses { - json_response::() +fn single_response(status: u16, response: Response) -> Responses { + assert!((100..=599).contains(&status), "response status must be in 100..=599"); + Responses { + default: None, + data: BTreeMap::from([(status.to_string(), Referenceable::Data(response))]), } } -impl Responsible for T -where - T: Schematic, -{ - fn response() -> Responses { - // A handler returning `()` sends an empty body, so it documents a `200` with no content. - // Wrapping the unit in `Json` is different — that really does send `null` — and goes - // through the `Json` impl above instead. - if T::empty_body() { - empty_body_response() - } else { - json_response::() +fn merge_response(target: &mut Referenceable, other: Referenceable) { + if serde_json::to_value(&*target).unwrap() == serde_json::to_value(&other).unwrap() { + return; + } + let (Referenceable::Data(target), Referenceable::Data(other)) = (target, other) else { + panic!("cannot combine distinct response-level references; use inline response definitions"); + }; + if target.description != other.description { + target.description.push_str(" / "); + target.description.push_str(&other.description); + } + if let Some(headers) = other.headers { + target.headers.get_or_insert_default().extend(headers); + } + if let Some(links) = other.links { + target.links.get_or_insert_default().extend(links); + } + for (media_type, incoming) in other.content.unwrap_or_default() { + match target.content.get_or_insert_default().entry(media_type) { + Entry::Vacant(entry) => { + entry.insert(incoming); + } + Entry::Occupied(mut entry) => { + let current = entry.get_mut(); + let left = serde_json::to_value(¤t.schema).unwrap(); + let right = serde_json::to_value(&incoming.schema).unwrap(); + if left != right { + // A missing schema is unconstrained. anyOf allows overlapping alternatives. + current.schema = if left.is_null() || right.is_null() { + None + } else { + Some(Referenceable::Data(Schema { + _type: None, + format: None, + nullable: None, + description: None, + extras: BTreeMap::from([("anyOf".into(), serde_json::json!([left, right]))]), + })) + }; + } + if let Some(examples) = incoming.examples { + current.examples.get_or_insert_default().extend(examples); + } + if let Some(encoding) = incoming.encoding { + current.encoding.get_or_insert_default().extend(encoding); + } + if incoming.example.is_some() { + current.example = incoming.example; + } + } } } } -/// Documents the error side of a `Result` handler return type. -/// -/// It contributes the error response(s) to the operation's [`Responses`] — as the OpenAPI -/// `default` response, which covers any status code not otherwise listed. Implemented for any -/// `E: Schematic` and for axum's `(StatusCode, Json)` idiom; custom error types can implement -/// it directly. -pub trait ErrorResponsible { - /// Add this error type's responses to the operation's existing ones. - fn error_responses(responses: &mut Responses); +/// Union response alternatives. Different schemas for the same status/media type become `anyOf`; +/// identical definitions are reused. Later header, link, and example keys win. Distinct response-level +/// `$ref`s cannot be combined and panic; schema references inside inline responses are supported. +pub fn merge_responses(target: &mut Responses, other: Responses) { + for (status, response) in other.data { + match target.data.entry(status) { + Entry::Vacant(entry) => { + entry.insert(response); + } + Entry::Occupied(mut entry) => merge_response(entry.get_mut(), response), + } + } + if let Some(response) = other.default { + match &mut target.default { + Some(current) => merge_response(current, response), + slot => *slot = Some(response), + } + } } -/// A `default` response whose JSON body is `E`'s schema (description taken from `E`'s doc comment). -fn schema_error_response() -> Referenceable { - let schema = E::generate_schema(); - Referenceable::Data(Response { - description: E::doc().unwrap_or_else(|| "error response".to_string()), - headers: None, - content: Some(BTreeMap::from([( - "application/json".to_string(), - MediaType { - schema: Some(Referenceable::Data(schema.schema)), - example: None, - examples: None, - encoding: None, +/// Move all response alternatives to one fixed status, combining their bodies. +pub fn with_status(responses: Responses, status: u16) -> Responses { + assert!((100..=599).contains(&status), "response status must be in 100..=599"); + let mut result = Responses { + default: None, + data: BTreeMap::new(), + }; + for response in responses.data.into_values().chain(responses.default) { + merge_responses( + &mut result, + Responses { + default: None, + data: BTreeMap::from([(status.to_string(), response)]), }, - )])), - links: None, - }) + ); + } + result } -impl ErrorResponsible for E { - fn error_responses(responses: &mut Responses) { - responses.default = Some(schema_error_response::()); +#[cfg(feature = "axum")] +fn dynamic_status(responses: Responses) -> Responses { + let mut result = with_status(responses, 200); + result.default = result.data.remove("200"); + result +} + +impl Responsible for () { + fn response() -> Responses { + empty_response(200, "no content") } } +impl Responsible for Infallible { + fn response() -> Responses { + Responses { + default: None, + data: BTreeMap::new(), + } + } +} + +macro_rules! text_response { + ($($ty:ty),* $(,)?) => { $(impl Responsible for $ty { + fn response() -> Responses { response::(200, "text/plain", "text response") } + })* }; +} +text_response!(String, &str, Box, std::borrow::Cow<'_, str>); -/// axum's `(StatusCode, Json)` error idiom documents `E`'s body. The status is a runtime value, -/// so the body is recorded as the `default` response. #[cfg(feature = "axum")] -impl ErrorResponsible for (axum::http::StatusCode, axum::Json) { - fn error_responses(responses: &mut Responses) { - responses.default = Some(schema_error_response::()); +mod axum_responses { + use super::*; + impl Responsible for axum::Json { + fn response() -> Responses { + response::(200, "application/json", T::doc().unwrap_or_else(|| "JSON response".into())) + } + } + impl Responsible for axum::response::Html { + fn response() -> Responses { + response::(200, "text/html", "HTML response") + } + } + macro_rules! binary_response { + ($($ty:ty),* $(,)?) => { $(impl Responsible for $ty { + fn response() -> Responses { + single_response(200, body_response(Schema { _type: Some("string".into()), format: Some("binary".into()), + nullable: None, description: None, extras: BTreeMap::new(), }, "application/octet-stream", "binary response")) + } + })* }; + } + binary_response!(axum::body::Bytes, Vec, &[u8], Box<[u8]>, std::borrow::Cow<'_, [u8]>); + impl Responsible for (axum::http::StatusCode, T) { + fn response() -> Responses { + dynamic_status(T::response()) + } + } + impl Responsible for axum::http::StatusCode { + fn response() -> Responses { + dynamic_status(empty_response(200, "runtime status, no content")) + } + } + impl Responsible for axum::response::Response { + fn response() -> Responses { + dynamic_status(empty_response(200, "runtime response; body unspecified")) + } } } -impl Responsible for Result -where - T: Responsible, - E: ErrorResponsible, -{ +impl Responsible for Result { fn response() -> Responses { - // Success side (`200`, ...) comes from `T`; the error side `E` documents its own response, - // so a `Result, ApiError>` handler documents both. let mut responses = T::response(); - E::error_responses(&mut responses); + merge_responses(&mut responses, E::response()); responses } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn colliding_statuses_union_media_types_and_schemas() { + let mut responses = response::(200, "application/json", "value"); + merge_responses(&mut responses, response::(200, "application/json", "value")); + let unchanged = serde_json::to_value(&responses).unwrap(); + assert!(unchanged["200"]["content"]["application/json"]["schema"]["anyOf"].is_null()); + merge_responses(&mut responses, response::(200, "application/json", "number")); + merge_responses(&mut responses, response::(200, "text/plain", "text")); + let json = serde_json::to_value(responses).unwrap(); + assert_eq!( + json["200"]["content"]["application/json"]["schema"]["anyOf"], + serde_json::json!([ + {"type":"string"}, {"type":"integer"} + ]) + ); + assert_eq!(json["200"]["content"]["text/plain"]["schema"]["type"], "string"); + } + + #[test] + fn fixed_status_collapses_all_alternatives_including_default() { + let mut responses = response::(200, "text/plain", "success"); + merge_responses(&mut responses, default_response::("application/json", "dynamic")); + let responses = with_status(responses, 201); + assert!(responses.default.is_none()); + assert_eq!(responses.data.keys().collect::>(), ["201"]); + let json = serde_json::to_value(responses).unwrap(); + assert_eq!(json["201"]["content"].as_object().unwrap().len(), 2); + } + + #[test] + fn unconstrained_schema_stays_unconstrained_when_merged() { + let mut responses = response::(200, "application/json", "value"); + let mut unknown = responses.clone(); + let Referenceable::Data(response) = unknown.data.get_mut("200").unwrap() else { + unreachable!() + }; + response.content.as_mut().unwrap().get_mut("application/json").unwrap().schema = None; + merge_responses(&mut responses, unknown); + let json = serde_json::to_value(responses).unwrap(); + assert!(json["200"]["content"]["application/json"]["schema"].is_null()); + } +} diff --git a/gotcha_macro/src/lib.rs b/gotcha_macro/src/lib.rs index 09e17b5..713528b 100644 --- a/gotcha_macro/src/lib.rs +++ b/gotcha_macro/src/lib.rs @@ -49,6 +49,12 @@ pub(crate) mod utils; /// - `summary` - Human-readable operation summary (defaults to the id in Title Case) /// - `deprecated` - Marks the operation as deprecated, e.g. `#[api(deprecated)]` /// - `security` - Name of a required security scheme, e.g. `#[api(security = "bearerAuth")]` +/// - `responses(response(status = 404, body = "ApiError", description = "Not found"))` - +/// Explicit response declarations, replacing inferred entries for those statuses. `body` +/// is an optional Rust type string; `content_type` defaults to `application/json` for bodies. +/// - `drop_default` - Remove the inferred `default` response. At least one response must remain. +/// +/// Response declarations describe HTTP behavior; they do not change what the handler sends. /// /// ## Example /// diff --git a/gotcha_macro/src/route.rs b/gotcha_macro/src/route.rs index 118f1a6..a04f018 100644 --- a/gotcha_macro/src/route.rs +++ b/gotcha_macro/src/route.rs @@ -18,6 +18,66 @@ pub struct RouteMeta { deprecated: Flag, /// Name of a security scheme required for this operation (empty scopes). security: Option, + responses: Option, + drop_default: Flag, +} + +#[derive(Debug, Default)] +struct ResponseDeclarations(Vec); + +#[derive(Debug, FromMeta)] +struct ResponseMeta { + status: u16, + body: Option, + content_type: Option, + description: Option, +} + +impl FromMeta for ResponseDeclarations { + fn from_list(items: &[syn::NestedMeta]) -> darling::Result { + let mut responses = Vec::new(); + let mut statuses = std::collections::BTreeSet::new(); + for item in items { + let syn::NestedMeta::Meta(syn::Meta::List(meta)) = item else { + return Err(darling::Error::custom("expected response(status = ..., body = \"Type\")").with_span(item)); + }; + if !meta.path.is_ident("response") { + return Err(darling::Error::custom("expected response(...)").with_span(meta)); + } + let response = ResponseMeta::from_list(&meta.nested.iter().cloned().collect::>())?; + if !(100..=599).contains(&response.status) { + return Err(darling::Error::custom("response status must be in 100..=599").with_span(meta)); + } + if !statuses.insert(response.status) { + return Err(darling::Error::custom("duplicate response status").with_span(meta)); + } + if let Some(body) = &response.body { + syn::parse_str::(body).map_err(|e| darling::Error::custom(e).with_span(meta))?; + if response.status < 200 || matches!(response.status, 204 | 205 | 304) { + return Err(darling::Error::custom("this status cannot declare a response body").with_span(meta)); + } + } else if response.content_type.is_some() { + return Err(darling::Error::custom("content_type requires a body type").with_span(meta)); + } + responses.push(response); + } + Ok(Self(responses)) + } +} + +impl ResponseMeta { + fn tokens(&self) -> proc_macro2::TokenStream { + let status = self.status; + let description = self.description.clone().unwrap_or_else(|| format!("HTTP {status}")); + match &self.body { + Some(body) => { + let body: syn::Type = syn::parse_str(body).expect("validated body type"); + let media_type = self.content_type.as_deref().unwrap_or("application/json"); + quote!(::gotcha::response::response::<#body>(#status, #media_type, #description)) + } + None => quote!(::gotcha::response::empty_response(#status, #description)), + } + } } pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> TokenStream { @@ -30,6 +90,8 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T } }; let meta = args; + let extra_responses: Vec<_> = meta.responses.unwrap_or_default().0.iter().map(ResponseMeta::tokens).collect(); + let drop_default = meta.drop_default.is_present(); let group = if let Some(group_name) = meta.group { quote! { Some(#group_name) } } else { @@ -93,18 +155,18 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T .collect(); let ret_pos = &input.sig.output; let ret_schematic = match ret_pos { - // A handler with no return type returns `()`, which documents as `204 No Content`. + // A handler with no return type returns `()`, which documents as 200 with no body. // This used to be `( () as ::gotcha::Responsible)` — a cast to a *trait*, which does not // compile (E0782), so such a handler could not be annotated at all. ReturnType::Default => { quote! { - Box::new(|| { <() as ::gotcha::Responsible>::response() }) + <() as ::gotcha::Responsible>::response() } } ReturnType::Type(_, ty) => { quote! { - Box::new(|| {<#ty as ::gotcha::Responsible>::response()}) + <#ty as ::gotcha::Responsible>::response() } } }; @@ -126,7 +188,14 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T ] }); static #ret_uuid_ident : ::gotcha::Lazy ::gotcha::oas::Responses + Send + Sync + 'static>> = ::gotcha::Lazy::new(||{ - #ret_schematic + Box::new(|| { + let mut responses = #ret_schematic; + // Explicit declarations replace inference for their status only. + #(responses.data.extend((#extra_responses).data);)* + if #drop_default { responses.default = None; } + assert!(!responses.data.is_empty() || responses.default.is_some(), "OpenAPI operation must declare at least one response"); + responses + }) }); ::gotcha::inventory::submit! { ::gotcha::Operable { @@ -144,3 +213,37 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T }; TokenStream::from(ret) } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(meta: syn::Meta) -> darling::Result { + let syn::Meta::List(meta) = meta else { unreachable!() }; + RouteMeta::from_list(&meta.nested.into_iter().collect::>()) + } + + #[test] + fn response_declarations_validate_statuses_and_body_types() { + let meta = parse(syn::parse_quote!(api( + responses( + response(status = 201, body = "Vec", description = "Created"), + response(status = 204) + ), + drop_default + ))) + .unwrap(); + assert_eq!(meta.responses.unwrap().0.len(), 2); + assert!(meta.drop_default.is_present()); + for invalid in [ + syn::parse_quote!(api(responses(response(status = 99)))), + syn::parse_quote!(api(responses(response(status = 600)))), + syn::parse_quote!(api(responses(response(status = 204, body = "String")))), + syn::parse_quote!(api(responses(response(status = 200, body = "Vec<")))), + syn::parse_quote!(api(responses(response(status = 200, content_type = "text/plain")))), + syn::parse_quote!(api(responses(response(status = 404), response(status = 404)))), + ] { + assert!(parse(invalid).is_err()); + } + } +}