From fdbc02aa76e3c4c16eebe8abc6141d41f15f47a4 Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Tue, 7 Jul 2026 09:31:23 +0200 Subject: [PATCH 1/4] feat: response headers --- README.md | 6 + crates/oapi-codegen/src/emit/axum.rs | 137 +++++++++++++++++- crates/oapi-codegen/src/ir.rs | 19 +++ crates/oapi-codegen/src/lower/paths.rs | 122 +++++++++++++++- crates/oapi-codegen/tests/coverage.rs | 4 + .../fixtures/server_response_headers.yaml | 41 ++++++ ...ver_unsupported_bytes_response_header.yaml | 16 ++ ...er_unsupported_object_response_header.yaml | 18 +++ .../generated/server_response_headers.rs | 97 +++++++++++++ .../oapi-codegen/tests/generated_compiles.rs | 59 +++++++- 10 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 crates/oapi-codegen/tests/fixtures/server_response_headers.yaml create mode 100644 crates/oapi-codegen/tests/fixtures/server_unsupported_bytes_response_header.yaml create mode 100644 crates/oapi-codegen/tests/fixtures/server_unsupported_object_response_header.yaml create mode 100644 crates/oapi-codegen/tests/generated/server_response_headers.rs diff --git a/README.md b/README.md index 2570a14..8af147a 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,10 @@ faithfully rather than emit subtly wrong code. `default`/range variants instead carry an `axum::http::StatusCode` the handler supplies (e.g. `GetBookResponse::Default(StatusCode::BAD_REQUEST, error)`), mirroring how oapi-codegen's strict server lets the handler set the code. +- **Response headers** — declared headers are emitted as named fields on the + response variant (scalars via `ToString`; required always written, optional + written only when set), inserted best-effort into the response `HeaderMap` — + a value that cannot encode as a header is skipped rather than panicking. - **Cross-file `$ref` parameters, request bodies, and responses** — the referenced structural object is read from the sibling file, resolved relative to the main spec's directory (chains across files are followed). A cross-file @@ -156,6 +160,8 @@ faithfully rather than emit subtly wrong code. `spaceDelimited`, …), an array header parameter, or a `byte`/`binary` header parameter. - An object or array cookie parameter, or a `byte`/`binary` cookie parameter. +- An object or array response header, a `byte`/`binary` response header, or a + response header declared via `$ref`. ## Coverage diff --git a/crates/oapi-codegen/src/emit/axum.rs b/crates/oapi-codegen/src/emit/axum.rs index 1406963..bfc91e5 100644 --- a/crates/oapi-codegen/src/emit/axum.rs +++ b/crates/oapi-codegen/src/emit/axum.rs @@ -13,6 +13,8 @@ use crate::ir::Cookies; use crate::ir::HeaderParam; use crate::ir::Headers; use crate::ir::Operation; +use crate::ir::ResponseCase; +use crate::ir::ResponseHeader; use crate::ir::ResponseStatus; use crate::ir::RustType; use crate::ir::Service; @@ -112,9 +114,15 @@ fn emit_response_enum(operation: &Operation) -> Result<(TokenStream, TokenStream for case in &operation.responses { let variant = case.variant.to_token(); let doc = doc_attr(&case.doc); - let (variant_def, arm) = match &case.status { - ResponseStatus::Fixed(code) => emit_fixed_response(&name, &variant, *code, &case.body)?, - ResponseStatus::Default | ResponseStatus::Range(_) => emit_dynamic_response(&name, &variant, &case.body)?, + let (variant_def, arm) = if case.headers.is_empty() { + match &case.status { + ResponseStatus::Fixed(code) => emit_fixed_response(&name, &variant, *code, &case.body)?, + ResponseStatus::Default | ResponseStatus::Range(_) => { + emit_dynamic_response(&name, &variant, &case.body)? + } + } + } else { + emit_response_with_headers(&name, &variant, case)? }; variants.push(quote! { #doc #variant_def }); arms.push(arm); @@ -302,6 +310,129 @@ fn emit_handler(operation: &Operation) -> Result { }); } +/// Emit a struct-variant definition and `IntoResponse` arm for a response that +/// declares headers. Field order: `status` (dynamic responses only), `body` +/// (when present), then one field per declared header (required → `T`, +/// optional → `Option`). Header values are formatted with `to_string()` and +/// inserted best-effort — a value that cannot encode as a `HeaderValue` is +/// skipped rather than panicking. +fn emit_response_with_headers( + name: &proc_macro2::Ident, + variant: &proc_macro2::Ident, + case: &ResponseCase, +) -> Result<(TokenStream, TokenStream)> { + let dynamic = !matches!(case.status, ResponseStatus::Fixed(_)); + + // Field definitions. + let mut field_defs: Vec = Vec::new(); + if dynamic { + field_defs.push(quote! { status: axum::http::StatusCode }); + } + if let Some(body) = &case.body { + let ty = emit_type(body)?; + field_defs.push(quote! { body: #ty }); + } + let mut header_field_defs = Vec::with_capacity(case.headers.len()); + for header in &case.headers { + header_field_defs.push(emit_response_header_field(header)?); + } + + let variant_def = quote! { + #variant { + #(#field_defs,)* + #(#header_field_defs)* + } + }; + + // Destructure pattern (bind every field we defined). + let mut binds: Vec = Vec::new(); + if dynamic { + binds.push(quote! { status }); + } + if case.body.is_some() { + binds.push(quote! { body }); + } + let header_idents: Vec = case.headers.iter().map(|h| return h.name.to_token()).collect(); + for ident in &header_idents { + binds.push(quote! { #ident }); + } + + // Status expression: constant for fixed, bound `status` for dynamic. + let status_expr = match &case.status { + ResponseStatus::Fixed(code) => { + let code = proc_macro2::Literal::u16_unsuffixed(*code); + quote! { + { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16(#code) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS + } + } + } + ResponseStatus::Default | ResponseStatus::Range(_) => quote! { status }, + }; + + // Header insertions (best-effort). + let mut inserts = Vec::with_capacity(case.headers.len()); + for header in &case.headers { + inserts.push(emit_response_header_insert(header)); + } + + let body_term = if case.body.is_some() { + quote! { , axum::Json(body) } + } else { + quote! {} + }; + + let arm = quote! { + #name::#variant { #(#binds),* } => { + let mut header_map = axum::http::HeaderMap::new(); + #(#inserts)* + return (#status_expr, header_map #body_term).into_response(); + } + }; + + return Ok((variant_def, arm)); +} + +/// Emit one struct-variant field for a response header (required → `T`, +/// optional → `Option`), with its doc attribute. +fn emit_response_header_field(header: &ResponseHeader) -> Result { + let ident = header.name.to_token(); + let doc = doc_attr(&header.doc); + let mut ty = emit_type(&header.ty)?; + if !header.required { + ty = quote! { Option<#ty> }; + } + return Ok(quote! { + #doc + #ident: #ty, + }); +} + +/// Emit the best-effort insertion of one response header into `header_map`. +/// Uses a lowercased static header name; a value that fails to encode as a +/// `HeaderValue` is skipped (never panics). +fn emit_response_header_insert(header: &ResponseHeader) -> TokenStream { + let ident = header.name.to_token(); + let lower_name = header.header_name.to_ascii_lowercase(); + let insert = quote! { + if let Ok(value) = axum::http::HeaderValue::from_str(&#ident.to_string()) { + header_map.insert(axum::http::HeaderName::from_static(#lower_name), value); + } + }; + if header.required { + return insert; + } + return quote! { + if let Some(#ident) = #ident { + #insert + } + }; +} + /// Emit a header struct and its hand-written `FromRequestParts` implementation. /// /// Header values are read and parsed individually from the request parts, so diff --git a/crates/oapi-codegen/src/ir.rs b/crates/oapi-codegen/src/ir.rs index 4788925..2248327 100644 --- a/crates/oapi-codegen/src/ir.rs +++ b/crates/oapi-codegen/src/ir.rs @@ -294,10 +294,29 @@ pub struct ResponseCase { pub status: ResponseStatus, /// JSON response body type, when the response declares content. pub body: Option, + /// Declared response headers written by the generated `IntoResponse`, in + /// declaration order. Empty means no headers (the pre-C5 variant shape). + pub headers: Vec, /// Doc comment derived from the response `description`. pub doc: Option, } +/// A single declared response header written by the generated `IntoResponse`. +#[derive(Debug, Clone, PartialEq)] +pub struct ResponseHeader { + /// Rust field identifier (`snake_case`). + pub name: RustIdent, + /// Exact header name as written to the response (e.g. `X-Request-Id`). + pub header_name: String, + /// Scalar type serialized to a header value via `ToString`. Bare element + /// type even when optional; the emitter adds the `Option<..>` wrapper. + pub ty: RustType, + /// Whether the header is always written (`false` → `Option<..>` field). + pub required: bool, + /// Doc comment derived from the header `description`. + pub doc: Option, +} + /// How a response variant's HTTP status code is produced. /// /// Fixed codes are emitted as a compile-time constant; `default` and range diff --git a/crates/oapi-codegen/src/lower/paths.rs b/crates/oapi-codegen/src/lower/paths.rs index d9819c4..9bb7685 100644 --- a/crates/oapi-codegen/src/lower/paths.rs +++ b/crates/oapi-codegen/src/lower/paths.rs @@ -86,6 +86,28 @@ const JSON_MEDIA_TYPE: &str = "application/json"; /// mechanisms rather than the parameter object (compared case-insensitively). const IGNORED_HEADER_NAMES: [&str; 3] = ["accept", "content-type", "authorization"]; +/// Check whether a header name is valid for use with `HeaderName::from_static`. +/// Visible ASCII printable characters excluding `:` (the field-name token set +/// per RFC 9110 §5.1). This prevents a later panic when emitting. +fn is_valid_header_name(name: &str) -> bool { + if name.is_empty() { + return false; + } + for byte in name.as_bytes() { + // RFC 7230 tchar. `-`..`9` (0x2D..0x39) would wrongly include `/` + // (0x2F), which is not a valid header-name char, so digits are their + // own range and `-`/`.` are listed explicitly. + let valid = matches!( + byte, + b'!' | b'#'..=b'\'' | b'*'..=b'+' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'^'..=b'z' | b'|' | b'~' + ); + if !valid { + return false; + } + } + return true; +} + /// Lower every operation in `spec` into the server IR, resolving cross-file /// schema references through `import_mapping`. pub fn generate_service(spec: &Spec, import_mapping: &BTreeMap) -> Result { @@ -426,14 +448,17 @@ impl Lowerer<'_> { }); } - /// Map a header parameter's schema to a scalar Rust type. `content`, - /// cross-file `$ref`s, non-scalar shapes (arrays/objects), and `byte`/ - /// `binary` strings (which have no `FromStr`) are rejected. - fn header_param_type( + /// Map a header/response-header schema to a scalar Rust type, applying the + /// shared rules: reject `content`, non-scalar shapes, and `byte`/`binary`; + /// resolve a same-document/origin schema `$ref` to a scalar; reject a + /// cross-file schema `$ref`. `kind_label` is used in error messages (e.g. + /// "header parameter" or "response header"). + fn scalar_from_format( &self, path: &str, method: &str, origin: Option<&str>, + kind_label: &str, name: &str, format: &ParameterSchemaOrContent, ) -> Result { @@ -443,7 +468,7 @@ impl Lowerer<'_> { return Err(Error::UnsupportedOperation { method: method.to_owned(), path: path.to_owned(), - reason: format!("header parameter `{name}` uses `content`, which is not supported"), + reason: format!("{kind_label} `{name}` uses `content`, which is not supported"), }); } }; @@ -453,7 +478,7 @@ impl Lowerer<'_> { return Err(Error::UnsupportedOperation { method: method.to_owned(), path: path.to_owned(), - reason: format!("header parameter `{name}` uses a cross-file `$ref`, which is not supported"), + reason: format!("{kind_label} `{name}` uses a cross-file `$ref`, which is not supported"), }); } ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?, @@ -462,19 +487,33 @@ impl Lowerer<'_> { return Error::UnsupportedOperation { method: method.to_owned(), path: path.to_owned(), - reason: format!("header parameter `{name}` must be a scalar"), + reason: format!("{kind_label} `{name}` must be a scalar"), }; })?; if matches!(ty, RustType::Bytes) { return Err(Error::UnsupportedOperation { method: method.to_owned(), path: path.to_owned(), - reason: format!("header parameter `{name}` uses a `byte`/`binary` format, which is not supported"), + reason: format!("{kind_label} `{name}` uses a `byte`/`binary` format, which is not supported"), }); } return Ok(ty); } + /// Map a header parameter's schema to a scalar Rust type. `content`, + /// cross-file `$ref`s, non-scalar shapes (arrays/objects), and `byte`/ + /// `binary` strings (which have no `FromStr`) are rejected. + fn header_param_type( + &self, + path: &str, + method: &str, + origin: Option<&str>, + name: &str, + format: &ParameterSchemaOrContent, + ) -> Result { + return self.scalar_from_format(path, method, origin, "header parameter", name, format); + } + /// Lower an operation's cookie parameters into a generated [`Cookies`] /// struct, returning `None` when the operation declares none. Per OpenAPI's /// override rule the first (operation-level) definition wins on a name @@ -676,6 +715,52 @@ impl Lowerer<'_> { return Ok(Some(ty)); } + /// Lower a response's declared headers into scalar-typed [`ResponseHeader`]s. + /// Inline `Header` objects only; a `Header` that is itself a `$ref` is + /// rejected. De-duplicated by case-insensitive name, first-seen winning. + fn lower_response_headers( + &self, + path: &str, + method: &str, + origin: Option<&str>, + response: &OasResponse, + ) -> Result> { + let mut headers = Vec::new(); + let mut seen: Vec = Vec::new(); + for (header_name, header_ref) in &response.headers { + let header = match header_ref { + ReferenceOr::Item(header) => header, + ReferenceOr::Reference { .. } => { + return Err(Error::UnsupportedOperation { + method: method.to_owned(), + path: path.to_owned(), + reason: format!("response header `{header_name}` uses a `$ref`, which is not supported"), + }); + } + }; + if seen.iter().any(|other| return other.eq_ignore_ascii_case(header_name)) { + continue; + } + if !is_valid_header_name(header_name) { + return Err(Error::UnsupportedOperation { + method: method.to_owned(), + path: path.to_owned(), + reason: format!("response header `{header_name}` has an invalid header name"), + }); + } + seen.push(header_name.clone()); + let ty = self.scalar_from_format(path, method, origin, "response header", header_name, &header.format)?; + headers.push(crate::ir::ResponseHeader { + name: to_ident(header_name, Case::Snake), + header_name: header_name.clone(), + ty, + required: header.required, + doc: header.description.as_deref().and_then(trimmed), + }); + } + return Ok(headers); + } + /// Lower an operation's responses into typed enum variants, resolving /// component `$ref` responses against the document. A fixed status code /// becomes a reason-named variant with a compile-time status constant; a @@ -712,10 +797,12 @@ impl Lowerer<'_> { }; let response = self.resolve_response_ref(response)?; let body = self.response_body(path, method, response.origin.as_deref(), &response.value)?; + let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?; cases.push(ResponseCase { variant, status, body, + headers, doc: trimmed(&response.value.description), }); } @@ -723,10 +810,12 @@ impl Lowerer<'_> { if let Some(default) = &operation.responses.default { let response = self.resolve_response_ref(default)?; let body = self.response_body(path, method, response.origin.as_deref(), &response.value)?; + let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?; cases.push(ResponseCase { variant: to_ident("default", Case::Pascal), status: ResponseStatus::Default, body, + headers, doc: trimmed(&response.value.description), }); } @@ -903,6 +992,23 @@ fn trimmed(text: &str) -> Option { mod tests { use super::*; + #[test] + fn valid_header_names_accept_tokens_and_reject_separators() { + // Real header names with `-` and digits and `.` are accepted. + assert!(is_valid_header_name("X-Request-Id")); + assert!(is_valid_header_name("X-RateLimit-Remaining")); + assert!(is_valid_header_name("Sec-CH-UA-Platform-Version")); + assert!(is_valid_header_name("a.b")); + // Empty and separator characters (which would panic `from_static`) are + // rejected — notably `/` (0x2F), which sits between `-` (0x2D) and the + // digits, and `:`, space, and control-ish punctuation. + assert!(!is_valid_header_name("")); + assert!(!is_valid_header_name("X/Y")); + assert!(!is_valid_header_name("X:Y")); + assert!(!is_valid_header_name("X Y")); + assert!(!is_valid_header_name("X(Y)")); + } + #[test] fn extracts_path_param_names_in_order() { assert_eq!(path_param_names("/v1/widgets"), Vec::::new()); diff --git a/crates/oapi-codegen/tests/coverage.rs b/crates/oapi-codegen/tests/coverage.rs index 7c2bb46..578b8b3 100644 --- a/crates/oapi-codegen/tests/coverage.rs +++ b/crates/oapi-codegen/tests/coverage.rs @@ -340,6 +340,7 @@ const SERVER_FIXTURES: &[&str] = &[ "server_component_body_ref", "server_component_param_ref_pet", "server_xfile_refs", + "server_response_headers", ]; /// Server fixtures whose generation must fail with a documented error, covering @@ -358,6 +359,8 @@ const SERVER_UNSUPPORTED_FIXTURES: &[&str] = &[ "server_unsupported_xfile_missing_component", "server_unsupported_xfile_no_import_mapping", "server_unsupported_xfile_object_path_param", + "server_unsupported_object_response_header", + "server_unsupported_bytes_response_header", ]; /// Absolute path to the crate's `tests` directory. @@ -534,6 +537,7 @@ server_generated_tests!( server_component_body_ref, server_component_param_ref_pet, server_xfile_refs, + server_response_headers, ); /// The server `#[test]`s must cover exactly the supported server fixtures. diff --git a/crates/oapi-codegen/tests/fixtures/server_response_headers.yaml b/crates/oapi-codegen/tests/fixtures/server_response_headers.yaml new file mode 100644 index 0000000..6c95c9b --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/server_response_headers.yaml @@ -0,0 +1,41 @@ +openapi: 3.0.3 +info: + title: response headers + version: "1" +paths: + /widgets: + get: + operationId: getWidgets + summary: List widgets with rate-limit headers. + responses: + "200": + description: The widgets. + headers: + X-Request-Id: + required: true + description: Correlation id echoed to the caller. + schema: + type: string + X-RateLimit-Remaining: + required: false + description: Remaining quota in the current window. + schema: + type: integer + format: int32 + content: + application/json: + schema: + type: array + items: + type: string + default: + description: An error, with a correlation id. + headers: + X-Request-Id: + required: true + schema: + type: string + content: + application/json: + schema: + type: string diff --git a/crates/oapi-codegen/tests/fixtures/server_unsupported_bytes_response_header.yaml b/crates/oapi-codegen/tests/fixtures/server_unsupported_bytes_response_header.yaml new file mode 100644 index 0000000..55a8824 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/server_unsupported_bytes_response_header.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.3 +info: + title: unsupported bytes response header + version: "1" +paths: + /widgets: + get: + operationId: getWidgets + responses: + "200": + description: ok + headers: + X-Token: + schema: + type: string + format: byte diff --git a/crates/oapi-codegen/tests/fixtures/server_unsupported_object_response_header.yaml b/crates/oapi-codegen/tests/fixtures/server_unsupported_object_response_header.yaml new file mode 100644 index 0000000..38fd053 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/server_unsupported_object_response_header.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.3 +info: + title: unsupported object response header + version: "1" +paths: + /widgets: + get: + operationId: getWidgets + responses: + "200": + description: ok + headers: + X-Meta: + schema: + type: object + properties: + page: + type: integer diff --git a/crates/oapi-codegen/tests/generated/server_response_headers.rs b/crates/oapi-codegen/tests/generated/server_response_headers.rs new file mode 100644 index 0000000..27cb5e4 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/server_response_headers.rs @@ -0,0 +1,97 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + /// List widgets with rate-limit headers. + fn get_widgets( + &self, + ) -> impl std::future::Future + Send; +} + +/// List widgets with rate-limit headers. +pub enum GetWidgetsResponse { + /// The widgets. + Ok { + body: Vec, + /// Correlation id echoed to the caller. + x_request_id: String, + /// Remaining quota in the current window. + x_rate_limit_remaining: Option, + }, + /// An error, with a correlation id. + Default { status: axum::http::StatusCode, body: String, x_request_id: String }, +} + +impl axum::response::IntoResponse for GetWidgetsResponse { + fn into_response(self) -> axum::response::Response { + match self { + GetWidgetsResponse::Ok { body, x_request_id, x_rate_limit_remaining } => { + let mut header_map = axum::http::HeaderMap::new(); + if let Ok(value) = axum::http::HeaderValue::from_str( + &x_request_id.to_string(), + ) { + header_map + .insert( + axum::http::HeaderName::from_static("x-request-id"), + value, + ); + } + if let Some(x_rate_limit_remaining) = x_rate_limit_remaining { + if let Ok(value) = axum::http::HeaderValue::from_str( + &x_rate_limit_remaining.to_string(), + ) { + header_map + .insert( + axum::http::HeaderName::from_static( + "x-ratelimit-remaining", + ), + value, + ); + } + } + return ( + { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => { + panic!("oapi-codegen emitted an invalid HTTP status code") + } + }; + STATUS + }, + header_map, + axum::Json(body), + ) + .into_response(); + } + GetWidgetsResponse::Default { status, body, x_request_id } => { + let mut header_map = axum::http::HeaderMap::new(); + if let Ok(value) = axum::http::HeaderValue::from_str( + &x_request_id.to_string(), + ) { + header_map + .insert( + axum::http::HeaderName::from_static("x-request-id"), + value, + ); + } + return (status, header_map, axum::Json(body)).into_response(); + } + } + } +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/widgets", axum::routing::get(get_widgets_handler::)) + .with_state(api) +} + +async fn get_widgets_handler( + axum::extract::State(api): axum::extract::State, +) -> GetWidgetsResponse { + api.get_widgets().await +} diff --git a/crates/oapi-codegen/tests/generated_compiles.rs b/crates/oapi-codegen/tests/generated_compiles.rs index 4c655b7..e50e84a 100644 --- a/crates/oapi-codegen/tests/generated_compiles.rs +++ b/crates/oapi-codegen/tests/generated_compiles.rs @@ -13,7 +13,7 @@ //! `implicit_return`/`dead_code` rules are about first-party source, not //! generated output. The handwritten tests below are linted normally. -#[allow(dead_code, clippy::implicit_return)] +#[allow(dead_code, clippy::implicit_return, clippy::collapsible_if)] mod generated { pub mod allof_merge { include!("generated/allof_merge.rs"); @@ -102,6 +102,9 @@ mod generated { pub mod server_xfile_refs { include!("generated/server_xfile_refs.rs"); } + pub mod server_response_headers { + include!("generated/server_response_headers.rs"); + } } /// Stand-in for the models crate the `server_refs` fixture's `import-mapping` @@ -445,3 +448,57 @@ fn generated_server_resolves_cross_file_param_ref() { let _router: axum::Router = server_xfile_refs::router(Service); } + +#[test] +fn generated_server_writes_response_headers() { + use axum::response::IntoResponse; + use generated::server_response_headers; + use server_response_headers::Api; + use server_response_headers::GetWidgetsResponse; + + #[derive(Clone)] + struct Service; + + impl Api for Service { + async fn get_widgets(&self) -> GetWidgetsResponse { + return GetWidgetsResponse::Ok { + body: vec!["w1".to_owned()], + x_request_id: "abc-123".to_owned(), + x_rate_limit_remaining: Some(42), + }; + } + } + + // The `Ok` variant renders its headers into the response. + let response = GetWidgetsResponse::Ok { + body: vec!["w1".to_owned()], + x_request_id: "abc-123".to_owned(), + x_rate_limit_remaining: Some(42), + } + .into_response(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + assert_eq!(response.headers().get("x-request-id").unwrap(), "abc-123"); + assert_eq!(response.headers().get("x-ratelimit-remaining").unwrap(), "42"); + + // An unset optional header is absent. + let response = GetWidgetsResponse::Ok { + body: Vec::new(), + x_request_id: "abc-123".to_owned(), + x_rate_limit_remaining: None, + } + .into_response(); + assert!(response.headers().get("x-ratelimit-remaining").is_none()); + + // The dynamic `Default` variant uses the handler-supplied status. + let response = GetWidgetsResponse::Default { + status: axum::http::StatusCode::BAD_GATEWAY, + body: "boom".to_owned(), + x_request_id: "abc-123".to_owned(), + } + .into_response(); + assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY); + assert_eq!(response.headers().get("x-request-id").unwrap(), "abc-123"); + + // Building the router proves the trait + handler wiring type-check. + let _router: axum::Router = server_response_headers::router(Service); +} From a3c7e34d936cf4391125b4a58ac0d481177a4594 Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Tue, 7 Jul 2026 09:47:05 +0200 Subject: [PATCH 2/4] fix: reject response-header field-name collisions; cover header-only response --- crates/oapi-codegen/src/lower/paths.rs | 24 +++++++++++++-- crates/oapi-codegen/tests/coverage.rs | 1 + .../fixtures/server_response_headers.yaml | 7 +++++ ...nsupported_colliding_response_headers.yaml | 24 +++++++++++++++ .../generated/server_response_headers.rs | 29 +++++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 crates/oapi-codegen/tests/fixtures/server_unsupported_colliding_response_headers.yaml diff --git a/crates/oapi-codegen/src/lower/paths.rs b/crates/oapi-codegen/src/lower/paths.rs index 9bb7685..b82ce3d 100644 --- a/crates/oapi-codegen/src/lower/paths.rs +++ b/crates/oapi-codegen/src/lower/paths.rs @@ -87,8 +87,9 @@ const JSON_MEDIA_TYPE: &str = "application/json"; const IGNORED_HEADER_NAMES: [&str; 3] = ["accept", "content-type", "authorization"]; /// Check whether a header name is valid for use with `HeaderName::from_static`. -/// Visible ASCII printable characters excluding `:` (the field-name token set -/// per RFC 9110 §5.1). This prevents a later panic when emitting. +/// Enforces the HTTP `tchar` token set (RFC 9110 §5.6.2 / RFC 7230): ASCII +/// alphanumerics plus ``!#$%&'*+-.^_`|~``. This prevents a later panic when +/// emitting `HeaderName::from_static`. fn is_valid_header_name(name: &str) -> bool { if name.is_empty() { return false; @@ -727,6 +728,7 @@ impl Lowerer<'_> { ) -> Result> { let mut headers = Vec::new(); let mut seen: Vec = Vec::new(); + let mut seen_idents: Vec = Vec::new(); for (header_name, header_ref) in &response.headers { let header = match header_ref { ReferenceOr::Item(header) => header, @@ -748,10 +750,26 @@ impl Lowerer<'_> { reason: format!("response header `{header_name}` has an invalid header name"), }); } + let ident = to_ident(header_name, Case::Snake); + // Distinct header names can collapse to the same Rust field + // identifier (e.g. `X-Foo` and `X_Foo` both → `x_foo`), which would + // emit a struct with duplicate fields. Reject rather than + // mis-generate. + if seen_idents.iter().any(|other| return other == ident.logical()) { + return Err(Error::UnsupportedOperation { + method: method.to_owned(), + path: path.to_owned(), + reason: format!( + "response header `{header_name}` maps to the same Rust field name as another header (`{}`)", + ident.logical() + ), + }); + } seen.push(header_name.clone()); + seen_idents.push(ident.logical().to_owned()); let ty = self.scalar_from_format(path, method, origin, "response header", header_name, &header.format)?; headers.push(crate::ir::ResponseHeader { - name: to_ident(header_name, Case::Snake), + name: ident, header_name: header_name.clone(), ty, required: header.required, diff --git a/crates/oapi-codegen/tests/coverage.rs b/crates/oapi-codegen/tests/coverage.rs index 578b8b3..c3263e5 100644 --- a/crates/oapi-codegen/tests/coverage.rs +++ b/crates/oapi-codegen/tests/coverage.rs @@ -361,6 +361,7 @@ const SERVER_UNSUPPORTED_FIXTURES: &[&str] = &[ "server_unsupported_xfile_object_path_param", "server_unsupported_object_response_header", "server_unsupported_bytes_response_header", + "server_unsupported_colliding_response_headers", ]; /// Absolute path to the crate's `tests` directory. diff --git a/crates/oapi-codegen/tests/fixtures/server_response_headers.yaml b/crates/oapi-codegen/tests/fixtures/server_response_headers.yaml index 6c95c9b..0cce1a3 100644 --- a/crates/oapi-codegen/tests/fixtures/server_response_headers.yaml +++ b/crates/oapi-codegen/tests/fixtures/server_response_headers.yaml @@ -28,6 +28,13 @@ paths: type: array items: type: string + "204": + description: No content, but a correlation id header is still returned. + headers: + X-Request-Id: + required: true + schema: + type: string default: description: An error, with a correlation id. headers: diff --git a/crates/oapi-codegen/tests/fixtures/server_unsupported_colliding_response_headers.yaml b/crates/oapi-codegen/tests/fixtures/server_unsupported_colliding_response_headers.yaml new file mode 100644 index 0000000..d832e17 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/server_unsupported_colliding_response_headers.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: + title: colliding response header idents (unsupported) + version: "1" +paths: + /widgets: + get: + operationId: getWidgets + responses: + "200": + description: two headers whose names collapse to the same Rust field + headers: + X-Foo: + required: true + schema: + type: string + X_Foo: + required: true + schema: + type: string + content: + application/json: + schema: + type: string diff --git a/crates/oapi-codegen/tests/generated/server_response_headers.rs b/crates/oapi-codegen/tests/generated/server_response_headers.rs index 27cb5e4..1ba0207 100644 --- a/crates/oapi-codegen/tests/generated/server_response_headers.rs +++ b/crates/oapi-codegen/tests/generated/server_response_headers.rs @@ -18,6 +18,8 @@ pub enum GetWidgetsResponse { /// Remaining quota in the current window. x_rate_limit_remaining: Option, }, + /// No content, but a correlation id header is still returned. + NoContent { x_request_id: String }, /// An error, with a correlation id. Default { status: axum::http::StatusCode, body: String, x_request_id: String }, } @@ -66,6 +68,33 @@ impl axum::response::IntoResponse for GetWidgetsResponse { ) .into_response(); } + GetWidgetsResponse::NoContent { x_request_id } => { + let mut header_map = axum::http::HeaderMap::new(); + if let Ok(value) = axum::http::HeaderValue::from_str( + &x_request_id.to_string(), + ) { + header_map + .insert( + axum::http::HeaderName::from_static("x-request-id"), + value, + ); + } + return ( + { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 204, + ) { + Ok(status) => status, + Err(_) => { + panic!("oapi-codegen emitted an invalid HTTP status code") + } + }; + STATUS + }, + header_map, + ) + .into_response(); + } GetWidgetsResponse::Default { status, body, x_request_id } => { let mut header_map = axum::http::HeaderMap::new(); if let Ok(value) = axum::http::HeaderValue::from_str( From 4bf00d8daeb4524aa2e2ebc0adebc774870db3c3 Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Tue, 7 Jul 2026 10:12:38 +0200 Subject: [PATCH 3/4] fix: pr comments --- README.md | 13 ++++++---- crates/oapi-codegen/tests/coverage.rs | 1 + ...erver_unsupported_ref_response_header.yaml | 24 +++++++++++++++++++ .../oapi-codegen/tests/generated_compiles.rs | 24 ++++++++++++++++--- 4 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 crates/oapi-codegen/tests/fixtures/server_unsupported_ref_response_header.yaml diff --git a/README.md b/README.md index 8af147a..c34b46e 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,10 @@ faithfully rather than emit subtly wrong code. supplies (e.g. `GetBookResponse::Default(StatusCode::BAD_REQUEST, error)`), mirroring how oapi-codegen's strict server lets the handler set the code. - **Response headers** — declared headers are emitted as named fields on the - response variant (scalars via `ToString`; required always written, optional - written only when set), inserted best-effort into the response `HeaderMap` — - a value that cannot encode as a header is skipped rather than panicking. + response variant (scalars via `ToString`; required values are always + attempted, optional ones only when set), inserted best-effort into the + response `HeaderMap` — a value that cannot encode as a header (even a required + one) is skipped rather than panicking. - **Cross-file `$ref` parameters, request bodies, and responses** — the referenced structural object is read from the sibling file, resolved relative to the main spec's directory (chains across files are followed). A cross-file @@ -160,8 +161,10 @@ faithfully rather than emit subtly wrong code. `spaceDelimited`, …), an array header parameter, or a `byte`/`binary` header parameter. - An object or array cookie parameter, or a `byte`/`binary` cookie parameter. -- An object or array response header, a `byte`/`binary` response header, or a - response header declared via `$ref`. +- An object or array response header, a `byte`/`binary` response header, a + response header declared via `$ref`, a response header with an invalid HTTP + header name, or two response headers whose names collide when mapped to the + same Rust field (e.g. `X-Foo` and `X_Foo`). ## Coverage diff --git a/crates/oapi-codegen/tests/coverage.rs b/crates/oapi-codegen/tests/coverage.rs index c3263e5..7d8eb88 100644 --- a/crates/oapi-codegen/tests/coverage.rs +++ b/crates/oapi-codegen/tests/coverage.rs @@ -362,6 +362,7 @@ const SERVER_UNSUPPORTED_FIXTURES: &[&str] = &[ "server_unsupported_object_response_header", "server_unsupported_bytes_response_header", "server_unsupported_colliding_response_headers", + "server_unsupported_ref_response_header", ]; /// Absolute path to the crate's `tests` directory. diff --git a/crates/oapi-codegen/tests/fixtures/server_unsupported_ref_response_header.yaml b/crates/oapi-codegen/tests/fixtures/server_unsupported_ref_response_header.yaml new file mode 100644 index 0000000..7dc0507 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/server_unsupported_ref_response_header.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: + title: response header via $ref (unsupported) + version: "1" +paths: + /widgets: + get: + operationId: getWidgets + responses: + "200": + description: ok + headers: + X-Request-Id: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + type: string +components: + headers: + RequestId: + required: true + schema: + type: string diff --git a/crates/oapi-codegen/tests/generated_compiles.rs b/crates/oapi-codegen/tests/generated_compiles.rs index e50e84a..0452c8d 100644 --- a/crates/oapi-codegen/tests/generated_compiles.rs +++ b/crates/oapi-codegen/tests/generated_compiles.rs @@ -477,8 +477,20 @@ fn generated_server_writes_response_headers() { } .into_response(); assert_eq!(response.status(), axum::http::StatusCode::OK); - assert_eq!(response.headers().get("x-request-id").unwrap(), "abc-123"); - assert_eq!(response.headers().get("x-ratelimit-remaining").unwrap(), "42"); + assert_eq!( + response + .headers() + .get("x-request-id") + .expect("missing x-request-id header"), + "abc-123" + ); + assert_eq!( + response + .headers() + .get("x-ratelimit-remaining") + .expect("missing x-ratelimit-remaining header"), + "42" + ); // An unset optional header is absent. let response = GetWidgetsResponse::Ok { @@ -497,7 +509,13 @@ fn generated_server_writes_response_headers() { } .into_response(); assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY); - assert_eq!(response.headers().get("x-request-id").unwrap(), "abc-123"); + assert_eq!( + response + .headers() + .get("x-request-id") + .expect("missing x-request-id header"), + "abc-123" + ); // Building the router proves the trait + handler wiring type-check. let _router: axum::Router = server_response_headers::router(Service); From e26cdeb7f562c70986e6b2b0f74d225fcfd0a9a7 Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Tue, 7 Jul 2026 10:21:57 +0200 Subject: [PATCH 4/4] fix: pr comments --- crates/oapi-codegen/src/lower/paths.rs | 20 +++++++++++++++++++ crates/oapi-codegen/tests/coverage.rs | 1 + ..._unsupported_reserved_response_header.yaml | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 crates/oapi-codegen/tests/fixtures/server_unsupported_reserved_response_header.yaml diff --git a/crates/oapi-codegen/src/lower/paths.rs b/crates/oapi-codegen/src/lower/paths.rs index b82ce3d..d398d58 100644 --- a/crates/oapi-codegen/src/lower/paths.rs +++ b/crates/oapi-codegen/src/lower/paths.rs @@ -86,6 +86,12 @@ const JSON_MEDIA_TYPE: &str = "application/json"; /// mechanisms rather than the parameter object (compared case-insensitively). const IGNORED_HEADER_NAMES: [&str; 3] = ["accept", "content-type", "authorization"]; +/// Rust field names the response emitter injects into a header-bearing struct +/// variant (`status` for dynamic responses, `body` when a body is present). A +/// declared response header whose `snake_case` identifier equals one of these +/// would collide, so such headers are rejected during lowering. +const RESERVED_RESPONSE_FIELDS: [&str; 2] = ["status", "body"]; + /// Check whether a header name is valid for use with `HeaderName::from_static`. /// Enforces the HTTP `tchar` token set (RFC 9110 §5.6.2 / RFC 7230): ASCII /// alphanumerics plus ``!#$%&'*+-.^_`|~``. This prevents a later panic when @@ -751,6 +757,20 @@ impl Lowerer<'_> { }); } let ident = to_ident(header_name, Case::Snake); + // The response emitter injects `status` (dynamic responses) and + // `body` (responses with a body) fields into the struct variant. A + // header whose Rust field name collides with one of those would emit + // duplicate fields. Reject rather than mis-generate. + if RESERVED_RESPONSE_FIELDS.contains(&ident.logical()) { + return Err(Error::UnsupportedOperation { + method: method.to_owned(), + path: path.to_owned(), + reason: format!( + "response header `{header_name}` maps to the reserved Rust field name `{}`", + ident.logical() + ), + }); + } // Distinct header names can collapse to the same Rust field // identifier (e.g. `X-Foo` and `X_Foo` both → `x_foo`), which would // emit a struct with duplicate fields. Reject rather than diff --git a/crates/oapi-codegen/tests/coverage.rs b/crates/oapi-codegen/tests/coverage.rs index 7d8eb88..e388fc4 100644 --- a/crates/oapi-codegen/tests/coverage.rs +++ b/crates/oapi-codegen/tests/coverage.rs @@ -363,6 +363,7 @@ const SERVER_UNSUPPORTED_FIXTURES: &[&str] = &[ "server_unsupported_bytes_response_header", "server_unsupported_colliding_response_headers", "server_unsupported_ref_response_header", + "server_unsupported_reserved_response_header", ]; /// Absolute path to the crate's `tests` directory. diff --git a/crates/oapi-codegen/tests/fixtures/server_unsupported_reserved_response_header.yaml b/crates/oapi-codegen/tests/fixtures/server_unsupported_reserved_response_header.yaml new file mode 100644 index 0000000..802203e --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/server_unsupported_reserved_response_header.yaml @@ -0,0 +1,20 @@ +openapi: 3.0.3 +info: + title: response header colliding with an injected field (unsupported) + version: "1" +paths: + /widgets: + get: + operationId: getWidgets + responses: + "200": + description: a header named Body collides with the injected body field + headers: + Body: + required: true + schema: + type: string + content: + application/json: + schema: + type: string