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