Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ 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 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
Expand All @@ -156,6 +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, 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

Expand Down
137 changes: 134 additions & 3 deletions crates/oapi-codegen/src/emit/axum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -302,6 +310,129 @@ fn emit_handler(operation: &Operation) -> Result<TokenStream> {
});
}

/// 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<T>`). 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<TokenStream> = 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<TokenStream> = Vec::new();
if dynamic {
binds.push(quote! { status });
}
if case.body.is_some() {
binds.push(quote! { body });
}
let header_idents: Vec<proc_macro2::Ident> = 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! {}
};
Comment thread
dotkas marked this conversation as resolved.

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<T>`), with its doc attribute.
fn emit_response_header_field(header: &ResponseHeader) -> Result<TokenStream> {
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
Expand Down
19 changes: 19 additions & 0 deletions crates/oapi-codegen/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,29 @@ pub struct ResponseCase {
pub status: ResponseStatus,
/// JSON response body type, when the response declares content.
pub body: Option<RustType>,
/// Declared response headers written by the generated `IntoResponse`, in
/// declaration order. Empty means no headers (the pre-C5 variant shape).
pub headers: Vec<ResponseHeader>,
/// Doc comment derived from the response `description`.
pub doc: Option<String>,
}

/// 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<String>,
}

/// How a response variant's HTTP status code is produced.
///
/// Fixed codes are emitted as a compile-time constant; `default` and range
Expand Down
Loading