Skip to main content

oapi_codegen/lower/
paths.rs

1//! Lowering OpenAPI paths/operations into the server [`crate::ir::Service`].
2//!
3//! Each operation is lowered into typed inputs (path/query/header parameters and
4//! a JSON request body) plus a response enum. The generator only models what it
5//! can translate faithfully. Anything else is rejected with an error rather than
6//! mis-generated.
7//!
8//! Supported:
9//!
10//! - **Path parameters** — inline scalars, or a same-document `$ref` to a scalar.
11//! - **Query parameters** — scalars and arrays of scalars, lowered into a
12//!   per-operation `Deserialize` struct extracted via
13//!   `axum_extra::extract::Query`. Required parameters stay bare. optional ones
14//!   become `Option<..>`. Arrays must use the default `form`/`explode: true`
15//!   encoding (repeated keys).
16//! - **Header parameters** — scalars only, lowered into a per-operation struct
17//!   extracted via a generated `FromRequestParts` impl. The reserved
18//!   `Accept`/`Content-Type`/`Authorization` headers are ignored.
19//! - **Cookie parameters** — scalars only, lowered into a per-operation struct
20//!   extracted via a generated `FromRequestParts` impl backed by
21//!   `axum_extra`'s `CookieJar`.
22//! - **Component `$ref` parameters and request bodies** — `$ref`s to
23//!   `#/components/parameters/*` and `#/components/requestBodies/*` are resolved
24//!   against the document.
25//! - **Responses** — explicit status codes, the `default` catch-all, and ranges
26//!   (`5XX`). Component `$ref` responses are resolved against the document.
27//! - **Cross-file `$ref` parameters, request bodies, and responses** — the
28//!   referenced structural object is read from the sibling file (resolved
29//!   relative to the main spec's directory), following chains across files.
30//!   Parameter inner schemas must still resolve to scalars. body/response inner
31//!   schema `$ref`s route through the `import-mapping` to an external type
32//!   (they are never inlined).
33//!
34//! Rejected: object or other non-scalar parameters, non-default query-array
35//! encodings, `content` parameters, array or `byte`/`binary` header parameters,
36//! `byte`/`binary` cookie parameters, path-item `$ref`s, cross-file schema-type
37//! `$ref`s (in a body or response) that lack an `import-mapping` entry for the
38//! referenced file, and an unrecognised response status code.
39
40use std::collections::BTreeMap;
41
42use http::StatusCode as HttpStatus;
43use openapiv3::ObjectType;
44use openapiv3::Operation as OasOperation;
45use openapiv3::Parameter;
46use openapiv3::ParameterData;
47use openapiv3::ParameterSchemaOrContent;
48use openapiv3::QueryStyle;
49use openapiv3::ReferenceOr;
50use openapiv3::RequestBody;
51use openapiv3::Response as OasResponse;
52use openapiv3::Schema;
53use openapiv3::SchemaData;
54use openapiv3::SchemaKind;
55use openapiv3::StatusCode;
56use openapiv3::Type;
57
58use crate::error::Error;
59use crate::error::Result;
60use crate::ir::Body;
61use crate::ir::BodyKind;
62use crate::ir::BodyVariant;
63use crate::ir::CookieParam;
64use crate::ir::Cookies;
65use crate::ir::Field;
66use crate::ir::HeaderParam;
67use crate::ir::Headers;
68use crate::ir::Multipart;
69use crate::ir::MultipartField;
70use crate::ir::NegotiatedBody;
71use crate::ir::Operation;
72use crate::ir::Param;
73use crate::ir::RequestPayload;
74use crate::ir::ResponseBody;
75use crate::ir::ResponseCase;
76use crate::ir::ResponseStatus;
77use crate::ir::RustType;
78use crate::ir::Service;
79use crate::ir::Struct;
80use crate::loader::Resolved;
81use crate::loader::Spec;
82use crate::loader::ref_component_name;
83use crate::loader::ref_file_part;
84use crate::lower::default::lower_default;
85use crate::lower::schema::integer_type;
86use crate::lower::schema::string_format_type;
87use crate::lower::security;
88use crate::naming::Case;
89use crate::naming::RustIdent;
90use crate::naming::X_RUST_NAME;
91use crate::naming::operations;
92use crate::naming::to_ident;
93
94/// Header parameter names that OpenAPI mandates be ignored when declared with
95/// `in: header`, since they are governed by content negotiation / security
96/// mechanisms rather than the parameter object (compared case-insensitively).
97const IGNORED_HEADER_NAMES: [&str; 3] = ["accept", "content-type", "authorization"];
98
99/// Content-type selection priority for request bodies. `multipart/form-data` is
100/// only offered here (not for responses): axum has a multipart *extractor* but
101/// no multipart *response* writer.
102const REQUEST_BODY_PRIORITY: [BodyKind; 4] = [BodyKind::Json, BodyKind::Form, BodyKind::Multipart, BodyKind::Text];
103
104/// Content-type selection priority for response bodies. Multipart is excluded:
105/// axum has a multipart *extractor* but no multipart *response* writer. A
106/// multipart-only response is therefore rejected, as any response whose every
107/// content type is unsupported is.
108const RESPONSE_BODY_PRIORITY: [BodyKind; 3] = [BodyKind::Json, BodyKind::Form, BodyKind::Text];
109
110/// A lowered response body before it is named. A single content type yields a
111/// [`Body`]. several yield the per-representation variants, which the caller
112/// names into a [`NegotiatedBody`] (the name depends on the response variant).
113enum LoweredResponseBody {
114    Single(Body),
115    Negotiated(Vec<BodyVariant>),
116}
117
118/// Rust field names the response emitter injects into a header-bearing struct
119/// variant (`status` for dynamic responses, `body` when a body is present). A
120/// declared response header whose `snake_case` identifier equals one of these
121/// will collide, so such headers are rejected during lowering.
122const RESERVED_RESPONSE_FIELDS: [&str; 2] = ["status", "body"];
123
124/// Check whether a header name is valid for use with `HeaderName::from_static`.
125/// Enforces the HTTP `tchar` token set (RFC 9110 §5.6.2 / RFC 7230): ASCII
126/// alphanumerics plus ``!#$%&'*+-.^_`|~``. This prevents a later panic when
127/// emitting `HeaderName::from_static`.
128fn is_valid_header_name(name: &str) -> bool {
129    if name.is_empty() {
130        return false;
131    }
132    for byte in name.as_bytes() {
133        // RFC 7230 tchar. `-`..`9` (0x2D..0x39) will wrongly include `/`
134        // (0x2F), which is not a valid header-name char, so digits are their
135        // own range and `-`/`.` are listed explicitly.
136        let valid = matches!(
137            byte,
138            b'!' | b'#'..=b'\'' | b'*'..=b'+' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'^'..=b'z' | b'|' | b'~'
139        );
140        if !valid {
141            return false;
142        }
143    }
144    return true;
145}
146
147/// Lower every operation in `spec` into the server IR, resolving cross-file
148/// schema references through `import_mapping`. `response_type_suffix` is
149/// appended to each operation's response-enum name.
150pub fn generate_service(
151    spec: &Spec,
152    import_mapping: &BTreeMap<String, String>,
153    response_type_suffix: &str,
154) -> Result<Service> {
155    let lowerer = Lowerer {
156        spec,
157        import_mapping,
158        response_type_suffix,
159    };
160    return lowerer.lower();
161}
162
163/// Carries the document and its import mapping through operation lowering.
164struct Lowerer<'a> {
165    spec: &'a Spec,
166    import_mapping: &'a BTreeMap<String, String>,
167    response_type_suffix: &'a str,
168}
169
170impl Lowerer<'_> {
171    /// Lower every operation in the document into the server IR.
172    fn lower(&self) -> Result<Service> {
173        let catalogue = security::scheme_catalogue(self.spec);
174        let mut operations = Vec::new();
175        let mut used_schemes: Vec<String> = Vec::new();
176        // Maps a claimed method name back to the `method path` that claimed it, so
177        // a collision error can name the earlier operation and not the identifier
178        // alone. Filtering runs before lowering (see `crate::filter`), and nothing
179        // prunes an operation afterwards, so every name claimed here reaches the
180        // file and every collision found here is real.
181        let mut claimed: BTreeMap<String, String> = BTreeMap::new();
182        let mut collisions = crate::lower::validate::Diagnostics::new();
183        for (path, entry) in self.spec.paths().iter() {
184            let item = match entry {
185                ReferenceOr::Item(item) => item,
186                ReferenceOr::Reference { .. } => {
187                    return Err(Error::UnsupportedOperation {
188                        method: "*".to_owned(),
189                        path: path.clone(),
190                        reason: "path-item `$ref`s are not supported".to_owned(),
191                    });
192                }
193            };
194            for (method, operation) in item.iter() {
195                let mut lowered = self.lower_operation(path, method, operation, &item.parameters)?;
196                let route = format!("{method} {path}");
197                match claimed.get(lowered.name.logical()) {
198                    Some(first) => {
199                        collisions.push(Error::OperationNameCollision {
200                            ident: lowered.name.logical().to_owned(),
201                            first: first.clone(),
202                            second: route,
203                            hint: operation_collision_hint(operation),
204                        });
205                        // Do not lower this operation into the service. Its
206                        // artifacts all derive from the colliding name, so keeping
207                        // it would emit the duplicates this check exists to stop.
208                        continue;
209                    }
210                    None => {
211                        claimed.insert(lowered.name.logical().to_owned(), route);
212                    }
213                }
214                lowered.security = self.operation_security(operation);
215                for key in &lowered.security {
216                    if !used_schemes.iter().any(|existing| return existing == key) {
217                        used_schemes.push(key.clone());
218                    }
219                }
220                operations.push(lowered);
221            }
222        }
223        collisions.into_result()?;
224        let security_schemes = catalogue
225            .into_iter()
226            .filter(|scheme| return used_schemes.iter().any(|key| return *key == scheme.key))
227            .collect();
228        return Ok(Service {
229            operations,
230            security_schemes,
231        });
232    }
233
234    /// Resolve an operation's effective security requirement into the ordered
235    /// keys of the schemes it applies.
236    ///
237    /// Keys are kept verbatim, including any not declared in
238    /// `components.securitySchemes`: the client emitter rejects an unresolved key
239    /// rather than silently dropping it, while the server emitter ignores
240    /// security entirely, so an unused-by-server dangling reference never blocks
241    /// server generation.
242    fn operation_security(&self, operation: &OasOperation) -> Vec<String> {
243        let effective = security::effective_requirements(operation.security.as_deref(), self.spec.global_security());
244        let Some(requirements) = effective else {
245            return Vec::new();
246        };
247        return security::required_keys(requirements);
248    }
249
250    /// Lower a single operation, given its path, method and path-item parameters.
251    fn lower_operation(
252        &self,
253        path: &str,
254        method: &str,
255        operation: &OasOperation,
256        shared_params: &[ReferenceOr<Parameter>],
257    ) -> Result<Operation> {
258        let name = operation_name(path, method, operation)?;
259        let response_enum = operations::response_enum_name(&name, self.response_type_suffix);
260
261        let params = self.resolve_parameters(operation, shared_params)?;
262        let path_params = self.lower_path_params(path, method, &params)?;
263        let query = self.lower_query_params(path, method, &params, &name)?;
264        let headers = self.lower_header_params(path, method, &params, &name)?;
265        let cookies = self.lower_cookie_params(path, method, &params, &name)?;
266        let request = self.lower_request_body(path, method, &name, operation)?;
267        let responses = self.lower_responses(path, method, &response_enum, operation)?;
268
269        return Ok(Operation {
270            name,
271            response_enum,
272            doc: operation_doc(operation),
273            method: method.to_owned(),
274            path: path.to_owned(),
275            path_params,
276            query,
277            headers,
278            cookies,
279            request,
280            responses,
281            security: Vec::new(),
282        });
283    }
284
285    /// Resolve an operation's parameters (its own, then the path-item's shared
286    /// parameters) into concrete `Parameter`s paired with the referenced file
287    /// each was resolved from (`None` for inline or same-document entries), so
288    /// inner schema `$ref`s can later be interpreted against the right document.
289    /// Operation-level entries precede shared ones, preserving override order.
290    fn resolve_parameters(
291        &self,
292        operation: &OasOperation,
293        shared_params: &[ReferenceOr<Parameter>],
294    ) -> Result<Vec<Resolved<Parameter>>> {
295        let mut resolved = Vec::new();
296        for parameter in operation.parameters.iter().chain(shared_params) {
297            let entry = match parameter {
298                ReferenceOr::Item(param) => Resolved {
299                    value: param.clone(),
300                    origin: None,
301                },
302                ReferenceOr::Reference { reference } => self.spec.resolve_parameter(reference)?,
303            };
304            resolved.push(entry);
305        }
306        return Ok(resolved);
307    }
308
309    /// Resolve the typed path parameters in their path-template order, which is
310    /// the order axum extracts a `Path<(..)>` tuple in.
311    fn lower_path_params(&self, path: &str, method: &str, params: &[Resolved<Parameter>]) -> Result<Vec<Param>> {
312        let placeholders = path_param_names(path);
313        let mut path_params = Vec::new();
314        for name in &placeholders {
315            let declared = path_param_schema(name, params);
316            let ty = match declared {
317                Some((format, origin)) => self.param_type(path, method, name, origin, format)?,
318                None => {
319                    return Err(Error::UndeclaredPathParameter {
320                        method: method.to_owned(),
321                        path: path.to_owned(),
322                        name: name.clone(),
323                    });
324                }
325            };
326            path_params.push(Param {
327                name: to_ident(name, Case::Snake),
328                ty,
329            });
330        }
331        // A parameter declared `in: path` must have a matching `{placeholder}` in
332        // the template. Driving the loop above from the template alone will
333        // otherwise silently drop such a parameter from the generated signature,
334        // producing a handler that omits a required input.
335        for parameter in params {
336            let Parameter::Path { parameter_data, .. } = &parameter.value else {
337                continue;
338            };
339            if !placeholders
340                .iter()
341                .any(|placeholder| return placeholder == &parameter_data.name)
342            {
343                return Err(Error::InvalidPathParameter {
344                    method: method.to_owned(),
345                    path: path.to_owned(),
346                    name: parameter_data.name.clone(),
347                });
348            }
349        }
350        return Ok(path_params);
351    }
352
353    /// Lower an operation's query parameters into a generated `Deserialize`
354    /// struct, returning `None` when the operation declares none. Per OpenAPI's
355    /// override rule, an operation-level parameter takes precedence over a
356    /// path-item one with the same name, so duplicates are de-duplicated keeping
357    /// the first (operation-level) definition.
358    fn lower_query_params(
359        &self,
360        path: &str,
361        method: &str,
362        params: &[Resolved<Parameter>],
363        operation_name: &RustIdent,
364    ) -> Result<Option<Struct>> {
365        let owner = operations::query_struct_name(operation_name);
366        let mut fields = Vec::new();
367        let mut seen: Vec<&str> = Vec::new();
368        for parameter in params {
369            let Parameter::Query {
370                parameter_data, style, ..
371            } = &parameter.value
372            else {
373                continue;
374            };
375            if seen.contains(&parameter_data.name.as_str()) {
376                continue;
377            }
378            seen.push(&parameter_data.name);
379            fields.push(self.query_field(path, method, parameter.origin.as_deref(), parameter_data, style, &owner)?);
380        }
381        if fields.is_empty() {
382            return Ok(None);
383        }
384        let name = owner;
385        return Ok(Some(Struct {
386            name,
387            doc: None,
388            deprecated: None,
389            fields,
390            additional_properties: None,
391            // A query struct denies no unknown key. A query string commonly
392            // carries a parameter the spec does not declare, such as one a proxy
393            // or an analytics tool adds, and rejecting the whole request for it
394            // would break a client that the spec permits.
395            deny_unknown_fields: false,
396        }));
397    }
398
399    /// Build a query struct field from a single query parameter's metadata,
400    /// wrapping optional parameters in `Option<..>`.
401    fn query_field(
402        &self,
403        path: &str,
404        method: &str,
405        origin: Option<&str>,
406        data: &ParameterData,
407        style: &QueryStyle,
408        owner: &RustIdent,
409    ) -> Result<Field> {
410        let schema = self.query_param_schema(path, method, origin, data)?;
411        let mut ty = self.query_param_type(path, method, origin, data, style, &schema)?;
412
413        // A required parameter is always present, so its `default` never fires.
414        let declared = match data.required {
415            true => None,
416            false => schema.schema_data.default.clone(),
417        };
418        if !data.required && declared.is_none() {
419            ty = ty.optional();
420        }
421        // A query parameter never has a named type, so its default can never
422        // name an enum variant.
423        let default = match &declared {
424            Some(json) => Some(lower_default(json, &ty, &|_| return None, owner.logical(), &data.name)?),
425            None => None,
426        };
427
428        let ident = to_ident(&data.name, Case::Snake);
429        let rename = crate::naming::rename_for(&data.name, &ident);
430        // A query parameter is the one place a server reads a value it does not
431        // own, so a constraint here does the most work.
432        let constraints = crate::lower::constraints::constraints_of(&schema);
433        let field = Field {
434            name: ident,
435            rename,
436            doc: data.description.as_deref().and_then(trimmed),
437            deprecated: None,
438            ty,
439            required: data.required,
440            omit_empty: None,
441            serde_skip: false,
442            default,
443            constraints,
444            // A parameter travels in a request only, so a direction mark on its
445            // schema states nothing the generator can act on.
446            access: crate::ir::Access::ReadWrite,
447        };
448        crate::lower::constraints::check_constraints(&field)?;
449        return Ok(field);
450    }
451
452    /// The schema of a query parameter, resolved one time for both the type and
453    /// the `default` to read.
454    ///
455    /// A `content` parameter is rejected here. This generator reads a query
456    /// parameter from a schema only.
457    fn query_param_schema(
458        &self,
459        path: &str,
460        method: &str,
461        origin: Option<&str>,
462        data: &ParameterData,
463    ) -> Result<Schema> {
464        let ParameterSchemaOrContent::Schema(schema) = &data.format else {
465            return Err(Error::UnsupportedOperation {
466                method: method.to_owned(),
467                path: path.to_owned(),
468                reason: format!("query parameter `{}` uses `content`, which is not supported", data.name),
469            });
470        };
471        return self.resolve_param_schema(path, method, origin, &data.name, schema);
472    }
473
474    /// Map a query parameter's schema to a scalar Rust type, or a `Vec<T>` of
475    /// scalars. Cross-file `$ref`s, `content`, and non-scalar shapes (including
476    /// arrays of non-scalars) are rejected. Array parameters must use OpenAPI's
477    /// default `form`/`explode: true` encoding (repeated keys), since the
478    /// generated server reads them through `axum-extra`'s `Query` extractor.
479    /// other array encodings are rejected rather than silently mis-parsed.
480    fn query_param_type(
481        &self,
482        path: &str,
483        method: &str,
484        origin: Option<&str>,
485        data: &ParameterData,
486        style: &QueryStyle,
487        schema: &Schema,
488    ) -> Result<RustType> {
489        let name = data.name.as_str();
490        let explode = data.explode;
491        if let SchemaKind::Type(Type::Array(array)) = &schema.schema_kind {
492            if !matches!(style, QueryStyle::Form) || explode == Some(false) {
493                return Err(Error::UnsupportedOperation {
494                    method: method.to_owned(),
495                    path: path.to_owned(),
496                    reason: format!(
497                        "query parameter `{name}` uses a non-default array encoding; only `style: form` with `explode: true` (repeated keys) is supported"
498                    ),
499                });
500            }
501            let element = match &array.items {
502                Some(ReferenceOr::Item(item)) => scalar_type(&item.schema_kind),
503                Some(ReferenceOr::Reference { reference }) if ref_file_part(reference).is_some() => {
504                    return Err(Error::UnsupportedOperation {
505                        method: method.to_owned(),
506                        path: path.to_owned(),
507                        reason: format!(
508                            "query parameter `{name}` uses array items via a cross-file `$ref`, which is not supported"
509                        ),
510                    });
511                }
512                Some(ReferenceOr::Reference { reference }) => {
513                    let item = self.spec.resolve_schema(origin, reference)?;
514                    scalar_type(&item.schema_kind)
515                }
516                None => {
517                    return Err(Error::UnsupportedOperation {
518                        method: method.to_owned(),
519                        path: path.to_owned(),
520                        reason: format!("query parameter `{name}` is an array without `items`"),
521                    });
522                }
523            };
524            let element = element.ok_or_else(|| {
525                return Error::UnsupportedOperation {
526                    method: method.to_owned(),
527                    path: path.to_owned(),
528                    reason: format!("query parameter `{name}` must be an array of scalars"),
529                };
530            })?;
531            return Ok(RustType::Vec(Box::new(element)));
532        }
533        let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
534            return Error::UnsupportedOperation {
535                method: method.to_owned(),
536                path: path.to_owned(),
537                reason: format!("query parameter `{name}` must be a scalar or an array of scalars"),
538            };
539        })?;
540        return Ok(ty);
541    }
542
543    /// Resolve a parameter schema reference to an owned concrete schema. A
544    /// same-document reference is resolved against the main document, or against
545    /// the referenced document the parameter came from (`origin`). a cross-file
546    /// inner `$ref` is out of scope and rejected.
547    fn resolve_param_schema(
548        &self,
549        path: &str,
550        method: &str,
551        origin: Option<&str>,
552        name: &str,
553        schema: &ReferenceOr<Schema>,
554    ) -> Result<Schema> {
555        match schema {
556            ReferenceOr::Item(schema) => return Ok(schema.clone()),
557            ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
558                return Err(Error::UnsupportedOperation {
559                    method: method.to_owned(),
560                    path: path.to_owned(),
561                    reason: format!("query parameter `{name}` uses a cross-file `$ref`, which is not supported"),
562                });
563            }
564            ReferenceOr::Reference { reference } => return self.spec.resolve_schema(origin, reference),
565        }
566    }
567
568    /// Lower an operation's header parameters into a generated [`Headers`]
569    /// struct, returning `None` when the operation declares none. Per OpenAPI's
570    /// override rule the first (operation-level) definition wins on a
571    /// case-insensitive name collision, and the `Accept`/`Content-Type`/
572    /// `Authorization` headers the specification reserves are skipped.
573    fn lower_header_params(
574        &self,
575        path: &str,
576        method: &str,
577        params: &[Resolved<Parameter>],
578        operation_name: &RustIdent,
579    ) -> Result<Option<Headers>> {
580        let mut header_params = Vec::new();
581        let mut seen: Vec<&str> = Vec::new();
582        for parameter in params {
583            let Parameter::Header { parameter_data, .. } = &parameter.value else {
584                continue;
585            };
586            let name = parameter_data.name.as_str();
587            if IGNORED_HEADER_NAMES
588                .iter()
589                .any(|ignored| return ignored.eq_ignore_ascii_case(name))
590            {
591                continue;
592            }
593            if seen.iter().any(|other| return other.eq_ignore_ascii_case(name)) {
594                continue;
595            }
596            seen.push(name);
597            header_params.push(self.header_param(path, method, parameter.origin.as_deref(), parameter_data)?);
598        }
599        if header_params.is_empty() {
600            return Ok(None);
601        }
602        let name = operations::headers_struct_name(operation_name);
603        return Ok(Some(Headers {
604            name,
605            params: header_params,
606        }));
607    }
608
609    /// Build a single header field, resolving its scalar type and recording the
610    /// exact header name for the generated case-insensitive lookup.
611    fn header_param(
612        &self,
613        path: &str,
614        method: &str,
615        origin: Option<&str>,
616        data: &ParameterData,
617    ) -> Result<HeaderParam> {
618        let ty = self.header_param_type(path, method, origin, &data.name, &data.format)?;
619        return Ok(HeaderParam {
620            name: to_ident(&data.name, Case::Snake),
621            header_name: data.name.clone(),
622            ty,
623            required: data.required,
624            doc: data.description.as_deref().and_then(trimmed),
625        });
626    }
627
628    /// Map a header/response-header schema to a scalar Rust type, applying the
629    /// shared rules: reject `content`, non-scalar shapes, and `byte`/`binary`.
630    /// resolve a same-document/origin schema `$ref` to a scalar. reject a
631    /// cross-file schema `$ref`. `kind_label` is used in error messages (for example
632    /// "header parameter" or "response header").
633    fn scalar_from_format(
634        &self,
635        path: &str,
636        method: &str,
637        origin: Option<&str>,
638        kind_label: &str,
639        name: &str,
640        format: &ParameterSchemaOrContent,
641    ) -> Result<RustType> {
642        let schema = match format {
643            ParameterSchemaOrContent::Schema(schema) => schema,
644            ParameterSchemaOrContent::Content(_) => {
645                return Err(Error::UnsupportedOperation {
646                    method: method.to_owned(),
647                    path: path.to_owned(),
648                    reason: format!("{kind_label} `{name}` uses `content`, which is not supported"),
649                });
650            }
651        };
652        let schema = match schema {
653            ReferenceOr::Item(schema) => schema.clone(),
654            ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
655                return Err(Error::UnsupportedOperation {
656                    method: method.to_owned(),
657                    path: path.to_owned(),
658                    reason: format!("{kind_label} `{name}` uses a cross-file `$ref`, which is not supported"),
659                });
660            }
661            ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
662        };
663        let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
664            return Error::UnsupportedOperation {
665                method: method.to_owned(),
666                path: path.to_owned(),
667                reason: format!("{kind_label} `{name}` must be a scalar"),
668            };
669        })?;
670        if matches!(ty, RustType::Bytes) {
671            return Err(Error::UnsupportedOperation {
672                method: method.to_owned(),
673                path: path.to_owned(),
674                reason: format!("{kind_label} `{name}` uses a `byte`/`binary` format, which is not supported"),
675            });
676        }
677        return Ok(ty);
678    }
679
680    /// Map a header parameter's schema to a scalar Rust type. `content`,
681    /// cross-file `$ref`s, non-scalar shapes (arrays/objects), and `byte`/
682    /// `binary` strings (which have no `FromStr`) are rejected.
683    fn header_param_type(
684        &self,
685        path: &str,
686        method: &str,
687        origin: Option<&str>,
688        name: &str,
689        format: &ParameterSchemaOrContent,
690    ) -> Result<RustType> {
691        return self.scalar_from_format(path, method, origin, "header parameter", name, format);
692    }
693
694    /// Lower an operation's cookie parameters into a generated [`Cookies`]
695    /// struct, returning `None` when the operation declares none. Per OpenAPI's
696    /// override rule the first (operation-level) definition wins on a name
697    /// collision. Cookies have no reserved-name analogue, so none are skipped.
698    fn lower_cookie_params(
699        &self,
700        path: &str,
701        method: &str,
702        params: &[Resolved<Parameter>],
703        operation_name: &RustIdent,
704    ) -> Result<Option<Cookies>> {
705        let mut cookie_params = Vec::new();
706        let mut seen: Vec<&str> = Vec::new();
707        for parameter in params {
708            let Parameter::Cookie { parameter_data, .. } = &parameter.value else {
709                continue;
710            };
711            let name = parameter_data.name.as_str();
712            if seen.contains(&name) {
713                continue;
714            }
715            seen.push(name);
716            let ty = self.cookie_param_type(
717                path,
718                method,
719                parameter.origin.as_deref(),
720                &parameter_data.name,
721                &parameter_data.format,
722            )?;
723            cookie_params.push(CookieParam {
724                name: to_ident(&parameter_data.name, Case::Snake),
725                cookie_name: parameter_data.name.clone(),
726                ty,
727                required: parameter_data.required,
728                doc: parameter_data.description.as_deref().and_then(trimmed),
729            });
730        }
731        if cookie_params.is_empty() {
732            return Ok(None);
733        }
734        let name = operations::cookies_struct_name(operation_name);
735        return Ok(Some(Cookies {
736            name,
737            params: cookie_params,
738        }));
739    }
740
741    /// Map a cookie parameter's schema to a scalar Rust type. `content`,
742    /// cross-file `$ref`s, non-scalar shapes (arrays/objects), and `byte`/
743    /// `binary` strings (no `FromStr`) are rejected.
744    fn cookie_param_type(
745        &self,
746        path: &str,
747        method: &str,
748        origin: Option<&str>,
749        name: &str,
750        format: &ParameterSchemaOrContent,
751    ) -> Result<RustType> {
752        let schema = match format {
753            ParameterSchemaOrContent::Schema(schema) => schema,
754            ParameterSchemaOrContent::Content(_) => {
755                return Err(Error::UnsupportedOperation {
756                    method: method.to_owned(),
757                    path: path.to_owned(),
758                    reason: format!("cookie parameter `{name}` uses `content`, which is not supported"),
759                });
760            }
761        };
762        let schema = match schema {
763            ReferenceOr::Item(schema) => schema.clone(),
764            ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
765                return Err(Error::UnsupportedOperation {
766                    method: method.to_owned(),
767                    path: path.to_owned(),
768                    reason: format!("cookie parameter `{name}` uses a cross-file `$ref`, which is not supported"),
769                });
770            }
771            ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
772        };
773        let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
774            return Error::UnsupportedOperation {
775                method: method.to_owned(),
776                path: path.to_owned(),
777                reason: format!("cookie parameter `{name}` must be a scalar"),
778            };
779        })?;
780        if matches!(ty, RustType::Bytes) {
781            return Err(Error::UnsupportedOperation {
782                method: method.to_owned(),
783                path: path.to_owned(),
784                reason: format!("cookie parameter `{name}` uses a `byte`/`binary` format, which is not supported"),
785            });
786        }
787        return Ok(ty);
788    }
789
790    /// Collect every supported content type a body declares, deduplicated by
791    /// [`BodyKind`] and ordered by the caller's `priority` (requests and
792    /// responses differ — see [`REQUEST_BODY_PRIORITY`] /
793    /// [`RESPONSE_BODY_PRIORITY`]). When several media entries map to the same
794    /// kind (for example `application/json` and `application/vnd.api+json`), the first
795    /// in document order wins. An empty result means the map declares no content
796    /// type the `priority` accepts.
797    fn supported_bodies<'m>(
798        &self,
799        content: &'m indexmap::IndexMap<String, openapiv3::MediaType>,
800        priority: &[BodyKind],
801    ) -> Vec<(BodyKind, &'m openapiv3::MediaType)> {
802        let mut selected = Vec::new();
803        for &wanted in priority {
804            for (name, media) in content {
805                if media_type_kind(name) == Some(wanted) {
806                    selected.push((wanted, media));
807                    break;
808                }
809            }
810        }
811        return selected;
812    }
813
814    /// Lower a selected body media entry into a typed [`Body`] for the given
815    /// content kind. Text bodies must be `string`. form bodies must reference a
816    /// named object schema. JSON reuses the existing body-type mapping.
817    fn body_from_media(
818        &self,
819        path: &str,
820        method: &str,
821        origin: Option<&str>,
822        kind: BodyKind,
823        media: &openapiv3::MediaType,
824    ) -> Result<Option<Body>> {
825        let schema = match &media.schema {
826            Some(schema) => schema,
827            None => return Ok(None),
828        };
829        let ty = match kind {
830            BodyKind::Json => self.body_type(path, method, origin, schema)?,
831            BodyKind::Text => {
832                let resolved = match schema {
833                    ReferenceOr::Item(schema) => schema.clone(),
834                    ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
835                };
836                if !matches!(resolved.schema_kind, SchemaKind::Type(Type::String(_))) {
837                    return Err(Error::UnsupportedOperation {
838                        method: method.to_owned(),
839                        path: path.to_owned(),
840                        reason: "text/plain body must be a `string` schema".to_owned(),
841                    });
842                }
843                RustType::String
844            }
845            BodyKind::Form => match schema {
846                ReferenceOr::Reference { reference } => {
847                    // A form body must be a struct of scalar fields for
848                    // `serde_urlencoded`. When the schema is resolvable here
849                    // (same-document, or an origin-file `$ref` without its own
850                    // file part) require it to be an `object`. A cross-file
851                    // (`file#/...`) ref is opaque — it maps to an external type
852                    // via the `import-mapping`, so its shape cannot be inspected
853                    // and is trusted.
854                    if ref_file_part(reference).is_none() {
855                        let resolved = self.spec.resolve_schema(origin, reference)?;
856                        if !matches!(resolved.schema_kind, SchemaKind::Type(Type::Object(_))) {
857                            return Err(Error::UnsupportedOperation {
858                                method: method.to_owned(),
859                                path: path.to_owned(),
860                                reason:
861                                    "form (`application/x-www-form-urlencoded`) body must reference an `object` schema"
862                                        .to_owned(),
863                            });
864                        }
865                    }
866                    self.schema_ref_type(path, method, origin, reference)?
867                }
868                ReferenceOr::Item(_) => {
869                    return Err(Error::UnsupportedOperation {
870                        method: method.to_owned(),
871                        path: path.to_owned(),
872                        reason: "form (`application/x-www-form-urlencoded`) body must reference a named object schema"
873                            .to_owned(),
874                    });
875                }
876            },
877            // Multipart is lowered by `lower_multipart_body`, and never selected
878            // for responses (`RESPONSE_BODY_PRIORITY` excludes it), so it does
879            // not reach the shared body mapping.
880            BodyKind::Multipart => {
881                return Err(Error::UnsupportedOperation {
882                    method: method.to_owned(),
883                    path: path.to_owned(),
884                    reason: "multipart/form-data is only supported for request bodies".to_owned(),
885                });
886            }
887        };
888        return Ok(Some(Body { ty, kind }));
889    }
890}
891
892/// Locate the `path` parameter named `name` within a resolved parameter list,
893/// returning its schema/content and the referenced file it was resolved from.
894fn path_param_schema<'a>(
895    name: &str,
896    params: &'a [Resolved<Parameter>],
897) -> Option<(&'a ParameterSchemaOrContent, Option<&'a str>)> {
898    for parameter in params {
899        let Parameter::Path { parameter_data, .. } = &parameter.value else {
900            continue;
901        };
902        if parameter_data.name == name {
903            return Some((&parameter_data.format, parameter.origin.as_deref()));
904        }
905    }
906    return None;
907}
908
909/// The enum-variant identifier for a negotiated body's content kind
910/// (`Json`, `Form`, `Text`). `Multipart` never participates in negotiation, so
911/// its arm is only for exhaustiveness.
912fn body_kind_ident(kind: BodyKind) -> RustIdent {
913    let name = match kind {
914        BodyKind::Json => "Json",
915        BodyKind::Form => "Form",
916        BodyKind::Text => "Text",
917        BodyKind::Multipart => "Multipart",
918    };
919    return to_ident(name, Case::Pascal);
920}
921
922/// The content types a body declares, in document order, for a message. Both
923/// directions report the same way, so one function builds the list.
924fn declared_content_types(content: &indexmap::IndexMap<String, openapiv3::MediaType>) -> String {
925    return content.keys().cloned().collect::<Vec<_>>().join(", ");
926}
927
928/// Classify a media type string into a supported [`BodyKind`], or `None`.
929/// Parameters after `;` (for example `; charset=utf-8`) are ignored. JSON matches
930/// broadly: `application/json` or any `+json`-suffixed type.
931fn media_type_kind(name: &str) -> Option<BodyKind> {
932    let base = name.split(';').next().unwrap_or(name).trim().to_ascii_lowercase();
933    if base == "application/json" || base.ends_with("+json") {
934        return Some(BodyKind::Json);
935    }
936    if base == "application/x-www-form-urlencoded" {
937        return Some(BodyKind::Form);
938    }
939    if base == "multipart/form-data" {
940        return Some(BodyKind::Multipart);
941    }
942    if base == "text/plain" {
943        return Some(BodyKind::Text);
944    }
945    return None;
946}
947
948/// Map an OpenAPI schema kind to its Rust type when it is one of the four
949/// supported scalars (`string`, `integer`, `number`, `boolean`), else `None`.
950fn scalar_type(kind: &SchemaKind) -> Option<RustType> {
951    let ty = match kind {
952        SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
953        SchemaKind::Type(Type::Integer(it)) => integer_type(it),
954        SchemaKind::Type(Type::Number(_)) => RustType::F64,
955        SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
956        _ => return None,
957    };
958    return Some(ty);
959}
960
961impl Lowerer<'_> {
962    /// Map a path parameter's schema to a scalar Rust type. Path parameters must
963    /// be scalars (they are parsed from URL segments into an axum `Path<..>`
964    /// tuple), so a `$ref` is resolved to its concrete schema and the scalar-only
965    /// rule is enforced — the same as header and cookie parameters. A
966    /// same-document `$ref` is resolved against the main document, or against the
967    /// referenced document the parameter came from (`origin`). a cross-file inner
968    /// `$ref` is rejected.
969    fn param_type(
970        &self,
971        path: &str,
972        method: &str,
973        name: &str,
974        origin: Option<&str>,
975        format: &ParameterSchemaOrContent,
976    ) -> Result<RustType> {
977        let schema = match format {
978            ParameterSchemaOrContent::Schema(schema) => schema,
979            ParameterSchemaOrContent::Content(_) => {
980                return Err(Error::UnsupportedOperation {
981                    method: method.to_owned(),
982                    path: path.to_owned(),
983                    reason: format!("path parameter `{name}` uses `content`, which is not supported"),
984                });
985            }
986        };
987        let schema = match schema {
988            ReferenceOr::Item(schema) => schema.clone(),
989            ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
990                return Err(Error::UnsupportedOperation {
991                    method: method.to_owned(),
992                    path: path.to_owned(),
993                    reason: format!("path parameter `{name}` uses a cross-file `$ref`, which is not supported"),
994                });
995            }
996            ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
997        };
998        let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
999            return Error::UnsupportedOperation {
1000                method: method.to_owned(),
1001                path: path.to_owned(),
1002                reason: format!("path parameter `{name}` must be a scalar type"),
1003            };
1004        })?;
1005        return Ok(ty);
1006    }
1007
1008    /// Lower an operation's request body, if it declares one. A cross-file
1009    /// wrapper `$ref` is resolved against the referenced file. Its inner schema
1010    /// `$ref`s are then interpreted against that file (`origin`). A single
1011    /// supported content type yields a [`RequestPayload::Single`]. a
1012    /// `multipart/form-data` body yields a per-operation extractor (`op_name`
1013    /// seeds its name). several supported content types yield a
1014    /// [`RequestPayload::Negotiated`] dispatch enum. `multipart/form-data`
1015    /// cannot be combined with other content types (it needs a bespoke
1016    /// extractor rather than a `Content-Type` branch).
1017    fn lower_request_body(
1018        &self,
1019        path: &str,
1020        method: &str,
1021        op_name: &RustIdent,
1022        operation: &OasOperation,
1023    ) -> Result<Option<RequestPayload>> {
1024        let body = match &operation.request_body {
1025            Some(body) => body,
1026            None => return Ok(None),
1027        };
1028        let (body, origin): (RequestBody, Option<String>) = match body {
1029            ReferenceOr::Item(body) => (body.clone(), None),
1030            ReferenceOr::Reference { reference } => {
1031                let resolved = self.spec.resolve_request_body(reference)?;
1032                (resolved.value, resolved.origin)
1033            }
1034        };
1035        let supported = self.supported_bodies(&body.content, &REQUEST_BODY_PRIORITY);
1036        if supported.is_empty() {
1037            if body.content.is_empty() {
1038                return Ok(None);
1039            }
1040            return Err(Error::UnsupportedContentType {
1041                method: method.to_owned(),
1042                path: path.to_owned(),
1043                location: "request body".to_owned(),
1044                declared: declared_content_types(&body.content),
1045                hint: "A request body must declare `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`. Add one of them, or remove the `requestBody`.".to_owned(),
1046            });
1047        }
1048        let has_multipart = supported.iter().any(|(kind, _)| return *kind == BodyKind::Multipart);
1049        if has_multipart {
1050            if supported.len() > 1 {
1051                return Err(Error::UnsupportedOperation {
1052                    method: method.to_owned(),
1053                    path: path.to_owned(),
1054                    reason: "multipart/form-data cannot be combined with other request content types".to_owned(),
1055                });
1056            }
1057            let Some(&(_, media)) = supported.first() else {
1058                unreachable!("multipart body content type count already validated to be exactly one");
1059            };
1060            let multipart = self.lower_multipart_body(path, method, op_name, origin.as_deref(), media)?;
1061            return Ok(Some(RequestPayload::Multipart(multipart)));
1062        }
1063        let mut variants = Vec::with_capacity(supported.len());
1064        for (kind, media) in supported {
1065            if let Some(body) = self.body_from_media(path, method, origin.as_deref(), kind, media)? {
1066                variants.push(BodyVariant {
1067                    variant: body_kind_ident(kind),
1068                    body,
1069                });
1070            }
1071        }
1072        if variants.len() == 1 {
1073            let Some(variant) = variants.pop() else {
1074                unreachable!("length checked to be 1 above");
1075            };
1076            return Ok(Some(RequestPayload::Single(variant.body)));
1077        }
1078        if variants.is_empty() {
1079            return Ok(None);
1080        }
1081        return Ok(Some(RequestPayload::Negotiated(NegotiatedBody {
1082            name: operations::request_body_enum_name(op_name),
1083            variants,
1084        })));
1085    }
1086
1087    /// Lower a `multipart/form-data` request body into a per-operation extractor
1088    /// struct (`<Op>Multipart`) that parses it. The schema must be an object,
1089    /// declared either inline or as a same-document `$ref` (so its fields can be
1090    /// enumerated). each property must be a scalar or a binary/file string.
1091    /// Composite, nested-object, array, and cross-file/external bodies are
1092    /// rejected.
1093    ///
1094    /// Unlike JSON/form/text bodies, a multipart body does not reuse a component
1095    /// model type: axum has no typed multipart extractor, so the generator owns
1096    /// a dedicated struct plus a hand-written `FromRequest`. Generating a
1097    /// per-operation struct (rather than reusing the referenced component) keeps
1098    /// multipart working under any model configuration — including
1099    /// `models: false` with cross-file `import-mapping` — and lets an inline
1100    /// object be used without declaring a redundant named component.
1101    fn lower_multipart_body(
1102        &self,
1103        path: &str,
1104        method: &str,
1105        op_name: &RustIdent,
1106        origin: Option<&str>,
1107        media: &openapiv3::MediaType,
1108    ) -> Result<Multipart> {
1109        let unsupported = |reason: String| {
1110            return Error::UnsupportedOperation {
1111                method: method.to_owned(),
1112                path: path.to_owned(),
1113                reason,
1114            };
1115        };
1116        let schema = media
1117            .schema
1118            .as_ref()
1119            .ok_or_else(|| return unsupported("multipart/form-data body must declare a schema".to_owned()))?;
1120        let object = self.multipart_object(path, method, origin, schema)?;
1121        let fields = self.lower_multipart_fields(path, method, &object)?;
1122        return Ok(Multipart {
1123            name: operations::multipart_struct_name(op_name),
1124            fields,
1125        });
1126    }
1127
1128    /// Resolve a multipart body schema to its [`ObjectType`]. An inline object is
1129    /// taken directly. a `$ref` must be same-document (no cross-file part, and
1130    /// the body must not itself come from a referenced file) and resolve to an
1131    /// object. Non-object schemas and cross-file/external references are
1132    /// rejected — their fields cannot be enumerated into a typed extractor.
1133    fn multipart_object(
1134        &self,
1135        path: &str,
1136        method: &str,
1137        origin: Option<&str>,
1138        schema: &ReferenceOr<Schema>,
1139    ) -> Result<ObjectType> {
1140        let reject = |reason: &str| {
1141            return Error::UnsupportedOperation {
1142                method: method.to_owned(),
1143                path: path.to_owned(),
1144                reason: reason.to_owned(),
1145            };
1146        };
1147        let not_object = "multipart/form-data body schema must be an `object`";
1148        let cross_file = "multipart/form-data body must be an inline object or a same-document `$ref`; cross-file/external multipart is unsupported";
1149        match schema {
1150            ReferenceOr::Item(item) => {
1151                if origin.is_some() {
1152                    return Err(reject(cross_file));
1153                }
1154                match &item.schema_kind {
1155                    SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
1156                    _ => return Err(reject(not_object)),
1157                }
1158            }
1159            ReferenceOr::Reference { reference } => {
1160                if origin.is_some() || ref_file_part(reference).is_some() {
1161                    return Err(reject(cross_file));
1162                }
1163                let resolved = self.spec.resolve_schema(origin, reference)?;
1164                match &resolved.schema_kind {
1165                    SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
1166                    _ => return Err(reject(not_object)),
1167                }
1168            }
1169        }
1170    }
1171
1172    /// Lower a multipart object's properties into [`MultipartField`]s: a field is
1173    /// `Option<..>` when it is not `required` or its schema is `nullable`, and
1174    /// its identifier is the property's `snake_case` name. Each property must
1175    /// resolve to a scalar (a binary/`byte` string becomes a `Vec<u8>` file
1176    /// field). a same-document `$ref` is resolved first. Non-scalar properties
1177    /// (nested objects/arrays) and cross-file `$ref` properties are rejected.
1178    fn lower_multipart_fields(&self, path: &str, method: &str, object: &ObjectType) -> Result<Vec<MultipartField>> {
1179        let mut fields = Vec::with_capacity(object.properties.len());
1180        for (wire_name, property) in &object.properties {
1181            let required = object.required.iter().any(|name| {
1182                return name == wire_name;
1183            });
1184            let (kind, nullable) = match property {
1185                ReferenceOr::Item(schema) => {
1186                    if !multipart_part_is_sent(&schema.schema_data, path, method, wire_name)? {
1187                        continue;
1188                    }
1189                    (schema.schema_kind.clone(), schema.schema_data.nullable)
1190                }
1191                ReferenceOr::Reference { reference } => {
1192                    if ref_file_part(reference).is_some() {
1193                        return Err(Error::UnsupportedOperation {
1194                            method: method.to_owned(),
1195                            path: path.to_owned(),
1196                            reason: format!(
1197                                "multipart field `{wire_name}` uses a cross-file `$ref`, which is not supported"
1198                            ),
1199                        });
1200                    }
1201                    let resolved = self.spec.resolve_schema(None, reference)?;
1202                    if !multipart_part_is_sent(&resolved.schema_data, path, method, wire_name)? {
1203                        continue;
1204                    }
1205                    (resolved.schema_kind, resolved.schema_data.nullable)
1206                }
1207            };
1208            let ty = scalar_type(&kind).ok_or_else(|| {
1209                return Error::UnsupportedOperation {
1210                    method: method.to_owned(),
1211                    path: path.to_owned(),
1212                    reason: format!(
1213                        "multipart field `{wire_name}` must be a scalar or binary string; nested objects and arrays are not supported"
1214                    ),
1215                };
1216            })?;
1217            fields.push(MultipartField {
1218                wire_name: wire_name.clone(),
1219                rust_name: to_ident(wire_name, Case::Snake),
1220                is_file: matches!(ty, RustType::Bytes),
1221                ty,
1222                optional: !required || nullable,
1223            });
1224        }
1225        return Ok(fields);
1226    }
1227
1228    /// Lower a response's declared headers into scalar-typed [`ResponseHeader`]s.
1229    /// Inline `Header` objects only. a `Header` that is itself a `$ref` is
1230    /// rejected. De-duplicated by case-insensitive name, first-seen winning.
1231    fn lower_response_headers(
1232        &self,
1233        path: &str,
1234        method: &str,
1235        origin: Option<&str>,
1236        response: &OasResponse,
1237    ) -> Result<Vec<crate::ir::ResponseHeader>> {
1238        let mut headers = Vec::new();
1239        let mut seen: Vec<String> = Vec::new();
1240        let mut seen_idents: Vec<String> = Vec::new();
1241        for (header_name, header_ref) in &response.headers {
1242            let header = match header_ref {
1243                ReferenceOr::Item(header) => header,
1244                ReferenceOr::Reference { .. } => {
1245                    return Err(Error::UnsupportedOperation {
1246                        method: method.to_owned(),
1247                        path: path.to_owned(),
1248                        reason: format!("response header `{header_name}` uses a `$ref`, which is not supported"),
1249                    });
1250                }
1251            };
1252            if seen.iter().any(|other| return other.eq_ignore_ascii_case(header_name)) {
1253                continue;
1254            }
1255            if !is_valid_header_name(header_name) {
1256                return Err(Error::UnsupportedOperation {
1257                    method: method.to_owned(),
1258                    path: path.to_owned(),
1259                    reason: format!("response header `{header_name}` has an invalid header name"),
1260                });
1261            }
1262            let ident = to_ident(header_name, Case::Snake);
1263            // The response emitter injects `status` (dynamic responses) and
1264            // `body` (responses with a body) fields into the struct variant. A
1265            // header whose Rust field name collides with one of those will emit
1266            // duplicate fields. Reject rather than mis-generate.
1267            if RESERVED_RESPONSE_FIELDS.contains(&ident.logical()) {
1268                return Err(Error::UnsupportedOperation {
1269                    method: method.to_owned(),
1270                    path: path.to_owned(),
1271                    reason: format!(
1272                        "response header `{header_name}` maps to the reserved Rust field name `{}`",
1273                        ident.logical()
1274                    ),
1275                });
1276            }
1277            // Distinct header names can collapse to the same Rust field
1278            // identifier (for example `X-Foo` and `X_Foo` both → `x_foo`), which will
1279            // emit a struct with duplicate fields. Reject rather than
1280            // mis-generate.
1281            if seen_idents.iter().any(|other| return other == ident.logical()) {
1282                return Err(Error::UnsupportedOperation {
1283                    method: method.to_owned(),
1284                    path: path.to_owned(),
1285                    reason: format!(
1286                        "response header `{header_name}` maps to the same Rust field name as another header (`{}`)",
1287                        ident.logical()
1288                    ),
1289                });
1290            }
1291            seen.push(header_name.clone());
1292            seen_idents.push(ident.logical().to_owned());
1293            let ty = self.scalar_from_format(path, method, origin, "response header", header_name, &header.format)?;
1294            headers.push(crate::ir::ResponseHeader {
1295                name: ident,
1296                header_name: header_name.clone(),
1297                ty,
1298                required: header.required,
1299                doc: header.description.as_deref().and_then(trimmed),
1300            });
1301        }
1302        return Ok(headers);
1303    }
1304
1305    /// Lower an operation's responses into typed enum variants, resolving
1306    /// component `$ref` responses against the document. A fixed status code
1307    /// becomes a reason-named variant with a compile-time status constant. a
1308    /// range (`5XX` → `Status5xx`) or the `default` response becomes a variant
1309    /// that carries the `axum::http::StatusCode` the handler supplies at runtime.
1310    fn lower_responses(
1311        &self,
1312        path: &str,
1313        method: &str,
1314        response_enum: &RustIdent,
1315        operation: &OasOperation,
1316    ) -> Result<Vec<ResponseCase>> {
1317        let mut cases = Vec::new();
1318        for (status_code, response) in &operation.responses.responses {
1319            let (status, variant) = match status_code {
1320                StatusCode::Code(code) => {
1321                    let reason = HttpStatus::from_u16(*code).ok().and_then(|status| {
1322                        return status.canonical_reason();
1323                    });
1324                    let reason = reason.ok_or_else(|| {
1325                        return Error::UnsupportedOperation {
1326                            method: method.to_owned(),
1327                            path: path.to_owned(),
1328                            reason: format!("status code `{code}` is not a recognised HTTP status"),
1329                        };
1330                    })?;
1331                    (ResponseStatus::Fixed(*code), to_ident(reason, Case::Pascal))
1332                }
1333                StatusCode::Range(range) => {
1334                    if !(1..=5).contains(range) {
1335                        return Err(Error::UnsupportedOperation {
1336                            method: method.to_owned(),
1337                            path: path.to_owned(),
1338                            reason: format!("response range `{range}XX` is not a valid HTTP status class"),
1339                        });
1340                    }
1341                    let variant = to_ident(&format!("status_{range}xx"), Case::Pascal);
1342                    let Ok(range) = u8::try_from(*range) else {
1343                        unreachable!("range checked to be within 1..=5 above");
1344                    };
1345                    (ResponseStatus::Range(range), variant)
1346                }
1347            };
1348            let response = self.resolve_response_ref(response)?;
1349            // The status code names the response for a message. Two responses of
1350            // one operation are otherwise indistinguishable to a reader.
1351            let location = format!("`{status_code}` response");
1352            let body = self.response_body(path, method, &location, response.origin.as_deref(), &response.value)?;
1353            let body = self.name_response_body(response_enum, &variant, body);
1354            let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
1355            cases.push(ResponseCase {
1356                variant,
1357                status,
1358                body,
1359                headers,
1360                doc: trimmed(&response.value.description),
1361            });
1362        }
1363
1364        if let Some(default) = &operation.responses.default {
1365            let response = self.resolve_response_ref(default)?;
1366            let variant = to_ident("default", Case::Pascal);
1367            let body = self.response_body(
1368                path,
1369                method,
1370                "`default` response",
1371                response.origin.as_deref(),
1372                &response.value,
1373            )?;
1374            let body = self.name_response_body(response_enum, &variant, body);
1375            let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
1376            cases.push(ResponseCase {
1377                variant,
1378                status: ResponseStatus::Default,
1379                body,
1380                headers,
1381                doc: trimmed(&response.value.description),
1382            });
1383        }
1384
1385        if cases.is_empty() {
1386            return Err(Error::UnsupportedOperation {
1387                method: method.to_owned(),
1388                path: path.to_owned(),
1389                reason: "operation declares no responses".to_owned(),
1390            });
1391        }
1392        return Ok(cases);
1393    }
1394
1395    /// Resolve a possibly-referenced response to an owned [`OasResponse`] plus
1396    /// the referenced file it came from (`None` for inline/same-document).
1397    fn resolve_response_ref(&self, response: &ReferenceOr<OasResponse>) -> Result<Resolved<OasResponse>> {
1398        match response {
1399            ReferenceOr::Item(response) => {
1400                return Ok(Resolved {
1401                    value: response.clone(),
1402                    origin: None,
1403                });
1404            }
1405            ReferenceOr::Reference { reference } => return self.spec.resolve_response(reference),
1406        }
1407    }
1408
1409    /// Extract a response's body, if it declares supported content. Inner
1410    /// schema `$ref`s are interpreted against the response's origin file when it
1411    /// was resolved from a referenced document. A single supported content type
1412    /// yields [`LoweredResponseBody::Single`]. several yield the
1413    /// per-representation variants the caller names into a [`NegotiatedBody`].
1414    ///
1415    /// A response that declares content, and no content type in
1416    /// [`RESPONSE_BODY_PRIORITY`], is an error. It is not a bodyless response. A
1417    /// bodyless response declares no `content:` at all, and `204` is the common
1418    /// case. The two are different statements, and emitting a bodyless variant
1419    /// for the first drops the body with no message. The request side already
1420    /// rejects the same input, so this keeps the two directions in agreement.
1421    fn response_body(
1422        &self,
1423        path: &str,
1424        method: &str,
1425        location: &str,
1426        origin: Option<&str>,
1427        response: &OasResponse,
1428    ) -> Result<Option<LoweredResponseBody>> {
1429        let supported = self.supported_bodies(&response.content, &RESPONSE_BODY_PRIORITY);
1430        if supported.is_empty() && !response.content.is_empty() {
1431            return Err(Error::UnsupportedContentType {
1432                method: method.to_owned(),
1433                path: path.to_owned(),
1434                location: location.to_owned(),
1435                declared: declared_content_types(&response.content),
1436                hint: "A response body must declare `application/json`, `application/x-www-form-urlencoded`, or `text/plain`. Add one of them, or declare no `content:` for a bodyless response.".to_owned(),
1437            });
1438        }
1439        let mut variants = Vec::with_capacity(supported.len());
1440        for (kind, media) in supported {
1441            if let Some(body) = self.body_from_media(path, method, origin, kind, media)? {
1442                variants.push(BodyVariant {
1443                    variant: body_kind_ident(kind),
1444                    body,
1445                });
1446            }
1447        }
1448        if variants.len() == 1 {
1449            let Some(variant) = variants.pop() else {
1450                unreachable!("length checked to be 1 above");
1451            };
1452            return Ok(Some(LoweredResponseBody::Single(variant.body)));
1453        }
1454        if variants.is_empty() {
1455            return Ok(None);
1456        }
1457        return Ok(Some(LoweredResponseBody::Negotiated(variants)));
1458    }
1459
1460    /// Name a lowered response body against its response variant: a single
1461    /// content type stays [`ResponseBody::Single`]. several become a
1462    /// [`ResponseBody::Negotiated`] enum named `<Response><Variant>Body`.
1463    fn name_response_body(
1464        &self,
1465        response_enum: &RustIdent,
1466        variant: &RustIdent,
1467        lowered: Option<LoweredResponseBody>,
1468    ) -> Option<ResponseBody> {
1469        return lowered.map(|body| {
1470            return match body {
1471                LoweredResponseBody::Single(body) => ResponseBody::Single(body),
1472                LoweredResponseBody::Negotiated(variants) => ResponseBody::Negotiated(NegotiatedBody {
1473                    name: operations::response_body_enum_name(response_enum, variant),
1474                    variants,
1475                }),
1476            };
1477        });
1478    }
1479
1480    /// Map a request/response body schema to a Rust type. Composite inline
1481    /// schemas must be referenced by name (`$ref`) so the models pass owns
1482    /// their emission. `origin` is the referenced file the enclosing wrapper was
1483    /// resolved from, so a same-document inner `$ref` lowers to the right module.
1484    fn body_type(
1485        &self,
1486        path: &str,
1487        method: &str,
1488        origin: Option<&str>,
1489        schema: &ReferenceOr<Schema>,
1490    ) -> Result<RustType> {
1491        match schema {
1492            ReferenceOr::Reference { reference } => return self.schema_ref_type(path, method, origin, reference),
1493            ReferenceOr::Item(schema) => return self.inline_body_type(path, method, origin, schema),
1494        }
1495    }
1496
1497    /// Map an inline (non-`$ref`) body schema to a Rust type.
1498    fn inline_body_type(&self, path: &str, method: &str, origin: Option<&str>, schema: &Schema) -> Result<RustType> {
1499        let ty = match &schema.schema_kind {
1500            SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
1501            SchemaKind::Type(Type::Integer(it)) => integer_type(it),
1502            SchemaKind::Type(Type::Number(_)) => RustType::F64,
1503            SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
1504            SchemaKind::Type(Type::Array(at)) => {
1505                let element = match &at.items {
1506                    Some(ReferenceOr::Reference { reference }) => {
1507                        self.schema_ref_type(path, method, origin, reference)?
1508                    }
1509                    Some(ReferenceOr::Item(item)) => self.inline_body_type(path, method, origin, item)?,
1510                    None => RustType::Value,
1511                };
1512                RustType::Vec(Box::new(element))
1513            }
1514            SchemaKind::Any(_) => RustType::Value,
1515            _ => {
1516                return Err(Error::UnsupportedOperation {
1517                    method: method.to_owned(),
1518                    path: path.to_owned(),
1519                    reason: "composite request/response bodies must reference a named schema (`$ref`)".to_owned(),
1520                });
1521            }
1522        };
1523        return Ok(ty);
1524    }
1525
1526    /// Decide the Rust type for a schema `$ref`, given the referenced file the
1527    /// enclosing structural object was resolved from (`origin`). A cross-file
1528    /// ref, or a same-document ref whose enclosing object came from a referenced
1529    /// file, resolves through the `import-mapping` to a [`RustType::External`].
1530    /// a same-document ref in the main document stays a local [`RustType::Named`].
1531    fn schema_ref_type(&self, path: &str, method: &str, origin: Option<&str>, reference: &str) -> Result<RustType> {
1532        let target = ref_component_name(reference, "schemas").ok_or_else(|| {
1533            return Error::UnsupportedOperation {
1534                method: method.to_owned(),
1535                path: path.to_owned(),
1536                reason: format!("reference `{reference}` must point at a component schema"),
1537            };
1538        })?;
1539        let file = ref_file_part(reference)
1540            .map(str::to_owned)
1541            .or_else(|| return origin.map(str::to_owned));
1542        let Some(file) = file else {
1543            // A local ref must name a schema this document declares. Without the
1544            // check the name reaches the output, and the generated file does not
1545            // compile. A cross-file ref below resolves through the
1546            // `import-mapping` instead, so the other document owns that name.
1547            if !self.spec.schemas().contains_key(target) {
1548                return Err(Error::UnresolvedRef(reference.to_owned()));
1549            }
1550            return Ok(RustType::Named(target.to_owned()));
1551        };
1552        let module = self.import_mapping.get(&file).ok_or_else(|| {
1553            return Error::UnsupportedOperation {
1554                method: method.to_owned(),
1555                path: path.to_owned(),
1556                reason: format!("cross-file reference `{reference}` needs an `import-mapping` entry for `{file}`"),
1557            };
1558        })?;
1559        return Ok(RustType::External {
1560            module: module.clone(),
1561            name: self.spec.external_schema_name(&file, target, reference)?,
1562        });
1563    }
1564}
1565
1566/// Whether a `multipart/form-data` part travels in a request.
1567///
1568/// A multipart body is a request body only, so the generator leaves a `readOnly`
1569/// part out of the extractor. `writeOnly` states what a multipart part already
1570/// does.
1571fn multipart_part_is_sent(data: &SchemaData, path: &str, method: &str, wire_name: &str) -> Result<bool> {
1572    let at = format!("{method} {path} multipart field `{wire_name}`");
1573    let access = crate::lower::schema::access_of(data, &at)?;
1574    return Ok(access.carried_by(crate::ir::Direction::Request));
1575}
1576
1577/// Derive the trait method name: an explicit `x-rust-name`, else the
1578/// `operationId`, else a name synthesised from the method and path (for example
1579/// `get /v1/widgets` -> `get_v1_widgets`).
1580///
1581/// `x-rust-name` is the escape hatch for two `operationId`s that collapse onto one
1582/// Rust name. It takes precedence over `operationId`, the same way it does for a
1583/// schema, so the author names the method and the generator does not.
1584fn operation_name(path: &str, method: &str, operation: &OasOperation) -> Result<crate::naming::RustIdent> {
1585    let at = format!("{method} {path}");
1586    if let Some(name) = crate::lower::extension::str_value(&operation.extensions, X_RUST_NAME, &at)? {
1587        return Ok(operations::operation_method_name(name));
1588    }
1589    if let Some(id) = &operation.operation_id {
1590        return Ok(operations::operation_method_name(id));
1591    }
1592    return Ok(operations::operation_method_name(&at));
1593}
1594
1595/// Build the remedy text for an operation-name collision.
1596///
1597/// `operation` is the one that collided, and the advice depends on what it already
1598/// declares. An operation that sets `x-rust-name` needs a different name and not
1599/// the extension it already uses. An operation with no `operationId` derives its
1600/// name from the method and path, so adding an `operationId` is the natural fix.
1601///
1602/// There is no suffix option for operations, unlike `type-name-suffix` for
1603/// schemas. A method name appears in the trait a consumer implements, so every
1604/// name a consumer writes stays the author's choice.
1605fn operation_collision_hint(operation: &OasOperation) -> String {
1606    if operation.extensions.contains_key(X_RUST_NAME) {
1607        return format!(
1608            "This operation already sets `{X_RUST_NAME}`, and that name collides too. \
1609             Give it a name that no other operation uses."
1610        );
1611    }
1612    if operation.operation_id.is_some() {
1613        return format!(
1614            "Two `operationId`s that differ only in case or in punctuation produce one Rust name. \
1615             Give one of the two operations a different `operationId`, or set `{X_RUST_NAME}` on it \
1616             to name the generated method directly."
1617        );
1618    }
1619    return format!(
1620        "This operation declares no `operationId`, so its name comes from the method and the path. \
1621         Add an `operationId`, or set `{X_RUST_NAME}` on it to name the generated method directly."
1622    );
1623}
1624
1625/// The operation's doc comment, preferring `summary` over `description`.
1626fn operation_doc(operation: &OasOperation) -> Option<String> {
1627    if let Some(summary) = &operation.summary
1628        && let Some(text) = trimmed(summary)
1629    {
1630        return Some(text);
1631    }
1632    return operation.description.as_ref().and_then(|text| {
1633        return trimmed(text);
1634    });
1635}
1636
1637/// Extract `{name}` path-parameter names in their order of appearance.
1638fn path_param_names(path: &str) -> Vec<String> {
1639    let mut names = Vec::new();
1640    let mut rest = path;
1641    while let Some(open) = rest.find('{') {
1642        let after_open = &rest[open + 1..];
1643        let Some(close) = after_open.find('}') else {
1644            break;
1645        };
1646        names.push(after_open[..close].to_owned());
1647        rest = &after_open[close + 1..];
1648    }
1649    return names;
1650}
1651
1652/// Trim a string and return `None` when it is empty.
1653fn trimmed(text: &str) -> Option<String> {
1654    let trimmed = text.trim();
1655    if trimmed.is_empty() {
1656        return None;
1657    }
1658    return Some(trimmed.to_owned());
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664
1665    #[test]
1666    fn valid_header_names_accept_tokens_and_reject_separators() {
1667        // Real header names with `-` and digits and `.` are accepted.
1668        assert!(is_valid_header_name("X-Request-Id"));
1669        assert!(is_valid_header_name("X-RateLimit-Remaining"));
1670        assert!(is_valid_header_name("Sec-CH-UA-Platform-Version"));
1671        assert!(is_valid_header_name("a.b"));
1672        // Empty and separator characters (which will panic `from_static`) are
1673        // rejected — notably `/` (0x2F), which sits between `-` (0x2D) and the
1674        // digits, and `:`, space, and control-ish punctuation.
1675        assert!(!is_valid_header_name(""));
1676        assert!(!is_valid_header_name("X/Y"));
1677        assert!(!is_valid_header_name("X:Y"));
1678        assert!(!is_valid_header_name("X Y"));
1679        assert!(!is_valid_header_name("X(Y)"));
1680    }
1681
1682    #[test]
1683    fn extracts_path_param_names_in_order() {
1684        assert_eq!(path_param_names("/v1/widgets"), Vec::<String>::new());
1685        assert_eq!(path_param_names("/pets/{id}"), vec!["id".to_owned()]);
1686        assert_eq!(
1687            path_param_names("/orgs/{org}/pets/{petId}"),
1688            vec!["org".to_owned(), "petId".to_owned()],
1689        );
1690    }
1691
1692    #[test]
1693    fn response_variants_are_named_after_the_status_reason() {
1694        let variant = |code| {
1695            let reason = HttpStatus::from_u16(code)
1696                .expect("test status code is a valid HTTP status")
1697                .canonical_reason()
1698                .expect("status code has a canonical reason phrase");
1699            return to_ident(reason, Case::Pascal).logical().to_owned();
1700        };
1701        assert_eq!(variant(200), "Ok");
1702        assert_eq!(variant(204), "NoContent");
1703        assert_eq!(variant(404), "NotFound");
1704        assert_eq!(variant(500), "InternalServerError");
1705    }
1706
1707    #[test]
1708    fn range_response_variants_are_derived_from_the_range_digit() {
1709        let variant = |range: u16| {
1710            return to_ident(&format!("status_{range}xx"), Case::Pascal)
1711                .logical()
1712                .to_owned();
1713        };
1714        assert_eq!(variant(4), "Status4xx");
1715        assert_eq!(variant(5), "Status5xx");
1716    }
1717}