Skip to main content

openapi_to_rust/server/
codegen.rs

1//! Server codegen — trait + typed response enums (P4).
2//!
3//! Emits one trait per tag (or a `ServerApi` trait for untagged
4//! operations) plus a per-operation response enum with an
5//! `IntoResponse` impl that maps each variant to its documented
6//! status code.
7//!
8//! Router wiring, extractors, and SSE response variants are P5.
9
10use crate::analysis::{
11    ObjectAdditionalProperties, OperationInfo, ParameterInfo, QuerySerialization,
12    RequestBodyContent, SchemaAnalysis, SchemaType,
13};
14use crate::config::ServerSection;
15use crate::generator::{CodeGenerator, GeneratedFile, GeneratorConfig};
16
17use super::{OperationIndex, Selector};
18use heck::{ToPascalCase, ToSnakeCase};
19use proc_macro2::TokenStream;
20use quote::{format_ident, quote};
21use std::collections::BTreeMap;
22use std::path::PathBuf;
23
24/// Compute the set of schema names transitively reachable from the
25/// request/response/parameter shapes of the given operations.
26///
27/// Used by client/server model pruning to drop unreferenced types from
28/// `types.rs`. Walks every `$ref` in each schema's raw JSON
29/// (`AnalyzedSchema.original`) rather than the analyzer's
30/// `dependencies` field — the latter is incomplete for some
31/// schemas (e.g. struct fields whose target schemas weren't
32/// individually tracked).
33///
34/// Inline parameter enums (whose `rust_type` is a synthetic name
35/// without a matching `analysis.schemas` entry) are not the
36/// responsibility of this walk — they're emitted directly by the
37/// server codegen from `parameter.enum_values`.
38pub fn reachable_schemas(
39    analysis: &SchemaAnalysis,
40    ops: &[&OperationInfo],
41) -> std::collections::BTreeSet<String> {
42    reachable_schemas_with_roots(analysis, ops, &[])
43}
44
45/// [`reachable_schemas`] plus explicit schema roots used by configured
46/// consumers such as SSE event-union types.
47pub fn reachable_schemas_with_roots(
48    analysis: &SchemaAnalysis,
49    ops: &[&OperationInfo],
50    extra_roots: &[String],
51) -> std::collections::BTreeSet<String> {
52    let mut keep: std::collections::BTreeSet<String> = Default::default();
53    let mut queue: Vec<String> = Vec::new();
54
55    let seed =
56        |name: &str, queue: &mut Vec<String>, keep: &mut std::collections::BTreeSet<String>| {
57            if !name.is_empty() && keep.insert(name.to_string()) {
58                queue.push(name.to_string());
59            }
60        };
61
62    for op in ops {
63        if let Some(rb) = &op.request_body
64            && let Some(name) = rb.schema_name()
65        {
66            seed(name, &mut queue, &mut keep);
67        }
68        for ty in op.response_schemas.values() {
69            seed(ty, &mut queue, &mut keep);
70        }
71        for p in &op.parameters {
72            if let Some(name) = &p.schema_ref {
73                seed(name, &mut queue, &mut keep);
74            }
75            if let Some(
76                QuerySerialization::FormExplodedArray {
77                    item_type: crate::analysis::ArrayItemType::EnumRef(name),
78                }
79                | QuerySerialization::FormArray {
80                    item_type: crate::analysis::ArrayItemType::EnumRef(name),
81                },
82            ) = &p.query_serialization
83            {
84                seed(name, &mut queue, &mut keep);
85            }
86        }
87    }
88    for root in extra_roots {
89        seed(root, &mut queue, &mut keep);
90    }
91
92    while let Some(name) = queue.pop() {
93        if let Some(schema) = analysis.schemas.get(&name) {
94            // Walk the raw JSON for every `$ref` string and feed
95            // the referenced schema names back into the queue.
96            collect_refs(&schema.original, &mut queue, &mut keep);
97            // Belt-and-braces: also include the analyzer's tracked
98            // dependencies, which sometimes catch refs that live
99            // outside the immediate JSON tree (e.g. allOf compositions
100            // resolved before the snapshot was captured).
101            for dep in &schema.dependencies {
102                seed(dep, &mut queue, &mut keep);
103            }
104            // The analyzed shape is the authoritative generated type graph.
105            // It includes ownership edges for inline/synthetic schemas that
106            // do not appear as `$ref`s in the source document.
107            collect_schema_type_refs(&schema.schema_type, &mut queue, &mut keep);
108        }
109    }
110
111    keep
112}
113
114fn collect_schema_type_refs(
115    schema_type: &SchemaType,
116    queue: &mut Vec<String>,
117    keep: &mut std::collections::BTreeSet<String>,
118) {
119    let seed =
120        |name: &str, queue: &mut Vec<String>, keep: &mut std::collections::BTreeSet<String>| {
121            if !name.is_empty() && keep.insert(name.to_string()) {
122                queue.push(name.to_string());
123            }
124        };
125
126    match schema_type {
127        SchemaType::Primitive { .. }
128        | SchemaType::StringEnum { .. }
129        | SchemaType::ExtensibleEnum { .. } => {}
130        SchemaType::Object {
131            properties,
132            additional_properties,
133            ..
134        } => {
135            for property in properties.values() {
136                collect_schema_type_refs(&property.schema_type, queue, keep);
137            }
138            if let ObjectAdditionalProperties::Typed { value_type } = additional_properties {
139                collect_schema_type_refs(value_type, queue, keep);
140            }
141        }
142        SchemaType::DiscriminatedUnion { variants, .. } => {
143            for variant in variants {
144                seed(&variant.type_name, queue, keep);
145            }
146        }
147        SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => {
148            for variant in variants {
149                seed(&variant.target, queue, keep);
150            }
151        }
152        SchemaType::Array { item_type } => collect_schema_type_refs(item_type, queue, keep),
153        SchemaType::Reference { target } => seed(target, queue, keep),
154    }
155}
156
157fn collect_refs(
158    value: &serde_json::Value,
159    queue: &mut Vec<String>,
160    keep: &mut std::collections::BTreeSet<String>,
161) {
162    match value {
163        serde_json::Value::Object(map) => {
164            for (k, v) in map {
165                if k == "$ref"
166                    && let Some(s) = v.as_str()
167                    && let Some(name) = s.strip_prefix("#/components/schemas/")
168                    && keep.insert(name.to_string())
169                {
170                    queue.push(name.to_string());
171                }
172                collect_refs(v, queue, keep);
173            }
174        }
175        serde_json::Value::Array(items) => {
176            for v in items {
177                collect_refs(v, queue, keep);
178            }
179        }
180        _ => {}
181    }
182}
183
184#[derive(Debug, thiserror::Error)]
185pub enum ServerCodegenError {
186    #[error("server selector: {0}")]
187    Parse(#[from] super::SelectorParseError),
188    #[error("server selector: {0}")]
189    Resolve(#[from] super::SelectorResolveError),
190    #[error("internal: {0}")]
191    Internal(String),
192    #[error(
193        "cannot generate exact Axum routes for custom HTTP methods on `{path}` across multiple primary tags ({tags}); Axum cannot merge multiple fallback dispatchers for one path. Put those operations under the same first tag or select only one custom method for this server"
194    )]
195    CrossTagCustomMethods { path: String, tags: String },
196    #[error("cannot generate Axum route for `{path}`: {reason}")]
197    InvalidRoutePath { path: String, reason: String },
198    #[error(
199        "cannot generate Axum query extraction for `{operation_id}` parameter `{parameter}`: {reason}"
200    )]
201    UnsupportedQueryParameter {
202        operation_id: String,
203        parameter: String,
204        reason: String,
205    },
206    #[error(
207        "cannot generate unambiguous Axum query extraction for `{operation_id}`: wire key `{wire_key}` is claimed by both `{first_parameter}` and `{second_parameter}`"
208    )]
209    AmbiguousQueryParameter {
210        operation_id: String,
211        wire_key: String,
212        first_parameter: String,
213        second_parameter: String,
214    },
215    #[error("request validation: {0}")]
216    Validation(String),
217    #[error(
218        "cannot generate Axum extraction for `{operation_id}` {location} parameter `{parameter}`: only scalar schema serialization is currently supported"
219    )]
220    UnsupportedParameterSerialization {
221        operation_id: String,
222        location: String,
223        parameter: String,
224    },
225    #[error(
226        "cannot generate Axum request body for `{operation_id}` media type `{media_type}`: {reason}"
227    )]
228    UnsupportedRequestBody {
229        operation_id: String,
230        media_type: String,
231        reason: String,
232    },
233}
234
235pub struct ServerCodegen<'a> {
236    config: &'a GeneratorConfig,
237    analysis: &'a SchemaAnalysis,
238    server: &'a ServerSection,
239    source_provenance: Option<String>,
240}
241
242impl<'a> ServerCodegen<'a> {
243    pub fn new(
244        config: &'a GeneratorConfig,
245        analysis: &'a SchemaAnalysis,
246        server: &'a ServerSection,
247    ) -> Self {
248        Self {
249            config,
250            analysis,
251            server,
252            source_provenance: None,
253        }
254    }
255
256    /// Attach a sanitized source label to generated server module headers.
257    pub fn with_source_provenance(mut self, source: Option<&str>) -> Self {
258        self.source_provenance = source.map(str::to_string);
259        self
260    }
261
262    fn provenance_attribute(&self) -> TokenStream {
263        self.source_provenance
264            .as_ref()
265            .map(|source| {
266                let provenance = format!(
267                    " Generated by openapi-to-rust v{}. Source OpenAPI document: {source}",
268                    env!("CARGO_PKG_VERSION")
269                );
270                quote! { #![doc = #provenance] }
271            })
272            .unwrap_or_default()
273    }
274
275    /// Resolve selectors and emit `server/{mod,api,errors}.rs`.
276    pub fn generate(&self) -> Result<Vec<GeneratedFile>, ServerCodegenError> {
277        if self.server.operations.is_empty() {
278            return Ok(Vec::new());
279        }
280        if !(1..=100).contains(&self.server.validation.max_errors) {
281            return Err(ServerCodegenError::Validation(
282                "max_errors must be between 1 and 100".to_string(),
283            ));
284        }
285        if !(1..=67_108_864).contains(&self.server.validation.max_body_bytes) {
286            return Err(ServerCodegenError::Validation(
287                "max_body_bytes must be between 1 and 67108864".to_string(),
288            ));
289        }
290
291        let index = OperationIndex::from_analysis(self.analysis);
292        let selectors: Vec<Selector> = self
293            .server
294            .operations
295            .iter()
296            .map(|s| Selector::parse(s))
297            .collect::<Result<_, _>>()?;
298        let resolution = super::resolve(&selectors, &index)?;
299
300        // Look up full OperationInfo for each resolved op (we need
301        // parameters, request body, response schemas — the summary
302        // only has the display surface).
303        let ops: Vec<&OperationInfo> = resolution
304            .operations
305            .iter()
306            .map(|s| {
307                self.analysis
308                    .operations
309                    .get(&s.operation_id)
310                    .ok_or_else(|| {
311                        ServerCodegenError::Internal(format!(
312                            "operation `{}` resolved but missing from analysis",
313                            s.operation_id
314                        ))
315                    })
316            })
317            .collect::<Result<_, _>>()?;
318        validate_custom_method_route_groups(&ops)?;
319        self.validate_query_parameters(&ops)?;
320        self.validate_supported_server_inputs(&ops)?;
321        if !self.server.validation.enabled
322            && ops.iter().any(|operation| {
323                operation
324                    .parameters
325                    .iter()
326                    .any(|parameter| matches!(parameter.location.as_str(), "header" | "cookie"))
327                    || matches!(
328                        &operation.request_body,
329                        Some(RequestBodyContent::FormUrlEncoded { .. })
330                    )
331            })
332        {
333            return Err(ServerCodegenError::Validation(
334                "typed header/cookie and form extraction requires server.validation.enabled=true"
335                    .to_string(),
336            ));
337        }
338
339        // Group by primary tag (first tag wins; untagged → "Server").
340        let groups = group_by_tag(&ops);
341
342        let validation_bundle = if self.server.validation.enabled {
343            Some(
344                super::validation::prepare_validation_bundle(
345                    &self.analysis.validation_context,
346                    &ops,
347                )
348                .map_err(|error| ServerCodegenError::Validation(error.to_string()))?,
349            )
350        } else {
351            eprintln!(
352                "⚠️  server.validation.enabled=false: generated handlers will not enforce the OpenAPI request contract"
353            );
354            None
355        };
356
357        let api_rs = self.emit_api(&groups);
358        let errors_rs = self.emit_errors(&ops, validation_bundle.is_some());
359        let router_rs = self.emit_router(&groups, validation_bundle.as_ref())?;
360        let mod_rs = self.emit_mod(validation_bundle.is_some());
361
362        let mut files = vec![
363            GeneratedFile {
364                path: PathBuf::from("server").join("mod.rs"),
365                content: format_or_raw(mod_rs),
366            },
367            GeneratedFile {
368                path: PathBuf::from("server").join("api.rs"),
369                content: format_or_raw(api_rs),
370            },
371            GeneratedFile {
372                path: PathBuf::from("server").join("errors.rs"),
373                content: format_or_raw(errors_rs),
374            },
375            GeneratedFile {
376                path: PathBuf::from("server").join("router.rs"),
377                content: format_or_raw(router_rs),
378            },
379        ];
380        if let Some(bundle) = &validation_bundle {
381            files.push(GeneratedFile {
382                path: PathBuf::from("server").join("validation.rs"),
383                content: format_or_raw(super::validation::emit_validation_module(
384                    bundle,
385                    self.server.validation.max_errors,
386                )),
387            });
388        }
389        Ok(files)
390    }
391
392    fn query_parameter_type(&self, parameter: &ParameterInfo) -> TokenStream {
393        CodeGenerator::new(self.config.clone()).get_param_owned_rust_type(parameter)
394    }
395
396    fn parameter_schema_is_scalar(&self, schema: &serde_json::Value) -> bool {
397        self.parameter_schema_is_scalar_inner(schema, &mut std::collections::BTreeSet::new())
398    }
399
400    fn parameter_schema_is_string(&self, parameter: &ParameterInfo) -> bool {
401        if parameter.enum_values.is_some() || parameter.rust_type == "String" {
402            return true;
403        }
404        let Some(schema) = parameter.validation_schema.as_ref() else {
405            return false;
406        };
407        self.parameter_schema_is_string_inner(schema, &mut std::collections::BTreeSet::new())
408    }
409
410    fn parameter_schema_is_string_inner(
411        &self,
412        schema: &serde_json::Value,
413        visited: &mut std::collections::BTreeSet<String>,
414    ) -> bool {
415        if let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) {
416            if let Some(name) = reference.strip_prefix("#/components/schemas/") {
417                return self
418                    .analysis
419                    .validation_context
420                    .component_schemas
421                    .get(name)
422                    .is_some_and(|component| {
423                        visited.insert(name.to_string())
424                            && self.parameter_schema_is_string_inner(component, visited)
425                    });
426            }
427        }
428        schema.get("type").and_then(serde_json::Value::as_str) == Some("string")
429    }
430
431    fn parameter_schema_is_scalar_inner(
432        &self,
433        schema: &serde_json::Value,
434        visited: &mut std::collections::BTreeSet<String>,
435    ) -> bool {
436        if let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str)
437            && let Some(name) = reference.strip_prefix("#/components/schemas/")
438            && let Some(component) = self.analysis.validation_context.component_schemas.get(name)
439        {
440            return visited.insert(name.to_string())
441                && self.parameter_schema_is_scalar_inner(component, visited);
442        }
443        !matches!(
444            schema.get("type").and_then(serde_json::Value::as_str),
445            Some("array" | "object")
446        ) && !schema.get("oneOf").is_some()
447            && !schema.get("anyOf").is_some()
448            && !schema.get("allOf").is_some()
449    }
450
451    fn form_field_names(
452        &self,
453        operation: &OperationInfo,
454    ) -> Result<Vec<String>, ServerCodegenError> {
455        let Some(RequestBodyContent::FormUrlEncoded { schema_name, .. }) = &operation.request_body
456        else {
457            return Ok(Vec::new());
458        };
459        let schema = self.resolve_query_schema(schema_name).ok_or_else(|| {
460            ServerCodegenError::UnsupportedRequestBody {
461                operation_id: operation.operation_id.clone(),
462                media_type: "application/x-www-form-urlencoded".to_string(),
463                reason: format!("schema `{schema_name}` cannot be resolved"),
464            }
465        })?;
466        let SchemaType::Object {
467            properties,
468            additional_properties,
469            ..
470        } = &schema.schema_type
471        else {
472            return Err(ServerCodegenError::UnsupportedRequestBody {
473                operation_id: operation.operation_id.clone(),
474                media_type: "application/x-www-form-urlencoded".to_string(),
475                reason: "only flat object schemas are supported".to_string(),
476            });
477        };
478        if !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
479            || properties.values().any(|property| {
480                !self.query_property_is_scalar(
481                    &property.schema_type,
482                    &mut std::collections::HashSet::new(),
483                )
484            })
485        {
486            return Err(ServerCodegenError::UnsupportedRequestBody {
487                operation_id: operation.operation_id.clone(),
488                media_type: "application/x-www-form-urlencoded".to_string(),
489                reason: "only flat scalar fields with additionalProperties forbidden are supported"
490                    .to_string(),
491            });
492        }
493        Ok(properties.keys().cloned().collect())
494    }
495
496    fn validate_supported_server_inputs(
497        &self,
498        operations: &[&OperationInfo],
499    ) -> Result<(), ServerCodegenError> {
500        for operation in operations {
501            for parameter in &operation.parameters {
502                if !matches!(
503                    parameter.location.as_str(),
504                    "path" | "query" | "header" | "cookie"
505                ) {
506                    return Err(ServerCodegenError::UnsupportedParameterSerialization {
507                        operation_id: operation.operation_id.clone(),
508                        location: parameter.location.clone(),
509                        parameter: parameter.name.clone(),
510                    });
511                }
512                if matches!(parameter.location.as_str(), "path" | "header" | "cookie")
513                    && parameter
514                        .validation_schema
515                        .as_ref()
516                        .is_none_or(|schema| !self.parameter_schema_is_scalar(schema))
517                {
518                    return Err(ServerCodegenError::UnsupportedParameterSerialization {
519                        operation_id: operation.operation_id.clone(),
520                        location: parameter.location.clone(),
521                        parameter: parameter.name.clone(),
522                    });
523                }
524            }
525            match &operation.request_body {
526                Some(RequestBodyContent::FormUrlEncoded { .. }) => {
527                    self.form_field_names(operation)?;
528                }
529                Some(RequestBodyContent::Multipart) => {
530                    return Err(ServerCodegenError::UnsupportedRequestBody {
531                        operation_id: operation.operation_id.clone(),
532                        media_type: "multipart/form-data".to_string(),
533                        reason: "typed multipart server extraction is not implemented".to_string(),
534                    });
535                }
536                Some(RequestBodyContent::OctetStream) => {
537                    return Err(ServerCodegenError::UnsupportedRequestBody {
538                        operation_id: operation.operation_id.clone(),
539                        media_type: "application/octet-stream".to_string(),
540                        reason: "binary server extraction is not implemented".to_string(),
541                    });
542                }
543                Some(RequestBodyContent::TextPlain) => {
544                    return Err(ServerCodegenError::UnsupportedRequestBody {
545                        operation_id: operation.operation_id.clone(),
546                        media_type: "text/plain".to_string(),
547                        reason: "text server extraction is not implemented".to_string(),
548                    });
549                }
550                Some(RequestBodyContent::SchemaLess { media_type }) => {
551                    return Err(ServerCodegenError::UnsupportedRequestBody {
552                        operation_id: operation.operation_id.clone(),
553                        media_type: media_type.clone(),
554                        reason: "request content has no schema to validate".to_string(),
555                    });
556                }
557                Some(RequestBodyContent::Unsupported { media_types }) => {
558                    return Err(ServerCodegenError::UnsupportedRequestBody {
559                        operation_id: operation.operation_id.clone(),
560                        media_type: media_types.join(", "),
561                        reason: "the selected request body uses unsupported media content"
562                            .to_string(),
563                    });
564                }
565                _ => {}
566            }
567        }
568        Ok(())
569    }
570
571    fn parameter_ident(&self, parameter: &ParameterInfo) -> syn::Ident {
572        let generator = CodeGenerator::new(self.config.clone());
573        CodeGenerator::to_field_ident(&generator.param_ident_str(parameter))
574    }
575
576    fn validation_target(
577        &self,
578        bundle: Option<&super::validation::ValidationBundle>,
579        operation: &OperationInfo,
580        location: &str,
581        parameter_name: Option<&str>,
582    ) -> Result<Option<TokenStream>, ServerCodegenError> {
583        let Some(bundle) = bundle else {
584            return Ok(None);
585        };
586        let target = bundle
587            .target_for(&operation.operation_id, location, parameter_name)
588            .ok_or_else(|| {
589                ServerCodegenError::Validation(format!(
590                    "missing generated validator target for operation `{}` {location} `{}`",
591                    operation.operation_id,
592                    parameter_name.unwrap_or("body")
593                ))
594            })?;
595        let ident = format_ident!("{}", target.constant);
596        Ok(Some(quote! { super::validation::#ident }))
597    }
598
599    fn resolve_query_schema(&self, schema_name: &str) -> Option<&crate::analysis::AnalyzedSchema> {
600        let mut current = schema_name;
601        let mut visited = std::collections::HashSet::new();
602        loop {
603            if !visited.insert(current) {
604                return None;
605            }
606            let schema = self.analysis.schemas.get(current)?;
607            if let SchemaType::Reference { target } = &schema.schema_type {
608                current = target;
609            } else {
610                return Some(schema);
611            }
612        }
613    }
614
615    fn query_object_properties(
616        &self,
617        parameter: &ParameterInfo,
618    ) -> Option<&BTreeMap<String, crate::analysis::PropertyInfo>> {
619        let schema = self.resolve_query_schema(parameter.schema_ref.as_deref()?)?;
620        match &schema.schema_type {
621            SchemaType::Object { properties, .. } => Some(properties),
622            _ => None,
623        }
624    }
625
626    fn query_object_required_properties(&self, parameter: &ParameterInfo) -> Vec<String> {
627        let Some(schema) = parameter
628            .schema_ref
629            .as_deref()
630            .and_then(|name| self.resolve_query_schema(name))
631        else {
632            return Vec::new();
633        };
634        let mut names = match &schema.schema_type {
635            SchemaType::Object { required, .. } => required.iter().cloned().collect(),
636            _ => Vec::new(),
637        };
638        names.sort();
639        names
640    }
641
642    fn form_required_field_names(
643        &self,
644        operation: &OperationInfo,
645    ) -> Result<Vec<String>, ServerCodegenError> {
646        let Some(RequestBodyContent::FormUrlEncoded { schema_name, .. }) = &operation.request_body
647        else {
648            return Ok(Vec::new());
649        };
650        let schema = self.resolve_query_schema(schema_name).ok_or_else(|| {
651            ServerCodegenError::UnsupportedRequestBody {
652                operation_id: operation.operation_id.clone(),
653                media_type: "application/x-www-form-urlencoded".to_string(),
654                reason: format!("schema `{schema_name}` cannot be resolved"),
655            }
656        })?;
657        let mut names = match &schema.schema_type {
658            SchemaType::Object { required, .. } => required.iter().cloned().collect(),
659            _ => Vec::new(),
660        };
661        names.sort();
662        Ok(names)
663    }
664
665    fn query_property_is_scalar(
666        &self,
667        schema_type: &SchemaType,
668        visited: &mut std::collections::HashSet<String>,
669    ) -> bool {
670        match schema_type {
671            SchemaType::Primitive { .. }
672            | SchemaType::StringEnum { .. }
673            | SchemaType::ExtensibleEnum { .. } => true,
674            SchemaType::Reference { target } if visited.insert(target.clone()) => {
675                self.analysis.schemas.get(target).is_some_and(|schema| {
676                    self.query_property_is_scalar(&schema.schema_type, visited)
677                })
678            }
679            _ => false,
680        }
681    }
682
683    fn validate_query_object(
684        &self,
685        operation: &OperationInfo,
686        parameter: &ParameterInfo,
687    ) -> Result<Vec<String>, ServerCodegenError> {
688        let error = |reason: String| ServerCodegenError::UnsupportedQueryParameter {
689            operation_id: operation.operation_id.clone(),
690            parameter: parameter.name.clone(),
691            reason,
692        };
693        let schema_name = parameter.schema_ref.as_deref().ok_or_else(|| {
694            error("styled object parameter has no analyzed schema type".to_string())
695        })?;
696        let schema = self.resolve_query_schema(schema_name).ok_or_else(|| {
697            error(format!(
698                "query object schema `{schema_name}` could not be resolved"
699            ))
700        })?;
701        let (properties, additional_properties) = match &schema.schema_type {
702            SchemaType::Object {
703                properties,
704                additional_properties,
705                ..
706            } => (properties, additional_properties),
707            _ => {
708                return Err(error(format!(
709                    "query schema `{schema_name}` does not resolve to a flat object"
710                )));
711            }
712        };
713        if !matches!(additional_properties, ObjectAdditionalProperties::Forbidden) {
714            return Err(error(
715                "styled object parameters with additionalProperties have an ambiguous wire namespace"
716                    .to_string(),
717            ));
718        }
719        for (property_name, property) in properties {
720            if !self.query_property_is_scalar(
721                &property.schema_type,
722                &mut std::collections::HashSet::new(),
723            ) {
724                return Err(error(format!(
725                    "property `{property_name}` is not scalar; nested arrays/objects are undefined for the generated query wire format"
726                )));
727            }
728        }
729        Ok(properties.keys().cloned().collect())
730    }
731
732    fn validate_query_parameters(
733        &self,
734        operations: &[&OperationInfo],
735    ) -> Result<(), ServerCodegenError> {
736        for operation in operations {
737            let mut claimed_keys: BTreeMap<String, String> = BTreeMap::new();
738            for parameter in operation
739                .parameters
740                .iter()
741                .filter(|parameter| parameter.location == "query")
742            {
743                let mut keys = match &parameter.query_serialization {
744                    Some(QuerySerialization::Unsupported { reason }) => {
745                        return Err(ServerCodegenError::UnsupportedQueryParameter {
746                            operation_id: operation.operation_id.clone(),
747                            parameter: parameter.name.clone(),
748                            reason: reason.clone(),
749                        });
750                    }
751                    Some(
752                        QuerySerialization::FormExplodedObject
753                        | QuerySerialization::FormObject
754                        | QuerySerialization::DeepObject,
755                    ) => {
756                        let property_keys = self.validate_query_object(operation, parameter)?;
757                        if matches!(
758                            parameter.query_serialization,
759                            Some(QuerySerialization::FormExplodedObject)
760                        ) {
761                            property_keys
762                        } else if matches!(
763                            parameter.query_serialization,
764                            Some(QuerySerialization::DeepObject)
765                        ) {
766                            property_keys
767                                .into_iter()
768                                .map(|property| format!("{}[{property}]", parameter.name))
769                                .collect()
770                        } else {
771                            vec![parameter.name.clone()]
772                        }
773                    }
774                    Some(
775                        QuerySerialization::FormExplodedArray { .. }
776                        | QuerySerialization::FormArray { .. },
777                    )
778                    | None => vec![parameter.name.clone()],
779                };
780                if matches!(
781                    &parameter.query_serialization,
782                    Some(
783                        QuerySerialization::FormExplodedObject
784                            | QuerySerialization::FormObject
785                            | QuerySerialization::DeepObject
786                            | QuerySerialization::FormExplodedArray { .. }
787                            | QuerySerialization::FormArray { .. }
788                    )
789                ) {
790                    keys.push(format!("{}[]", parameter.name));
791                }
792                for key in keys {
793                    if let Some(first_parameter) =
794                        claimed_keys.insert(key.clone(), parameter.name.clone())
795                    {
796                        return Err(ServerCodegenError::AmbiguousQueryParameter {
797                            operation_id: operation.operation_id.clone(),
798                            wire_key: key,
799                            first_parameter,
800                            second_parameter: parameter.name.clone(),
801                        });
802                    }
803                }
804            }
805        }
806        Ok(())
807    }
808
809    fn emit_mod(&self, validation_enabled: bool) -> TokenStream {
810        let provenance_attribute = self.provenance_attribute();
811        let validation_module = validation_enabled.then(|| quote! { pub(crate) mod validation; });
812        quote! {
813            //! Server scaffolding emitted by openapi-to-rust.
814            //!
815            //! Implement the per-tag trait(s) in `api` on your own struct,
816            //! then build an `axum::Router` via `router::router(impl)`.
817
818            #provenance_attribute
819
820            pub mod api;
821            pub mod errors;
822            pub mod router;
823            #validation_module
824
825            pub use api::*;
826            pub use errors::*;
827            pub use router::*;
828        }
829    }
830
831    fn emit_router(
832        &self,
833        groups: &BTreeMap<String, Vec<&OperationInfo>>,
834        validation_bundle: Option<&super::validation::ValidationBundle>,
835    ) -> Result<TokenStream, ServerCodegenError> {
836        let provenance_attribute = self.provenance_attribute();
837        let factories: Vec<TokenStream> = groups
838            .iter()
839            .map(|(tag, ops)| self.emit_router_for_trait(tag, ops, validation_bundle))
840            .collect::<Result<_, _>>()?;
841
842        // Per-op Query structs — one per op that has any query params.
843        let query_structs: Vec<TokenStream> = groups
844            .values()
845            .flatten()
846            .filter_map(|op| self.emit_query_struct(op))
847            .collect();
848        let has_query_parameters = groups.values().flatten().any(|operation| {
849            operation
850                .parameters
851                .iter()
852                .any(|parameter| parameter.location == "query")
853        });
854        let query_helpers = has_query_parameters.then(|| {
855            quote! {
856                fn __query_pairs(raw: ::std::option::Option<&str>) -> ::std::vec::Vec<(String, String)> {
857                    raw.map(|query| {
858                        ::url::form_urlencoded::parse(query.as_bytes())
859                            .into_owned()
860                            .collect()
861                    })
862                    .unwrap_or_default()
863                }
864
865                fn __validate_urlencoded(raw: &str) -> ::std::result::Result<(), String> {
866                    let bytes = raw.as_bytes();
867                    let mut index = 0;
868                    while index < bytes.len() {
869                        if bytes[index] == b'%' {
870                            if index + 2 >= bytes.len()
871                                || !bytes[index + 1].is_ascii_hexdigit()
872                                || !bytes[index + 2].is_ascii_hexdigit()
873                            {
874                                return Err("malformed percent encoding".to_string());
875                            }
876                            index += 3;
877                        } else {
878                            index += 1;
879                        }
880                    }
881                    Ok(())
882                }
883
884                fn __query_one(
885                    pairs: &[(String, String)],
886                    key: &str,
887                ) -> ::std::result::Result<::std::option::Option<String>, String> {
888                    let mut values = pairs
889                        .iter()
890                        .filter(|(candidate, _)| candidate == key)
891                        .map(|(_, value)| value.clone());
892                    let value = values.next();
893                    if values.next().is_some() {
894                        return Err(format!("query parameter `{key}` appeared more than once"));
895                    }
896                    Ok(value)
897                }
898
899                fn __decode_query_scalar<T>(
900                    value: &str,
901                    label: &str,
902                ) -> ::std::result::Result<T, String>
903                where
904                    T: ::serde::de::DeserializeOwned,
905                {
906                    ::serde_json::from_value(::serde_json::Value::String(value.to_string()))
907                        .or_else(|_| ::serde_json::from_str(value))
908                        .map_err(|error| format!("invalid query value for `{label}`: {error}"))
909                }
910
911                fn __decode_query_object<T>(
912                    fields: &[(String, String)],
913                    label: &str,
914                ) -> ::std::result::Result<T, String>
915                where
916                    T: ::serde::de::DeserializeOwned,
917                {
918                    let mut serializer =
919                        ::url::form_urlencoded::Serializer::new(String::new());
920                    for (key, value) in fields {
921                        serializer.append_pair(key, value);
922                    }
923                    ::serde_urlencoded::from_str(&serializer.finish())
924                        .map_err(|error| format!("invalid query object `{label}`: {error}"))
925                }
926
927                fn __query_empty_marker(
928                    pairs: &[(String, String)],
929                    key: &str,
930                ) -> ::std::result::Result<bool, String> {
931                    let marker = format!("{key}[]");
932                    match __query_one(pairs, &marker)? {
933                        Some(value) if value.is_empty() => Ok(true),
934                        Some(_) => Err(format!(
935                            "zero-cardinality marker `{marker}` must have an empty value"
936                        )),
937                        None => Ok(false),
938                    }
939                }
940            }
941        });
942
943        // When the picked operations span multiple tags, emit a
944        // top-level `build_router(impl1, impl2, ...)` that takes one
945        // generic per trait and `.merge()`s the per-tag factories.
946        // For a single-tag selection this is unnecessary noise — the
947        // user calls the per-tag factory directly.
948        let combined = if groups.len() > 1 {
949            Some(self.emit_combined_router(groups))
950        } else {
951            None
952        };
953
954        Ok(quote! {
955            //! Router factories — one per trait. Each takes any
956            //! `T: <TraitName> + Clone + Send + Sync + 'static` and
957            //! returns an `axum::Router` with state pre-attached.
958
959            #provenance_attribute
960
961            use super::api::*;
962            use super::errors::*;
963            // Pull schemas directly from the types module (always a
964            // sibling of mod.rs). Doesn't rely on the parent module
965            // re-exporting types::*, so users can mount the generated
966            // tree at any path without rewriting these imports.
967            #[allow(unused_imports)]
968            use super::super::types::*;
969
970            #query_helpers
971
972            #(#query_structs)*
973
974            #(#factories)*
975
976            #combined
977        })
978    }
979
980    fn emit_combined_router(&self, groups: &BTreeMap<String, Vec<&OperationInfo>>) -> TokenStream {
981        // Stable ordering: BTreeMap iteration is already alphabetical
982        // by tag, which gives us deterministic generic ordering across
983        // generator runs.
984        let entries: Vec<(syn::Ident, syn::Ident, syn::Ident)> = groups
985            .keys()
986            .enumerate()
987            .map(|(i, tag)| {
988                let trait_ident = trait_ident_for_tag(tag);
989                let factory = format_ident!("{}_router", trait_ident.to_string().to_snake_case());
990                let generic = format_ident!("T{}", i + 1);
991                (trait_ident, factory, generic)
992            })
993            .collect();
994
995        let generics: Vec<&syn::Ident> = entries.iter().map(|(_, _, g)| g).collect();
996        let args: Vec<TokenStream> = entries
997            .iter()
998            .map(|(trait_ident, _, g)| {
999                let arg_ident = format_ident!("{}", trait_ident.to_string().to_snake_case());
1000                quote! { #arg_ident: #g }
1001            })
1002            .collect();
1003        let bounds: Vec<TokenStream> = entries
1004            .iter()
1005            .map(|(trait_ident, _, g)| {
1006                quote! { #g: #trait_ident + Clone + Send + Sync + 'static }
1007            })
1008            .collect();
1009
1010        // Fold the factories: `factory1(arg1).merge(factory2(arg2)).merge(...)`.
1011        let first = &entries[0];
1012        let first_arg = format_ident!("{}", first.0.to_string().to_snake_case());
1013        let first_factory = &first.1;
1014        let rest = entries
1015            .iter()
1016            .skip(1)
1017            .map(|(trait_ident, factory, _)| {
1018                let arg = format_ident!("{}", trait_ident.to_string().to_snake_case());
1019                quote! { .merge(#factory(#arg)) }
1020            })
1021            .collect::<Vec<_>>();
1022
1023        let trait_names: Vec<String> = entries.iter().map(|(t, _, _)| t.to_string()).collect();
1024        let doc = format!(
1025            " Combined router spanning {} traits: {}.",
1026            entries.len(),
1027            trait_names.join(", "),
1028        );
1029
1030        quote! {
1031            #[doc = #doc]
1032            pub fn build_router<#(#generics),*>(
1033                #(#args),*
1034            ) -> ::axum::Router
1035            where
1036                #(#bounds),*
1037            {
1038                #first_factory(#first_arg) #(#rest)*
1039            }
1040        }
1041    }
1042
1043    fn emit_router_for_trait(
1044        &self,
1045        tag: &str,
1046        ops: &[&OperationInfo],
1047        validation_bundle: Option<&super::validation::ValidationBundle>,
1048    ) -> Result<TokenStream, ServerCodegenError> {
1049        let trait_ident = trait_ident_for_tag(tag);
1050        let fn_ident = format_ident!("{}_router", trait_ident.to_string().to_snake_case());
1051
1052        let mut routes: Vec<TokenStream> = Vec::new();
1053        let mut custom_by_path: BTreeMap<String, Vec<(String, syn::Ident)>> = BTreeMap::new();
1054        for op in ops {
1055            let handler = format_ident!("{}_handler", op.operation_id.to_snake_case());
1056            let path = openapi_to_axum_path(&op.path)?;
1057            if let Some(method_call) = axum_method_call(&op.method) {
1058                routes.push(quote! { .route(#path, ::axum::routing::#method_call(#handler::<T>)) });
1059            } else {
1060                custom_by_path
1061                    .entry(path)
1062                    .or_default()
1063                    .push((op.method.to_ascii_uppercase(), handler));
1064            }
1065        }
1066        let mut custom_dispatchers = Vec::new();
1067        for (path, methods) in custom_by_path {
1068            let first_handler = &methods[0].1;
1069            let dispatcher = format_ident!("{}_custom_method_dispatch", first_handler);
1070            let (route, dispatcher_fn) =
1071                axum_custom_route(&path, &dispatcher, &methods, &trait_ident);
1072            routes.push(route);
1073            custom_dispatchers.push(dispatcher_fn);
1074        }
1075
1076        let handlers: Vec<TokenStream> = ops
1077            .iter()
1078            .map(|op| self.emit_axum_handler(&trait_ident, op, validation_bundle))
1079            .collect::<Result<_, _>>()?;
1080
1081        let doc = format!(" Build an axum::Router for the `{trait_ident}` trait.");
1082
1083        Ok(quote! {
1084            #[doc = #doc]
1085            pub fn #fn_ident<T>(api: T) -> ::axum::Router
1086            where
1087                T: #trait_ident + Clone + Send + Sync + 'static,
1088            {
1089                ::axum::Router::new()
1090                    #(#routes)*
1091                    .with_state(api)
1092            }
1093
1094            #(#custom_dispatchers)*
1095
1096            #(#handlers)*
1097        })
1098    }
1099
1100    fn emit_axum_handler(
1101        &self,
1102        trait_ident: &syn::Ident,
1103        op: &OperationInfo,
1104        validation_bundle: Option<&super::validation::ValidationBundle>,
1105    ) -> Result<TokenStream, ServerCodegenError> {
1106        let handler_ident = format_ident!("{}_handler", op.operation_id.to_snake_case());
1107        let trait_method = format_ident!("{}", op.operation_id.to_snake_case());
1108
1109        // Build extractor list + call argument list.
1110        let mut extractors: Vec<TokenStream> =
1111            vec![quote! { ::axum::extract::State(api): ::axum::extract::State<T> }];
1112        let mut call_args: Vec<TokenStream> = Vec::new();
1113
1114        // Path parameters. With validation enabled, extract by wire name so
1115        // declaration order cannot drift from the route template and all
1116        // malformed values use the public rejection profile.
1117        let path_params: Vec<&_> = op
1118            .parameters
1119            .iter()
1120            .filter(|p| p.location == "path")
1121            .collect();
1122        let mut path_decode = TokenStream::new();
1123        if !path_params.is_empty() && validation_bundle.is_some() {
1124            extractors.push(quote! {
1125                __path_result: ::std::result::Result<
1126                    ::axum::extract::Path<::std::collections::HashMap<String, String>>,
1127                    ::axum::extract::rejection::PathRejection,
1128                >
1129            });
1130            let mut decoders = Vec::new();
1131            for parameter in &path_params {
1132                let ident = self.parameter_ident(parameter);
1133                let ty = self.query_parameter_type(parameter);
1134                let wire = parameter.name.as_str();
1135                let location = parameter_location("path", wire);
1136                let target = self
1137                    .validation_target(validation_bundle, op, "path", Some(wire))?
1138                    .ok_or_else(|| {
1139                        ServerCodegenError::Validation(format!(
1140                            "validation target unexpectedly disabled for operation `{}` path `{wire}`",
1141                            op.operation_id
1142                        ))
1143                    })?;
1144                let string_wire = self.parameter_schema_is_string(parameter);
1145                decoders.push(quote! {
1146                    let #ident: #ty = match __path_values.remove(#wire) {
1147                        Some(raw) => match super::validation::decode_parameter(
1148                            &raw, #target, #location, #string_wire,
1149                        ) {
1150                            Ok(value) => value,
1151                            Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1152                        },
1153                        None => return ::axum::response::IntoResponse::into_response(
1154                            super::validation::generated_contract_error()
1155                        ),
1156                    };
1157                });
1158                call_args.push(quote! { #ident });
1159            }
1160            path_decode = quote! {
1161                let ::axum::extract::Path(mut __path_values) = match __path_result {
1162                    Ok(path) => path,
1163                    Err(_) => return ::axum::response::IntoResponse::into_response(
1164                        super::validation::malformed_parameter("/path")
1165                    ),
1166                };
1167                #(#decoders)*
1168            };
1169        } else if !path_params.is_empty() {
1170            let idents: Vec<syn::Ident> = path_params
1171                .iter()
1172                .map(|p| self.parameter_ident(p))
1173                .collect();
1174            let types: Vec<TokenStream> = path_params
1175                .iter()
1176                .map(|p| self.query_parameter_type(p))
1177                .collect();
1178            if path_params.len() == 1 {
1179                let i = &idents[0];
1180                let t = &types[0];
1181                extractors.push(quote! { ::axum::extract::Path(#i): ::axum::extract::Path<#t> });
1182            } else {
1183                extractors.push(quote! { ::axum::extract::Path((#(#idents),*)): ::axum::extract::Path<(#(#types),*)> });
1184            }
1185            for i in &idents {
1186                call_args.push(quote! { #i });
1187            }
1188        }
1189
1190        // Query parameters — extract via a per-op `<Op>Query` struct
1191        // (emitted in the same router.rs above). Required params are
1192        // unwrapped here (short-circuit 400 if missing) so the trait
1193        // method sees a `T` rather than `Option<T>`.
1194        let query_params: Vec<&_> = op
1195            .parameters
1196            .iter()
1197            .filter(|p| p.location == "query")
1198            .collect();
1199        let mut required_query_checks: Vec<TokenStream> = Vec::new();
1200        let mut query_validation_checks: Vec<TokenStream> = Vec::new();
1201        let mut raw_query_validation_checks: Vec<TokenStream> = Vec::new();
1202        let mut query_decode = TokenStream::new();
1203        if !query_params.is_empty() {
1204            let query_ident = format_ident!("{}Query", op.operation_id.to_pascal_case());
1205            let decode_ident = format_ident!("__decode_{}_query", op.operation_id.to_snake_case());
1206            extractors.push(quote! {
1207                ::axum::extract::RawQuery(__raw_query): ::axum::extract::RawQuery
1208            });
1209            query_decode = if validation_bundle.is_some() {
1210                quote! {
1211                    let __q: #query_ident = match #decode_ident(__raw_query.as_deref()) {
1212                        Ok(query) => query,
1213                        Err(_) => return ::axum::response::IntoResponse::into_response(
1214                            super::validation::malformed_parameter("/query")
1215                        ),
1216                    };
1217                }
1218            } else {
1219                quote! {
1220                    let __q: #query_ident = match #decode_ident(__raw_query.as_deref()) {
1221                        Ok(query) => query,
1222                        Err(message) => return ::axum::response::IntoResponse::into_response(
1223                            (
1224                                ::axum::http::StatusCode::BAD_REQUEST,
1225                                ::axum::Json(::serde_json::json!({ "error": message })),
1226                            )
1227                        ),
1228                    };
1229                }
1230            };
1231            for p in &query_params {
1232                let f = self.parameter_ident(p);
1233                let wire = p.name.as_str();
1234                let location = parameter_location("query", wire);
1235                let target = self.validation_target(validation_bundle, op, "query", Some(wire))?;
1236                if p.query_serialization.is_none() && self.parameter_schema_is_string(p) {
1237                    if let Some(target) = target.as_ref() {
1238                        raw_query_validation_checks.push(quote! {
1239                            if let Ok(Some(raw)) = __query_one(&__raw_query_pairs, #wire) {
1240                                if let Err(rejection) = super::validation::validate_string_parameter(
1241                                    #target, #location, &raw,
1242                                ) {
1243                                    return ::axum::response::IntoResponse::into_response(rejection);
1244                                }
1245                            }
1246                        });
1247                    }
1248                }
1249                if p.required {
1250                    required_query_checks.push(if validation_bundle.is_some() {
1251                        quote! {
1252                            let #f = match __q.#f {
1253                                Some(v) => v,
1254                                None => return ::axum::response::IntoResponse::into_response(
1255                                    super::validation::missing_parameter(#location)
1256                                ),
1257                            };
1258                        }
1259                    } else {
1260                        let missing_msg = format!("missing required query parameter `{wire}`");
1261                        quote! {
1262                            let #f = match __q.#f {
1263                                Some(v) => v,
1264                                None => return ::axum::response::IntoResponse::into_response(
1265                                    (
1266                                        ::axum::http::StatusCode::BAD_REQUEST,
1267                                        ::axum::Json(::serde_json::json!({
1268                                            "error": #missing_msg
1269                                        })),
1270                                    )
1271                                ),
1272                            };
1273                        }
1274                    });
1275                    if let Some(target) = target {
1276                        query_validation_checks.push(quote! {
1277                            if let Err(rejection) = super::validation::validate_parameter(
1278                                #target, #location, &#f,
1279                            ) {
1280                                return ::axum::response::IntoResponse::into_response(rejection);
1281                            }
1282                        });
1283                    }
1284                    call_args.push(quote! { #f });
1285                } else {
1286                    if let Some(target) = target {
1287                        query_validation_checks.push(quote! {
1288                            if let Some(value) = &__q.#f {
1289                                if let Err(rejection) = super::validation::validate_parameter(
1290                                    #target, #location, value,
1291                                ) {
1292                                    return ::axum::response::IntoResponse::into_response(rejection);
1293                                }
1294                            }
1295                        });
1296                    }
1297                    call_args.push(quote! { __q.#f });
1298                }
1299            }
1300        }
1301
1302        // Scalar header and cookie parameters are decoded to their generated
1303        // Rust types before schema validation. Raw transport/parser errors are
1304        // deliberately discarded at the public boundary.
1305        let header_params: Vec<&_> = op
1306            .parameters
1307            .iter()
1308            .filter(|p| p.location == "header")
1309            .collect();
1310        let cookie_params: Vec<&_> = op
1311            .parameters
1312            .iter()
1313            .filter(|p| p.location == "cookie")
1314            .collect();
1315        let mut parameter_decode_checks: Vec<TokenStream> = Vec::new();
1316        if !header_params.is_empty() || !cookie_params.is_empty() {
1317            extractors.push(quote! { __headers: ::axum::http::HeaderMap });
1318        }
1319        if !header_params.is_empty() {
1320            for p in &header_params {
1321                let wire = p.name.as_str();
1322                let ident = self.parameter_ident(p);
1323                let ty = self.query_parameter_type(p);
1324                let location = parameter_location("header", wire);
1325                let target = self.validation_target(validation_bundle, op, "header", Some(wire))?;
1326                let string_wire = self.parameter_schema_is_string(p);
1327                if p.required {
1328                    if let Some(target) = target {
1329                        parameter_decode_checks.push(quote! {
1330                            let mut __values = __headers.get_all(#wire).iter();
1331                            let #ident: #ty = match (__values.next(), __values.next()) {
1332                                (Some(value), None) => match value.to_str() {
1333                                    Ok(raw) => match super::validation::decode_parameter(
1334                                        raw, #target, #location, #string_wire,
1335                                    ) {
1336                                        Ok(value) => value,
1337                                        Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1338                                    },
1339                                    Err(_) => return ::axum::response::IntoResponse::into_response(
1340                                        super::validation::malformed_parameter(#location)
1341                                    ),
1342                                },
1343                                (None, _) => return ::axum::response::IntoResponse::into_response(
1344                                    super::validation::missing_parameter(#location)
1345                                ),
1346                                _ => return ::axum::response::IntoResponse::into_response(
1347                                    super::validation::malformed_parameter(#location)
1348                                ),
1349                            };
1350                        });
1351                    } else {
1352                        parameter_decode_checks.push(quote! {
1353                            let #ident: #ty = match __headers.get(#wire).and_then(|v| v.to_str().ok()) {
1354                                Some(raw) => match __decode_query_scalar(raw, #wire) {
1355                                    Ok(value) => value,
1356                                    Err(_) => return ::axum::http::StatusCode::BAD_REQUEST.into_response(),
1357                                },
1358                                None => return ::axum::http::StatusCode::BAD_REQUEST.into_response(),
1359                            };
1360                        });
1361                    }
1362                    call_args.push(quote! { #ident });
1363                } else {
1364                    if let Some(target) = target {
1365                        parameter_decode_checks.push(quote! {
1366                            let mut __values = __headers.get_all(#wire).iter();
1367                            let #ident: ::std::option::Option<#ty> = match (__values.next(), __values.next()) {
1368                                (Some(value), None) => match value.to_str() {
1369                                    Ok(raw) => match super::validation::decode_parameter(raw, #target, #location, #string_wire) {
1370                                        Ok(value) => Some(value),
1371                                        Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1372                                    },
1373                                    Err(_) => return ::axum::response::IntoResponse::into_response(
1374                                        super::validation::malformed_parameter(#location)
1375                                    ),
1376                                },
1377                                (None, _) => None,
1378                                _ => return ::axum::response::IntoResponse::into_response(
1379                                    super::validation::malformed_parameter(#location)
1380                                ),
1381                            };
1382                        });
1383                    } else {
1384                        parameter_decode_checks.push(quote! {
1385                            let #ident: ::std::option::Option<#ty> = __headers.get(#wire)
1386                                .and_then(|value| value.to_str().ok())
1387                                .and_then(|raw| __decode_query_scalar(raw, #wire).ok());
1388                        });
1389                    }
1390                    call_args.push(quote! { #ident });
1391                }
1392            }
1393        }
1394        if !cookie_params.is_empty() {
1395            parameter_decode_checks.push(quote! {
1396                let mut __cookies = match super::validation::parse_cookies(&__headers) {
1397                    Ok(cookies) => cookies,
1398                    Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1399                };
1400            });
1401            for p in &cookie_params {
1402                let wire = p.name.as_str();
1403                let ident = self.parameter_ident(p);
1404                let ty = self.query_parameter_type(p);
1405                let location = parameter_location("cookie", wire);
1406                let target = self
1407                    .validation_target(validation_bundle, op, "cookie", Some(wire))?
1408                    .ok_or_else(|| {
1409                        ServerCodegenError::Validation(format!(
1410                            "cookie extraction requires validation for operation `{}`",
1411                            op.operation_id
1412                        ))
1413                    })?;
1414                let string_wire = self.parameter_schema_is_string(p);
1415                if p.required {
1416                    parameter_decode_checks.push(quote! {
1417                        let #ident: #ty = match __cookies.remove(#wire) {
1418                            Some(raw) => match super::validation::decode_parameter(&raw, #target, #location, #string_wire) {
1419                                Ok(value) => value,
1420                                Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1421                            },
1422                            None => return ::axum::response::IntoResponse::into_response(
1423                                super::validation::missing_parameter(#location)
1424                            ),
1425                        };
1426                    });
1427                    call_args.push(quote! { #ident });
1428                } else {
1429                    parameter_decode_checks.push(quote! {
1430                        let #ident: ::std::option::Option<#ty> = match __cookies.remove(#wire) {
1431                            Some(raw) => match super::validation::decode_parameter(&raw, #target, #location, #string_wire) {
1432                                Ok(value) => Some(value),
1433                                Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1434                            },
1435                            None => None,
1436                        };
1437                    });
1438                    call_args.push(quote! { #ident });
1439                }
1440            }
1441        }
1442
1443        // Body
1444        let mut body_decode = TokenStream::new();
1445        let body_ty_opt = body_type(op);
1446        if let Some(body_ty) = &body_ty_opt {
1447            let body_ty_tokens = parse_type(body_ty);
1448            let validated_json = matches!(&op.request_body, Some(RequestBodyContent::Json { .. }))
1449                && validation_bundle.is_some();
1450            let validated_form = matches!(
1451                &op.request_body,
1452                Some(RequestBodyContent::FormUrlEncoded { .. })
1453            ) && validation_bundle.is_some();
1454            if validated_json {
1455                extractors.push(quote! { __request: ::axum::extract::Request });
1456                let Some(RequestBodyContent::Json { media_type, .. }) = &op.request_body else {
1457                    return Err(ServerCodegenError::Internal(
1458                        "validated JSON body lost its media type".to_string(),
1459                    ));
1460                };
1461                let target = self
1462                    .validation_target(validation_bundle, op, "body", None)?
1463                    .ok_or_else(|| {
1464                        ServerCodegenError::Validation(format!(
1465                            "validation target unexpectedly disabled for operation `{}` body",
1466                            op.operation_id
1467                        ))
1468                    })?;
1469                let required = op.request_body_required;
1470                let max_body_bytes = self.server.validation.max_body_bytes;
1471                body_decode = if required {
1472                    quote! {
1473                        let body: #body_ty_tokens = match super::validation::decode_json_body::<#body_ty_tokens>(
1474                            __request,
1475                            #target,
1476                            #media_type,
1477                            true,
1478                            #max_body_bytes,
1479                        ).await {
1480                            Ok(Some(body)) => body,
1481                            Ok(None) => return ::axum::response::IntoResponse::into_response(
1482                                super::validation::generated_contract_error()
1483                            ),
1484                            Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1485                        };
1486                    }
1487                } else {
1488                    quote! {
1489                        let body: ::std::option::Option<#body_ty_tokens> =
1490                            match super::validation::decode_json_body::<#body_ty_tokens>(
1491                                __request,
1492                                #target,
1493                                #media_type,
1494                                false,
1495                                #max_body_bytes,
1496                            ).await {
1497                                Ok(body) => body,
1498                                Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1499                            };
1500                    }
1501                };
1502                call_args.push(quote! { body });
1503            } else if validated_form {
1504                extractors.push(quote! { __request: ::axum::extract::Request });
1505                let Some(RequestBodyContent::FormUrlEncoded { media_type, .. }) = &op.request_body
1506                else {
1507                    return Err(ServerCodegenError::Internal(
1508                        "validated form body lost its media type".to_string(),
1509                    ));
1510                };
1511                let target = self
1512                    .validation_target(validation_bundle, op, "body", None)?
1513                    .ok_or_else(|| {
1514                        ServerCodegenError::Validation(format!(
1515                            "validation target unexpectedly disabled for operation `{}` body",
1516                            op.operation_id
1517                        ))
1518                    })?;
1519                let allowed_fields = self.form_field_names(op)?;
1520                let required_fields = self.form_required_field_names(op)?;
1521                let required = op.request_body_required;
1522                let max_body_bytes = self.server.validation.max_body_bytes;
1523                body_decode = quote! {
1524                    let body: ::std::option::Option<#body_ty_tokens> =
1525                        match super::validation::decode_form_body::<#body_ty_tokens>(
1526                            __request,
1527                            #target,
1528                            #media_type,
1529                            #required,
1530                            #max_body_bytes,
1531                            &[#(#allowed_fields),*],
1532                            &[#(#required_fields),*],
1533                        ).await {
1534                            Ok(body) => body,
1535                            Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection),
1536                        };
1537                };
1538                if required {
1539                    body_decode.extend(quote! {
1540                        let body = match body {
1541                            Some(body) => body,
1542                            None => return ::axum::response::IntoResponse::into_response(
1543                                super::validation::generated_contract_error()
1544                            ),
1545                        };
1546                    });
1547                }
1548                call_args.push(quote! { body });
1549            } else if op.request_body_required {
1550                extractors.push(quote! {
1551                    ::axum::Json(body): ::axum::Json<#body_ty_tokens>
1552                });
1553                call_args.push(quote! { body });
1554            } else {
1555                extractors.push(quote! {
1556                    body: ::std::option::Option<::axum::Json<#body_ty_tokens>>
1557                });
1558                call_args.push(quote! { body.map(|::axum::Json(b)| b) });
1559            }
1560        }
1561
1562        let _ = format_ident!("{}Response", op.operation_id.to_pascal_case());
1563        // Keep referencing trait_ident so the where-bound name is
1564        // visible to downstream readers — clippy would otherwise flag
1565        // it as unused in some configurations.
1566        let _ = trait_ident;
1567        let raw_query_validation = (!raw_query_validation_checks.is_empty()).then(|| {
1568            quote! {
1569                if let Some(raw) = __raw_query.as_deref() {
1570                    if __validate_urlencoded(raw).is_err() {
1571                        return ::axum::response::IntoResponse::into_response(
1572                            super::validation::malformed_parameter("/query")
1573                        );
1574                    }
1575                }
1576                let __raw_query_pairs = __query_pairs(__raw_query.as_deref());
1577                #(#raw_query_validation_checks)*
1578            }
1579        });
1580
1581        // Handler returns `axum::response::Response` so the required-
1582        // param short-circuit (400 BadRequest) and the trait method's
1583        // typed response enum (via IntoResponse) can both flow out
1584        // through the same return type.
1585        Ok(quote! {
1586            async fn #handler_ident<T>(
1587                #(#extractors),*
1588            ) -> ::axum::response::Response
1589            where
1590                T: super::api::#trait_ident + Clone + Send + Sync + 'static,
1591            {
1592                #path_decode
1593                #raw_query_validation
1594                #query_decode
1595                #(#required_query_checks)*
1596                #(#query_validation_checks)*
1597                #(#parameter_decode_checks)*
1598                #body_decode
1599                ::axum::response::IntoResponse::into_response(
1600                    api.#trait_method(#(#call_args),*).await,
1601                )
1602            }
1603        })
1604    }
1605
1606    fn emit_api(&self, groups: &BTreeMap<String, Vec<&OperationInfo>>) -> TokenStream {
1607        let provenance_attribute = self.provenance_attribute();
1608        let traits: Vec<TokenStream> = groups
1609            .iter()
1610            .map(|(tag, ops)| self.emit_trait(tag, ops))
1611            .collect();
1612
1613        // Inline string enums declared on parameters get synthetic
1614        // type names (e.g. `ListInputItemsOrder`). The analyzer
1615        // surfaces enum_values; we emit the enum here so the trait
1616        // signature compiles. Dedup by name in case two ops in the
1617        // same picked set share the same synthetic name.
1618        let mut emitted: std::collections::BTreeSet<String> = Default::default();
1619        let mut param_enums: Vec<TokenStream> = Vec::new();
1620        for op in groups.values().flatten() {
1621            for p in &op.parameters {
1622                if let Some(values) = &p.enum_values {
1623                    if emitted.insert(p.rust_type.clone()) {
1624                        param_enums.push(emit_param_enum(&p.rust_type, values));
1625                    }
1626                }
1627            }
1628        }
1629
1630        quote! {
1631            //! Per-tag traits. Implement one of these on your own
1632            //! struct; the router (P5) wires it into axum.
1633
1634            #provenance_attribute
1635
1636            #![allow(clippy::too_many_arguments)]
1637
1638            use super::errors::*;
1639            // Schemas live in `<parent>/types.rs`. Reaching them via
1640            // `super::super::types::*` instead of a glob on the
1641            // parent module keeps these imports stable regardless of
1642            // how the user mounts the generated tree.
1643            #[allow(unused_imports)]
1644            use super::super::types::*;
1645
1646            #(#param_enums)*
1647
1648            #(#traits)*
1649        }
1650    }
1651
1652    fn emit_trait(&self, tag: &str, ops: &[&OperationInfo]) -> TokenStream {
1653        let trait_ident = trait_ident_for_tag(tag);
1654        let methods: Vec<TokenStream> = ops.iter().map(|op| self.emit_method_sig(op)).collect();
1655        let doc = format!(" Operations under the `{tag}` tag.");
1656        quote! {
1657            #[doc = #doc]
1658            #[async_trait::async_trait]
1659            pub trait #trait_ident: Send + Sync + 'static {
1660                #(#methods)*
1661            }
1662        }
1663    }
1664
1665    fn emit_method_sig(&self, op: &OperationInfo) -> TokenStream {
1666        let name = format_ident!("{}", op.operation_id.to_snake_case());
1667        let response_ty = format_ident!("{}Response", op.operation_id.to_pascal_case());
1668
1669        // Order: path → query → header → body. Required params keep
1670        // their declared rust_type; optional params wrap in Option<…>.
1671        // This mirrors what the router handler extracts so positional
1672        // ordering matches the call site exactly.
1673        let mut params: Vec<TokenStream> = Vec::new();
1674        for p in &op.parameters {
1675            if p.location == "path" {
1676                let ident = self.parameter_ident(p);
1677                let ty = self.query_parameter_type(p);
1678                params.push(quote! { #ident: #ty });
1679            }
1680        }
1681        for p in &op.parameters {
1682            if p.location == "query" {
1683                let ident = self.parameter_ident(p);
1684                let ty = self.query_parameter_type(p);
1685                // Required query params land as `T`; the handler
1686                // validates presence and returns 400 if absent, so
1687                // by the time the trait method sees the value it
1688                // must be Some. Optional → `Option<T>`.
1689                if p.required {
1690                    params.push(quote! { #ident: #ty });
1691                } else {
1692                    params.push(quote! { #ident: ::std::option::Option<#ty> });
1693                }
1694            }
1695        }
1696        for p in &op.parameters {
1697            if p.location == "header" {
1698                let ident = self.parameter_ident(p);
1699                let ty = self.query_parameter_type(p);
1700                if p.required {
1701                    params.push(quote! { #ident: #ty });
1702                } else {
1703                    params.push(quote! { #ident: ::std::option::Option<#ty> });
1704                }
1705            }
1706        }
1707        for p in &op.parameters {
1708            if p.location == "cookie" {
1709                let ident = self.parameter_ident(p);
1710                let ty = self.query_parameter_type(p);
1711                if p.required {
1712                    params.push(quote! { #ident: #ty });
1713                } else {
1714                    params.push(quote! { #ident: ::std::option::Option<#ty> });
1715                }
1716            }
1717        }
1718        if let Some(body) = body_type(op) {
1719            let body_ty = parse_type(&body);
1720            if op.request_body_required {
1721                params.push(quote! { body: #body_ty });
1722            } else {
1723                params.push(quote! { body: Option<#body_ty> });
1724            }
1725        }
1726
1727        let summary_doc = op
1728            .summary
1729            .as_deref()
1730            .map(|s| format!(" {s}"))
1731            .unwrap_or_default();
1732        let route_doc = format!(" `{} {}`", op.method, op.path);
1733
1734        quote! {
1735            #[doc = #summary_doc]
1736            #[doc = ""]
1737            #[doc = #route_doc]
1738            async fn #name(&self, #(#params),*) -> #response_ty;
1739        }
1740    }
1741
1742    /// Per-op `<Op>Query` struct emitted into router.rs when the op
1743    /// has any query parameters. An operation-specific decoder fills it from
1744    /// Axum's raw query so repeated and structured keys remain observable.
1745    fn emit_query_struct(&self, op: &OperationInfo) -> Option<TokenStream> {
1746        let query_params: Vec<&_> = op
1747            .parameters
1748            .iter()
1749            .filter(|p| p.location == "query")
1750            .collect();
1751        if query_params.is_empty() {
1752            return None;
1753        }
1754        let ident = format_ident!("{}Query", op.operation_id.to_pascal_case());
1755        let decode_ident = format_ident!("__decode_{}_query", op.operation_id.to_snake_case());
1756        let mut fields = Vec::new();
1757        let mut decoders = Vec::new();
1758        let mut field_idents = Vec::new();
1759        for parameter in query_params {
1760            let field_ident = self.parameter_ident(parameter);
1761            let field_type = self.query_parameter_type(parameter);
1762            let wire_name = parameter.name.as_str();
1763            fields.push(quote! {
1764                pub #field_ident: ::std::option::Option<#field_type>
1765            });
1766            field_idents.push(field_ident.clone());
1767
1768            let decoder = match &parameter.query_serialization {
1769                Some(QuerySerialization::FormExplodedArray { .. }) => quote! {
1770                    let #field_ident = {
1771                        let empty_marker = __query_empty_marker(&__pairs, #wire_name)?;
1772                        let raw_values: Vec<&str> = __pairs
1773                            .iter()
1774                            .filter(|(key, _)| key == #wire_name)
1775                            .map(|(_, value)| value.as_str())
1776                            .collect();
1777                        if empty_marker && !raw_values.is_empty() {
1778                            return Err(format!(
1779                                "query array `{}` cannot combine values with its empty marker",
1780                                #wire_name,
1781                            ));
1782                        }
1783                        if empty_marker {
1784                            Some(Vec::new())
1785                        } else if raw_values.is_empty() {
1786                            None
1787                        } else {
1788                            let mut values = Vec::with_capacity(raw_values.len());
1789                            for raw in raw_values {
1790                                values.push(__decode_query_scalar(raw, #wire_name)?);
1791                            }
1792                            Some(values)
1793                        }
1794                    };
1795                },
1796                Some(QuerySerialization::FormArray { .. }) => quote! {
1797                    let #field_ident = match (
1798                        __query_one(&__pairs, #wire_name)?,
1799                        __query_empty_marker(&__pairs, #wire_name)?,
1800                    ) {
1801                        (Some(_), true) => return Err(format!(
1802                            "query array `{}` cannot combine a value with its empty marker",
1803                            #wire_name,
1804                        )),
1805                        (Some(raw), false) => {
1806                            let mut values = Vec::new();
1807                            for item in raw.split(',') {
1808                                values.push(__decode_query_scalar(item, #wire_name)?);
1809                            }
1810                            Some(values)
1811                        }
1812                        (None, true) => Some(Vec::new()),
1813                        (None, false) => None,
1814                    };
1815                },
1816                Some(QuerySerialization::FormExplodedObject) => {
1817                    let property_names = self
1818                        .query_object_properties(parameter)
1819                        .map(|properties| properties.keys().cloned().collect::<Vec<_>>())
1820                        .unwrap_or_default();
1821                    let required_names = self.query_object_required_properties(parameter);
1822                    quote! {
1823                        let #field_ident = {
1824                            let empty_marker = __query_empty_marker(&__pairs, #wire_name)?;
1825                            let allowed = [#(#property_names),*];
1826                            let object_fields: Vec<(String, String)> = __pairs
1827                                .iter()
1828                                .filter(|(key, _)| allowed.contains(&key.as_str()))
1829                                .cloned()
1830                                .collect();
1831                            if empty_marker && !object_fields.is_empty() {
1832                                return Err(format!(
1833                                    "query object `{}` cannot combine properties with its empty marker",
1834                                    #wire_name,
1835                                ));
1836                            }
1837                            if object_fields.is_empty() && !empty_marker {
1838                                None
1839                            } else {
1840                                let required_properties: &[&str] = &[#(#required_names),*];
1841                                for required in required_properties {
1842                                    if !object_fields.iter().any(|(key, _)| key == required) {
1843                                        return Err(format!("query object `{}` is missing a required property", #wire_name));
1844                                    }
1845                                }
1846                                Some(__decode_query_object(&object_fields, #wire_name)?)
1847                            }
1848                        };
1849                    }
1850                }
1851                Some(QuerySerialization::FormObject) => {
1852                    let property_names = self
1853                        .query_object_properties(parameter)
1854                        .map(|properties| properties.keys().cloned().collect::<Vec<_>>())
1855                        .unwrap_or_default();
1856                    let required_names = self.query_object_required_properties(parameter);
1857                    let has_required_names = !required_names.is_empty();
1858                    quote! {
1859                        let #field_ident = match (
1860                            __query_one(&__pairs, #wire_name)?,
1861                            __query_empty_marker(&__pairs, #wire_name)?,
1862                        ) {
1863                            (Some(_), true) => return Err(format!(
1864                                "query object `{}` cannot combine a value with its empty marker",
1865                                #wire_name,
1866                            )),
1867                            (Some(raw), false) => {
1868                                let parts: Vec<&str> = raw.split(',').collect();
1869                                if parts.len() % 2 != 0 {
1870                                    return Err(format!(
1871                                        "query object `{}` must contain alternating key,value entries",
1872                                        #wire_name,
1873                                    ));
1874                                }
1875                                let allowed = [#(#property_names),*];
1876                                let mut seen = ::std::collections::BTreeSet::new();
1877                                let object_fields: Vec<(String, String)> = parts
1878                                    .chunks_exact(2)
1879                                    .map(|pair| (pair[0].to_string(), pair[1].to_string()))
1880                                    .collect();
1881                                for (key, _) in &object_fields {
1882                                    if !allowed.contains(&key.as_str()) || !seen.insert(key.as_str()) {
1883                                        return Err(format!("query object `{}` has invalid properties", #wire_name));
1884                                    }
1885                                }
1886                                let required_properties: &[&str] = &[#(#required_names),*];
1887                                for required in required_properties {
1888                                    if !seen.contains(required) {
1889                                        return Err(format!("query object `{}` is missing a required property", #wire_name));
1890                                    }
1891                                }
1892                                Some(__decode_query_object(&object_fields, #wire_name)?)
1893                            }
1894                            (None, true) if #has_required_names => return Err(format!(
1895                                "query object `{}` is missing a required property",
1896                                #wire_name,
1897                            )),
1898                            (None, true) => Some(__decode_query_object(&[], #wire_name)?),
1899                            (None, false) => None,
1900                        };
1901                    }
1902                }
1903                Some(QuerySerialization::DeepObject) => {
1904                    let property_names = self
1905                        .query_object_properties(parameter)
1906                        .map(|properties| properties.keys().cloned().collect::<Vec<_>>())
1907                        .unwrap_or_default();
1908                    let required_names = self.query_object_required_properties(parameter);
1909                    quote! {
1910                        let #field_ident = {
1911                            let empty_marker = __query_empty_marker(&__pairs, #wire_name)?;
1912                            let prefix = format!("{}[", #wire_name);
1913                            let allowed = [#(#property_names),*];
1914                            let mut object_fields = Vec::new();
1915                            for (key, value) in &__pairs {
1916                                if let Some(property) = key
1917                                    .strip_prefix(&prefix)
1918                                    .and_then(|rest| rest.strip_suffix(']'))
1919                                {
1920                                    if property.is_empty() {
1921                                        continue;
1922                                    }
1923                                    if !allowed.contains(&property) {
1924                                        return Err(format!(
1925                                            "unknown deepObject property `{}[{}]`",
1926                                            #wire_name,
1927                                            property,
1928                                        ));
1929                                    }
1930                                    object_fields.push((property.to_string(), value.clone()));
1931                                }
1932                            }
1933                            if empty_marker && !object_fields.is_empty() {
1934                                return Err(format!(
1935                                    "query object `{}` cannot combine properties with its empty marker",
1936                                    #wire_name,
1937                                ));
1938                            }
1939                            if object_fields.is_empty() && !empty_marker {
1940                                None
1941                            } else {
1942                                let required_properties: &[&str] = &[#(#required_names),*];
1943                                for required in required_properties {
1944                                    if !object_fields.iter().any(|(key, _)| key == required) {
1945                                        return Err(format!("query object `{}` is missing a required property", #wire_name));
1946                                    }
1947                                }
1948                                Some(__decode_query_object(&object_fields, #wire_name)?)
1949                            }
1950                        };
1951                    }
1952                }
1953                Some(QuerySerialization::Unsupported { .. }) | None => quote! {
1954                    let #field_ident = __query_one(&__pairs, #wire_name)?
1955                        .map(|raw| __decode_query_scalar(&raw, #wire_name))
1956                        .transpose()?;
1957                },
1958            };
1959            decoders.push(decoder);
1960        }
1961        let doc = format!(
1962            " Query parameters for `{} {}` (operationId `{}`).",
1963            op.method, op.path, op.operation_id
1964        );
1965        Some(quote! {
1966            #[doc = #doc]
1967            #[derive(Debug, Default)]
1968            pub struct #ident {
1969                #(#fields),*
1970            }
1971
1972            fn #decode_ident(
1973                raw: ::std::option::Option<&str>,
1974            ) -> ::std::result::Result<#ident, String> {
1975                if let Some(raw) = raw {
1976                    __validate_urlencoded(raw)?;
1977                }
1978                let __pairs = __query_pairs(raw);
1979                #(#decoders)*
1980                Ok(#ident {
1981                    #(#field_idents),*
1982                })
1983            }
1984        })
1985    }
1986
1987    fn emit_errors(&self, ops: &[&OperationInfo], validation_enabled: bool) -> TokenStream {
1988        let provenance_attribute = self.provenance_attribute();
1989        let any_streaming = ops.iter().any(|op| op.supports_streaming);
1990        let enums: Vec<TokenStream> = ops.iter().map(|op| self.emit_response_enum(op)).collect();
1991        let problem_types = validation_enabled.then(|| {
1992            quote! {
1993                /// RFC 9457 Problem Details profile used for rejected requests.
1994                #[derive(Debug, Clone, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)]
1995                pub struct ProblemDetails {
1996                    #[serde(rename = "type")]
1997                    pub r#type: String,
1998                    pub title: String,
1999                    pub status: u16,
2000                    pub code: String,
2001                    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2002                    pub errors: Vec<InvalidParameter>,
2003                }
2004
2005                /// One sanitized request-contract violation.
2006                #[derive(Debug, Clone, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)]
2007                pub struct InvalidParameter {
2008                    pub code: String,
2009                    pub location: String,
2010                    pub message: String,
2011                }
2012
2013                /// Axum rejection wrapper which always uses `application/problem+json`.
2014                #[derive(Debug, Clone)]
2015                pub struct RequestValidationRejection(pub ProblemDetails);
2016
2017                impl IntoResponse for RequestValidationRejection {
2018                    fn into_response(self) -> ::axum::response::Response {
2019                        let status = StatusCode::from_u16(self.0.status)
2020                            .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
2021                        let mut response = (status, Json(self.0)).into_response();
2022                        response.headers_mut().insert(
2023                            ::axum::http::header::CONTENT_TYPE,
2024                            ::axum::http::HeaderValue::from_static("application/problem+json"),
2025                        );
2026                        response
2027                    }
2028                }
2029            }
2030        });
2031
2032        // The SSE type alias is emitted exactly when at least one
2033        // picked op streams. Bringing it in unconditionally would force
2034        // `futures-core` into the user's dep tree even when they don't
2035        // need it.
2036        let stream_alias = if any_streaming {
2037            quote! {
2038                /// Stream payload carried by `*Stream` variants. Each
2039                /// yielded item is a pre-built `axum::response::sse::Event`.
2040                pub type ServerEventStream = ::std::pin::Pin<
2041                    Box<
2042                        dyn ::futures_core::Stream<
2043                                Item = ::std::result::Result<
2044                                    ::axum::response::sse::Event,
2045                                    ::std::convert::Infallible,
2046                                >,
2047                            > + ::std::marker::Send
2048                            + 'static,
2049                    >,
2050                >;
2051
2052                /// Wrap any `Stream<Item = Result<Event, Infallible>>` in
2053                /// a `Sse<ServerEventStream>` ready to drop into the
2054                /// `OkStream` variant. Replaces the
2055                /// `Sse::new(Box::pin(...))` dance.
2056                pub fn sse_response<S>(stream: S) -> ::axum::response::sse::Sse<ServerEventStream>
2057                where
2058                    S: ::futures_core::Stream<
2059                            Item = ::std::result::Result<
2060                                ::axum::response::sse::Event,
2061                                ::std::convert::Infallible,
2062                            >,
2063                        > + ::std::marker::Send
2064                        + 'static,
2065                {
2066                    ::axum::response::sse::Sse::new(Box::pin(stream))
2067                }
2068            }
2069        } else {
2070            quote! {}
2071        };
2072
2073        // We deliberately do NOT import `axum::response::Response` here:
2074        // many specs declare a schema literally named `Response`
2075        // (OpenAI's `createResponse` is one such case), and an explicit
2076        // import would shadow the glob-imported schema name. The
2077        // IntoResponse impl returns `axum::response::Response`
2078        // fully qualified.
2079        quote! {
2080            //! Per-operation response enums. Pick a variant to pick a
2081            //! status code — IntoResponse maps each variant to its
2082            //! documented (StatusCode, Json) pair.
2083
2084            #provenance_attribute
2085
2086            #![allow(clippy::large_enum_variant)]
2087
2088            use axum::{
2089                http::StatusCode,
2090                response::IntoResponse,
2091                Json,
2092            };
2093            // Schemas live in `<parent>/types.rs`. Reaching them via
2094            // `super::super::types::*` instead of a glob on the
2095            // parent module keeps these imports stable regardless of
2096            // how the user mounts the generated tree.
2097            #[allow(unused_imports)]
2098            use super::super::types::*;
2099
2100            #problem_types
2101
2102            #stream_alias
2103
2104            #(#enums)*
2105        }
2106    }
2107
2108    fn emit_response_enum(&self, op: &OperationInfo) -> TokenStream {
2109        let enum_ident = format_ident!("{}Response", op.operation_id.to_pascal_case());
2110        let mut variants: Vec<TokenStream> = Vec::new();
2111        let mut arms: Vec<TokenStream> = Vec::new();
2112
2113        for (status, schema_name) in &op.response_schemas {
2114            let variant = format_ident!("{}", status_variant_name(status));
2115            let body_ty = parse_type(schema_name);
2116            variants.push(quote! { #variant(#body_ty) });
2117            let status_expr = status_token(status);
2118            arms.push(quote! {
2119                Self::#variant(body) => (#status_expr, Json(body)).into_response()
2120            });
2121        }
2122
2123        // SSE: when the operation declares text/event-stream on any
2124        // response, add a streaming sibling variant. The user picks
2125        // it when their request had `stream: true` (or whatever the
2126        // streaming trigger is). Variant payload is a fully built
2127        // `axum::Sse` so the user controls keep-alive, retry interval,
2128        // etc.
2129        if op.supports_streaming {
2130            variants.push(quote! {
2131                OkStream(::axum::response::sse::Sse<ServerEventStream>)
2132            });
2133            arms.push(quote! {
2134                Self::OkStream(sse) => sse.into_response()
2135            });
2136        }
2137
2138        // Fallback: if no response variants were declared we still need
2139        // a no-op enum so the trait method has a return type. Use an
2140        // empty `Empty` variant returning 204.
2141        if variants.is_empty() {
2142            variants.push(quote! { Empty });
2143            arms.push(quote! {
2144                Self::Empty => StatusCode::NO_CONTENT.into_response()
2145            });
2146        }
2147
2148        let doc = format!(
2149            " Response for `{} {}` (operationId `{}`).",
2150            op.method, op.path, op.operation_id
2151        );
2152
2153        quote! {
2154            #[doc = #doc]
2155            pub enum #enum_ident {
2156                #(#variants),*
2157            }
2158
2159            impl IntoResponse for #enum_ident {
2160                fn into_response(self) -> ::axum::response::Response {
2161                    match self {
2162                        #(#arms),*
2163                    }
2164                }
2165            }
2166        }
2167    }
2168}
2169
2170fn parameter_location(location: &str, name: &str) -> String {
2171    format!("/{location}/{}", name.replace('~', "~0").replace('/', "~1"))
2172}
2173
2174/// Emit a string-enum type for a parameter whose inline schema
2175/// declared `enum: [...]`. The analyzer sets `rust_type` to a
2176/// synthetic name (`{OpId}{Param}` in PascalCase) and surfaces the
2177/// values; the codegen layer is what actually writes the enum.
2178fn emit_param_enum(name: &str, values: &[String]) -> TokenStream {
2179    let enum_ident = format_ident!("{}", name);
2180    let variants: Vec<TokenStream> = values
2181        .iter()
2182        .enumerate()
2183        .map(|(i, raw)| {
2184            let pascal = raw.to_pascal_case();
2185            // PascalCase can produce an empty string (pure-symbol
2186            // input) or an identifier starting with a digit
2187            // (e.g. `1d` stays `1d`) — both invalid as Rust idents.
2188            // Fall back to a positional name so the enum compiles.
2189            let starts_with_digit = pascal
2190                .chars()
2191                .next()
2192                .map(|c| c.is_ascii_digit())
2193                .unwrap_or(true);
2194            let v_name = if pascal.is_empty() || starts_with_digit {
2195                format!("Variant{i}")
2196            } else {
2197                pascal
2198            };
2199            let v_ident = format_ident!("{}", v_name);
2200            let default_marker = if i == 0 {
2201                quote! { #[default] }
2202            } else {
2203                quote! {}
2204            };
2205            quote! {
2206                #default_marker
2207                #[serde(rename = #raw)]
2208                #v_ident
2209            }
2210        })
2211        .collect();
2212    quote! {
2213        #[derive(Debug, Clone, PartialEq, Eq, ::serde::Deserialize, ::serde::Serialize, Default)]
2214        pub enum #enum_ident {
2215            #(#variants),*
2216        }
2217    }
2218}
2219
2220fn axum_method_call(method: &str) -> Option<TokenStream> {
2221    match method.to_ascii_uppercase().as_str() {
2222        "CONNECT" => Some(quote! { connect }),
2223        "DELETE" => Some(quote! { delete }),
2224        "GET" => Some(quote! { get }),
2225        "HEAD" => Some(quote! { head }),
2226        "OPTIONS" => Some(quote! { options }),
2227        "PATCH" => Some(quote! { patch }),
2228        "POST" => Some(quote! { post }),
2229        "PUT" => Some(quote! { put }),
2230        "TRACE" => Some(quote! { trace }),
2231        _ => None,
2232    }
2233}
2234
2235/// Build one exact-method dispatcher for every nonstandard operation sharing a
2236/// path within one generated trait. Axum has convenience functions for the
2237/// standard RFC methods, but OpenAPI 3.2 also defines QUERY and permits custom
2238/// `additionalOperations`. Axum allows only one `any` fallback per path, so all
2239/// custom methods on that path and trait must share this dispatcher. Generation
2240/// rejects the cross-trait form before reaching this helper.
2241fn axum_custom_route(
2242    path: &str,
2243    dispatcher: &syn::Ident,
2244    methods: &[(String, syn::Ident)],
2245    trait_ident: &syn::Ident,
2246) -> (TokenStream, TokenStream) {
2247    let arms = methods.iter().map(|(method, handler)| {
2248        quote! {
2249            #method => ::axum::handler::Handler::call(#handler::<T>, request, api).await
2250        }
2251    });
2252    let route = quote! {
2253        .route(#path, ::axum::routing::any(#dispatcher::<T>))
2254    };
2255    let dispatcher_fn = quote! {
2256        async fn #dispatcher<T>(
2257            ::axum::extract::State(api): ::axum::extract::State<T>,
2258            request: ::axum::extract::Request,
2259        ) -> ::axum::response::Response
2260        where
2261            T: #trait_ident + Clone + Send + Sync + 'static,
2262        {
2263            match request.method().as_str() {
2264                #(#arms,)*
2265                _ => ::axum::response::IntoResponse::into_response(
2266                    ::axum::http::StatusCode::METHOD_NOT_ALLOWED,
2267                ),
2268            }
2269        }
2270    };
2271    (route, dispatcher_fn)
2272}
2273
2274/// Validate and convert an OpenAPI path template into Axum 0.8 route syntax.
2275///
2276/// Both formats use `{parameter}` for a dynamic segment. Axum only supports a
2277/// capture as a complete segment, so OpenAPI templates embedded in a literal
2278/// segment are rejected during generation instead of panicking when the
2279/// generated router is constructed.
2280fn openapi_to_axum_path(path: &str) -> Result<String, ServerCodegenError> {
2281    let invalid = |reason: &str| ServerCodegenError::InvalidRoutePath {
2282        path: path.to_string(),
2283        reason: reason.to_string(),
2284    };
2285
2286    if !path.starts_with('/') {
2287        return Err(invalid("paths must start with `/`"));
2288    }
2289    for segment in path.split('/').skip(1) {
2290        if segment.is_empty() {
2291            continue;
2292        }
2293        if segment.starts_with(':') || segment.starts_with('*') {
2294            return Err(invalid(
2295                "segments beginning with `:` or `*` conflict with Axum route syntax",
2296            ));
2297        }
2298
2299        let has_open = segment.contains('{');
2300        let has_close = segment.contains('}');
2301        if has_open || has_close {
2302            let Some(name) = segment
2303                .strip_prefix('{')
2304                .and_then(|value| value.strip_suffix('}'))
2305            else {
2306                return Err(invalid(
2307                    "path parameters must occupy a complete segment such as `{pet_id}`",
2308                ));
2309            };
2310            if name.is_empty() || name.contains(['{', '}']) {
2311                return Err(invalid(
2312                    "path parameter names must be non-empty and cannot contain braces",
2313                ));
2314            }
2315        }
2316    }
2317
2318    Ok(path.to_string())
2319}
2320
2321fn body_type(op: &OperationInfo) -> Option<String> {
2322    match &op.request_body {
2323        Some(RequestBodyContent::Json { schema_name, .. })
2324        | Some(RequestBodyContent::FormUrlEncoded { schema_name, .. }) => Some(schema_name.clone()),
2325        _ => None,
2326    }
2327}
2328
2329fn group_by_tag<'a>(ops: &[&'a OperationInfo]) -> BTreeMap<String, Vec<&'a OperationInfo>> {
2330    let mut groups: BTreeMap<String, Vec<&OperationInfo>> = BTreeMap::new();
2331    for op in ops {
2332        let tag = primary_tag(op);
2333        groups.entry(tag).or_default().push(op);
2334    }
2335    groups
2336}
2337
2338fn primary_tag(op: &OperationInfo) -> String {
2339    op.tags.first().cloned().unwrap_or_else(|| "Server".into())
2340}
2341
2342fn validate_custom_method_route_groups(ops: &[&OperationInfo]) -> Result<(), ServerCodegenError> {
2343    let mut tags_by_path: BTreeMap<&str, std::collections::BTreeSet<String>> = BTreeMap::new();
2344    for op in ops {
2345        if axum_method_call(&op.method).is_none() {
2346            tags_by_path
2347                .entry(&op.path)
2348                .or_default()
2349                .insert(primary_tag(op));
2350        }
2351    }
2352    if let Some((path, tags)) = tags_by_path.into_iter().find(|(_, tags)| tags.len() > 1) {
2353        return Err(ServerCodegenError::CrossTagCustomMethods {
2354            path: path.to_string(),
2355            tags: tags.into_iter().collect::<Vec<_>>().join(", "),
2356        });
2357    }
2358    Ok(())
2359}
2360
2361fn trait_ident_for_tag(tag: &str) -> syn::Ident {
2362    let pascal = tag.to_pascal_case();
2363    let base = if pascal.is_empty() {
2364        "Server".into()
2365    } else {
2366        pascal
2367    };
2368    format_ident!("{}Api", base)
2369}
2370
2371/// Convert a status code (or `default`, or wildcard `4XX`) to a
2372/// variant identifier.
2373fn status_variant_name(status: &str) -> String {
2374    match status {
2375        "200" => "Ok".into(),
2376        "201" => "Created".into(),
2377        "202" => "Accepted".into(),
2378        "204" => "NoContent".into(),
2379        "301" => "MovedPermanently".into(),
2380        "302" => "Found".into(),
2381        "304" => "NotModified".into(),
2382        "400" => "BadRequest".into(),
2383        "401" => "Unauthorized".into(),
2384        "403" => "Forbidden".into(),
2385        "404" => "NotFound".into(),
2386        "409" => "Conflict".into(),
2387        "410" => "Gone".into(),
2388        "422" => "UnprocessableEntity".into(),
2389        "429" => "TooManyRequests".into(),
2390        "500" => "InternalServerError".into(),
2391        "502" => "BadGateway".into(),
2392        "503" => "ServiceUnavailable".into(),
2393        "default" => "Default".into(),
2394        "2XX" => "Success".into(),
2395        "3XX" => "Redirection".into(),
2396        "4XX" => "ClientError".into(),
2397        "5XX" => "ServerError".into(),
2398        other => format!("Status{}", other.to_ascii_uppercase().replace('X', "x")),
2399    }
2400}
2401
2402/// Emit a StatusCode expression for a status string. Numeric codes use
2403/// the named constants where possible; wildcard ranges and `default`
2404/// pick a representative code (the lowest in-range).
2405fn status_token(status: &str) -> TokenStream {
2406    match status {
2407        "200" => quote! { StatusCode::OK },
2408        "201" => quote! { StatusCode::CREATED },
2409        "202" => quote! { StatusCode::ACCEPTED },
2410        "204" => quote! { StatusCode::NO_CONTENT },
2411        "301" => quote! { StatusCode::MOVED_PERMANENTLY },
2412        "302" => quote! { StatusCode::FOUND },
2413        "304" => quote! { StatusCode::NOT_MODIFIED },
2414        "400" => quote! { StatusCode::BAD_REQUEST },
2415        "401" => quote! { StatusCode::UNAUTHORIZED },
2416        "403" => quote! { StatusCode::FORBIDDEN },
2417        "404" => quote! { StatusCode::NOT_FOUND },
2418        "409" => quote! { StatusCode::CONFLICT },
2419        "410" => quote! { StatusCode::GONE },
2420        "422" => quote! { StatusCode::UNPROCESSABLE_ENTITY },
2421        "429" => quote! { StatusCode::TOO_MANY_REQUESTS },
2422        "500" => quote! { StatusCode::INTERNAL_SERVER_ERROR },
2423        "502" => quote! { StatusCode::BAD_GATEWAY },
2424        "503" => quote! { StatusCode::SERVICE_UNAVAILABLE },
2425        "default" => quote! { StatusCode::INTERNAL_SERVER_ERROR },
2426        "2XX" => quote! { StatusCode::OK },
2427        "3XX" => quote! { StatusCode::MOVED_PERMANENTLY },
2428        "4XX" => quote! { StatusCode::BAD_REQUEST },
2429        "5XX" => quote! { StatusCode::INTERNAL_SERVER_ERROR },
2430        // Specific numeric codes not in our table — fall back to
2431        // StatusCode::from_u16. Codegen ensures a panic-free path by
2432        // unwrapping on a value that must parse (we already
2433        // know the spec wrote a numeric status here).
2434        other => {
2435            if let Ok(n) = other.parse::<u16>() {
2436                quote! {
2437                    StatusCode::from_u16(#n).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
2438                }
2439            } else {
2440                quote! { StatusCode::INTERNAL_SERVER_ERROR }
2441            }
2442        }
2443    }
2444}
2445
2446fn parse_type(ty: &str) -> TokenStream {
2447    syn::parse_str::<syn::Type>(ty)
2448        .map(|t| quote! { #t })
2449        .unwrap_or_else(|_| {
2450            let ident = format_ident!("{}", ty);
2451            quote! { #ident }
2452        })
2453}
2454
2455fn format_or_raw(ts: TokenStream) -> String {
2456    let raw = ts.to_string();
2457    match syn::parse_file(&raw) {
2458        Ok(parsed) => prettyplease::unparse(&parsed),
2459        Err(_) => raw,
2460    }
2461}
2462
2463#[cfg(test)]
2464mod tests {
2465    use super::*;
2466
2467    #[test]
2468    fn status_variant_name_maps_known_codes() {
2469        assert_eq!(status_variant_name("200"), "Ok");
2470        assert_eq!(status_variant_name("4XX"), "ClientError");
2471        assert_eq!(status_variant_name("default"), "Default");
2472        assert_eq!(status_variant_name("418"), "Status418");
2473    }
2474
2475    #[test]
2476    fn trait_ident_for_tag_appends_api() {
2477        let id = trait_ident_for_tag("Responses");
2478        assert_eq!(id.to_string(), "ResponsesApi");
2479    }
2480
2481    #[test]
2482    fn untagged_falls_back_to_server_api() {
2483        let id = trait_ident_for_tag("");
2484        assert_eq!(id.to_string(), "ServerApi");
2485    }
2486
2487    #[test]
2488    fn custom_methods_on_one_path_share_an_exact_dispatcher() {
2489        let dispatcher = format_ident!("cache_custom_method_dispatch");
2490        let methods = vec![
2491            ("PURGE".to_string(), format_ident!("purge_cache_handler")),
2492            ("QUERY".to_string(), format_ident!("query_cache_handler")),
2493        ];
2494        let trait_ident = format_ident!("CacheApi");
2495        let (route, dispatcher) = axum_custom_route("/cache", &dispatcher, &methods, &trait_ident);
2496        let route = route.to_string();
2497        let dispatcher = dispatcher.to_string();
2498        assert!(route.contains("routing :: any"));
2499        assert_eq!(route.matches("routing :: any").count(), 1);
2500        assert!(dispatcher.contains("\"PURGE\""));
2501        assert!(dispatcher.contains("\"QUERY\""));
2502        assert!(dispatcher.contains("purge_cache_handler"));
2503        assert!(dispatcher.contains("query_cache_handler"));
2504        assert!(dispatcher.contains("METHOD_NOT_ALLOWED"));
2505    }
2506
2507    #[test]
2508    fn standard_methods_use_axum_method_routes_without_guards() {
2509        assert!(axum_method_call("TRACE").is_some());
2510        assert!(axum_method_call("QUERY").is_none());
2511    }
2512
2513    #[test]
2514    fn openapi_parameterized_paths_are_axum_08_paths() {
2515        assert_eq!(
2516            openapi_to_axum_path("/pets/{pet_id}").unwrap(),
2517            "/pets/{pet_id}"
2518        );
2519        assert_eq!(openapi_to_axum_path("/").unwrap(), "/");
2520    }
2521
2522    #[test]
2523    fn malformed_or_unsupported_route_templates_are_rejected() {
2524        for path in [
2525            "pets/{pet_id}",
2526            "/pets/{pet_id}.json",
2527            "/pets/{}",
2528            "/pets/:id",
2529        ] {
2530            assert!(
2531                matches!(
2532                    openapi_to_axum_path(path),
2533                    Err(ServerCodegenError::InvalidRoutePath { .. })
2534                ),
2535                "{path} should be rejected"
2536            );
2537        }
2538    }
2539}