diff --git a/MIGRATION.md b/MIGRATION.md index ebe7704..755e264 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,8 +15,9 @@ # 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`. +sent over HTTP. For automatic response inference, custom HTTP 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: @@ -48,10 +49,11 @@ compilation when constructed. Informational statuses, 204, 205, and 304 discard 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 +**Automatic inference for `Result` requires `Responsible` on both sides.** `ErrorResponsible` +and its schema blanket impl have been removed. Migrate custom errors to `Responsible`, use a fixed +HTTP type such as `WithStatus, 409>`, or declare the endpoint's complete error contract +with `errors(...)` as shown below. 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: @@ -79,6 +81,37 @@ default, and at least one response must remain. These declarations change docume 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. +For a `thiserror` enum that already implements `IntoResponse`, use `errors(...)` to describe all +errors for this endpoint without implementing `Responsible` on the enum: + +```rust,ignore +#[api(errors( + response(status = 404, body = "ErrorBody", description = "Not found"), + response(status = 409, body = "ErrorBody", description = "Duplicate"), + response(status = 500, body = "ErrorBody", description = "Internal error") +))] +async fn create_user() -> Result, ApiError> { + // ApiError::into_response() sends the actual status and ErrorBody. +} +``` + +`ErrorBody` is the serialized data type and must implement `Schematic`; `ApiError` itself needs +neither `Schematic` nor `Responsible`. The success type still needs `Responsible`. Type aliases +such as `type ApiResult = Result` work as well. See the +[README example](README.md#openapi-documentation) for a complete `thiserror` implementation. + +`errors(...)` requires a `Result` return type and at least one response, using the same declaration +fields and validation as `responses(...)`. It completely replaces inference of the error branch, +even if the error type implements `Responsible`. Another endpoint using the same enum can declare +a different error set. The success contract is kept in full, including any dynamic `default`; +`drop_default` is not needed to suppress error inference. + +Assembly order is: infer the success branch, merge the declared error alternatives, apply +`responses(...)` overrides, then apply an explicit `drop_default`. If success and error branches +share a status, their media types and schemas are combined using the same rules as ordinary +`Result` inference. With no `errors(...)`, existing inference and `responses(...)` behavior are +unchanged. None of these attributes changes runtime HTTP behavior. + 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 @@ -409,7 +442,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** now require `Responsible` on both sides; see [HTTP response contracts](#unreleased-http-response-contracts) for migrating schema-only errors and the former `ErrorResponsible` trait. +- **`Result` handlers** require `Responsible` on both sides for automatic inference, or can use `#[api(errors(...))]` for explicit error documentation; see [HTTP response contracts](#unreleased-http-response-contracts) for migration. - **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 4a54134..dc9ad33 100644 --- a/README.md +++ b/README.md @@ -244,12 +244,50 @@ async fn create() -> WithStatus, 201> { } ``` -`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. +`Result` combines both HTTP response contracts by default. Custom response/error types can +implement `Responsible` using the helpers in `gotcha::response`. + +To document errors on each endpoint, use `#[api(errors(...))]`. Only the success response is inferred; +your error enum needs `IntoResponse` for HTTP behavior, with no `Responsible` implementation: + +```rust +use gotcha::{Json, axum::{http::StatusCode, response::{IntoResponse, Response}}}; + +#[derive(Debug, thiserror::Error)] +enum ApiError { + #[error("User not found")] + NotFound, + #[error("User already exists")] + Conflict, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = match self { + Self::NotFound => StatusCode::NOT_FOUND, + Self::Conflict => StatusCode::CONFLICT, + }; + (status, Json(self.to_string())).into_response() + } +} + +#[cfg_attr(feature = "openapi", gotcha::api(errors( + response(status = 404, body = "String", description = "User not found"), + response(status = 409, body = "String", description = "User already exists") +)))] +async fn create_user() -> Result, ApiError> { + Err(ApiError::Conflict) +} +``` + +Each endpoint declares its complete error set; the same enum can be shared by endpoints with +different errors. `body` describes the actual wire data, which may be separate from the error enum. +`errors(...)` also supports return type aliases such as `ApiResult`. + +Use `responses(...)` to add or override individual statuses while keeping response inference; +`drop_default` explicitly removes an inferred default. Declarations only affect documentation. +See [the migration guide](MIGRATION.md#unreleased-http-response-contracts) for media types and +inference/override rules. ### Configuration System diff --git a/gotcha/src/response.rs b/gotcha/src/response.rs index b161d52..1e2f9e2 100644 --- a/gotcha/src/response.rs +++ b/gotcha/src/response.rs @@ -8,6 +8,10 @@ use axum::{ #[cfg(feature = "openapi")] pub use gotcha_core::responsible::{default_response, empty_response, merge_responses, response, Responsible}; +#[cfg(feature = "openapi")] +#[doc(hidden)] +pub use gotcha_core::responsible::ResultResponse; + /// Override a response's status both at runtime and in OpenAPI. /// /// ```rust diff --git a/gotcha/tests/explicit_error_responses.rs b/gotcha/tests/explicit_error_responses.rs new file mode 100644 index 0000000..00e399c --- /dev/null +++ b/gotcha/tests/explicit_error_responses.rs @@ -0,0 +1,145 @@ +#![cfg(feature = "openapi")] + +use gotcha::axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + response::{IntoResponse, Response}, +}; +use gotcha::{api, ConfigWrapper, EmptyConfig, GotchaApp, GotchaContext, GotchaResult, GotchaRouter, Json, Path, Schematic, WithStatus}; +use serde::Serialize; +use serde_json::{json, Value}; +use tower::ServiceExt; + +#[derive(Serialize, Schematic)] +struct User { + id: u32, +} + +#[derive(Serialize, Schematic)] +struct ErrorBody { + message: String, +} + +// No Responsible or Schematic impl: only the wire body needs a schema. +#[derive(Debug, thiserror::Error)] +enum ApiError { + #[error("user not found")] + NotFound, + #[error("user already exists")] + Conflict, + #[error("internal error")] + Internal, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = match self { + Self::NotFound => StatusCode::NOT_FOUND, + Self::Conflict => StatusCode::CONFLICT, + Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(ErrorBody { message: self.to_string() })).into_response() + } +} + +type ApiResult = std::result::Result; + +#[api(errors( + response(status = 404, body = "ErrorBody", description = "Missing user"), + response(status = 409, body = "ErrorBody", description = "Duplicate user"), + response(status = 500, body = "ErrorBody", description = "Internal failure") +))] +async fn create(Path(id): Path) -> ApiResult, 201>> { + match id { + 7 => Ok(WithStatus::new(Json(User { id }))), + 0 => Err(ApiError::Conflict), + 1 => Err(ApiError::NotFound), + _ => Err(ApiError::Internal), + } +} + +#[api(errors(response(status = 404, body = "ErrorBody")))] +async fn lookup(Path(id): Path) -> Result, ApiError> { + if id == 7 { + Ok(Json(User { id })) + } else { + Err(ApiError::NotFound) + } +} + +#[api( + errors(response(status = 409, body = "ErrorBody")), + responses(response(status = 409, body = "ErrorBody", description = "Operation-specific conflict")) +)] +async fn dynamic() -> std::result::Result<(StatusCode, Json), ApiError> { + Ok((StatusCode::ACCEPTED, Json(User { id: 7 }))) +} + +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("/create/{id}", create).get("/lookup/{id}", lookup).get("/dynamic", dynamic) + } +} + +async fn request(router: &gotcha::axum::Router, path: &str) -> (u16, Value) { + let response = router.clone().oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()).await.unwrap(); + let status = response.status().as_u16(); + assert_eq!(response.headers()["content-type"], "application/json"); + let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + (status, serde_json::from_slice(&body).unwrap()) +} + +#[tokio::test] +async fn handlers_declare_their_own_errors_without_an_error_response_trait() { + let router = App + .build_router(GotchaContext { + state: (), + config: ConfigWrapper::default(), + }) + .await + .unwrap(); + let (_, spec) = request(&router, "/openapi.json").await; + let responses = |path: &str| spec["paths"][path]["get"]["responses"].as_object().unwrap(); + assert_eq!( + responses("/create/{id}").keys().map(String::as_str).collect::>(), + ["201", "404", "409", "500"] + ); + assert_eq!(responses("/lookup/{id}").keys().map(String::as_str).collect::>(), ["200", "404"]); + assert_eq!(responses("/dynamic").keys().map(String::as_str).collect::>(), ["409", "default"]); + assert_eq!(responses("/create/{id}")["409"]["description"], "Duplicate user"); + assert_eq!(responses("/dynamic")["409"]["description"], "Operation-specific conflict"); + + for (path, operation, status, body, schema) in [ + ("/create/7", "/create/{id}", 201, json!({"id": 7}), "User"), + ("/create/1", "/create/{id}", 404, json!({"message": "user not found"}), "ErrorBody"), + ("/create/0", "/create/{id}", 409, json!({"message": "user already exists"}), "ErrorBody"), + ("/create/9", "/create/{id}", 500, json!({"message": "internal error"}), "ErrorBody"), + ("/lookup/1", "/lookup/{id}", 404, json!({"message": "user not found"}), "ErrorBody"), + ("/dynamic", "/dynamic", 202, json!({"id": 7}), "User"), + ] { + assert_eq!(request(&router, path).await, (status, body), "{path}"); + let declared = responses(operation) + .get(&status.to_string()) + .unwrap_or_else(|| &responses(operation)["default"]); + assert_eq!( + declared["content"]["application/json"]["schema"]["$ref"], + format!("#/components/schemas/{schema}") + ); + } + let schemas = spec["components"]["schemas"].as_object().unwrap(); + assert_eq!(schemas.keys().map(String::as_str).collect::>(), ["ErrorBody", "User"]); + assert_eq!(schemas["ErrorBody"]["properties"]["message"]["type"], "string"); +} + +#[test] +fn explicit_errors_preserve_the_other_response_bounds() { + trybuild::TestCases::new().compile_fail("tests/fail/error_responses/*.rs"); +} diff --git a/gotcha/tests/fail/error_responses/requires_result.rs b/gotcha/tests/fail/error_responses/requires_result.rs new file mode 100644 index 0000000..9e3836b --- /dev/null +++ b/gotcha/tests/fail/error_responses/requires_result.rs @@ -0,0 +1,8 @@ +use gotcha::api; + +#[api(errors(response(status = 404)))] +async fn handler() -> String { + "hello".into() +} + +fn main() {} diff --git a/gotcha/tests/fail/error_responses/requires_result.stderr b/gotcha/tests/fail/error_responses/requires_result.stderr new file mode 100644 index 0000000..32f20be --- /dev/null +++ b/gotcha/tests/fail/error_responses/requires_result.stderr @@ -0,0 +1,11 @@ +error[E0277]: the trait bound `std::string::String: gotcha::response::ResultResponse` is not satisfied + --> tests/fail/error_responses/requires_result.rs:4:23 + | +4 | async fn handler() -> String { + | ^^^^^^ the trait `gotcha::response::ResultResponse` is not implemented for `std::string::String` + | +help: the trait `gotcha::response::ResultResponse` is implemented for `Result` + --> $WORKSPACE/gotcha_core/src/responsible.rs + | + | impl ResultResponse for Result { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/gotcha/tests/fail/error_responses/requires_success_contract.rs b/gotcha/tests/fail/error_responses/requires_success_contract.rs new file mode 100644 index 0000000..34dafd3 --- /dev/null +++ b/gotcha/tests/fail/error_responses/requires_success_contract.rs @@ -0,0 +1,11 @@ +use gotcha::{api, Json}; + +#[derive(serde::Serialize)] +struct NoSchema; + +#[api(errors(response(status = 404)))] +async fn handler() -> Result, gotcha::axum::http::StatusCode> { + Ok(Json(NoSchema)) +} + +fn main() {} diff --git a/gotcha/tests/fail/error_responses/requires_success_contract.stderr b/gotcha/tests/fail/error_responses/requires_success_contract.stderr new file mode 100644 index 0000000..571dfe4 --- /dev/null +++ b/gotcha/tests/fail/error_responses/requires_success_contract.stderr @@ -0,0 +1,23 @@ +error[E0277]: the trait bound `NoSchema: Schematic` is not satisfied + --> tests/fail/error_responses/requires_success_contract.rs:7:23 + | +7 | async fn handler() -> Result, gotcha::axum::http::StatusCode> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `Schematic` is not implemented for `NoSchema` + --> tests/fail/error_responses/requires_success_contract.rs:4:1 + | +4 | struct NoSchema; + | ^^^^^^^^^^^^^^^ + = help: the following other types implement trait `Schematic`: + &T + &str + () + HashMap + HashSet + JsonValue + Vec + bigdecimal::BigDecimal + and $N others + = note: required for `gotcha::Json` to implement `Responsible` + = note: required for `Result, StatusCode>` to implement `gotcha::response::ResultResponse` diff --git a/gotcha/tests/fail/error_responses/responses_still_infers_errors.rs b/gotcha/tests/fail/error_responses/responses_still_infers_errors.rs new file mode 100644 index 0000000..1b93efe --- /dev/null +++ b/gotcha/tests/fail/error_responses/responses_still_infers_errors.rs @@ -0,0 +1,16 @@ +use gotcha::{api, axum::response::{IntoResponse, Response}}; + +struct UndocumentedError; + +impl IntoResponse for UndocumentedError { + fn into_response(self) -> Response { + gotcha::axum::http::StatusCode::NOT_FOUND.into_response() + } +} + +#[api(responses(response(status = 404)), drop_default)] +async fn handler() -> Result { + Err(UndocumentedError) +} + +fn main() {} diff --git a/gotcha/tests/fail/error_responses/responses_still_infers_errors.stderr b/gotcha/tests/fail/error_responses/responses_still_infers_errors.stderr new file mode 100644 index 0000000..a0895d9 --- /dev/null +++ b/gotcha/tests/fail/error_responses/responses_still_infers_errors.stderr @@ -0,0 +1,22 @@ +error[E0277]: the trait bound `UndocumentedError: Responsible` is not satisfied + --> tests/fail/error_responses/responses_still_infers_errors.rs:12:23 + | +12 | async fn handler() -> Result { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `Responsible` is not implemented for `UndocumentedError` + --> tests/fail/error_responses/responses_still_infers_errors.rs:3:1 + | + 3 | struct UndocumentedError; + | ^^^^^^^^^^^^^^^^^^^^^^^^ + = help: the following other types implement trait `Responsible`: + &[u8] + &str + () + (StatusCode, T) + Box<[u8]> + Box + Cow<'_, [u8]> + Cow<'_, str> + and $N others + = note: required for `Result` to implement `Responsible` diff --git a/gotcha_core/src/responsible.rs b/gotcha_core/src/responsible.rs index 30fd284..2ba8547 100644 --- a/gotcha_core/src/responsible.rs +++ b/gotcha_core/src/responsible.rs @@ -13,6 +13,19 @@ pub trait Responsible { fn response() -> Responses; } +/// Macro support for documenting only the `Ok` branch of a `Result`. +/// Using trait resolution also supports type aliases without inspecting their spelling. +#[doc(hidden)] +pub trait ResultResponse { + fn success_responses() -> Responses; +} + +impl ResultResponse for Result { + fn success_responses() -> Responses { + T::response() + } +} + fn body_response(schema: Schema, media_type: &str, description: impl Into) -> Response { Response { description: description.into(), @@ -239,6 +252,28 @@ impl Responsible for Result { mod tests { use super::*; + #[test] + fn success_inference_preserves_the_contract_without_consulting_the_error() { + struct Success; + impl Responsible for Success { + fn response() -> Responses { + let mut responses = response::(201, "text/plain", "created"); + merge_responses(&mut responses, default_response::("text/plain", "runtime status")); + responses + } + } + struct Error; + impl Responsible for Error { + fn response() -> Responses { + panic!("explicit errors must bypass the error contract"); + } + } + assert_eq!( + serde_json::to_value( as ResultResponse>::success_responses()).unwrap(), + serde_json::to_value(Success::response()).unwrap() + ); + } + #[test] fn colliding_statuses_union_media_types_and_schemas() { let mut responses = response::(200, "application/json", "value"); diff --git a/gotcha_macro/src/lib.rs b/gotcha_macro/src/lib.rs index 713528b..1ee35c2 100644 --- a/gotcha_macro/src/lib.rs +++ b/gotcha_macro/src/lib.rs @@ -52,9 +52,18 @@ pub(crate) mod utils; /// - `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. +/// - `errors(response(status = 404, body = "ErrorBody", description = "Not found"))` - +/// Fully describe the `Err` branch of `Result` (including type aliases). Requires at +/// least one declaration and only infers `T: Responsible`; `E` does not need `Responsible`. +/// Uses the same fields as `responses`. Error alternatives merge with the inferred success +/// contract; `responses(...)` overrides matching statuses afterward. /// - `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. +/// `errors(...)` preserves any `default` from the success branch unless `drop_default` is explicit. +/// The error type still needs `IntoResponse` when the handler is registered on a router, and +/// declared body types must implement `Schematic`. Without `errors(...)`, the whole return type +/// must implement `Responsible`, even when `responses(...)` is present. /// /// ## Example /// diff --git a/gotcha_macro/src/route.rs b/gotcha_macro/src/route.rs index a04f018..0208842 100644 --- a/gotcha_macro/src/route.rs +++ b/gotcha_macro/src/route.rs @@ -19,12 +19,26 @@ pub struct RouteMeta { /// Name of a security scheme required for this operation (empty scopes). security: Option, responses: Option, + errors: Option, drop_default: Flag, } #[derive(Debug, Default)] struct ResponseDeclarations(Vec); +#[derive(Debug)] +struct ErrorDeclarations(Vec); + +impl FromMeta for ErrorDeclarations { + fn from_list(items: &[syn::NestedMeta]) -> darling::Result { + let ResponseDeclarations(responses) = ResponseDeclarations::from_list(items)?; + if responses.is_empty() { + return Err(darling::Error::custom("errors(...) must declare at least one response")); + } + Ok(Self(responses)) + } +} + #[derive(Debug, FromMeta)] struct ResponseMeta { status: u16, @@ -91,6 +105,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 explicit_errors = meta.errors.is_some(); + let error_responses: Vec<_> = meta.errors.into_iter().flat_map(|errors| errors.0).map(|response| response.tokens()).collect(); let drop_default = meta.drop_default.is_present(); let group = if let Some(group_name) = meta.group { quote! { Some(#group_name) } @@ -153,22 +169,15 @@ 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 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! { - <() as ::gotcha::Responsible>::response() - } - } - - ReturnType::Type(_, ty) => { - quote! { - <#ty as ::gotcha::Responsible>::response() - } - } + let ret_type = match &input.sig.output { + // A handler without an explicit return type returns `()`. + ReturnType::Default => quote!(()), + ReturnType::Type(_, ty) => quote!(#ty), + }; + let ret_responses = if explicit_errors { + quote!(<#ret_type as ::gotcha::response::ResultResponse>::success_responses()) + } else { + quote!(<#ret_type as ::gotcha::Responsible>::response()) }; input.sig.inputs.iter_mut().for_each(|param| { @@ -189,7 +198,9 @@ 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(||{ Box::new(|| { - let mut responses = #ret_schematic; + let mut responses = #ret_responses; + // Declared Err alternatives join the inferred Ok contract, including shared statuses. + #(::gotcha::response::merge_responses(&mut responses, #error_responses);)* // Explicit declarations replace inference for their status only. #(responses.data.extend((#extra_responses).data);)* if #drop_default { responses.default = None; } @@ -246,4 +257,17 @@ mod tests { assert!(parse(invalid).is_err()); } } + + #[test] + fn explicit_errors_require_nonempty_valid_response_declarations() { + let meta = parse(syn::parse_quote!(api(errors( + response(status = 404, body = "Problem", description = "Missing"), + response(status = 409, body = "Problem") + )))) + .unwrap(); + assert_eq!(meta.errors.unwrap().0.len(), 2); + assert!(parse(syn::parse_quote!(api(errors()))).is_err()); + // Both attributes use the same response validation; one invalid case checks the wiring. + assert!(parse(syn::parse_quote!(api(errors(response(status = 600))))).is_err()); + } }