From 889cf5f4376f14ae0ad2a295396180a334f80d17 Mon Sep 17 00:00:00 2001 From: Collin Diekvoss Date: Fri, 6 Nov 2026 12:16:34 -0600 Subject: [PATCH] feat(server): generate typed response headers Response Objects with declared headers now produce a {Enum}{Variant}Headers struct as the variant's last field and into_response appends each value as its own header line. Set-Cookie is always multi-value per RFC 9110 section 5.3; a declared Content-Type stays ignored per the specification. --- CHANGELOG.md | 15 ++ src/analysis.rs | 99 +++++++++ src/client_generator.rs | 7 +- src/server/codegen.rs | 205 +++++++++++++++++-- tests/client_cookie_param_test.rs | 100 +++++++++ tests/server_response_headers_test.rs | 283 ++++++++++++++++++++++++++ 6 files changed, 694 insertions(+), 15 deletions(-) create mode 100644 tests/client_cookie_param_test.rs create mode 100644 tests/server_response_headers_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a89c26..24631ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,23 @@ when correcting output that was wrong or incomplete on the wire. ## [Unreleased] +### Added + +- Generated Axum servers emit the response headers a Response Object + declares. A response with `headers` gets a `{Enum}{Variant}Headers` struct + as its variant's last field (`String` when required, `Option` + otherwise, `Vec` for `array` schemas and always for `Set-Cookie`, + one header line per value), and `into_response` appends them; an invalid + value yields a 500. Header `$ref`s to `#/components/headers` resolve, a + declared `Content-Type` is ignored as the specification requires, and + header names that are not HTTP tokens fail generation. Responses without + declared headers generate exactly as before. + ### Fixed +- Generated clients format string cookie parameters through `.as_ref()`; + they are `impl AsRef`, which is not `Display`, so any operation with + a string cookie parameter previously failed to compile. - Publish documentation at all six generated Axum Problem Details type URIs so existing validation error links resolve (#109). Website builds now check that every emitted problem URI has a page. Document response middleware for diff --git a/src/analysis.rs b/src/analysis.rs index c843544..3fc2902 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -595,6 +595,25 @@ pub struct OperationResponse { pub unsupported_media_types: Vec, /// Every supported representation, keyed by exact declared media type. pub representations: BTreeMap, + /// Headers the Response Object declares, sorted by name. `Content-Type` + /// is excluded: the body representation owns it, and the OpenAPI + /// specification says a `Content-Type` response header is ignored. + pub headers: Vec, +} + +/// One header declared by a Response Object. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct OperationResponseHeader { + /// Header name exactly as declared. + pub name: String, + /// Header Object `description`, for generated docs. + pub description: Option, + /// Header Object `required`. + pub required: bool, + /// Whether the header carries a list of values, each sent as its own + /// header line. True for `array` schemas, and always for `Set-Cookie`, + /// which RFC 9110 section 5.3 forbids combining into one line. + pub multiple: bool, } /// Stable identity of an operation in the effective OpenAPI document. @@ -8293,6 +8312,7 @@ impl SchemaAnalyzer { .content .as_ref() .is_some_and(|content| !content.is_empty()), + headers: self.response_headers(&response)?, ..Default::default() }; if let Some((media_type, schema)) = response.json_content() { @@ -8718,6 +8738,85 @@ impl SchemaAnalyzer { Ok(current) } + /// Resolve a Header Object `$ref` chain to the local Header Object it + /// names, with the same pointer rules as [`Self::resolve_response`]. + fn resolve_header(&self, header: &crate::openapi::Header) -> Result { + let mut current = header.clone(); + let mut visited = HashSet::new(); + while let Some(reference) = current.reference.clone() { + if !visited.insert(reference.clone()) { + return Err(GeneratorError::CircularDependency(format!( + "header reference {reference}" + ))); + } + let pointer = reference.strip_prefix('#').ok_or_else(|| { + GeneratorError::UnresolvedReference(format!( + "external header reference `{reference}` is not supported" + )) + })?; + let value = self.openapi_spec.pointer(pointer).ok_or_else(|| { + GeneratorError::UnresolvedReference(format!( + "header reference `{reference}` does not exist" + )) + })?; + current = serde_json::from_value(value.clone()).map_err(|error| { + GeneratorError::InvalidSchema(format!( + "header reference `{reference}` is not a valid OpenAPI Header Object: {error}" + )) + })?; + } + Ok(current) + } + + /// The headers a resolved Response Object declares, sorted by name. + fn response_headers( + &self, + response: &crate::openapi::Response, + ) -> Result> { + let Some(headers) = &response.headers else { + return Ok(Vec::new()); + }; + let mut declared = Vec::with_capacity(headers.len()); + for (name, header) in headers { + if name.eq_ignore_ascii_case("content-type") { + continue; + } + let header = self.resolve_header(header)?; + let multiple = name.eq_ignore_ascii_case("set-cookie") + || header + .schema + .as_ref() + .is_some_and(|schema| self.header_schema_is_array(schema)); + declared.push(OperationResponseHeader { + name: name.clone(), + description: header.description.clone(), + required: header.required.unwrap_or(false), + multiple, + }); + } + Ok(declared) + } + + /// Whether a header schema (following one local `$ref`) is an array. + fn header_schema_is_array(&self, schema: &crate::openapi::Schema) -> bool { + let Ok(mut value) = serde_json::to_value(schema) else { + return false; + }; + if let Some(target) = value + .get("$ref") + .and_then(serde_json::Value::as_str) + .and_then(|reference| reference.strip_prefix('#')) + .and_then(|pointer| self.openapi_spec.pointer(pointer)) + { + value = target.clone(); + } + match value.get("type") { + Some(serde_json::Value::String(ty)) => ty == "array", + Some(serde_json::Value::Array(types)) => types.iter().any(|ty| ty == "array"), + _ => false, + } + } + /// Generate a type name for an inline response schema. /// /// 200 (the canonical success status) keeps the unsuffixed `{Op}Response` diff --git a/src/client_generator.rs b/src/client_generator.rs index 060075d..c579737 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -2261,14 +2261,17 @@ impl CodeGenerator { for parameter in cookie_params { let ident = Self::to_field_ident(&self.operation_param_ident(op, parameter)); let wire_name = parameter.name.as_str(); + // String cookies are `impl AsRef` (not `Display`), like + // header parameters above. + let as_str = Self::param_uses_as_ref_str(parameter).then(|| quote! { .as_ref() }); if parameter.required { emit.push(quote! { - __cookie_fields.push(format!("{}={}", #wire_name, #ident)); + __cookie_fields.push(format!("{}={}", #wire_name, #ident #as_str)); }); } else { emit.push(quote! { if let Some(value) = #ident { - __cookie_fields.push(format!("{}={}", #wire_name, value)); + __cookie_fields.push(format!("{}={}", #wire_name, value #as_str)); } }); } diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 3cdcca3..0525826 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -306,6 +306,14 @@ pub enum ServerCodegenError { status: String, media_types: String, }, + #[error( + "cannot generate response for `{operation_id}` status `{status}`: header name `{name}` is not a valid HTTP field name" + )] + InvalidResponseHeaderName { + operation_id: String, + status: String, + name: String, + }, } pub struct ServerCodegen<'a> { @@ -799,6 +807,17 @@ impl<'a> ServerCodegen<'a> { // Generation is viable whenever at least one buffered or SSE // representation can be emitted; unsupported alternatives // do not invalidate that supported path. + if let Some(header) = response + .headers + .iter() + .find(|header| !is_http_token(&header.name)) + { + return Err(ServerCodegenError::InvalidResponseHeaderName { + operation_id: operation.operation_id.clone(), + status: status.clone(), + name: header.name.clone(), + }); + } if response.has_content && response.body.is_none() && response.schema_name.is_none() @@ -3315,6 +3334,7 @@ impl<'a> ServerCodegen<'a> { has_content: true, unsupported_media_types: Vec::new(), representations: BTreeMap::new(), + headers: Vec::new(), }, ) }) @@ -3322,6 +3342,7 @@ impl<'a> ServerCodegen<'a> { &fallback_responses }; + let mut header_structs: Vec = Vec::new(); for (status, response) in responses { let base_name = status_variant_name(status); let variant = format_ident!("{}", base_name); @@ -3329,6 +3350,19 @@ impl<'a> ServerCodegen<'a> { let status_expr = status_token(status); let status_guard = runtime_status_guard(status, quote! { status }); + // Declared response headers ride along as the variant's last + // field. Responses without any keep their exact previous shape: + // the three pieces below are then empty token streams. + let headers_plan = response_headers_plan(op, enum_ident, &base_name, status, response); + let (headers_field, headers_binding, apply_headers) = match &headers_plan { + Some(plan) => { + header_structs.push(plan.definition.clone()); + let ty = &plan.ty; + (quote! { , #ty }, quote! { , headers }, plan.apply.clone()) + } + None => (quote! {}, quote! {}, quote! {}), + }; + let buffered_body = response.body.clone().or_else(|| { response .schema_name @@ -3369,10 +3403,10 @@ impl<'a> ServerCodegen<'a> { if wildcard { if runtime_status { variants.push(quote! { - #variant(StatusCode, ::axum::http::HeaderValue, #body_ty) + #variant(StatusCode, ::axum::http::HeaderValue, #body_ty #headers_field) }); arms.push(quote! { - Self::#variant(status, content_type, body) => { + Self::#variant(status, content_type, body #headers_binding) => { if !(#status_guard) { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } @@ -3381,28 +3415,30 @@ impl<'a> ServerCodegen<'a> { ::axum::http::header::CONTENT_TYPE, content_type, ); + #apply_headers response } }); } else { variants.push(quote! { - #variant(::axum::http::HeaderValue, #body_ty) + #variant(::axum::http::HeaderValue, #body_ty #headers_field) }); arms.push(quote! { - Self::#variant(content_type, body) => { + Self::#variant(content_type, body #headers_binding) => { let mut response = (#status_expr, #response_body).into_response(); response.headers_mut().insert( ::axum::http::header::CONTENT_TYPE, content_type, ); + #apply_headers response } }); } } else if runtime_status { - variants.push(quote! { #variant(StatusCode, #body_ty) }); + variants.push(quote! { #variant(StatusCode, #body_ty #headers_field) }); arms.push(quote! { - Self::#variant(status, body) => { + Self::#variant(status, body #headers_binding) => { if !(#status_guard) { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } @@ -3414,13 +3450,14 @@ impl<'a> ServerCodegen<'a> { ::axum::http::header::CONTENT_TYPE, content_type, ); + #apply_headers response } }); } else { - variants.push(quote! { #variant(#body_ty) }); + variants.push(quote! { #variant(#body_ty #headers_field) }); arms.push(quote! { - Self::#variant(body) => { + Self::#variant(body #headers_binding) => { let mut response = (#status_expr, #response_body).into_response(); let Ok(content_type) = ::axum::http::HeaderValue::from_bytes(#media_type.as_bytes()) else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); @@ -3429,12 +3466,37 @@ impl<'a> ServerCodegen<'a> { ::axum::http::header::CONTENT_TYPE, content_type, ); + #apply_headers response } }); } } else if !response.has_content { - if runtime_status { + if let Some(plan) = &headers_plan { + let ty = &plan.ty; + if runtime_status { + variants.push(quote! { #variant(StatusCode, #ty) }); + arms.push(quote! { + Self::#variant(status, headers) => { + if !(#status_guard) { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + let mut response = status.into_response(); + #apply_headers + response + } + }); + } else { + variants.push(quote! { #variant(#ty) }); + arms.push(quote! { + Self::#variant(headers) => { + let mut response = #status_expr.into_response(); + #apply_headers + response + } + }); + } + } else if runtime_status { variants.push(quote! { #variant(StatusCode) }); arms.push(quote! { Self::#variant(status) => { @@ -3459,26 +3521,28 @@ impl<'a> ServerCodegen<'a> { let stream_variant = format_ident!("{}Stream", base_name); if runtime_status { variants.push(quote! { - #stream_variant(StatusCode, ::axum::response::sse::Sse) + #stream_variant(StatusCode, ::axum::response::sse::Sse #headers_field) }); arms.push(quote! { - Self::#stream_variant(status, sse) => { + Self::#stream_variant(status, sse #headers_binding) => { if !(#status_guard) { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } let mut response = sse.into_response(); *response.status_mut() = status; + #apply_headers response } }); } else { variants.push(quote! { - #stream_variant(::axum::response::sse::Sse) + #stream_variant(::axum::response::sse::Sse #headers_field) }); arms.push(quote! { - Self::#stream_variant(sse) => { + Self::#stream_variant(sse #headers_binding) => { let mut response = sse.into_response(); *response.status_mut() = #status_expr; + #apply_headers response } }); @@ -3502,6 +3566,8 @@ impl<'a> ServerCodegen<'a> { ); quote! { + #(#header_structs)* + #[doc = #doc] pub enum #enum_ident { #(#variants),* @@ -3518,6 +3584,119 @@ impl<'a> ServerCodegen<'a> { } } +/// Generated pieces for one response's declared headers. +struct ResponseHeadersPlan { + /// `{Enum}{Variant}Headers`, the variant's trailing field type. + ty: syn::Ident, + /// The struct definition. + definition: TokenStream, + /// Statements appending `headers`' values to `response` (both locals in + /// the generated `into_response` arm). Invalid values yield a 500. + apply: TokenStream, +} + +/// Plan the headers struct for a response, or `None` when it declares none. +/// +/// Values are strings already serialized for the wire (OpenAPI `simple` +/// style). Optional headers are `Option`; `multiple` headers are +/// `Vec` with one header line per item, which is the only valid +/// encoding of `Set-Cookie`. +fn response_headers_plan( + op: &OperationInfo, + enum_ident: &syn::Ident, + base_name: &str, + status: &str, + response: &OperationResponse, +) -> Option { + if response.headers.is_empty() { + return None; + } + let ty = format_ident!("{}{}Headers", enum_ident, base_name); + let mut used = std::collections::HashSet::new(); + let mut fields = Vec::new(); + let mut apply = Vec::new(); + for header in &response.headers { + let mut field_name = header_field_name(&header.name); + let base = field_name.clone(); + let mut suffix = 2; + while !used.insert(field_name.clone()) { + field_name = format!("{base}_{suffix}"); + suffix += 1; + } + let field = format_ident!("{}", field_name); + let wire = header.name.to_ascii_lowercase(); + let name = quote! { ::axum::http::HeaderName::from_static(#wire) }; + let mut docs: Vec = header + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(|description| { + description + .lines() + .map(|line| format!(" {line}")) + .chain([String::new()]) + .collect() + }) + .unwrap_or_default(); + docs.push(format!(" `{}` response header.", header.name)); + let append_value = quote! { + match ::axum::http::HeaderValue::try_from(value) { + Ok(value) => { + response.headers_mut().append(#name, value); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + }; + if header.multiple { + fields.push(quote! { #(#[doc = #docs])* pub #field: ::std::vec::Vec }); + apply.push(quote! { for value in headers.#field { #append_value } }); + } else if header.required { + fields.push(quote! { #(#[doc = #docs])* pub #field: String }); + apply.push(quote! { { let value = headers.#field; #append_value } }); + } else { + fields.push(quote! { #(#[doc = #docs])* pub #field: ::std::option::Option }); + apply.push(quote! { if let Some(value) = headers.#field { #append_value } }); + } + } + let doc = format!( + " Headers of the `{status}` response for `{} {}` (operationId `{}`).", + op.method, op.path, op.operation_id + ); + Some(ResponseHeadersPlan { + definition: quote! { + #[doc = #doc] + #[derive(Debug, Clone, Default, PartialEq, Eq)] + pub struct #ty { + #(#fields),* + } + }, + ty, + apply: quote! { #(#apply)* }, + }) +} + +/// A Rust field name for a header: snake case, never a keyword, never +/// starting with a digit. +fn header_field_name(header: &str) -> String { + let mut name = header.to_snake_case(); + if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) { + name = format!("header_{name}"); + } + if syn::parse_str::(&name).is_err() { + name.push('_'); + } + name +} + +/// RFC 9110 `token`: the grammar of an HTTP field name. +fn is_http_token(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)) +} + fn parameter_location(location: &str, name: &str) -> String { format!("/{location}/{}", name.replace('~', "~0").replace('/', "~1")) } diff --git a/tests/client_cookie_param_test.rs b/tests/client_cookie_param_test.rs new file mode 100644 index 0000000..8a8580f --- /dev/null +++ b/tests/client_cookie_param_test.rs @@ -0,0 +1,100 @@ +//! String cookie parameters are `impl AsRef` in generated clients and +//! must be formatted through `.as_ref()` (they are not `Display`). + +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +fn spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "cookie params", "version": "1.0.0" }, + "paths": { "/me": { "get": { + "operationId": "me", + "parameters": [ + { "name": "session", "in": "cookie", "required": true, "schema": { "type": "string" } }, + { "name": "theme", "in": "cookie", "schema": { "type": "string" } }, + { "name": "visits", "in": "cookie", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "echo", "content": { + "application/json": { "schema": { "type": "string" } } + }}} + }}} + }) +} + +#[test] +fn string_cookie_parameters_compile_and_reach_the_wire() { + let temp = tempfile::TempDir::new().unwrap(); + let mut analysis = SchemaAnalyzer::new(spec()).unwrap().analyze().unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: temp.path().join("src/generated"), + module_name: "cookies".into(), + enable_async_client: true, + tracing_enabled: false, + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + let dependencies = + std::fs::read_to_string(temp.path().join("src/generated/REQUIRED_DEPS.toml")).unwrap(); + + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "generated-client-cookies" +version = "0.0.0" +edition = "2024" +publish = false +{dependencies} +[dev-dependencies] +axum = "0.8" +tokio = {{version="1", features=["macros","net","rt-multi-thread"]}} +"# + ), + ) + .unwrap(); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::client::HttpClient; + use axum::{http::HeaderMap, routing::get, Json, Router}; + + #[tokio::test] + async fn cookies_are_sent() { + let app = Router::new().route("/me", get(|headers: HeaderMap| async move { + Json(headers["cookie"].to_str().unwrap().to_owned()) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = HttpClient::new().with_base_url(format!("http://{address}")); + + let sent = client.me(String::from("abc"), Some("dark"), Some(3)).await.unwrap(); + assert_eq!(sent, "session=abc; theme=dark; visits=3"); + let sent = client.me("abc", None::<&str>, None).await.unwrap(); + assert_eq!(sent, "session=abc"); + } +} +"#, + ) + .unwrap(); + + let output = Command::new("cargo") + .args(["test", "--quiet"]) + .current_dir(temp.path()) + .env("CARGO_TARGET_DIR", temp.path().join("cargo-target")) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .unwrap(); + assert!( + output.status.success(), + "generated client failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/tests/server_response_headers_test.rs b/tests/server_response_headers_test.rs new file mode 100644 index 0000000..3d24d48 --- /dev/null +++ b/tests/server_response_headers_test.rs @@ -0,0 +1,283 @@ +//! Declared response headers (`responses.*.headers`) in generated servers. + +use openapi_to_rust::analysis::OperationResponseHeader; +use openapi_to_rust::config::{ServerSection, ServerValidationSection}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +fn headers_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "response headers", "version": "1.0.0" }, + "paths": { + "/login": { "post": { + "operationId": "login", + "tags": ["Auth"], + "responses": { + "200": { + "description": "signed in", + "headers": { + "Set-Cookie": { "$ref": "#/components/headers/SetCookie" }, + "X-Request-Id": { + "description": "Correlates logs for this request.", + "required": true, + "schema": { "type": "string" } + }, + "Content-Type": { "schema": { "type": "string" } } + }, + "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Account" } + }} + }, + "204": { + "description": "signed out", + "headers": { + "Link": { + "schema": { "type": "array", "items": { "type": "string" } } + } + } + }, + "4XX": { + "description": "rejected", + "headers": { + "Retry-After": { "schema": { "type": "integer" } } + }, + "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Account" } + }} + } + } + }}, + "/plain": { "get": { + "operationId": "plain", + "tags": ["Auth"], + "responses": { + "200": { "description": "no headers", "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Account" } + }}} + } + }} + }, + "components": { + "schemas": { + "Account": { "type": "object", "properties": { "id": { "type": "string" } } } + }, + "headers": { + "SetCookie": { + "description": "Session cookie.", + "schema": { "type": "string" } + } + } + } + }) +} + +#[test] +fn analysis_collects_declared_headers_except_content_type() { + let analysis = SchemaAnalyzer::new(headers_spec()) + .unwrap() + .analyze() + .unwrap(); + let login = &analysis.operation_responses["login"]; + assert_eq!( + login["200"].headers, + vec![ + // `$ref` resolved; Set-Cookie is always one line per value. + OperationResponseHeader { + name: "Set-Cookie".into(), + description: Some("Session cookie.".into()), + required: false, + multiple: true, + }, + OperationResponseHeader { + name: "X-Request-Id".into(), + description: Some("Correlates logs for this request.".into()), + required: true, + multiple: false, + }, + ] + ); + assert!( + login["204"].headers[0].multiple, + "array schemas are multi-valued" + ); + assert!( + analysis.operation_responses["plain"]["200"] + .headers + .is_empty() + ); +} + +#[test] +fn invalid_header_names_are_rejected() { + let mut spec = headers_spec(); + spec["paths"]["/plain"]["get"]["responses"]["200"]["headers"] = + json!({ "Bad Header": { "schema": { "type": "string" } } }); + let mut analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let temp = tempfile::TempDir::new().unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: temp.path().join("generated"), + module_name: "headers".into(), + enable_async_client: false, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["plain".into()], + prune_models: true, + validation: ServerValidationSection::default(), + }), + ..Default::default() + }); + let error = generator + .generate_all(&mut analysis) + .unwrap_err() + .to_string(); + assert!(error.contains("Bad Header"), "{error}"); +} + +#[test] +fn generated_variants_carry_and_emit_declared_headers() { + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let mut analysis = SchemaAnalyzer::new(headers_spec()) + .unwrap() + .analyze() + .unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "headers".into(), + enable_async_client: false, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["login".into(), "plain".into()], + prune_models: true, + validation: ServerValidationSection { + enabled: false, + ..Default::default() + }, + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::server::*; + use super::generated::types::Account; + use axum::{http::{header, StatusCode}, response::IntoResponse}; + + fn values(response: &axum::response::Response, name: header::HeaderName) -> Vec { + response + .headers() + .get_all(name) + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect() + } + + #[test] + fn declared_headers_are_emitted() { + let response = LoginResponse::Ok( + Account::default(), + LoginResponseOkHeaders { + set_cookie: vec![ + "session=abc; HttpOnly; Secure".to_owned(), + "theme=dark, light".to_owned(), + ], + x_request_id: "req-1".to_owned(), + }, + ) + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + // One `Set-Cookie` line per value, never comma-joined. + assert_eq!( + values(&response, header::SET_COOKIE), + ["session=abc; HttpOnly; Secure", "theme=dark, light"], + ); + assert_eq!(response.headers()["x-request-id"], "req-1"); + + let empty = LoginResponse::NoContent(LoginResponseNoContentHeaders { + link: vec!["; rel=next".to_owned()], + }) + .into_response(); + assert_eq!(empty.status(), StatusCode::NO_CONTENT); + assert_eq!(values(&empty, header::LINK), ["; rel=next"]); + + let limited = LoginResponse::ClientError( + StatusCode::TOO_MANY_REQUESTS, + Account::default(), + LoginResponseClientErrorHeaders { retry_after: Some("30".to_owned()) }, + ) + .into_response(); + assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(limited.headers()[header::RETRY_AFTER], "30"); + let unset = LoginResponse::ClientError( + StatusCode::BAD_REQUEST, + Account::default(), + LoginResponseClientErrorHeaders::default(), + ) + .into_response(); + assert!(unset.headers().get(header::RETRY_AFTER).is_none()); + + // Responses without declared headers keep their previous shape. + assert_eq!( + PlainResponse::Ok(Account::default()).into_response().status(), + StatusCode::OK, + ); + } + + #[test] + fn invalid_header_values_fail_closed() { + let response = LoginResponse::Ok( + Account::default(), + LoginResponseOkHeaders { + set_cookie: Vec::new(), + x_request_id: "line\nbreak".to_owned(), + }, + ) + .into_response(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "server-response-headers" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1" +axum = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +serde_urlencoded = "0.7" +"#, + ) + .unwrap(); + + let output = Command::new("cargo") + .arg("test") + .arg("--quiet") + .current_dir(temp.path()) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .env("CARGO_TARGET_DIR", temp.path().join("cargo-target")) + .output() + .unwrap(); + assert!( + output.status.success(), + "generated server failed its header test:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +}