Skip to main content

oapi_codegen/lower/
paths.rs

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