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::{OperationInfo, RequestBodyContent, SchemaAnalysis};
11use crate::config::ServerSection;
12use crate::generator::{GeneratedFile, GeneratorConfig};
13
14use super::{OperationIndex, Selector};
15use heck::{ToPascalCase, ToSnakeCase};
16use proc_macro2::TokenStream;
17use quote::{format_ident, quote};
18use std::collections::BTreeMap;
19use std::path::PathBuf;
20
21/// Compute the set of schema names transitively reachable from the
22/// request/response/parameter shapes of the given operations.
23///
24/// Used by `[server].prune_models = true` to drop unreferenced types
25/// from `types.rs`. Walks every `$ref` in each schema's raw JSON
26/// (`AnalyzedSchema.original`) rather than the analyzer's
27/// `dependencies` field — the latter is incomplete for some
28/// schemas (e.g. struct fields whose target schemas weren't
29/// individually tracked).
30///
31/// Inline parameter enums (whose `rust_type` is a synthetic name
32/// without a matching `analysis.schemas` entry) are not the
33/// responsibility of this walk — they're emitted directly by the
34/// server codegen from `parameter.enum_values`.
35pub fn reachable_schemas(
36    analysis: &SchemaAnalysis,
37    ops: &[&OperationInfo],
38) -> std::collections::BTreeSet<String> {
39    let mut keep: std::collections::BTreeSet<String> = Default::default();
40    let mut queue: Vec<String> = Vec::new();
41
42    let seed =
43        |name: &str, queue: &mut Vec<String>, keep: &mut std::collections::BTreeSet<String>| {
44            if !name.is_empty() && keep.insert(name.to_string()) {
45                queue.push(name.to_string());
46            }
47        };
48
49    for op in ops {
50        if let Some(rb) = &op.request_body
51            && let Some(name) = rb.schema_name()
52        {
53            seed(name, &mut queue, &mut keep);
54        }
55        for ty in op.response_schemas.values() {
56            seed(ty, &mut queue, &mut keep);
57        }
58        for p in &op.parameters {
59            if let Some(name) = &p.schema_ref {
60                seed(name, &mut queue, &mut keep);
61            }
62        }
63    }
64
65    // Synthetic types: the analyzer registers per-struct inline
66    // enums (e.g. `WebSearchApproximateLocation` + a `type` field
67    // with an inline enum → `WebSearchApproximateLocationType`) as
68    // schemas, but nothing in the spec $refs them. The codegen
69    // emits them as siblings of the parent struct's emission.
70    //
71    // Prefix-matching to a parent is unsafe because real specs use
72    // prefix-named sibling schemas (`Response` vs.
73    // `ResponsesServerEvent`). Instead, seed the BFS with every
74    // name that is never `$ref`-d anywhere in the spec. Those names
75    // and everything they transitively reach get retained. Yes this
76    // over-keeps for very large specs with rich never-referenced
77    // top-level islands; the alternative is missing-type compile
78    // errors, which we can't surface cleanly to the user.
79    let ref_named = collect_all_ref_names(analysis);
80    for name in analysis.schemas.keys() {
81        if !ref_named.contains(name) && keep.insert(name.clone()) {
82            queue.push(name.clone());
83        }
84    }
85
86    while let Some(name) = queue.pop() {
87        if let Some(schema) = analysis.schemas.get(&name) {
88            // Walk the raw JSON for every `$ref` string and feed
89            // the referenced schema names back into the queue.
90            collect_refs(&schema.original, &mut queue, &mut keep);
91            // Belt-and-braces: also include the analyzer's tracked
92            // dependencies, which sometimes catch refs that live
93            // outside the immediate JSON tree (e.g. allOf compositions
94            // resolved before the snapshot was captured).
95            for dep in &schema.dependencies {
96                seed(dep, &mut queue, &mut keep);
97            }
98        }
99    }
100
101    keep
102}
103
104/// Collect every `#/components/schemas/<Name>` referenced anywhere
105/// in the spec snapshot (including from operations). Used to
106/// distinguish spec-named schemas from analyzer-synthesised ones
107/// during pruning.
108fn collect_all_ref_names(analysis: &SchemaAnalysis) -> std::collections::HashSet<String> {
109    let mut out: std::collections::HashSet<String> = Default::default();
110    for schema in analysis.schemas.values() {
111        gather_refs(&schema.original, &mut out);
112    }
113    // Operations reference schemas via request body, response
114    // bodies, and parameter schemas. Include those names too —
115    // otherwise normal spec types reached only via operations end
116    // up classified as synthetics.
117    for op in analysis.operations.values() {
118        if let Some(rb) = &op.request_body
119            && let Some(name) = rb.schema_name()
120        {
121            out.insert(name.to_string());
122        }
123        for ty in op.response_schemas.values() {
124            out.insert(ty.clone());
125        }
126        for p in &op.parameters {
127            if let Some(name) = &p.schema_ref {
128                out.insert(name.clone());
129            }
130        }
131    }
132    out
133}
134
135fn gather_refs(value: &serde_json::Value, out: &mut std::collections::HashSet<String>) {
136    match value {
137        serde_json::Value::Object(map) => {
138            for (k, v) in map {
139                if k == "$ref"
140                    && let Some(s) = v.as_str()
141                    && let Some(name) = s.strip_prefix("#/components/schemas/")
142                {
143                    out.insert(name.to_string());
144                }
145                gather_refs(v, out);
146            }
147        }
148        serde_json::Value::Array(items) => items.iter().for_each(|v| gather_refs(v, out)),
149        _ => {}
150    }
151}
152
153fn collect_refs(
154    value: &serde_json::Value,
155    queue: &mut Vec<String>,
156    keep: &mut std::collections::BTreeSet<String>,
157) {
158    match value {
159        serde_json::Value::Object(map) => {
160            for (k, v) in map {
161                if k == "$ref"
162                    && let Some(s) = v.as_str()
163                    && let Some(name) = s.strip_prefix("#/components/schemas/")
164                    && keep.insert(name.to_string())
165                {
166                    queue.push(name.to_string());
167                }
168                collect_refs(v, queue, keep);
169            }
170        }
171        serde_json::Value::Array(items) => {
172            for v in items {
173                collect_refs(v, queue, keep);
174            }
175        }
176        _ => {}
177    }
178}
179
180#[derive(Debug, thiserror::Error)]
181pub enum ServerCodegenError {
182    #[error("server selector: {0}")]
183    Parse(#[from] super::SelectorParseError),
184    #[error("server selector: {0}")]
185    Resolve(#[from] super::SelectorResolveError),
186    #[error("internal: {0}")]
187    Internal(String),
188}
189
190pub struct ServerCodegen<'a> {
191    config: &'a GeneratorConfig,
192    analysis: &'a SchemaAnalysis,
193    server: &'a ServerSection,
194}
195
196impl<'a> ServerCodegen<'a> {
197    pub fn new(
198        config: &'a GeneratorConfig,
199        analysis: &'a SchemaAnalysis,
200        server: &'a ServerSection,
201    ) -> Self {
202        Self {
203            config,
204            analysis,
205            server,
206        }
207    }
208
209    /// Resolve selectors and emit `server/{mod,api,errors}.rs`.
210    pub fn generate(&self) -> Result<Vec<GeneratedFile>, ServerCodegenError> {
211        if self.server.operations.is_empty() {
212            return Ok(Vec::new());
213        }
214
215        let index = OperationIndex::from_analysis(self.analysis);
216        let selectors: Vec<Selector> = self
217            .server
218            .operations
219            .iter()
220            .map(|s| Selector::parse(s))
221            .collect::<Result<_, _>>()?;
222        let resolution = super::resolve(&selectors, &index)?;
223
224        // Look up full OperationInfo for each resolved op (we need
225        // parameters, request body, response schemas — the summary
226        // only has the display surface).
227        let ops: Vec<&OperationInfo> = resolution
228            .operations
229            .iter()
230            .map(|s| {
231                self.analysis
232                    .operations
233                    .get(&s.operation_id)
234                    .ok_or_else(|| {
235                        ServerCodegenError::Internal(format!(
236                            "operation `{}` resolved but missing from analysis",
237                            s.operation_id
238                        ))
239                    })
240            })
241            .collect::<Result<_, _>>()?;
242
243        // Group by primary tag (first tag wins; untagged → "Server").
244        let groups = group_by_tag(&ops);
245
246        let api_rs = self.emit_api(&groups);
247        let errors_rs = self.emit_errors(&ops);
248        let router_rs = self.emit_router(&groups);
249        let mod_rs = self.emit_mod();
250
251        Ok(vec![
252            GeneratedFile {
253                path: PathBuf::from("server").join("mod.rs"),
254                content: format_or_raw(mod_rs),
255            },
256            GeneratedFile {
257                path: PathBuf::from("server").join("api.rs"),
258                content: format_or_raw(api_rs),
259            },
260            GeneratedFile {
261                path: PathBuf::from("server").join("errors.rs"),
262                content: format_or_raw(errors_rs),
263            },
264            GeneratedFile {
265                path: PathBuf::from("server").join("router.rs"),
266                content: format_or_raw(router_rs),
267            },
268        ])
269    }
270
271    fn emit_mod(&self) -> TokenStream {
272        let _ = &self.config; // keep field access future-proof
273        quote! {
274            //! Server scaffolding emitted by openapi-to-rust.
275            //!
276            //! Implement the per-tag trait(s) in `api` on your own struct,
277            //! then build an `axum::Router` via `router::router(impl)`.
278
279            pub mod api;
280            pub mod errors;
281            pub mod router;
282
283            pub use api::*;
284            pub use errors::*;
285            pub use router::*;
286        }
287    }
288
289    fn emit_router(&self, groups: &BTreeMap<String, Vec<&OperationInfo>>) -> TokenStream {
290        let factories: Vec<TokenStream> = groups
291            .iter()
292            .map(|(tag, ops)| self.emit_router_for_trait(tag, ops))
293            .collect();
294
295        // Per-op Query structs — one per op that has any query params.
296        let query_structs: Vec<TokenStream> = groups
297            .values()
298            .flatten()
299            .filter_map(|op| self.emit_query_struct(op))
300            .collect();
301
302        // When the picked operations span multiple tags, emit a
303        // top-level `build_router(impl1, impl2, ...)` that takes one
304        // generic per trait and `.merge()`s the per-tag factories.
305        // For a single-tag selection this is unnecessary noise — the
306        // user calls the per-tag factory directly.
307        let combined = if groups.len() > 1 {
308            Some(self.emit_combined_router(groups))
309        } else {
310            None
311        };
312
313        quote! {
314            //! Router factories — one per trait. Each takes any
315            //! `T: <TraitName> + Clone + Send + Sync + 'static` and
316            //! returns an `axum::Router` with state pre-attached.
317
318            use super::api::*;
319            use super::errors::*;
320            // Pull schemas directly from the types module (always a
321            // sibling of mod.rs). Doesn't rely on the parent module
322            // re-exporting types::*, so users can mount the generated
323            // tree at any path without rewriting these imports.
324            #[allow(unused_imports)]
325            use super::super::types::*;
326
327            #(#query_structs)*
328
329            #(#factories)*
330
331            #combined
332        }
333    }
334
335    fn emit_combined_router(&self, groups: &BTreeMap<String, Vec<&OperationInfo>>) -> TokenStream {
336        // Stable ordering: BTreeMap iteration is already alphabetical
337        // by tag, which gives us deterministic generic ordering across
338        // generator runs.
339        let entries: Vec<(syn::Ident, syn::Ident, syn::Ident)> = groups
340            .keys()
341            .enumerate()
342            .map(|(i, tag)| {
343                let trait_ident = trait_ident_for_tag(tag);
344                let factory = format_ident!("{}_router", trait_ident.to_string().to_snake_case());
345                let generic = format_ident!("T{}", i + 1);
346                (trait_ident, factory, generic)
347            })
348            .collect();
349
350        let generics: Vec<&syn::Ident> = entries.iter().map(|(_, _, g)| g).collect();
351        let args: Vec<TokenStream> = entries
352            .iter()
353            .map(|(trait_ident, _, g)| {
354                let arg_ident = format_ident!("{}", trait_ident.to_string().to_snake_case());
355                quote! { #arg_ident: #g }
356            })
357            .collect();
358        let bounds: Vec<TokenStream> = entries
359            .iter()
360            .map(|(trait_ident, _, g)| {
361                quote! { #g: #trait_ident + Clone + Send + Sync + 'static }
362            })
363            .collect();
364
365        // Fold the factories: `factory1(arg1).merge(factory2(arg2)).merge(...)`.
366        let first = &entries[0];
367        let first_arg = format_ident!("{}", first.0.to_string().to_snake_case());
368        let first_factory = &first.1;
369        let rest = entries
370            .iter()
371            .skip(1)
372            .map(|(trait_ident, factory, _)| {
373                let arg = format_ident!("{}", trait_ident.to_string().to_snake_case());
374                quote! { .merge(#factory(#arg)) }
375            })
376            .collect::<Vec<_>>();
377
378        let trait_names: Vec<String> = entries.iter().map(|(t, _, _)| t.to_string()).collect();
379        let doc = format!(
380            " Combined router spanning {} traits: {}.",
381            entries.len(),
382            trait_names.join(", "),
383        );
384
385        quote! {
386            #[doc = #doc]
387            pub fn build_router<#(#generics),*>(
388                #(#args),*
389            ) -> ::axum::Router
390            where
391                #(#bounds),*
392            {
393                #first_factory(#first_arg) #(#rest)*
394            }
395        }
396    }
397
398    fn emit_router_for_trait(&self, tag: &str, ops: &[&OperationInfo]) -> TokenStream {
399        let trait_ident = trait_ident_for_tag(tag);
400        let fn_ident = format_ident!("{}_router", trait_ident.to_string().to_snake_case());
401
402        let routes: Vec<TokenStream> = ops
403            .iter()
404            .map(|op| {
405                let method = axum_method_call(&op.method);
406                let handler = format_ident!("{}_handler", op.operation_id.to_snake_case());
407                let path = openapi_to_axum_path(&op.path);
408                quote! { .route(#path, ::axum::routing::#method(#handler::<T>)) }
409            })
410            .collect();
411
412        let handlers: Vec<TokenStream> = ops
413            .iter()
414            .map(|op| self.emit_axum_handler(&trait_ident, op))
415            .collect();
416
417        let doc = format!(" Build an axum::Router for the `{trait_ident}` trait.");
418
419        quote! {
420            #[doc = #doc]
421            pub fn #fn_ident<T>(api: T) -> ::axum::Router
422            where
423                T: #trait_ident + Clone + Send + Sync + 'static,
424            {
425                ::axum::Router::new()
426                    #(#routes)*
427                    .with_state(api)
428            }
429
430            #(#handlers)*
431        }
432    }
433
434    fn emit_axum_handler(&self, trait_ident: &syn::Ident, op: &OperationInfo) -> TokenStream {
435        let handler_ident = format_ident!("{}_handler", op.operation_id.to_snake_case());
436        let trait_method = format_ident!("{}", op.operation_id.to_snake_case());
437
438        // Build extractor list + call argument list.
439        let mut extractors: Vec<TokenStream> =
440            vec![quote! { ::axum::extract::State(api): ::axum::extract::State<T> }];
441        let mut call_args: Vec<TokenStream> = Vec::new();
442
443        // Path parameters → axum::extract::Path tuple
444        let path_params: Vec<&_> = op
445            .parameters
446            .iter()
447            .filter(|p| p.location == "path")
448            .collect();
449        if !path_params.is_empty() {
450            let idents: Vec<syn::Ident> = path_params
451                .iter()
452                .map(|p| format_ident!("{}", p.name.to_snake_case()))
453                .collect();
454            let types: Vec<TokenStream> = path_params
455                .iter()
456                .map(|p| parse_type(&p.rust_type))
457                .collect();
458            if path_params.len() == 1 {
459                let i = &idents[0];
460                let t = &types[0];
461                extractors.push(quote! { ::axum::extract::Path(#i): ::axum::extract::Path<#t> });
462            } else {
463                extractors.push(quote! { ::axum::extract::Path((#(#idents),*)): ::axum::extract::Path<(#(#types),*)> });
464            }
465            for i in &idents {
466                call_args.push(quote! { #i });
467            }
468        }
469
470        // Query parameters — extract via a per-op `<Op>Query` struct
471        // (emitted in the same router.rs above). Required params are
472        // unwrapped here (short-circuit 400 if missing) so the trait
473        // method sees a `T` rather than `Option<T>`.
474        let query_params: Vec<&_> = op
475            .parameters
476            .iter()
477            .filter(|p| p.location == "query")
478            .collect();
479        let mut required_query_checks: Vec<TokenStream> = Vec::new();
480        if !query_params.is_empty() {
481            let query_ident = format_ident!("{}Query", op.operation_id.to_pascal_case());
482            extractors.push(quote! {
483                ::axum::extract::Query(__q): ::axum::extract::Query<#query_ident>
484            });
485            for p in &query_params {
486                let f = format_ident!("{}", p.name.to_snake_case());
487                let wire = p.name.as_str();
488                if p.required {
489                    let missing_msg = format!("missing required query parameter `{wire}`");
490                    required_query_checks.push(quote! {
491                        let #f = match __q.#f {
492                            Some(v) => v,
493                            None => return ::axum::response::IntoResponse::into_response(
494                                (
495                                    ::axum::http::StatusCode::BAD_REQUEST,
496                                    ::axum::Json(::serde_json::json!({
497                                        "error": #missing_msg
498                                    })),
499                                )
500                            ),
501                        };
502                    });
503                    call_args.push(quote! { #f });
504                } else {
505                    call_args.push(quote! { __q.#f });
506                }
507            }
508        }
509
510        // Header parameters — extract via HeaderMap and read each
511        // header by name. Required headers short-circuit with 400 if
512        // missing or non-UTF-8.
513        let header_params: Vec<&_> = op
514            .parameters
515            .iter()
516            .filter(|p| p.location == "header")
517            .collect();
518        let mut required_header_checks: Vec<TokenStream> = Vec::new();
519        if !header_params.is_empty() {
520            extractors.push(quote! { __headers: ::axum::http::HeaderMap });
521            for p in &header_params {
522                let wire = p.name.as_str();
523                let ident = format_ident!("{}", header_param_ident(&p.name));
524                if p.required {
525                    let missing_msg = format!("missing required header `{wire}`");
526                    required_header_checks.push(quote! {
527                        let #ident = match __headers
528                            .get(#wire)
529                            .and_then(|v| v.to_str().ok())
530                            .map(::std::string::String::from)
531                        {
532                            Some(v) => v,
533                            None => return ::axum::response::IntoResponse::into_response(
534                                (
535                                    ::axum::http::StatusCode::BAD_REQUEST,
536                                    ::axum::Json(::serde_json::json!({
537                                        "error": #missing_msg
538                                    })),
539                                )
540                            ),
541                        };
542                    });
543                    call_args.push(quote! { #ident });
544                } else {
545                    call_args.push(quote! {
546                        __headers
547                            .get(#wire)
548                            .and_then(|v| v.to_str().ok())
549                            .map(::std::string::String::from)
550                    });
551                }
552            }
553        }
554
555        // Body
556        let body_ty_opt = body_type(op);
557        if let Some(body_ty) = &body_ty_opt {
558            let body_ty_tokens = parse_type(body_ty);
559            if op.request_body_required {
560                extractors.push(quote! {
561                    ::axum::Json(body): ::axum::Json<#body_ty_tokens>
562                });
563                call_args.push(quote! { body });
564            } else {
565                extractors.push(quote! {
566                    body: ::std::option::Option<::axum::Json<#body_ty_tokens>>
567                });
568                call_args.push(quote! { body.map(|::axum::Json(b)| b) });
569            }
570        }
571
572        let _ = format_ident!("{}Response", op.operation_id.to_pascal_case());
573        // Keep referencing trait_ident so the where-bound name is
574        // visible to downstream readers — clippy would otherwise flag
575        // it as unused in some configurations.
576        let _ = trait_ident;
577
578        // Handler returns `axum::response::Response` so the required-
579        // param short-circuit (400 BadRequest) and the trait method's
580        // typed response enum (via IntoResponse) can both flow out
581        // through the same return type.
582        quote! {
583            async fn #handler_ident<T>(
584                #(#extractors),*
585            ) -> ::axum::response::Response
586            where
587                T: super::api::#trait_ident + Clone + Send + Sync + 'static,
588            {
589                #(#required_query_checks)*
590                #(#required_header_checks)*
591                ::axum::response::IntoResponse::into_response(
592                    api.#trait_method(#(#call_args),*).await,
593                )
594            }
595        }
596    }
597
598    fn emit_api(&self, groups: &BTreeMap<String, Vec<&OperationInfo>>) -> TokenStream {
599        let _ = &self.config; // reserved for future module-aware emission
600        let traits: Vec<TokenStream> = groups
601            .iter()
602            .map(|(tag, ops)| self.emit_trait(tag, ops))
603            .collect();
604
605        // Inline string enums declared on parameters get synthetic
606        // type names (e.g. `ListInputItemsOrder`). The analyzer
607        // surfaces enum_values; we emit the enum here so the trait
608        // signature compiles. Dedup by name in case two ops in the
609        // same picked set share the same synthetic name.
610        let mut emitted: std::collections::BTreeSet<String> = Default::default();
611        let mut param_enums: Vec<TokenStream> = Vec::new();
612        for op in groups.values().flatten() {
613            for p in &op.parameters {
614                if let Some(values) = &p.enum_values {
615                    if emitted.insert(p.rust_type.clone()) {
616                        param_enums.push(emit_param_enum(&p.rust_type, values));
617                    }
618                }
619            }
620        }
621
622        quote! {
623            //! Per-tag traits. Implement one of these on your own
624            //! struct; the router (P5) wires it into axum.
625
626            #![allow(clippy::too_many_arguments)]
627
628            use super::errors::*;
629            // Schemas live in `<parent>/types.rs`. Reaching them via
630            // `super::super::types::*` instead of a glob on the
631            // parent module keeps these imports stable regardless of
632            // how the user mounts the generated tree.
633            #[allow(unused_imports)]
634            use super::super::types::*;
635
636            #(#param_enums)*
637
638            #(#traits)*
639        }
640    }
641
642    fn emit_trait(&self, tag: &str, ops: &[&OperationInfo]) -> TokenStream {
643        let trait_ident = trait_ident_for_tag(tag);
644        let methods: Vec<TokenStream> = ops.iter().map(|op| self.emit_method_sig(op)).collect();
645        let doc = format!(" Operations under the `{tag}` tag.");
646        quote! {
647            #[doc = #doc]
648            #[axum::async_trait]
649            pub trait #trait_ident: Send + Sync + 'static {
650                #(#methods)*
651            }
652        }
653    }
654
655    fn emit_method_sig(&self, op: &OperationInfo) -> TokenStream {
656        let name = format_ident!("{}", op.operation_id.to_snake_case());
657        let response_ty = format_ident!("{}Response", op.operation_id.to_pascal_case());
658
659        // Order: path → query → header → body. Required params keep
660        // their declared rust_type; optional params wrap in Option<…>.
661        // This mirrors what the router handler extracts so positional
662        // ordering matches the call site exactly.
663        let mut params: Vec<TokenStream> = Vec::new();
664        for p in &op.parameters {
665            if p.location == "path" {
666                let ident = format_ident!("{}", p.name.to_snake_case());
667                let ty = parse_type(&p.rust_type);
668                params.push(quote! { #ident: #ty });
669            }
670        }
671        for p in &op.parameters {
672            if p.location == "query" {
673                let ident = format_ident!("{}", p.name.to_snake_case());
674                let ty = parse_type(&p.rust_type);
675                // Required query params land as `T`; the handler
676                // validates presence and returns 400 if absent, so
677                // by the time the trait method sees the value it
678                // must be Some. Optional → `Option<T>`.
679                if p.required {
680                    params.push(quote! { #ident: #ty });
681                } else {
682                    params.push(quote! { #ident: ::std::option::Option<#ty> });
683                }
684            }
685        }
686        for p in &op.parameters {
687            if p.location == "header" {
688                let ident = format_ident!("{}", header_param_ident(&p.name));
689                if p.required {
690                    params.push(quote! { #ident: String });
691                } else {
692                    params.push(quote! { #ident: ::std::option::Option<String> });
693                }
694            }
695        }
696        if let Some(body) = body_type(op) {
697            let body_ty = parse_type(&body);
698            if op.request_body_required {
699                params.push(quote! { body: #body_ty });
700            } else {
701                params.push(quote! { body: Option<#body_ty> });
702            }
703        }
704
705        let summary_doc = op
706            .summary
707            .as_deref()
708            .map(|s| format!(" {s}"))
709            .unwrap_or_default();
710        let route_doc = format!(" `{} {}`", op.method, op.path);
711
712        quote! {
713            #[doc = #summary_doc]
714            #[doc = ""]
715            #[doc = #route_doc]
716            async fn #name(&self, #(#params),*) -> #response_ty;
717        }
718    }
719
720    /// Per-op `<Op>Query` struct emitted into router.rs when the op
721    /// has any query parameters. Drives axum's `Query<T>` extractor.
722    fn emit_query_struct(&self, op: &OperationInfo) -> Option<TokenStream> {
723        let query_params: Vec<&_> = op
724            .parameters
725            .iter()
726            .filter(|p| p.location == "query")
727            .collect();
728        if query_params.is_empty() {
729            return None;
730        }
731        let ident = format_ident!("{}Query", op.operation_id.to_pascal_case());
732        let fields: Vec<TokenStream> = query_params
733            .iter()
734            .map(|p| {
735                let f_ident = format_ident!("{}", p.name.to_snake_case());
736                let ty = parse_type(&p.rust_type);
737                let serde_rename = if p.name.to_snake_case() == p.name {
738                    quote! {}
739                } else {
740                    let wire = p.name.as_str();
741                    quote! { #[serde(rename = #wire)] }
742                };
743                quote! {
744                    #serde_rename
745                    #[serde(default)]
746                    pub #f_ident: ::std::option::Option<#ty>
747                }
748            })
749            .collect();
750        let doc = format!(
751            " Query parameters for `{} {}` (operationId `{}`).",
752            op.method, op.path, op.operation_id
753        );
754        Some(quote! {
755            #[doc = #doc]
756            #[derive(Debug, Default, ::serde::Deserialize)]
757            pub struct #ident {
758                #(#fields),*
759            }
760        })
761    }
762
763    fn emit_errors(&self, ops: &[&OperationInfo]) -> TokenStream {
764        let _ = &self.config; // reserved for future module-aware emission
765        let any_streaming = ops.iter().any(|op| op.supports_streaming);
766        let enums: Vec<TokenStream> = ops.iter().map(|op| self.emit_response_enum(op)).collect();
767
768        // The SSE type alias is emitted exactly when at least one
769        // picked op streams. Bringing it in unconditionally would force
770        // `futures-core` into the user's dep tree even when they don't
771        // need it.
772        let stream_alias = if any_streaming {
773            quote! {
774                /// Stream payload carried by `*Stream` variants. Each
775                /// yielded item is a pre-built `axum::response::sse::Event`.
776                pub type ServerEventStream = ::std::pin::Pin<
777                    Box<
778                        dyn ::futures_core::Stream<
779                                Item = ::std::result::Result<
780                                    ::axum::response::sse::Event,
781                                    ::std::convert::Infallible,
782                                >,
783                            > + ::std::marker::Send
784                            + 'static,
785                    >,
786                >;
787
788                /// Wrap any `Stream<Item = Result<Event, Infallible>>` in
789                /// a `Sse<ServerEventStream>` ready to drop into the
790                /// `OkStream` variant. Replaces the
791                /// `Sse::new(Box::pin(...))` dance.
792                pub fn sse_response<S>(stream: S) -> ::axum::response::sse::Sse<ServerEventStream>
793                where
794                    S: ::futures_core::Stream<
795                            Item = ::std::result::Result<
796                                ::axum::response::sse::Event,
797                                ::std::convert::Infallible,
798                            >,
799                        > + ::std::marker::Send
800                        + 'static,
801                {
802                    ::axum::response::sse::Sse::new(Box::pin(stream))
803                }
804            }
805        } else {
806            quote! {}
807        };
808
809        // We deliberately do NOT import `axum::response::Response` here:
810        // many specs declare a schema literally named `Response`
811        // (OpenAI's `createResponse` is one such case), and an explicit
812        // import would shadow the glob-imported schema name. The
813        // IntoResponse impl returns `axum::response::Response`
814        // fully qualified.
815        quote! {
816            //! Per-operation response enums. Pick a variant to pick a
817            //! status code — IntoResponse maps each variant to its
818            //! documented (StatusCode, Json) pair.
819
820            #![allow(clippy::large_enum_variant)]
821
822            use axum::{
823                http::StatusCode,
824                response::IntoResponse,
825                Json,
826            };
827            // Schemas live in `<parent>/types.rs`. Reaching them via
828            // `super::super::types::*` instead of a glob on the
829            // parent module keeps these imports stable regardless of
830            // how the user mounts the generated tree.
831            #[allow(unused_imports)]
832            use super::super::types::*;
833
834            #stream_alias
835
836            #(#enums)*
837        }
838    }
839
840    fn emit_response_enum(&self, op: &OperationInfo) -> TokenStream {
841        let enum_ident = format_ident!("{}Response", op.operation_id.to_pascal_case());
842        let mut variants: Vec<TokenStream> = Vec::new();
843        let mut arms: Vec<TokenStream> = Vec::new();
844
845        for (status, schema_name) in &op.response_schemas {
846            let variant = format_ident!("{}", status_variant_name(status));
847            let body_ty = parse_type(schema_name);
848            variants.push(quote! { #variant(#body_ty) });
849            let status_expr = status_token(status);
850            arms.push(quote! {
851                Self::#variant(body) => (#status_expr, Json(body)).into_response()
852            });
853        }
854
855        // SSE: when the operation declares text/event-stream on any
856        // response, add a streaming sibling variant. The user picks
857        // it when their request had `stream: true` (or whatever the
858        // streaming trigger is). Variant payload is a fully built
859        // `axum::Sse` so the user controls keep-alive, retry interval,
860        // etc.
861        if op.supports_streaming {
862            variants.push(quote! {
863                OkStream(::axum::response::sse::Sse<ServerEventStream>)
864            });
865            arms.push(quote! {
866                Self::OkStream(sse) => sse.into_response()
867            });
868        }
869
870        // Fallback: if no response variants were declared we still need
871        // a no-op enum so the trait method has a return type. Use an
872        // empty `Empty` variant returning 204.
873        if variants.is_empty() {
874            variants.push(quote! { Empty });
875            arms.push(quote! {
876                Self::Empty => StatusCode::NO_CONTENT.into_response()
877            });
878        }
879
880        let doc = format!(
881            " Response for `{} {}` (operationId `{}`).",
882            op.method, op.path, op.operation_id
883        );
884
885        quote! {
886            #[doc = #doc]
887            pub enum #enum_ident {
888                #(#variants),*
889            }
890
891            impl IntoResponse for #enum_ident {
892                fn into_response(self) -> ::axum::response::Response {
893                    match self {
894                        #(#arms),*
895                    }
896                }
897            }
898        }
899    }
900}
901
902/// Convert a wire-level header name (e.g. `anthropic-version`,
903/// `X-Request-Id`) to a Rust identifier (`anthropic_version`,
904/// `x_request_id`). Snake_case lowercases + replaces hyphens.
905fn header_param_ident(name: &str) -> String {
906    name.replace('-', "_").to_snake_case()
907}
908
909/// Emit a string-enum type for a parameter whose inline schema
910/// declared `enum: [...]`. The analyzer sets `rust_type` to a
911/// synthetic name (`{OpId}{Param}` in PascalCase) and surfaces the
912/// values; the codegen layer is what actually writes the enum.
913fn emit_param_enum(name: &str, values: &[String]) -> TokenStream {
914    let enum_ident = format_ident!("{}", name);
915    let variants: Vec<TokenStream> = values
916        .iter()
917        .enumerate()
918        .map(|(i, raw)| {
919            let pascal = raw.to_pascal_case();
920            // PascalCase can produce an empty string (pure-symbol
921            // input) or an identifier starting with a digit
922            // (e.g. `1d` stays `1d`) — both invalid as Rust idents.
923            // Fall back to a positional name so the enum compiles.
924            let starts_with_digit = pascal
925                .chars()
926                .next()
927                .map(|c| c.is_ascii_digit())
928                .unwrap_or(true);
929            let v_name = if pascal.is_empty() || starts_with_digit {
930                format!("Variant{i}")
931            } else {
932                pascal
933            };
934            let v_ident = format_ident!("{}", v_name);
935            let default_marker = if i == 0 {
936                quote! { #[default] }
937            } else {
938                quote! {}
939            };
940            quote! {
941                #default_marker
942                #[serde(rename = #raw)]
943                #v_ident
944            }
945        })
946        .collect();
947    quote! {
948        #[derive(Debug, Clone, PartialEq, Eq, ::serde::Deserialize, ::serde::Serialize, Default)]
949        pub enum #enum_ident {
950            #(#variants),*
951        }
952    }
953}
954
955fn axum_method_call(method: &str) -> TokenStream {
956    match method.to_ascii_uppercase().as_str() {
957        "GET" => quote! { get },
958        "POST" => quote! { post },
959        "PUT" => quote! { put },
960        "PATCH" => quote! { patch },
961        "DELETE" => quote! { delete },
962        "HEAD" => quote! { head },
963        "OPTIONS" => quote! { options },
964        // Any other verb (TRACE, CONNECT, custom) → fall back to the
965        // generic routing builder.
966        _ => quote! { any },
967    }
968}
969
970/// OpenAPI uses `{param}` placeholders; axum 0.7 accepts the same
971/// `{param}` syntax for typed extraction, so this is currently a
972/// pass-through. The helper exists so future syntax shifts (e.g.
973/// nested wildcards) live in one place.
974fn openapi_to_axum_path(p: &str) -> String {
975    p.to_string()
976}
977
978fn body_type(op: &OperationInfo) -> Option<String> {
979    match &op.request_body {
980        Some(RequestBodyContent::Json { schema_name })
981        | Some(RequestBodyContent::FormUrlEncoded { schema_name }) => Some(schema_name.clone()),
982        _ => None,
983    }
984}
985
986fn group_by_tag<'a>(ops: &[&'a OperationInfo]) -> BTreeMap<String, Vec<&'a OperationInfo>> {
987    let mut groups: BTreeMap<String, Vec<&OperationInfo>> = BTreeMap::new();
988    for op in ops {
989        let tag = op.tags.first().cloned().unwrap_or_else(|| "Server".into());
990        groups.entry(tag).or_default().push(op);
991    }
992    groups
993}
994
995fn trait_ident_for_tag(tag: &str) -> syn::Ident {
996    let pascal = tag.to_pascal_case();
997    let base = if pascal.is_empty() {
998        "Server".into()
999    } else {
1000        pascal
1001    };
1002    format_ident!("{}Api", base)
1003}
1004
1005/// Convert a status code (or `default`, or wildcard `4XX`) to a
1006/// variant identifier.
1007fn status_variant_name(status: &str) -> String {
1008    match status {
1009        "200" => "Ok".into(),
1010        "201" => "Created".into(),
1011        "202" => "Accepted".into(),
1012        "204" => "NoContent".into(),
1013        "301" => "MovedPermanently".into(),
1014        "302" => "Found".into(),
1015        "304" => "NotModified".into(),
1016        "400" => "BadRequest".into(),
1017        "401" => "Unauthorized".into(),
1018        "403" => "Forbidden".into(),
1019        "404" => "NotFound".into(),
1020        "409" => "Conflict".into(),
1021        "410" => "Gone".into(),
1022        "422" => "UnprocessableEntity".into(),
1023        "429" => "TooManyRequests".into(),
1024        "500" => "InternalServerError".into(),
1025        "502" => "BadGateway".into(),
1026        "503" => "ServiceUnavailable".into(),
1027        "default" => "Default".into(),
1028        "2XX" => "Success".into(),
1029        "3XX" => "Redirection".into(),
1030        "4XX" => "ClientError".into(),
1031        "5XX" => "ServerError".into(),
1032        other => format!("Status{}", other.to_ascii_uppercase().replace('X', "x")),
1033    }
1034}
1035
1036/// Emit a StatusCode expression for a status string. Numeric codes use
1037/// the named constants where possible; wildcard ranges and `default`
1038/// pick a representative code (the lowest in-range).
1039fn status_token(status: &str) -> TokenStream {
1040    match status {
1041        "200" => quote! { StatusCode::OK },
1042        "201" => quote! { StatusCode::CREATED },
1043        "202" => quote! { StatusCode::ACCEPTED },
1044        "204" => quote! { StatusCode::NO_CONTENT },
1045        "301" => quote! { StatusCode::MOVED_PERMANENTLY },
1046        "302" => quote! { StatusCode::FOUND },
1047        "304" => quote! { StatusCode::NOT_MODIFIED },
1048        "400" => quote! { StatusCode::BAD_REQUEST },
1049        "401" => quote! { StatusCode::UNAUTHORIZED },
1050        "403" => quote! { StatusCode::FORBIDDEN },
1051        "404" => quote! { StatusCode::NOT_FOUND },
1052        "409" => quote! { StatusCode::CONFLICT },
1053        "410" => quote! { StatusCode::GONE },
1054        "422" => quote! { StatusCode::UNPROCESSABLE_ENTITY },
1055        "429" => quote! { StatusCode::TOO_MANY_REQUESTS },
1056        "500" => quote! { StatusCode::INTERNAL_SERVER_ERROR },
1057        "502" => quote! { StatusCode::BAD_GATEWAY },
1058        "503" => quote! { StatusCode::SERVICE_UNAVAILABLE },
1059        "default" => quote! { StatusCode::INTERNAL_SERVER_ERROR },
1060        "2XX" => quote! { StatusCode::OK },
1061        "3XX" => quote! { StatusCode::MOVED_PERMANENTLY },
1062        "4XX" => quote! { StatusCode::BAD_REQUEST },
1063        "5XX" => quote! { StatusCode::INTERNAL_SERVER_ERROR },
1064        // Specific numeric codes not in our table — fall back to
1065        // StatusCode::from_u16. Codegen ensures a panic-free path by
1066        // unwrapping on a value that must parse (we already
1067        // know the spec wrote a numeric status here).
1068        other => {
1069            if let Ok(n) = other.parse::<u16>() {
1070                quote! {
1071                    StatusCode::from_u16(#n).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
1072                }
1073            } else {
1074                quote! { StatusCode::INTERNAL_SERVER_ERROR }
1075            }
1076        }
1077    }
1078}
1079
1080fn parse_type(ty: &str) -> TokenStream {
1081    syn::parse_str::<syn::Type>(ty)
1082        .map(|t| quote! { #t })
1083        .unwrap_or_else(|_| {
1084            let ident = format_ident!("{}", ty);
1085            quote! { #ident }
1086        })
1087}
1088
1089fn format_or_raw(ts: TokenStream) -> String {
1090    let raw = ts.to_string();
1091    match syn::parse_file(&raw) {
1092        Ok(parsed) => prettyplease::unparse(&parsed),
1093        Err(_) => raw,
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    #[test]
1102    fn status_variant_name_maps_known_codes() {
1103        assert_eq!(status_variant_name("200"), "Ok");
1104        assert_eq!(status_variant_name("4XX"), "ClientError");
1105        assert_eq!(status_variant_name("default"), "Default");
1106        assert_eq!(status_variant_name("418"), "Status418");
1107    }
1108
1109    #[test]
1110    fn trait_ident_for_tag_appends_api() {
1111        let id = trait_ident_for_tag("Responses");
1112        assert_eq!(id.to_string(), "ResponsesApi");
1113    }
1114
1115    #[test]
1116    fn untagged_falls_back_to_server_api() {
1117        let id = trait_ident_for_tag("");
1118        assert_eq!(id.to_string(), "ServerApi");
1119    }
1120}