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