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