Skip to main content

openapi_to_rust/
client_generator.rs

1//! HTTP client generation for OpenAPI specifications.
2//!
3//! This module is part of the code generator that creates production-ready HTTP clients
4//! from OpenAPI specifications. It generates clients with middleware support including
5//! retry logic and request tracing.
6//!
7//! # Overview
8//!
9//! The client generator creates:
10//! - `HttpClient` struct with middleware stack (reqwest-middleware)
11//! - Retry logic with exponential backoff (reqwest-retry)
12//! - Request/response tracing (reqwest-tracing)
13//! - Direct methods for all API operations (GET, POST, PUT, DELETE, PATCH)
14//! - Comprehensive error handling with [`HttpError`](crate::http_error::HttpError)
15//! - Builder pattern for configuration
16//!
17//! # Generated Code Structure
18//!
19//! For each OpenAPI specification, the generator creates:
20//!
21//! ```rust,ignore
22//! // Generated client.rs file
23//!
24//! use crate::types::*;
25//! use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
26//! use std::collections::BTreeMap;
27//!
28//! pub struct HttpClient {
29//!     base_url: String,
30//!     api_key: Option<String>,
31//!     http_client: ClientWithMiddleware,
32//!     custom_headers: BTreeMap<String, String>,
33//! }
34//!
35//! impl HttpClient {
36//!     pub fn new() -> Self { /* ... */ }
37//!     pub fn with_config(retry_config: Option<RetryConfig>, enable_tracing: bool) -> Self { /* ... */ }
38//!     pub fn with_base_url(self, base_url: String) -> Self { /* ... */ }
39//!     pub fn with_api_key(self, api_key: String) -> Self { /* ... */ }
40//!     pub fn with_header(self, key: String, value: String) -> Self { /* ... */ }
41//!
42//!     // Generated operation methods
43//!     pub async fn list_items(&self) -> Result<ItemList, HttpError> { /* ... */ }
44//!     pub async fn create_item(&self, request: CreateItemRequest) -> Result<Item, HttpError> { /* ... */ }
45//!     pub async fn get_item(&self, id: impl AsRef<str>) -> Result<Item, HttpError> { /* ... */ }
46//! }
47//! ```
48//!
49//! # Middleware Stack
50//!
51//! The generated client uses `reqwest-middleware` to build a composable middleware stack:
52//!
53//! 1. **Tracing Middleware** (optional, enabled by default)
54//!    - Logs HTTP requests/responses
55//!    - Creates spans for distributed tracing
56//!    - Integrates with `tracing` ecosystem
57//!
58//! 2. **Retry Middleware** (optional, configured via TOML)
59//!    - Exponential backoff retry policy
60//!    - Automatically retries transient errors (429, 500, 502, 503, 504)
61//!    - Configurable max retries and delay bounds
62//!
63//! # Configuration
64//!
65//! ## Via TOML
66//!
67//! ```toml
68//! [http_client]
69//! base_url = "https://api.example.com"
70//! timeout_seconds = 30
71//!
72//! [http_client.retry]
73//! max_retries = 3
74//! initial_delay_ms = 500
75//! max_delay_ms = 16000
76//!
77//! [http_client.tracing]
78//! enabled = true
79//! ```
80//!
81//! ## Via Rust API
82//!
83//! ```no_run
84//! use openapi_to_rust::{GeneratorConfig, http_config::*};
85//! use std::path::PathBuf;
86//!
87//! let config = GeneratorConfig {
88//!     spec_path: PathBuf::from("openapi.json"),
89//!     enable_async_client: true,
90//!     retry_config: Some(RetryConfig {
91//!         max_retries: 3,
92//!         initial_delay_ms: 500,
93//!         max_delay_ms: 16000,
94//!     }),
95//!     tracing_enabled: true,
96//!     // ... other fields
97//!     ..Default::default()
98//! };
99//! ```
100//!
101//! # Generated Client Usage
102//!
103//! ```rust,ignore
104//! use crate::generated::client::HttpClient;
105//!
106//! #[tokio::main]
107//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
108//!     // Create client with retry and tracing
109//!     let client = HttpClient::new()
110//!         .with_base_url("https://api.example.com".to_string())
111//!         .with_api_key("your-api-key".to_string())
112//!         .with_header("X-Custom-Header".to_string(), "value".to_string());
113//!
114//!     // Make API calls - retries happen automatically
115//!     let items = client.list_items().await?;
116//!     println!("Found {} items", items.items.len());
117//!
118//!     Ok(())
119//! }
120//! ```
121//!
122//! # HTTP Method Support
123//!
124//! The generator supports all standard HTTP methods:
125//! - `GET` - List and retrieve operations
126//! - `POST` - Create operations
127//! - `PUT` - Full update operations
128//! - `PATCH` - Partial update operations
129//! - `DELETE` - Delete operations
130//!
131//! # Error Handling
132//!
133//! All generated methods return `Result<T, HttpError>` where `HttpError` provides:
134//! - Detailed error information
135//! - Retry detection via `is_retryable()`
136//! - Error categorization (client errors, server errors)
137//!
138//! See [`http_error`](crate::http_error) module for details.
139//!
140//! # Implementation Details
141//!
142//! The generator uses the following approach:
143//! 1. Analyzes OpenAPI operations to extract HTTP methods, paths, parameters
144//! 2. Generates typed request/response handling
145//! 3. Creates method signatures with proper parameter types
146//! 4. Generates path parameter substitution
147//! 5. Handles query parameters and request bodies
148//! 6. Configures middleware stack based on generator config
149
150use crate::analysis::{OperationInfo, OperationResponseBody, ParameterInfo, SchemaAnalysis};
151use crate::generator::CodeGenerator;
152use heck::{ToPascalCase, ToSnakeCase};
153use proc_macro2::TokenStream;
154use quote::{format_ident, quote};
155use std::collections::BTreeMap;
156
157struct AllocatedOperationParam<'a> {
158    param: &'a ParameterInfo,
159    ident: syn::Ident,
160}
161
162#[derive(Clone)]
163struct BodyFieldPlan {
164    wire_name: String,
165    preferred_method_name: String,
166    value_ident: syn::Ident,
167    value_type: TokenStream,
168    access_path: Vec<syn::Ident>,
169}
170
171#[derive(Clone, Copy)]
172enum MultipartClientFieldKind {
173    RawBytes,
174    Base64,
175    Base64UrlUnpadded,
176    Text,
177}
178
179#[derive(Clone, Copy)]
180enum ClientSuccessBody<'a> {
181    Json(&'a str),
182    Text,
183    Binary,
184    EventStream,
185    Empty,
186}
187
188#[derive(Clone)]
189struct ClientSuccessSelection<'a> {
190    statuses: Vec<&'a str>,
191    body: ClientSuccessBody<'a>,
192    accept: Option<&'a str>,
193}
194
195enum RequiredBodyConstruction {
196    Default,
197    New(Vec<BodyConstructorParam>),
198    Whole,
199}
200
201struct BodyConstructorParam {
202    preferred_ident: syn::Ident,
203    value_type: TokenStream,
204}
205
206struct BodyModelPlan {
207    body_ident: syn::Ident,
208    body_type: TokenStream,
209    required_construction: RequiredBodyConstruction,
210    optional_fields: Vec<BodyFieldPlan>,
211}
212
213impl CodeGenerator {
214    /// Generate the HTTP client struct with middleware support
215    pub fn generate_http_client_struct(&self) -> TokenStream {
216        let has_retry = self.config().retry_config.is_some();
217        let has_tracing = self.config().tracing_enabled;
218
219        // Generate RetryConfig struct if needed
220        let retry_config_struct = if has_retry {
221            quote! {
222                /// Retry configuration for HTTP requests
223                #[derive(Debug, Clone)]
224                pub struct RetryConfig {
225                    pub max_retries: u32,
226                    pub initial_delay_ms: u64,
227                    pub max_delay_ms: u64,
228                }
229
230                impl Default for RetryConfig {
231                    fn default() -> Self {
232                        Self {
233                            max_retries: 3,
234                            initial_delay_ms: 500,
235                            max_delay_ms: 16000,
236                        }
237                    }
238                }
239            }
240        } else {
241            quote! {}
242        };
243
244        // Generate the main HttpClient struct
245        let client_struct = quote! {
246            use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
247            use std::collections::BTreeMap;
248
249            /// Default upper bound for any response body buffered in memory.
250            pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024;
251
252            /// HTTP client for making API requests
253            #[derive(Clone)]
254            pub struct HttpClient {
255                base_url: String,
256                api_key: Option<String>,
257                http_client: ClientWithMiddleware,
258                custom_headers: BTreeMap<String, String>,
259                max_response_body_bytes: usize,
260            }
261
262            async fn __read_bounded_response_body(
263                mut response: reqwest::Response,
264                limit: usize,
265            ) -> Result<Vec<u8>, HttpError> {
266                let mut body = Vec::new();
267                while let Some(chunk) = response.chunk().await.map_err(HttpError::Network)? {
268                    let next_len = body.len().checked_add(chunk.len());
269                    if next_len.is_none_or(|next_len| next_len > limit) {
270                        return Err(HttpError::ResponseTooLarge { limit });
271                    }
272                    body.extend_from_slice(&chunk);
273                }
274                Ok(body)
275            }
276        };
277
278        // Generate constructor
279        let constructor = self.generate_constructor(has_retry, has_tracing);
280
281        // Generate builder methods
282        let builder_methods = self.generate_builder_methods();
283
284        // Generate Default implementation
285        let default_impl = quote! {
286            impl Default for HttpClient {
287                fn default() -> Self {
288                    Self::new()
289                }
290            }
291        };
292
293        // Path-segment percent encoder, used by url construction (T5).
294        // Encodes per RFC3986 §3.3: only ALPHA, DIGIT, and `-._~` pass through;
295        // everything else becomes `%XX`.
296        let path_encoder = quote! {
297            fn __pct_encode_path_segment(s: &str) -> String {
298                let mut out = String::with_capacity(s.len());
299                for &b in s.as_bytes() {
300                    match b {
301                        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
302                            out.push(b as char);
303                        }
304                        _ => {
305                            out.push('%');
306                            out.push_str(&format!("{:02X}", b));
307                        }
308                    }
309                }
310                out
311            }
312        };
313
314        // Combine all parts
315        quote! {
316            #retry_config_struct
317            #client_struct
318
319            impl HttpClient {
320                #constructor
321                #builder_methods
322            }
323
324            #default_impl
325            #path_encoder
326        }
327    }
328
329    /// Generate the constructor method
330    fn generate_constructor(&self, has_retry: bool, has_tracing: bool) -> TokenStream {
331        // Seed `base_url` from configuration rather than always starting empty.
332        // A client built with an empty base URL sends every request to a
333        // relative path and fails, so a user who set `[http_client] base_url`
334        // in their TOML — or whose spec declares `servers[0].url`, which the
335        // config layer resolves into the same field — previously had to repeat
336        // it via `with_base_url` or watch every call 404 (openapi-generator-igg).
337        let configured_base_url = self
338            .config()
339            .http_client_config
340            .as_ref()
341            .and_then(|http| http.base_url.as_deref())
342            .unwrap_or_default();
343        let default_base_url = quote! { #configured_base_url.to_string() };
344        let max_response_body_bytes = self
345            .config()
346            .http_client_config
347            .as_ref()
348            .and_then(|http| http.max_response_body_bytes)
349            .unwrap_or(8 * 1024 * 1024);
350
351        let retry_param = if has_retry {
352            quote! { retry_config: Option<RetryConfig>, }
353        } else {
354            quote! {}
355        };
356
357        let tracing_param = if has_tracing {
358            quote! { enable_tracing: bool, }
359        } else {
360            quote! {}
361        };
362
363        let retry_middleware = if has_retry {
364            quote! {
365                if let Some(config) = retry_config {
366                    use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
367
368                    let retry_policy = ExponentialBackoff::builder()
369                        .retry_bounds(
370                            std::time::Duration::from_millis(config.initial_delay_ms),
371                            std::time::Duration::from_millis(config.max_delay_ms),
372                        )
373                        .build_with_max_retries(config.max_retries);
374
375                    let retry_middleware = RetryTransientMiddleware::new_with_policy(retry_policy);
376                    client_builder = client_builder.with(retry_middleware);
377                }
378            }
379        } else {
380            quote! {}
381        };
382
383        let tracing_middleware = if has_tracing {
384            quote! {
385                if enable_tracing {
386                    use reqwest_tracing::TracingMiddleware;
387                    client_builder = client_builder.with(TracingMiddleware::default());
388                }
389            }
390        } else {
391            quote! {}
392        };
393
394        let default_constructor = if has_retry && has_tracing {
395            quote! {
396                /// Create a new HTTP client with default configuration
397                pub fn new() -> Self {
398                    Self::with_config(None, true)
399                }
400            }
401        } else if has_retry {
402            quote! {
403                /// Create a new HTTP client with default configuration
404                pub fn new() -> Self {
405                    Self::with_config(None)
406                }
407            }
408        } else if has_tracing {
409            quote! {
410                /// Create a new HTTP client with default configuration
411                pub fn new() -> Self {
412                    Self::with_config(true)
413                }
414            }
415        } else {
416            quote! {
417                /// Create a new HTTP client with default configuration
418                pub fn new() -> Self {
419                    let reqwest_client = reqwest::Client::new();
420                    let client_builder = ClientBuilder::new(reqwest_client);
421                    let http_client = client_builder.build();
422
423                    Self {
424                        base_url: #default_base_url,
425                        api_key: None,
426                        http_client,
427                        custom_headers: BTreeMap::new(),
428                        max_response_body_bytes: #max_response_body_bytes,
429                    }
430                }
431            }
432        };
433
434        if has_retry || has_tracing {
435            quote! {
436                #default_constructor
437
438                /// Create a new HTTP client with custom configuration
439                pub fn with_config(#retry_param #tracing_param) -> Self {
440                    let reqwest_client = reqwest::Client::new();
441                    let mut client_builder = ClientBuilder::new(reqwest_client);
442
443                    #tracing_middleware
444                    #retry_middleware
445
446                    let http_client = client_builder.build();
447
448                    Self {
449                        base_url: #default_base_url,
450                        api_key: None,
451                        http_client,
452                        custom_headers: BTreeMap::new(),
453                        max_response_body_bytes: #max_response_body_bytes,
454                    }
455                }
456            }
457        } else {
458            default_constructor
459        }
460    }
461
462    /// Generate builder methods for configuration
463    fn generate_builder_methods(&self) -> TokenStream {
464        quote! {
465            /// Set the base URL for all requests
466            pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
467                self.base_url = base_url.into();
468                self
469            }
470
471            /// Set the API key for authentication
472            pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
473                self.api_key = Some(api_key.into());
474                self
475            }
476
477            /// Set the maximum number of response-body bytes buffered in memory.
478            pub fn with_max_response_body_bytes(mut self, limit: usize) -> Self {
479                self.max_response_body_bytes = limit;
480                self
481            }
482
483            /// Add a custom header to all requests
484            pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
485                self.custom_headers.insert(name.into(), value.into());
486                self
487            }
488
489            /// Add multiple custom headers
490            pub fn with_headers(mut self, headers: BTreeMap<String, String>) -> Self {
491                self.custom_headers.extend(headers);
492                self
493            }
494        }
495    }
496
497    /// Generate HTTP operation methods for the client.
498    ///
499    /// Emits per-operation typed error enums (one variant per declared non-2xx
500    /// response with a body schema) BEFORE the `impl HttpClient` block so the
501    /// generated method signatures can reference them. This low-level helper
502    /// intentionally emits every analyzed operation; use
503    /// [`Self::generate_http_client`] or [`Self::generate_all`] to honor the
504    /// configured `[client].operations` scope.
505    pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream {
506        let operations: Vec<&OperationInfo> = analysis.operations.values().collect();
507        self.generate_operation_methods_for(analysis, &operations)
508    }
509
510    /// Generate every operation-owned client artifact from one resolved
511    /// operation slice. This keeps methods, parameter enums, and typed error
512    /// enums in lockstep for selective clients.
513    pub(crate) fn generate_operation_methods_for(
514        &self,
515        analysis: &SchemaAnalysis,
516        operations: &[&OperationInfo],
517    ) -> TokenStream {
518        let param_enums = self.generate_param_enum_types(operations);
519
520        let op_error_enums: Vec<TokenStream> = operations
521            .iter()
522            .copied()
523            .filter_map(|op| self.generate_op_error_enum(op))
524            .collect();
525
526        let methods: Vec<TokenStream> = operations
527            .iter()
528            .copied()
529            .map(|op| self.generate_single_operation_method(analysis, op))
530            .collect();
531
532        let (operation_builders, builder_entries) =
533            self.generate_operation_builders(analysis, operations);
534
535        quote! {
536            #param_enums
537
538            #(#op_error_enums)*
539
540            #(#operation_builders)*
541
542            impl HttpClient {
543                #(#methods)*
544                #(#builder_entries)*
545            }
546        }
547    }
548
549    fn generate_operation_builders(
550        &self,
551        analysis: &SchemaAnalysis,
552        operations: &[&OperationInfo],
553    ) -> (Vec<TokenStream>, Vec<TokenStream>) {
554        if !self.config().builders.enabled {
555            return (Vec::new(), Vec::new());
556        }
557
558        let mut used_entry_methods: std::collections::HashSet<String> = operations
559            .iter()
560            .map(|operation| self.get_method_name(operation).to_string())
561            .collect();
562        let mut used_type_names = std::collections::HashSet::new();
563        for schema_name in analysis.schemas.keys() {
564            let rust_name = self.to_rust_type_name(schema_name);
565            used_type_names.insert(rust_name.clone());
566            // Request-model builders live in `types.rs` and are imported by
567            // glob into the client module. Reserve their conventional names
568            // so operation builders cannot create ambiguous re-exports.
569            used_type_names.insert(format!("{rust_name}Builder"));
570        }
571        used_type_names.insert("HttpClient".to_string());
572        used_type_names.insert("ApiOpError".to_string());
573        used_type_names.extend(
574            [
575                "ClientBuilder",
576                "ClientWithMiddleware",
577                "RetryConfig",
578                "HttpError",
579                "BTreeMap",
580            ]
581            .into_iter()
582            .map(str::to_string),
583        );
584        for operation in operations {
585            used_type_names.insert(self.op_error_enum_ident(operation).to_string());
586            used_type_names.extend(
587                operation
588                    .parameters
589                    .iter()
590                    .filter(|parameter| parameter.enum_values.is_some())
591                    .map(|parameter| parameter.rust_type.clone()),
592            );
593        }
594
595        let mut definitions = Vec::new();
596        let mut entries = Vec::new();
597        for operation in operations {
598            let allocated_params = self.allocated_operation_params(operation);
599            let body_plan = self.body_model_plan(operation, analysis);
600            let optional_param_count = allocated_params
601                .iter()
602                .filter(|allocated| !Self::builder_param_is_required(allocated.param))
603                .count();
604            let optional_body_count =
605                usize::from(operation.request_body.is_some() && !operation.request_body_required);
606            let body_field_count = body_plan
607                .as_ref()
608                .filter(|plan| {
609                    operation.request_body_required
610                        || matches!(
611                            &plan.required_construction,
612                            RequiredBodyConstruction::Default
613                        )
614                })
615                .map_or(0, |plan| plan.optional_fields.len());
616            let optional_count = optional_param_count + optional_body_count + body_field_count;
617            if optional_count <= self.config().builders.threshold {
618                continue;
619            }
620
621            let flat_method = self.get_method_name(operation);
622            let entry_base = format!("{flat_method}_builder");
623            let entry_name = Self::allocate_name(&entry_base, &mut used_entry_methods);
624            let entry_ident = Self::to_field_ident(&entry_name);
625
626            let builder_base = format!("{}Builder", flat_method.to_string().to_pascal_case());
627            let builder_name = Self::allocate_type_name(&builder_base, &mut used_type_names);
628            let builder_ident = format_ident!("{builder_name}");
629
630            let (definition, entry) = self.generate_single_operation_builder(
631                analysis,
632                operation,
633                &allocated_params,
634                body_plan,
635                &flat_method,
636                &entry_ident,
637                &builder_ident,
638            );
639            definitions.push(definition);
640            entries.push(entry);
641        }
642
643        (definitions, entries)
644    }
645
646    #[allow(clippy::too_many_arguments)]
647    fn generate_single_operation_builder(
648        &self,
649        analysis: &SchemaAnalysis,
650        operation: &OperationInfo,
651        allocated_params: &[AllocatedOperationParam<'_>],
652        body_plan: Option<BodyModelPlan>,
653        flat_method: &syn::Ident,
654        entry_ident: &syn::Ident,
655        builder_ident: &syn::Ident,
656    ) -> (TokenStream, TokenStream) {
657        let mut fields = vec![quote! { client: &'a HttpClient }];
658        let mut entry_parameters = Vec::new();
659        let mut initializers = vec![quote! { client: self }];
660        let mut call_arguments = Vec::new();
661        let mut setters = Vec::new();
662        let mut used_entry_params = std::collections::HashSet::new();
663        let mut used_methods = std::collections::HashSet::from(["send".to_string()]);
664
665        for allocated in allocated_params {
666            let field_ident = &allocated.ident;
667            let storage_type = self.builder_param_storage_type(allocated.param);
668            if Self::builder_param_is_required(allocated.param) {
669                fields.push(quote! { #field_ident: #storage_type });
670                let entry_name =
671                    Self::allocate_name(&field_ident.to_string(), &mut used_entry_params);
672                let entry_param = Self::to_field_ident(&entry_name);
673                if Self::param_has_impl_as_ref_type(allocated.param) {
674                    entry_parameters.push(quote! { #entry_param: impl Into<String> });
675                    initializers.push(quote! { #field_ident: #entry_param.into() });
676                } else {
677                    entry_parameters.push(quote! { #entry_param: #storage_type });
678                    initializers.push(quote! { #field_ident: #entry_param });
679                }
680            } else {
681                fields.push(quote! { #field_ident: Option<#storage_type> });
682                initializers.push(quote! { #field_ident: None });
683                let setter_ident =
684                    Self::allocate_builder_method(&field_ident.to_string(), &mut used_methods);
685                let wire_name = &allocated.param.name;
686                let assignment = if Self::param_has_impl_as_ref_type(allocated.param) {
687                    quote! { self.#field_ident = Some(#field_ident.into()); }
688                } else {
689                    quote! { self.#field_ident = Some(#field_ident); }
690                };
691                let setter_type = if Self::param_has_impl_as_ref_type(allocated.param) {
692                    quote! { impl Into<String> }
693                } else {
694                    storage_type.clone()
695                };
696                setters.push(quote! {
697                    #[doc = concat!("Set the optional `", #wire_name, "` operation parameter.")]
698                    #[must_use]
699                    pub fn #setter_ident(mut self, #field_ident: #setter_type) -> Self {
700                        #assignment
701                        self
702                    }
703                });
704            }
705            call_arguments.push(quote! { self.#field_ident });
706        }
707
708        if let Some(body_plan) = body_plan {
709            let BodyModelPlan {
710                body_ident,
711                body_type,
712                required_construction,
713                optional_fields,
714            } = body_plan;
715            let can_initialize_optional_body =
716                matches!(&required_construction, RequiredBodyConstruction::Default);
717            if operation.request_body_required {
718                fields.push(quote! { #body_ident: #body_type });
719                match required_construction {
720                    RequiredBodyConstruction::Default => {
721                        initializers.push(quote! { #body_ident: Default::default() });
722                    }
723                    RequiredBodyConstruction::New(constructor_params) => {
724                        let mut constructor_args = Vec::new();
725                        for constructor in constructor_params {
726                            let preferred = constructor.preferred_ident.to_string();
727                            let entry_name =
728                                Self::allocate_name(&preferred, &mut used_entry_params);
729                            let entry_param = Self::to_field_ident(&entry_name);
730                            let value_type = constructor.value_type;
731                            entry_parameters.push(quote! { #entry_param: #value_type });
732                            constructor_args.push(entry_param);
733                        }
734                        initializers.push(quote! {
735                            #body_ident: #body_type::new(#(#constructor_args),*)
736                        });
737                    }
738                    RequiredBodyConstruction::Whole => {
739                        let entry_name =
740                            Self::allocate_name(&body_ident.to_string(), &mut used_entry_params);
741                        let entry_param = Self::to_field_ident(&entry_name);
742                        entry_parameters.push(quote! { #entry_param: #body_type });
743                        initializers.push(quote! { #body_ident: #entry_param });
744                    }
745                }
746            } else {
747                fields.push(quote! { #body_ident: Option<#body_type> });
748                initializers.push(quote! { #body_ident: None });
749            }
750
751            let body_setter =
752                Self::allocate_builder_method(&body_ident.to_string(), &mut used_methods);
753            let body_assignment = if operation.request_body_required {
754                quote! { self.#body_ident = #body_ident; }
755            } else {
756                quote! { self.#body_ident = Some(#body_ident); }
757            };
758            setters.push(quote! {
759                /// Replace the complete request body.
760                #[must_use]
761                pub fn #body_setter(mut self, #body_ident: #body_type) -> Self {
762                    #body_assignment
763                    self
764                }
765            });
766
767            if operation.request_body_required || can_initialize_optional_body {
768                for field in optional_fields {
769                    let setter_ident = Self::allocate_builder_method(
770                        &field.preferred_method_name,
771                        &mut used_methods,
772                    );
773                    let value_ident = field.value_ident;
774                    let value_type = field.value_type;
775                    let wire_name = field.wire_name;
776                    let access_path = field.access_path;
777                    let assignment = if operation.request_body_required {
778                        let mut target = quote! { self.#body_ident };
779                        for access in &access_path {
780                            target = quote! { #target.#access };
781                        }
782                        quote! { #target = Some(#value_ident); }
783                    } else {
784                        let mut target = quote! { request };
785                        for access in &access_path {
786                            target = quote! { #target.#access };
787                        }
788                        quote! {
789                            let request = self.#body_ident.get_or_insert_with(Default::default);
790                            #target = Some(#value_ident);
791                        }
792                    };
793                    setters.push(quote! {
794                        #[doc = concat!("Set the optional request-body field `", #wire_name, "`.")]
795                        #[must_use]
796                        pub fn #setter_ident(mut self, #value_ident: #value_type) -> Self {
797                            #assignment
798                            self
799                        }
800                    });
801                }
802            }
803            call_arguments.push(quote! { self.#body_ident });
804        }
805
806        let response_type = self.get_response_type(analysis, operation);
807        let error_type = self.op_error_type_token(operation);
808        let operation_id = &operation.operation_id;
809        let definition = quote! {
810            #[doc = concat!("Additive request builder for `", #operation_id, "`.")]
811            #[must_use]
812            pub struct #builder_ident<'a> {
813                #(#fields,)*
814            }
815
816            impl<'a> #builder_ident<'a> {
817                #(#setters)*
818
819                /// Send the request through the existing flat operation method.
820                pub async fn send(self) -> Result<#response_type, ApiOpError<#error_type>> {
821                    self.client.#flat_method(#(#call_arguments),*).await
822                }
823            }
824        };
825        let entry = quote! {
826            #[doc = concat!("Start an additive builder for `", #operation_id, "`.")]
827            pub fn #entry_ident(
828                &self,
829                #(#entry_parameters),*
830            ) -> #builder_ident<'_> {
831                #builder_ident {
832                    #(#initializers,)*
833                }
834            }
835        };
836        (definition, entry)
837    }
838
839    fn allocate_name(base: &str, used: &mut std::collections::HashSet<String>) -> String {
840        let mut candidate = base.to_string();
841        let mut suffix = 2;
842        while !used.insert(candidate.clone()) {
843            candidate = format!("{base}_{suffix}");
844            suffix += 1;
845        }
846        candidate
847    }
848
849    fn allocate_type_name(base: &str, used: &mut std::collections::HashSet<String>) -> String {
850        if used.insert(base.to_string()) {
851            return base.to_string();
852        }
853
854        let mut suffix = 2;
855        loop {
856            let candidate = format!("{base}{suffix}");
857            if used.insert(candidate.clone()) {
858                return candidate;
859            }
860            suffix += 1;
861        }
862    }
863
864    fn allocate_builder_method(
865        preferred: &str,
866        used: &mut std::collections::HashSet<String>,
867    ) -> syn::Ident {
868        let plain = preferred.strip_prefix("r#").unwrap_or(preferred);
869        let base = if used.contains(preferred) {
870            format!("with_{plain}")
871        } else {
872            preferred.to_string()
873        };
874        let allocated = Self::allocate_name(&base, used);
875        Self::to_field_ident(&allocated)
876    }
877
878    fn allocated_operation_params<'a>(
879        &self,
880        operation: &'a OperationInfo,
881    ) -> Vec<AllocatedOperationParam<'a>> {
882        // Builder-internal storage uses these names. Operation parameters are
883        // positional when delegated to the flat method, so suffixing only the
884        // builder field is safe and prevents duplicate struct fields.
885        let mut used = std::collections::HashSet::from([
886            "client".to_string(),
887            "request".to_string(),
888            "form".to_string(),
889            "body".to_string(),
890        ]);
891        let mut allocated = Vec::new();
892        for location in ["path", "query", "header", "cookie"] {
893            for parameter in &operation.parameters {
894                if parameter.location != location {
895                    continue;
896                }
897                let raw = self.param_ident_str(parameter);
898                let chosen = Self::allocate_name(&raw, &mut used);
899                allocated.push(AllocatedOperationParam {
900                    param: parameter,
901                    ident: Self::to_field_ident(&chosen),
902                });
903            }
904        }
905        allocated
906    }
907
908    fn builder_param_is_required(parameter: &ParameterInfo) -> bool {
909        // The existing flat signature always emits path parameters as bare
910        // values. Invalid real-world specs sometimes omit `required: true`;
911        // mirror the flat contract so builder delegation remains type-correct.
912        parameter.location == "path" || parameter.required
913    }
914
915    fn builder_param_storage_type(&self, parameter: &ParameterInfo) -> TokenStream {
916        self.get_param_owned_rust_type(parameter)
917    }
918
919    fn param_has_impl_as_ref_type(parameter: &ParameterInfo) -> bool {
920        !matches!(
921            &parameter.query_serialization,
922            Some(
923                crate::analysis::QuerySerialization::FormExplodedArray { .. }
924                    | crate::analysis::QuerySerialization::FormArray { .. }
925                    | crate::analysis::QuerySerialization::SimpleHeaderArray { .. },
926            )
927        ) && Self::param_uses_as_ref_str(parameter)
928    }
929
930    fn body_model_plan(
931        &self,
932        operation: &OperationInfo,
933        analysis: &SchemaAnalysis,
934    ) -> Option<BodyModelPlan> {
935        use crate::analysis::{ObjectAdditionalProperties, RequestBodyContent, SchemaType};
936
937        let request_body = operation.request_body.as_ref()?;
938        let (body_name, body_ident) = match request_body {
939            RequestBodyContent::Json { schema_name, .. }
940            | RequestBodyContent::FormUrlEncoded { schema_name, .. }
941            | RequestBodyContent::Multipart { schema_name, .. } => {
942                (schema_name.as_str(), format_ident!("request"))
943            }
944            RequestBodyContent::OctetStream { .. }
945            | RequestBodyContent::Binary { .. }
946            | RequestBodyContent::Unsupported { .. } => {
947                return Some(BodyModelPlan {
948                    body_ident: format_ident!("body"),
949                    body_type: quote! { Vec<u8> },
950                    required_construction: RequiredBodyConstruction::Whole,
951                    optional_fields: Vec::new(),
952                });
953            }
954            RequestBodyContent::TextPlain { .. } => {
955                return Some(BodyModelPlan {
956                    body_ident: format_ident!("body"),
957                    body_type: quote! { String },
958                    required_construction: RequiredBodyConstruction::Whole,
959                    optional_fields: Vec::new(),
960                });
961            }
962            RequestBodyContent::SchemaLess { .. } => return None,
963        };
964        let body_type_name = self.to_rust_type_name(body_name);
965        let body_type = syn::Ident::new(&body_type_name, proc_macro2::Span::call_site());
966        let Some((resolved_name, resolved_schema)) =
967            self.resolve_reference_schema(body_name, analysis)
968        else {
969            return Some(BodyModelPlan {
970                body_ident,
971                body_type: quote! { #body_type },
972                required_construction: RequiredBodyConstruction::Whole,
973                optional_fields: Vec::new(),
974            });
975        };
976
977        let mut optional_fields = Vec::new();
978        let mut stack = std::collections::HashSet::new();
979        self.collect_optional_body_fields(
980            resolved_name,
981            Vec::new(),
982            analysis,
983            &mut stack,
984            &mut optional_fields,
985        );
986
987        let required_construction = match &resolved_schema.schema_type {
988            SchemaType::Object {
989                properties,
990                required,
991                additional_properties,
992            } if !self.is_discriminated_variant(resolved_name, analysis) => {
993                let emitted = self.emitted_object_properties(
994                    resolved_name,
995                    properties,
996                    required,
997                    additional_properties,
998                    analysis,
999                    None,
1000                );
1001                let required_fields: Vec<_> = emitted
1002                    .iter()
1003                    .filter(|field| field.is_required)
1004                    .map(|field| BodyConstructorParam {
1005                        preferred_ident: field.ident.clone(),
1006                        value_type: field.field_type.clone(),
1007                    })
1008                    .collect();
1009                if required_fields.is_empty() {
1010                    RequiredBodyConstruction::Default
1011                } else if emitted.iter().any(|field| !field.is_required)
1012                    || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
1013                {
1014                    RequiredBodyConstruction::New(required_fields)
1015                } else {
1016                    RequiredBodyConstruction::Whole
1017                }
1018            }
1019            _ => RequiredBodyConstruction::Whole,
1020        };
1021
1022        Some(BodyModelPlan {
1023            body_ident,
1024            body_type: quote! { #body_type },
1025            required_construction,
1026            optional_fields,
1027        })
1028    }
1029
1030    fn resolve_reference_schema<'a>(
1031        &self,
1032        schema_name: &'a str,
1033        analysis: &'a SchemaAnalysis,
1034    ) -> Option<(&'a str, &'a crate::analysis::AnalyzedSchema)> {
1035        let mut current = schema_name;
1036        let mut visited = std::collections::HashSet::new();
1037        loop {
1038            if !visited.insert(current) {
1039                return None;
1040            }
1041            let schema = analysis.schemas.get(current)?;
1042            if let crate::analysis::SchemaType::Reference { target } = &schema.schema_type {
1043                current = target;
1044            } else {
1045                return Some((current, schema));
1046            }
1047        }
1048    }
1049
1050    fn collect_optional_body_fields(
1051        &self,
1052        schema_name: &str,
1053        access_path: Vec<syn::Ident>,
1054        analysis: &SchemaAnalysis,
1055        stack: &mut std::collections::HashSet<String>,
1056        output: &mut Vec<BodyFieldPlan>,
1057    ) {
1058        use crate::analysis::SchemaType;
1059        if !stack.insert(schema_name.to_string()) {
1060            return;
1061        }
1062        let Some(schema) = analysis.schemas.get(schema_name) else {
1063            stack.remove(schema_name);
1064            return;
1065        };
1066        match &schema.schema_type {
1067            SchemaType::Reference { target } => {
1068                self.collect_optional_body_fields(target, access_path, analysis, stack, output);
1069            }
1070            SchemaType::Object {
1071                properties,
1072                required,
1073                additional_properties,
1074            } if !self.is_discriminated_variant(schema_name, analysis) => {
1075                for field in self.emitted_object_properties(
1076                    schema_name,
1077                    properties,
1078                    required,
1079                    additional_properties,
1080                    analysis,
1081                    None,
1082                ) {
1083                    if field.is_required {
1084                        continue;
1085                    }
1086                    let mut field_path = access_path.clone();
1087                    field_path.push(field.ident.clone());
1088                    output.push(BodyFieldPlan {
1089                        wire_name: field.wire_name.to_string(),
1090                        preferred_method_name: field.ident.to_string(),
1091                        value_ident: field.ident.clone(),
1092                        value_type: self.generate_property_base_type(
1093                            schema_name,
1094                            field.wire_name,
1095                            field.property,
1096                            analysis,
1097                        ),
1098                        access_path: field_path,
1099                    });
1100                }
1101            }
1102            SchemaType::Composition { schemas } => {
1103                for (index, schema_ref) in schemas.iter().enumerate() {
1104                    let mut nested_path = access_path.clone();
1105                    nested_path.push(format_ident!("part_{index}"));
1106                    self.collect_optional_body_fields(
1107                        &schema_ref.target,
1108                        nested_path,
1109                        analysis,
1110                        stack,
1111                        output,
1112                    );
1113                }
1114            }
1115            _ => {}
1116        }
1117        stack.remove(schema_name);
1118    }
1119
1120    fn is_discriminated_variant(&self, schema_name: &str, analysis: &SchemaAnalysis) -> bool {
1121        analysis.schemas.values().any(|schema| {
1122            matches!(
1123                &schema.schema_type,
1124                crate::analysis::SchemaType::DiscriminatedUnion { variants, .. }
1125                    if variants.iter().any(|variant| variant.type_name == schema_name)
1126            )
1127        })
1128    }
1129
1130    /// Emit inline enum types for parameters whose schema is `type: string`
1131    /// with `enum` or `const`. The generated enum implements `Display` so it
1132    /// drops into the existing `format!`-based path/query templating without
1133    /// any special-casing at the call site. See issue #10 follow-up.
1134    fn generate_param_enum_types(&self, operations: &[&OperationInfo]) -> TokenStream {
1135        let mut by_name: BTreeMap<String, &ParameterInfo> = BTreeMap::new();
1136        for op in operations {
1137            for param in &op.parameters {
1138                if param.enum_values.is_some() {
1139                    by_name.entry(param.rust_type.clone()).or_insert(param);
1140                }
1141            }
1142        }
1143
1144        if by_name.is_empty() {
1145            return quote! {};
1146        }
1147
1148        let defs: Vec<TokenStream> = by_name
1149            .values()
1150            .map(|param| self.generate_single_param_enum(param))
1151            .collect();
1152
1153        quote! { #(#defs)* }
1154    }
1155
1156    fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream {
1157        let Some(values) = param.enum_values.as_deref() else {
1158            return quote! {};
1159        };
1160
1161        let enum_ident = format_ident!("{}", param.rust_type);
1162
1163        // Dedupe variant names. Real-world specs use sort enums like
1164        // `["created_at", "-created_at"]` (descending prefix), and both
1165        // PascalCase to `CreatedAt`. Suffix collisions with `_2`/`_3`/…
1166        // while keeping each `serde(rename)` pointing at the original
1167        // wire string.
1168        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1169        // `x-enum-varnames` wins over the naming heuristic when the spec
1170        // supplies it — the whole point of the extension is that the author
1171        // knows better than a transformation of the wire string. Schema-level
1172        // enums already honored it; parameter enums did not, so the same spec
1173        // produced different variant names depending on where its enum lived.
1174        // Suffix disambiguation still applies, since nothing stops a spec from
1175        // declaring two names that collide once converted to an identifier.
1176        let variant_names: Vec<String> = values
1177            .iter()
1178            .enumerate()
1179            .map(|(index, value)| {
1180                let base = param
1181                    .enum_varnames
1182                    .as_ref()
1183                    .and_then(|names| names.get(index))
1184                    .map(|name| self.to_rust_enum_variant(name))
1185                    .unwrap_or_else(|| self.to_rust_enum_variant(value));
1186                let mut chosen = base.clone();
1187                let mut suffix = 2;
1188                while !used.insert(chosen.clone()) {
1189                    chosen = format!("{base}_{suffix}");
1190                    suffix += 1;
1191                }
1192                chosen
1193            })
1194            .collect();
1195
1196        let variants: Vec<TokenStream> = values
1197            .iter()
1198            .zip(&variant_names)
1199            .map(|(value, name)| {
1200                let variant_ident = format_ident!("{}", name);
1201                quote! {
1202                    #[serde(rename = #value)]
1203                    #variant_ident,
1204                }
1205            })
1206            .collect();
1207
1208        let display_arms: Vec<TokenStream> = values
1209            .iter()
1210            .zip(&variant_names)
1211            .map(|(value, name)| {
1212                let variant_ident = format_ident!("{}", name);
1213                quote! { Self::#variant_ident => #value, }
1214            })
1215            .collect();
1216
1217        let doc = format!(
1218            "Allowed values for the `{}` {} parameter.",
1219            param.name, param.location
1220        );
1221
1222        quote! {
1223            #[doc = #doc]
1224            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
1225            pub enum #enum_ident {
1226                #(#variants)*
1227            }
1228
1229            impl #enum_ident {
1230                pub fn as_str(&self) -> &'static str {
1231                    match self {
1232                        #(#display_arms)*
1233                    }
1234                }
1235            }
1236
1237            impl std::fmt::Display for #enum_ident {
1238                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1239                    f.write_str(self.as_str())
1240                }
1241            }
1242
1243            impl AsRef<str> for #enum_ident {
1244                fn as_ref(&self) -> &str {
1245                    self.as_str()
1246                }
1247            }
1248        }
1249    }
1250
1251    /// Generate the per-operation typed error enum, if the op has any non-2xx
1252    /// responses with a body schema. Returns None when the op has no declared
1253    /// error bodies — those operations use `ApiOpError<serde_json::Value>` so
1254    /// the raw response body is still inspectable.
1255    fn generate_op_error_enum(&self, op: &OperationInfo) -> Option<TokenStream> {
1256        let variants: Vec<(String, String)> = op
1257            .response_schemas
1258            .iter()
1259            .filter(|(code, _)| !code.starts_with('2'))
1260            .map(|(code, schema)| (code.clone(), schema.clone()))
1261            .collect();
1262
1263        if variants.is_empty() {
1264            return None;
1265        }
1266
1267        let enum_ident = self.op_error_enum_ident(op);
1268        let variant_decls: Vec<TokenStream> = variants
1269            .iter()
1270            .map(|(code, schema)| {
1271                let variant_ident = Self::op_error_variant_ident(code);
1272                let payload_ty_name = self.to_rust_type_name(schema);
1273                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
1274                quote! { #variant_ident(#payload_ty) }
1275            })
1276            .collect();
1277
1278        let doc = format!(
1279            "Typed error responses for `{}`. One variant per declared non-2xx response.",
1280            op.operation_id
1281        );
1282
1283        Some(quote! {
1284            #[doc = #doc]
1285            #[derive(Debug, Clone)]
1286            pub enum #enum_ident {
1287                #(#variant_decls,)*
1288            }
1289        })
1290    }
1291
1292    /// Type name (Ident) for the per-op error enum, e.g. `ListTodosApiError`.
1293    fn op_error_enum_ident(&self, op: &OperationInfo) -> syn::Ident {
1294        use heck::ToPascalCase;
1295        let name = format!(
1296            "{}ApiError",
1297            op.operation_id.replace('.', "_").to_pascal_case()
1298        );
1299        syn::Ident::new(&name, proc_macro2::Span::call_site())
1300    }
1301
1302    /// Variant name for a status code: "400" → Status400, "default" → Default,
1303    /// "4XX" → Status4xx.
1304    fn op_error_variant_ident(status_code: &str) -> syn::Ident {
1305        let raw = match status_code {
1306            "default" | "Default" => "Default".to_string(),
1307            other if other.chars().all(|c| c.is_ascii_digit()) => format!("Status{other}"),
1308            other => format!("Status{}", other.to_ascii_lowercase()),
1309        };
1310        syn::Ident::new(&raw, proc_macro2::Span::call_site())
1311    }
1312
1313    /// Token stream for the type plugged into `ApiOpError<T>` for an op:
1314    /// either the per-op enum, or `serde_json::Value` for ops with no
1315    /// declared error body schemas.
1316    fn op_error_type_token(&self, op: &OperationInfo) -> TokenStream {
1317        if op
1318            .response_schemas
1319            .iter()
1320            .any(|(code, _)| !code.starts_with('2'))
1321        {
1322            let ident = self.op_error_enum_ident(op);
1323            quote! { #ident }
1324        } else {
1325            quote! { serde_json::Value }
1326        }
1327    }
1328
1329    /// Generate a single operation method
1330    fn generate_single_operation_method(
1331        &self,
1332        analysis: &SchemaAnalysis,
1333        op: &OperationInfo,
1334    ) -> TokenStream {
1335        let method_name = self.get_method_name(op);
1336        let http_method_call = self.http_method_call(op);
1337        let path = &op.path;
1338        let request_param = self.generate_request_param(op);
1339        let request_body = self.generate_request_body(op, analysis);
1340        let query_params = self.generate_query_params(op);
1341        let header_params = self.generate_header_params(op);
1342        let cookie_params = self.generate_cookie_params(op);
1343        let auth_application = self.generate_auth_application();
1344        let success = self.get_success_response(analysis, op);
1345        let response_type = self.get_response_type(analysis, op);
1346        let op_error_type = self.op_error_type_token(op);
1347        let accept = success.accept;
1348        let error_handling = self.generate_error_handling(op, success);
1349        let (custom_headers, accept_header) = if let Some(media_type) = accept {
1350            (
1351                quote! {
1352                    for (name, value) in &self.custom_headers {
1353                        if !name.eq_ignore_ascii_case("accept") {
1354                            req = req.header(name, value);
1355                        }
1356                    }
1357                },
1358                quote! {
1359                    req = req.header(reqwest::header::ACCEPT, #media_type);
1360                },
1361            )
1362        } else {
1363            (
1364                quote! {
1365                    for (name, value) in &self.custom_headers {
1366                        req = req.header(name, value);
1367                    }
1368                },
1369                TokenStream::new(),
1370            )
1371        };
1372        let url_construction = self.generate_url_construction(path, op);
1373        let doc_comment = self.generate_operation_doc_comment(op);
1374
1375        quote! {
1376            #doc_comment
1377            pub async fn #method_name(
1378                &self,
1379                #request_param
1380            ) -> Result<#response_type, ApiOpError<#op_error_type>> {
1381                #url_construction
1382
1383                let mut req = #http_method_call;
1384                #request_body
1385
1386                #query_params
1387                #header_params
1388                #cookie_params
1389
1390                // Apply configured authentication (T3). Was previously
1391                // hardcoded to bearer_auth regardless of GeneratorConfig.
1392                #auth_application
1393
1394                // Add custom headers
1395                #custom_headers
1396
1397                // Keep content negotiation aligned with the generated return type,
1398                // replacing any custom Accept value for this operation.
1399                #accept_header
1400
1401                let response = req.send().await?;
1402                #error_handling
1403            }
1404        }
1405    }
1406
1407    /// T3: emit the auth-token application based on the configured AuthConfig.
1408    /// Default (no config) is Bearer on Authorization. ApiKey emits a custom
1409    /// header. Custom honors header_value_prefix.
1410    fn generate_auth_application(&self) -> TokenStream {
1411        use crate::http_config::AuthConfig;
1412        match &self.config().auth_config {
1413            Some(AuthConfig::Bearer { header_name }) if header_name == "Authorization" => quote! {
1414                if let Some(api_key) = &self.api_key {
1415                    req = req.bearer_auth(api_key);
1416                }
1417            },
1418            Some(AuthConfig::Bearer { header_name }) => {
1419                let h = header_name.clone();
1420                quote! {
1421                    if let Some(api_key) = &self.api_key {
1422                        req = req.header(#h, format!("Bearer {}", api_key));
1423                    }
1424                }
1425            }
1426            Some(AuthConfig::ApiKey { header_name }) => {
1427                let h = header_name.clone();
1428                quote! {
1429                    if let Some(api_key) = &self.api_key {
1430                        req = req.header(#h, api_key.as_str());
1431                    }
1432                }
1433            }
1434            Some(AuthConfig::Custom {
1435                header_name,
1436                header_value_prefix,
1437            }) => {
1438                let h = header_name.clone();
1439                let prefix = header_value_prefix.clone().unwrap_or_default();
1440                if prefix.is_empty() {
1441                    quote! {
1442                        if let Some(api_key) = &self.api_key {
1443                            req = req.header(#h, api_key.as_str());
1444                        }
1445                    }
1446                } else {
1447                    let format_str = format!("{}{{}}", prefix);
1448                    quote! {
1449                        if let Some(api_key) = &self.api_key {
1450                            req = req.header(#h, format!(#format_str, api_key));
1451                        }
1452                    }
1453                }
1454            }
1455            None => quote! {
1456                if let Some(api_key) = &self.api_key {
1457                    req = req.bearer_auth(api_key);
1458                }
1459            },
1460        }
1461    }
1462
1463    /// Generate header-parameter handling. Emits `req = req.header(name, ...)`
1464    /// for each `in: header` parameter — required headers unconditionally,
1465    /// optional ones gated on `Some(_)`.
1466    fn generate_header_params(&self, op: &OperationInfo) -> TokenStream {
1467        let header_params: Vec<_> = op
1468            .parameters
1469            .iter()
1470            .filter(|p| p.location == "header")
1471            .collect();
1472        if header_params.is_empty() {
1473            return quote! {};
1474        }
1475        let mut emit = Vec::new();
1476        for param in header_params {
1477            let param_name_snake = self.param_ident_str(param);
1478            let param_ident = Self::to_field_ident(&param_name_snake);
1479            let header_name = &param.name;
1480            if matches!(
1481                param.query_serialization,
1482                Some(crate::analysis::QuerySerialization::SimpleHeaderArray { .. })
1483            ) {
1484                let encode = quote! {
1485                    v.iter().map(::std::string::ToString::to_string).collect::<Vec<_>>().join(",")
1486                };
1487                if param.required {
1488                    emit.push(quote! {
1489                        let v = #param_ident;
1490                        req = req.header(#header_name, #encode);
1491                    });
1492                } else {
1493                    emit.push(quote! {
1494                        if let Some(v) = #param_ident {
1495                            req = req.header(#header_name, #encode);
1496                        }
1497                    });
1498                }
1499                continue;
1500            }
1501            if param.required {
1502                if Self::param_uses_as_ref_str(param) {
1503                    emit.push(quote! {
1504                        req = req.header(#header_name, #param_ident.as_ref());
1505                    });
1506                } else {
1507                    emit.push(quote! {
1508                        req = req.header(#header_name, #param_ident.to_string());
1509                    });
1510                }
1511            } else if Self::param_uses_as_ref_str(param) {
1512                emit.push(quote! {
1513                    if let Some(v) = #param_ident {
1514                        req = req.header(#header_name, v.as_ref());
1515                    }
1516                });
1517            } else {
1518                emit.push(quote! {
1519                    if let Some(v) = #param_ident {
1520                        req = req.header(#header_name, v.to_string());
1521                    }
1522                });
1523            }
1524        }
1525        quote! {
1526            #(#emit)*
1527        }
1528    }
1529
1530    fn generate_cookie_params(&self, op: &OperationInfo) -> TokenStream {
1531        let cookie_params: Vec<_> = op
1532            .parameters
1533            .iter()
1534            .filter(|parameter| parameter.location == "cookie")
1535            .collect();
1536        if cookie_params.is_empty() {
1537            return quote! {};
1538        }
1539        let mut emit = Vec::new();
1540        for parameter in cookie_params {
1541            let ident = Self::to_field_ident(&self.param_ident_str(parameter));
1542            let wire_name = parameter.name.as_str();
1543            if parameter.required {
1544                emit.push(quote! {
1545                    __cookie_fields.push(format!("{}={}", #wire_name, #ident));
1546                });
1547            } else {
1548                emit.push(quote! {
1549                    if let Some(value) = #ident {
1550                        __cookie_fields.push(format!("{}={}", #wire_name, value));
1551                    }
1552                });
1553            }
1554        }
1555        quote! {
1556            let mut __cookie_fields = Vec::new();
1557            #(#emit)*
1558            if !__cookie_fields.is_empty() {
1559                req = req.header(::reqwest::header::COOKIE, __cookie_fields.join("; "));
1560            }
1561        }
1562    }
1563
1564    /// Generate query parameter handling
1565    fn generate_query_params(&self, op: &OperationInfo) -> TokenStream {
1566        let query_params: Vec<_> = op
1567            .parameters
1568            .iter()
1569            .filter(|p| p.location == "query")
1570            .collect();
1571
1572        if query_params.is_empty() {
1573            return quote! {};
1574        }
1575
1576        let mut param_building = Vec::new();
1577        // Serialization applied on `req` directly, after the pair-vector
1578        // block: form-exploded objects and deepObject objects, whose keys
1579        // aren't the static parameter name.
1580        let mut req_appends = Vec::new();
1581
1582        for param in query_params {
1583            use crate::analysis::QuerySerialization;
1584
1585            // Use snake_case for Rust variable name with keyword escaping
1586            let param_name_snake = self.param_ident_str(param);
1587            let param_name = Self::to_field_ident(&param_name_snake);
1588
1589            // Use the original parameter name from OpenAPI spec as the query string key
1590            let param_key = &param.name;
1591
1592            match &param.query_serialization {
1593                Some(QuerySerialization::FormExplodedNestedObject { properties }) => {
1594                    let emit_properties = properties.iter().map(|property| {
1595                        let wire_name = property.wire_name.as_str();
1596                        let field_ident = CodeGenerator::to_field_ident(
1597                            &self.to_rust_field_name(wire_name),
1598                        );
1599                        match &property.value_type {
1600                            crate::analysis::QueryStructPropertyType::Scalar(_) => quote! {
1601                                let value = serde_json::to_value(&v.#field_ident)
1602                                    .map_err(HttpError::serialization_error)?;
1603                                if !value.is_null() {
1604                                    let value = match value {
1605                                        serde_json::Value::String(value) => value,
1606                                        serde_json::Value::Bool(value) => value.to_string(),
1607                                        serde_json::Value::Number(value) => value.to_string(),
1608                                        _ => return Err(HttpError::serialization_error(
1609                                            format!("query field `{}` did not serialize as a scalar", #wire_name)
1610                                        ).into()),
1611                                    };
1612                                    nested_params.push((format!("{}.{}", #param_key, #wire_name), value));
1613                                }
1614                            },
1615                            crate::analysis::QueryStructPropertyType::Object { properties } => {
1616                                let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>();
1617                                quote! {
1618                                    let value = serde_json::to_value(&v.#field_ident)
1619                                        .map_err(HttpError::serialization_error)?;
1620                                    if !value.is_null() {
1621                                        let serde_json::Value::Object(object) = value else {
1622                                            return Err(HttpError::serialization_error(format!("query field `{}` did not serialize as an object", #wire_name)).into());
1623                                        };
1624                                        if object.is_empty() {
1625                                            nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
1626                                        } else {
1627                                            for leaf in [#(#leaves),*] {
1628                                                let Some(value) = object.get(leaf) else { continue };
1629                                                if value.is_null() { continue; }
1630                                                let value = match value {
1631                                                    serde_json::Value::String(value) => value.clone(),
1632                                                    serde_json::Value::Bool(value) => value.to_string(),
1633                                                    serde_json::Value::Number(value) => value.to_string(),
1634                                                    _ => return Err(HttpError::serialization_error(format!("query field `{}` contained a non-scalar leaf", #wire_name)).into()),
1635                                                };
1636                                                nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, leaf), value));
1637                                            }
1638                                        }
1639                                    }
1640                                }
1641                            }
1642                            crate::analysis::QueryStructPropertyType::Array { item_type } => {
1643                                let nested_properties = match item_type {
1644                                    crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => Some(
1645                                        properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>()
1646                                    ),
1647                                    _ => None,
1648                                };
1649                                if let Some(nested_properties) = nested_properties {
1650                                    quote! {
1651                                        let values = serde_json::to_value(&v.#field_ident)
1652                                            .map_err(HttpError::serialization_error)?;
1653                                        if !values.is_null() {
1654                                            let serde_json::Value::Array(values) = values else {
1655                                                return Err(HttpError::serialization_error(
1656                                                    format!("query field `{}` did not serialize as an array", #wire_name)
1657                                                ).into());
1658                                            };
1659                                            if values.is_empty() {
1660                                                nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
1661                                            }
1662                                            for (index, value) in values.into_iter().enumerate() {
1663                                                let serde_json::Value::Object(object) = value else {
1664                                                    return Err(HttpError::serialization_error(
1665                                                        format!("query field `{}` contained a non-object item", #wire_name)
1666                                                    ).into());
1667                                                };
1668                                                for leaf in [#(#nested_properties),*] {
1669                                                    let Some(value) = object.get(leaf) else { continue };
1670                                                    if value.is_null() { continue; }
1671                                                    let value = match value {
1672                                                        serde_json::Value::String(value) => value.clone(),
1673                                                        serde_json::Value::Bool(value) => value.to_string(),
1674                                                        serde_json::Value::Number(value) => value.to_string(),
1675                                                        _ => return Err(HttpError::serialization_error(
1676                                                            format!("query field `{}` contained a non-scalar leaf", #wire_name)
1677                                                        ).into()),
1678                                                    };
1679                                                    nested_params.push((format!("{}.{}.{}.{}", #param_key, #wire_name, index + 1, leaf), value));
1680                                                }
1681                                            }
1682                                        }
1683                                    }
1684                                } else {
1685                                    quote! {
1686                                        let values = serde_json::to_value(&v.#field_ident)
1687                                            .map_err(HttpError::serialization_error)?;
1688                                        if !values.is_null() {
1689                                            let serde_json::Value::Array(values) = values else {
1690                                                return Err(HttpError::serialization_error(
1691                                                    format!("query field `{}` did not serialize as an array", #wire_name)
1692                                                ).into());
1693                                            };
1694                                            if values.is_empty() {
1695                                                nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
1696                                            }
1697                                            for (index, value) in values.into_iter().enumerate() {
1698                                                let value = match value {
1699                                                    serde_json::Value::String(value) => value,
1700                                                    serde_json::Value::Bool(value) => value.to_string(),
1701                                                    serde_json::Value::Number(value) => value.to_string(),
1702                                                    _ => return Err(HttpError::serialization_error(
1703                                                        format!("query field `{}` contained a non-scalar item", #wire_name)
1704                                                    ).into()),
1705                                                };
1706                                                nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, index + 1), value));
1707                                            }
1708                                        }
1709                                    }
1710                                }
1711                            }
1712                        }
1713                    }).collect::<Vec<_>>();
1714                    let apply = quote! {
1715                        let mut nested_params: Vec<(String, String)> = Vec::new();
1716                        #(#emit_properties)*
1717                        if nested_params.is_empty() {
1718                            nested_params.push((format!("{}[]", #param_key), String::new()));
1719                        }
1720                        req = req.query(&nested_params);
1721                    };
1722                    if param.required {
1723                        req_appends.push(quote! {{ let v = #param_name; #apply }});
1724                    } else {
1725                        req_appends.push(quote! { if let Some(v) = #param_name { #apply } });
1726                    }
1727                    continue;
1728                }
1729                Some(QuerySerialization::FormExplodedObject) => {
1730                    // Issue #27: reqwest serializes the struct through
1731                    // serde_urlencoded, so each property becomes its own
1732                    // `key=value` pair; the parameter's own name never
1733                    // appears in the query string (RFC 6570 form-explosion).
1734                    // `name[]=` is the shared zero-cardinality marker used to
1735                    // preserve Some(empty) and required-empty values.
1736                    let apply = quote! {
1737                        let __empty = match serde_json::to_value(&v)
1738                            .map_err(HttpError::serialization_error)?
1739                        {
1740                            serde_json::Value::Object(map) => map.is_empty(),
1741                            _ => false,
1742                        };
1743                        if __empty {
1744                            req = req.query(&[(format!("{}[]", #param_key), String::new())]);
1745                        } else {
1746                            req = req.query(&v);
1747                        }
1748                    };
1749                    if param.required {
1750                        req_appends.push(quote! {
1751                            {
1752                                let v = #param_name;
1753                                #apply
1754                            }
1755                        });
1756                    } else {
1757                        req_appends.push(quote! {
1758                            if let Some(v) = #param_name {
1759                                #apply
1760                            }
1761                        });
1762                    }
1763                    continue;
1764                }
1765                Some(QuerySerialization::DeepObject) => {
1766                    // `?filter[color]=red&filter[size]=5`. Property values
1767                    // stringify through their JSON form; Null (unset
1768                    // Option) properties are skipped.
1769                    let apply = quote! {
1770                        let map = match serde_json::to_value(&v)
1771                            .map_err(HttpError::serialization_error)?
1772                        {
1773                            serde_json::Value::Object(map) => map,
1774                            _ => return Err(HttpError::serialization_error(
1775                                format!("query parameter `{}` did not serialize as an object", #param_key)
1776                            ).into()),
1777                        };
1778                        let mut deep_params: Vec<(String, String)> = Vec::new();
1779                        for (k, val) in map {
1780                            let s = match val {
1781                                serde_json::Value::Null => continue,
1782                                serde_json::Value::String(s) => s,
1783                                other => other.to_string(),
1784                            };
1785                            deep_params.push((format!("{}[{}]", #param_key, k), s));
1786                        }
1787                        if deep_params.is_empty() {
1788                            deep_params.push((format!("{}[]", #param_key), String::new()));
1789                        }
1790                        req = req.query(&deep_params);
1791                    };
1792                    if param.required {
1793                        req_appends.push(quote! {
1794                            {
1795                                let v = #param_name;
1796                                #apply
1797                            }
1798                        });
1799                    } else {
1800                        req_appends.push(quote! {
1801                            if let Some(v) = #param_name {
1802                                #apply
1803                            }
1804                        });
1805                    }
1806                    continue;
1807                }
1808                Some(QuerySerialization::FormObject) => {
1809                    // `?filter=color,red,size,big` — one pair whose value is
1810                    // the comma-joined key,value list (RFC 6570 form,
1811                    // explode=false).
1812                    let apply = quote! {
1813                        let map = match serde_json::to_value(&v)
1814                            .map_err(HttpError::serialization_error)?
1815                        {
1816                            serde_json::Value::Object(map) => map,
1817                            _ => return Err(HttpError::serialization_error(
1818                                format!("query parameter `{}` did not serialize as an object", #param_key)
1819                            ).into()),
1820                        };
1821                        let mut parts: Vec<String> = Vec::new();
1822                        for (k, val) in map {
1823                            let s = match val {
1824                                serde_json::Value::Null => continue,
1825                                serde_json::Value::String(s) => s,
1826                                other => other.to_string(),
1827                            };
1828                            if k.contains(',') || s.contains(',') {
1829                                return Err(HttpError::serialization_error(
1830                                    format!(
1831                                        "query object `{}` contains a comma in key `{}`; use explode=true for lossless string values",
1832                                        #param_key,
1833                                        k,
1834                                    )
1835                                ).into());
1836                            }
1837                            parts.push(k);
1838                            parts.push(s);
1839                        }
1840                        if parts.is_empty() {
1841                            query_params.push((
1842                                format!("{}[]", #param_key),
1843                                String::new(),
1844                            ));
1845                        } else {
1846                            query_params.push((#param_key.to_string(), parts.join(",")));
1847                        }
1848                    };
1849                    if param.required {
1850                        param_building.push(quote! {
1851                            {
1852                                let v = #param_name;
1853                                #apply
1854                            }
1855                        });
1856                    } else {
1857                        param_building.push(quote! {
1858                            if let Some(v) = #param_name {
1859                                #apply
1860                            }
1861                        });
1862                    }
1863                    continue;
1864                }
1865                Some(QuerySerialization::FormExplodedArray { item_type }) => {
1866                    // `?tags=a&tags=b` — one pair per element; flat structures
1867                    // expand AWS query-protocol style as `?tags.1.Key=k&tags.1.Value=v`.
1868                    let struct_properties = match item_type {
1869                        crate::analysis::ArrayItemType::FlatStructRef { properties, .. }
1870                        | crate::analysis::ArrayItemType::NestedStructRef { properties, .. } => {
1871                            Some(properties.clone())
1872                        }
1873                        _ => None,
1874                    };
1875                    let emit_items = if let Some(properties) = struct_properties {
1876                        let pushes = properties
1877                            .iter()
1878                            .map(|property| {
1879                                let wire_name = &property.wire_name;
1880                                // Wire names such as `Type` land on struct
1881                                // fields via the same keyword-escaping the
1882                                // model generator uses (`r#type`).
1883                                let field_ident = CodeGenerator::to_field_ident(
1884                                    &self.to_rust_field_name(wire_name),
1885                                );
1886                                match &property.value_type {
1887                                    crate::analysis::QueryStructPropertyType::Scalar(_) => quote! {
1888                                        let value = serde_json::to_value(&item.#field_ident)
1889                                            .map_err(HttpError::serialization_error)?;
1890                                        if !value.is_null() {
1891                                            let value = match value {
1892                                                serde_json::Value::String(value) => value,
1893                                                serde_json::Value::Bool(value) => value.to_string(),
1894                                                serde_json::Value::Number(value) => value.to_string(),
1895                                                _ => return Err(HttpError::serialization_error(
1896                                                    format!("query field `{}.{}` did not serialize as a scalar", #param_key, #wire_name)
1897                                                ).into()),
1898                                            };
1899                                            query_params.push((
1900                                                format!("{}.{}.{}", #param_key, index, #wire_name),
1901                                                value,
1902                                            ));
1903                                        }
1904                                    },
1905                                    crate::analysis::QueryStructPropertyType::Object { properties } => {
1906                                        let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>();
1907                                        quote! {
1908                                            let value = serde_json::to_value(&item.#field_ident)
1909                                                .map_err(HttpError::serialization_error)?;
1910                                            if !value.is_null() {
1911                                                let serde_json::Value::Object(object) = value else {
1912                                                    return Err(HttpError::serialization_error(format!("query field `{}.{}` did not serialize as an object", #param_key, #wire_name)).into());
1913                                                };
1914                                                if object.is_empty() {
1915                                                    query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
1916                                                }
1917                                                for leaf in [#(#leaves),*] {
1918                                                    let Some(value) = object.get(leaf) else { continue };
1919                                                    if value.is_null() { continue; }
1920                                                    let value = match value {
1921                                                        serde_json::Value::String(value) => value.clone(),
1922                                                        serde_json::Value::Bool(value) => value.to_string(),
1923                                                        serde_json::Value::Number(value) => value.to_string(),
1924                                                        _ => return Err(HttpError::serialization_error(format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name)).into()),
1925                                                    };
1926                                                    query_params.push((format!("{}.{}.{}.{}", #param_key, index, #wire_name, leaf), value));
1927                                                }
1928                                            }
1929                                        }
1930                                    }
1931                                    crate::analysis::QueryStructPropertyType::Array { item_type } => {
1932                                        let nested_properties = match item_type {
1933                                            crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => {
1934                                                Some(properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>())
1935                                            }
1936                                            _ => None,
1937                                        };
1938                                        if let Some(nested_properties) = nested_properties {
1939                                            quote! {
1940                                                let values = serde_json::to_value(&item.#field_ident)
1941                                                    .map_err(HttpError::serialization_error)?;
1942                                                if !values.is_null() {
1943                                                    let serde_json::Value::Array(values) = values else {
1944                                                        return Err(HttpError::serialization_error(
1945                                                            format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name)
1946                                                        ).into());
1947                                                    };
1948                                                    if values.is_empty() {
1949                                                        query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
1950                                                    }
1951                                                    for (nested_index, value) in values.into_iter().enumerate() {
1952                                                        let serde_json::Value::Object(object) = value else {
1953                                                            return Err(HttpError::serialization_error(
1954                                                                format!("query field `{}.{}` contained a non-object item", #param_key, #wire_name)
1955                                                            ).into());
1956                                                        };
1957                                                        if object.is_empty() {
1958                                                            query_params.push((format!("{}.{}.{}.{}[]", #param_key, index, #wire_name, nested_index + 1), String::new()));
1959                                                        }
1960                                                        for nested_wire_name in [#(#nested_properties),*] {
1961                                                            let Some(value) = object.get(nested_wire_name) else { continue };
1962                                                            if value.is_null() { continue; }
1963                                                            let value = match value {
1964                                                                serde_json::Value::String(value) => value.clone(),
1965                                                                serde_json::Value::Bool(value) => value.to_string(),
1966                                                                serde_json::Value::Number(value) => value.to_string(),
1967                                                                _ => return Err(HttpError::serialization_error(
1968                                                                    format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name)
1969                                                                ).into()),
1970                                                            };
1971                                                            query_params.push((
1972                                                                format!("{}.{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1, nested_wire_name),
1973                                                                value,
1974                                                            ));
1975                                                        }
1976                                                    }
1977                                                }
1978                                            }
1979                                        } else {
1980                                            quote! {
1981                                                let values = serde_json::to_value(&item.#field_ident)
1982                                                    .map_err(HttpError::serialization_error)?;
1983                                                if !values.is_null() {
1984                                                    let serde_json::Value::Array(values) = values else {
1985                                                        return Err(HttpError::serialization_error(
1986                                                            format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name)
1987                                                        ).into());
1988                                                    };
1989                                                    if values.is_empty() {
1990                                                        query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
1991                                                    }
1992                                                    for (nested_index, value) in values.into_iter().enumerate() {
1993                                                        let value = match value {
1994                                                            serde_json::Value::String(value) => value,
1995                                                            serde_json::Value::Bool(value) => value.to_string(),
1996                                                            serde_json::Value::Number(value) => value.to_string(),
1997                                                            _ => return Err(HttpError::serialization_error(
1998                                                                format!("query field `{}.{}` contained a non-scalar item", #param_key, #wire_name)
1999                                                            ).into()),
2000                                                        };
2001                                                        query_params.push((
2002                                                            format!("{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1),
2003                                                            value,
2004                                                        ));
2005                                                    }
2006                                                }
2007                                            }
2008                                        }
2009                                    }
2010                                }
2011                            })
2012                            .collect::<Vec<_>>();
2013                        quote! {
2014                            for (index, item) in v.iter().enumerate() {
2015                                let index = index + 1;
2016                                let item_start_len = query_params.len();
2017                                #(#pushes)*
2018                                if query_params.len() == item_start_len {
2019                                    query_params.push((format!("{}.{}[]", #param_key, index), String::new()));
2020                                }
2021                            }
2022                        }
2023                    } else {
2024                        quote! {
2025                            for item in v {
2026                                query_params.push((#param_key.to_string(), item.to_string()));
2027                            }
2028                        }
2029                    };
2030                    if param.required {
2031                        param_building.push(quote! {
2032                            let v = #param_name;
2033                            if v.is_empty() {
2034                                query_params.push((
2035                                    format!("{}[]", #param_key),
2036                                    String::new(),
2037                                ));
2038                            } else {
2039                                #emit_items
2040                            }
2041                        });
2042                    } else {
2043                        param_building.push(quote! {
2044                            if let Some(v) = #param_name {
2045                                if v.is_empty() {
2046                                    query_params.push((
2047                                        format!("{}[]", #param_key),
2048                                        String::new(),
2049                                    ));
2050                                } else {
2051                                    #emit_items
2052                                }
2053                            }
2054                        });
2055                    }
2056                    continue;
2057                }
2058                Some(QuerySerialization::FormArray { .. }) => {
2059                    // `?tags=a,b,c` — one comma-joined pair. Empty vectors
2060                    // use the shared `tags[]=` zero-cardinality marker.
2061                    let apply = quote! {
2062                        if v.is_empty() {
2063                            query_params.push((
2064                                format!("{}[]", #param_key),
2065                                String::new(),
2066                            ));
2067                        } else {
2068                            let mut parts = Vec::with_capacity(v.len());
2069                            for item in &v {
2070                                let item = item.to_string();
2071                                if item.contains(',') {
2072                                    return Err(HttpError::serialization_error(
2073                                        format!(
2074                                            "query array `{}` contains a comma; use explode=true for lossless string values",
2075                                            #param_key,
2076                                        )
2077                                    ).into());
2078                                }
2079                                parts.push(item);
2080                            }
2081                            query_params.push((
2082                                #param_key.to_string(),
2083                                parts.join(","),
2084                            ));
2085                        }
2086                    };
2087                    if param.required {
2088                        param_building.push(quote! {
2089                            {
2090                                let v = #param_name;
2091                                #apply
2092                            }
2093                        });
2094                    } else {
2095                        param_building.push(quote! {
2096                            if let Some(v) = #param_name {
2097                                #apply
2098                            }
2099                        });
2100                    }
2101                    continue;
2102                }
2103                Some(
2104                    QuerySerialization::Unsupported { .. }
2105                    | QuerySerialization::SimpleHeaderArray { .. },
2106                ) => {}
2107                None => {}
2108            }
2109
2110            if param.required {
2111                // Required parameters: always add
2112                if Self::param_uses_as_ref_str(param) {
2113                    param_building.push(quote! {
2114                        query_params.push((#param_key.to_string(), #param_name.as_ref().to_string()));
2115                    });
2116                } else {
2117                    param_building.push(quote! {
2118                        query_params.push((#param_key.to_string(), #param_name.to_string()));
2119                    });
2120                }
2121            } else {
2122                // Optional parameters: add only if Some
2123                if Self::param_uses_as_ref_str(param) {
2124                    param_building.push(quote! {
2125                        if let Some(v) = #param_name {
2126                            query_params.push((#param_key.to_string(), v.as_ref().to_string()));
2127                        }
2128                    });
2129                } else {
2130                    param_building.push(quote! {
2131                        if let Some(v) = #param_name {
2132                            query_params.push((#param_key.to_string(), v.to_string()));
2133                        }
2134                    });
2135                }
2136            }
2137        }
2138
2139        // Ops whose query params all serialize on `req` directly skip the
2140        // pair-vector block entirely.
2141        let pairs_block = if param_building.is_empty() {
2142            quote! {}
2143        } else {
2144            quote! {
2145                {
2146                    let mut query_params: Vec<(String, String)> = Vec::new();
2147                    #(#param_building)*
2148                    if !query_params.is_empty() {
2149                        req = req.query(&query_params);
2150                    }
2151                }
2152            }
2153        };
2154
2155        quote! {
2156            // Add query parameters
2157            #pairs_block
2158            #(#req_appends)*
2159        }
2160    }
2161
2162    /// Generate the rustdoc block for an operation, surfacing summary,
2163    /// description, the HTTP method+path, and any tags from the OAS spec
2164    /// (T13). Also marks the method `#[deprecated]` if the operation is.
2165    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
2166        let method = op.method.to_uppercase();
2167        let path = &op.path;
2168        let mut docs: Vec<String> = Vec::new();
2169        if let Some(s) = &op.summary {
2170            if !s.is_empty() {
2171                docs.push(s.clone());
2172                docs.push(String::new());
2173            }
2174        }
2175        if let Some(d) = &op.description {
2176            if !d.is_empty() {
2177                for line in d.lines() {
2178                    docs.push(line.to_string());
2179                }
2180                docs.push(String::new());
2181            }
2182        }
2183        docs.push(format!("`{} {}`", method, path));
2184        let doc_attrs: Vec<TokenStream> = docs
2185            .iter()
2186            .map(|line| {
2187                let prefixed = if line.is_empty() {
2188                    String::new()
2189                } else {
2190                    format!(" {line}")
2191                };
2192                quote! { #[doc = #prefixed] }
2193            })
2194            .collect();
2195        quote! { #(#doc_attrs)* }
2196    }
2197
2198    /// Get the method name from the operation
2199    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
2200        let name = if !op.operation_id.is_empty() {
2201            op.operation_id.to_snake_case()
2202        } else {
2203            // Fallback: generate from HTTP method and path
2204            format!(
2205                "{}_{}",
2206                op.method,
2207                op.path.replace('/', "_").replace(['{', '}'], "")
2208            )
2209            .to_snake_case()
2210        };
2211
2212        syn::Ident::new(&name, proc_macro2::Span::call_site())
2213    }
2214
2215    /// Build the request-builder expression for the operation's HTTP method.
2216    /// Named reqwest methods (`.get`/`.post`/…) are used where available;
2217    /// OPTIONS and TRACE go through `Client::request(Method::OPTIONS, _)` since
2218    /// reqwest doesn't expose those as named methods.
2219    fn http_method_call(&self, op: &OperationInfo) -> TokenStream {
2220        match op.method.to_uppercase().as_str() {
2221            "GET" => quote! { self.http_client.get(request_url) },
2222            "POST" => quote! { self.http_client.post(request_url) },
2223            "PUT" => quote! { self.http_client.put(request_url) },
2224            "DELETE" => quote! { self.http_client.delete(request_url) },
2225            "PATCH" => quote! { self.http_client.patch(request_url) },
2226            "HEAD" => quote! { self.http_client.head(request_url) },
2227            "OPTIONS" => quote! {
2228                self.http_client.request(reqwest::Method::OPTIONS, request_url)
2229            },
2230            "TRACE" => quote! {
2231                self.http_client.request(reqwest::Method::TRACE, request_url)
2232            },
2233            // D1: 3.2 `QUERY` verb + any custom verb from
2234            // PathItem.additionalOperations. reqwest's Method::from_bytes
2235            // accepts arbitrary uppercase tokens that match the RFC7230
2236            // method grammar.
2237            other => {
2238                let upper = other.to_string();
2239                quote! {
2240                    self.http_client.request(
2241                        reqwest::Method::from_bytes(#upper.as_bytes())
2242                            .expect("invalid HTTP method"),
2243                        request_url,
2244                    )
2245                }
2246            }
2247        }
2248    }
2249
2250    /// Generate request parameters including path, query, header, and request body.
2251    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
2252        let mut params = Vec::new();
2253        // Dedup parameter Rust idents within this method signature. Real-world
2254        // specs sometimes declare two parameters that sanitize to the same
2255        // snake_case name (modern-treasury declared `name` twice across
2256        // different param objects). Suffixing with `_2`, `_3`, … keeps each
2257        // parameter accessible while preserving the original wire-level name
2258        // (which is used elsewhere as the query/path/header key).
2259        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
2260        let mut unique_param_ident = |raw: String| -> syn::Ident {
2261            let mut chosen = raw.clone();
2262            let mut suffix = 2;
2263            while !used.insert(chosen.clone()) {
2264                chosen = format!("{raw}_{suffix}");
2265                suffix += 1;
2266            }
2267            Self::to_field_ident(&chosen)
2268        };
2269
2270        // Add path parameters
2271        for param in &op.parameters {
2272            if param.location == "path" {
2273                let param_name_snake = self.param_ident_str(param);
2274                let param_name = unique_param_ident(param_name_snake);
2275                let param_type = self.get_param_rust_type(param);
2276                params.push(quote! { #param_name: #param_type });
2277            }
2278        }
2279
2280        // Add query parameters (all as Option<T>)
2281        for param in &op.parameters {
2282            if param.location == "query" {
2283                let param_name_snake = self.param_ident_str(param);
2284                let param_name = unique_param_ident(param_name_snake);
2285                let param_type = self.get_param_rust_type(param);
2286
2287                // Query parameters should be Option unless explicitly required
2288                if param.required {
2289                    params.push(quote! { #param_name: #param_type });
2290                } else {
2291                    params.push(quote! { #param_name: Option<#param_type> });
2292                }
2293            }
2294        }
2295
2296        // Add header parameters. Required headers are bare; optional ones are
2297        // Option<T>. Per OAS 3.x §"Parameter Object", header names matching
2298        // `Accept`, `Content-Type`, and `Authorization` are forbidden — those
2299        // are described by other mechanisms — but we leave that validation to
2300        // analysis.
2301        for param in &op.parameters {
2302            if param.location == "header" {
2303                let param_name_snake = self.param_ident_str(param);
2304                let param_name = unique_param_ident(param_name_snake);
2305                let param_type = self.get_param_rust_type(param);
2306                if param.required {
2307                    params.push(quote! { #param_name: #param_type });
2308                } else {
2309                    params.push(quote! { #param_name: Option<#param_type> });
2310                }
2311            }
2312        }
2313
2314        for param in &op.parameters {
2315            if param.location == "cookie" {
2316                let param_name_snake = self.param_ident_str(param);
2317                let param_name = unique_param_ident(param_name_snake);
2318                let param_type = self.get_param_rust_type(param);
2319                if param.required {
2320                    params.push(quote! { #param_name: #param_type });
2321                } else {
2322                    params.push(quote! { #param_name: Option<#param_type> });
2323                }
2324            }
2325        }
2326
2327        // Add request body parameter based on content type. Optional bodies
2328        // (`requestBody.required` is false or absent) become `Option<T>` per T11.
2329        if let Some(ref rb) = op.request_body {
2330            use crate::analysis::RequestBodyContent;
2331            if matches!(rb, RequestBodyContent::SchemaLess { .. }) {
2332                return if params.is_empty() {
2333                    quote! {}
2334                } else {
2335                    quote! { #(#params),* }
2336                };
2337            }
2338            let required = op.request_body_required;
2339            let body_type = match rb {
2340                RequestBodyContent::Json { schema_name, .. }
2341                | RequestBodyContent::FormUrlEncoded { schema_name, .. }
2342                | RequestBodyContent::Multipart { schema_name, .. } => {
2343                    let rust_type_name = self.to_rust_type_name(schema_name);
2344                    let request_ident =
2345                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
2346                    quote! { #request_ident }
2347                }
2348
2349                RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. } => {
2350                    quote! { Vec<u8> }
2351                }
2352                RequestBodyContent::TextPlain { .. } => quote! { String },
2353                RequestBodyContent::Unsupported { .. } => quote! { Vec<u8> },
2354                RequestBodyContent::SchemaLess { .. } => unreachable!(
2355                    "schema-less request bodies preserve the historical client signature"
2356                ),
2357            };
2358            let body_ident = match rb {
2359                RequestBodyContent::OctetStream { .. }
2360                | RequestBodyContent::Binary { .. }
2361                | RequestBodyContent::TextPlain { .. }
2362                | RequestBodyContent::Unsupported { .. } => quote! { body },
2363                RequestBodyContent::SchemaLess { .. } => unreachable!(
2364                    "schema-less request bodies preserve the historical client signature"
2365                ),
2366                _ => quote! { request },
2367            };
2368            if required {
2369                params.push(quote! { #body_ident: #body_type });
2370            } else {
2371                params.push(quote! { #body_ident: Option<#body_type> });
2372            }
2373        }
2374
2375        if params.is_empty() {
2376            quote! {}
2377        } else {
2378            quote! { #(#params),* }
2379        }
2380    }
2381
2382    /// Get the Rust type for a parameter
2383    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
2384        if Self::param_has_impl_as_ref_type(param) {
2385            quote! { impl AsRef<str> }
2386        } else {
2387            self.get_param_owned_rust_type(param)
2388        }
2389    }
2390
2391    /// Owned parameter type shared by client-builder storage and generated
2392    /// server extraction. [`ParameterInfo::query_serialization`] is the
2393    /// authoritative projection for typed query objects and arrays.
2394    pub(crate) fn get_param_owned_rust_type(
2395        &self,
2396        param: &crate::analysis::ParameterInfo,
2397    ) -> TokenStream {
2398        use crate::analysis::QuerySerialization;
2399        // Typed form-style arrays take Vec<item> (openapi-generator-anu).
2400        // Scalars parse as-is (they may be type paths from [type_mappings]);
2401        // schema refs are raw schema names and go through the same
2402        // to_rust_type_name sanitization as every other schema reference
2403        // (cloudflare has enum schemas like `resource-sharing_resource_type`).
2404        if let Some(
2405            QuerySerialization::FormExplodedArray { item_type }
2406            | QuerySerialization::FormArray { item_type }
2407            | QuerySerialization::SimpleHeaderArray { item_type },
2408        ) = &param.query_serialization
2409        {
2410            use crate::analysis::ArrayItemType;
2411            let item_ty: syn::Type = match item_type {
2412                ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type)
2413                    .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")),
2414                ArrayItemType::SchemaRef(schema_name) => {
2415                    let rust_name = self.to_rust_type_name(schema_name);
2416                    syn::parse_str(&rust_name)
2417                        .unwrap_or_else(|_| panic!("invalid schema item type `{rust_name}`"))
2418                }
2419                ArrayItemType::FlatStructRef { schema_name, .. } => {
2420                    let rust_name = self.to_rust_type_name(schema_name);
2421                    syn::parse_str(&rust_name)
2422                        .unwrap_or_else(|_| panic!("invalid struct item type `{rust_name}`"))
2423                }
2424                ArrayItemType::NestedStructRef { schema_name, .. } => {
2425                    let rust_name = self.to_rust_type_name(schema_name);
2426                    syn::parse_str(&rust_name)
2427                        .unwrap_or_else(|_| panic!("invalid nested struct item type `{rust_name}`"))
2428                }
2429            };
2430            return quote! { Vec<#item_ty> };
2431        }
2432        // T10: $ref-typed parameters used to lose their type because we only
2433        // consulted `rust_type` (which stays "String"). Now: prefer the
2434        // resolved schema reference if present.
2435        if let Some(ref schema_name) = param.schema_ref {
2436            let rust_name = self.to_rust_type_name(schema_name);
2437            let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site());
2438            return quote! { #ident };
2439        }
2440        syn::parse_str::<syn::Type>(&param.rust_type)
2441            .map(|ty| quote! { #ty })
2442            .unwrap_or_else(|_| {
2443                let type_ident = syn::Ident::new(&param.rust_type, proc_macro2::Span::call_site());
2444                quote! { #type_ident }
2445            })
2446    }
2447
2448    /// True when the parameter's compile-time type is `impl AsRef<str>` and
2449    /// we should call `.as_ref()` on it before stringifying. False for any
2450    /// $ref-resolved type (T10) or non-String primitive — those just call
2451    /// `.to_string()`.
2452    fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
2453        param.schema_ref.is_none() && param.rust_type == "String"
2454    }
2455
2456    fn resolve_multipart_wire_schema<'a>(
2457        schema: &'a serde_json::Value,
2458        analysis: &'a SchemaAnalysis,
2459        visited: &mut std::collections::HashSet<String>,
2460    ) -> Option<&'a serde_json::Value> {
2461        let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) else {
2462            return Some(schema);
2463        };
2464        let name = reference.strip_prefix("#/components/schemas/")?;
2465        if !visited.insert(name.to_string()) {
2466            return None;
2467        }
2468        let resolved = analysis.validation_context.component_schemas.get(name)?;
2469        Self::resolve_multipart_wire_schema(resolved, analysis, visited)
2470    }
2471
2472    fn multipart_client_field_kind(
2473        schema_type: &crate::analysis::SchemaType,
2474        analysis: &SchemaAnalysis,
2475        visited: &mut std::collections::HashSet<String>,
2476    ) -> Option<MultipartClientFieldKind> {
2477        match schema_type {
2478            crate::analysis::SchemaType::Primitive {
2479                rust_type,
2480                serde_with,
2481            } => {
2482                let rust_type = rust_type.replace(' ', "");
2483                if rust_type == "bytes::Bytes" {
2484                    Some(MultipartClientFieldKind::RawBytes)
2485                } else if serde_with
2486                    .as_deref()
2487                    .is_some_and(|codec| codec.contains("base64_url"))
2488                {
2489                    Some(MultipartClientFieldKind::Base64UrlUnpadded)
2490                } else if serde_with
2491                    .as_deref()
2492                    .is_some_and(|codec| codec.contains("base64"))
2493                    || rust_type == "Vec<u8>"
2494                {
2495                    Some(MultipartClientFieldKind::Base64)
2496                } else {
2497                    Some(MultipartClientFieldKind::Text)
2498                }
2499            }
2500            crate::analysis::SchemaType::StringEnum { .. }
2501            | crate::analysis::SchemaType::ExtensibleEnum { .. } => {
2502                Some(MultipartClientFieldKind::Text)
2503            }
2504            crate::analysis::SchemaType::Reference { target } => {
2505                if !visited.insert(target.clone()) {
2506                    return None;
2507                }
2508                analysis.schemas.get(target).and_then(|schema| {
2509                    Self::multipart_client_field_kind(&schema.schema_type, analysis, visited)
2510                })
2511            }
2512            _ => None,
2513        }
2514    }
2515
2516    fn generate_typed_multipart_form(
2517        &self,
2518        schema_name: &str,
2519        validation_schema: &serde_json::Value,
2520        analysis: &SchemaAnalysis,
2521    ) -> TokenStream {
2522        use crate::analysis::{ObjectAdditionalProperties, SchemaType};
2523
2524        let Some((resolved_name, resolved_schema)) =
2525            self.resolve_reference_schema(schema_name, analysis)
2526        else {
2527            let message = format!(
2528                "multipart request schema `{schema_name}` could not be resolved during generation"
2529            );
2530            return quote! {
2531                return Err(HttpError::Config(#message.to_string()).into());
2532            };
2533        };
2534        let SchemaType::Object {
2535            properties,
2536            required,
2537            additional_properties,
2538        } = &resolved_schema.schema_type
2539        else {
2540            let message =
2541                format!("multipart request schema `{schema_name}` must resolve to an object");
2542            return quote! {
2543                return Err(HttpError::Config(#message.to_string()).into());
2544            };
2545        };
2546        let wire_schema = Self::resolve_multipart_wire_schema(
2547            validation_schema,
2548            analysis,
2549            &mut std::collections::HashSet::new(),
2550        );
2551        let wire_properties = wire_schema
2552            .and_then(|schema| schema.get("properties"))
2553            .and_then(serde_json::Value::as_object);
2554        let fields = self.emitted_object_properties(
2555            resolved_name,
2556            properties,
2557            required,
2558            additional_properties,
2559            analysis,
2560            None,
2561        );
2562        if matches!(
2563            additional_properties,
2564            ObjectAdditionalProperties::Typed { .. }
2565        ) {
2566            let message = format!(
2567                "multipart request schema `{schema_name}` cannot contain typed additional properties"
2568            );
2569            return quote! {
2570                return Err(HttpError::Config(#message.to_string()).into());
2571            };
2572        }
2573
2574        let mut parts = Vec::new();
2575        for field in fields {
2576            let wire_name = field.wire_name;
2577            let ident = field.ident;
2578            let wire_format = wire_properties
2579                .and_then(|properties| properties.get(wire_name))
2580                .and_then(|schema| {
2581                    Self::resolve_multipart_wire_schema(
2582                        schema,
2583                        analysis,
2584                        &mut std::collections::HashSet::new(),
2585                    )
2586                })
2587                .and_then(|schema| schema.get("format"))
2588                .and_then(serde_json::Value::as_str);
2589            let kind = if wire_format == Some("binary") {
2590                match self.config().types.binary {
2591                    crate::type_mapping::BinaryStrategy::String => MultipartClientFieldKind::Text,
2592                    crate::type_mapping::BinaryStrategy::Bytes
2593                    | crate::type_mapping::BinaryStrategy::VecU8 => {
2594                        MultipartClientFieldKind::RawBytes
2595                    }
2596                }
2597            } else if let Some(kind) = Self::multipart_client_field_kind(
2598                &field.property.schema_type,
2599                analysis,
2600                &mut std::collections::HashSet::new(),
2601            ) {
2602                kind
2603            } else {
2604                let message =
2605                    format!("multipart field `{wire_name}` must be binary or a scalar text field");
2606                return quote! {
2607                    return Err(HttpError::Config(#message.to_string()).into());
2608                };
2609            };
2610            let add_value = match kind {
2611                MultipartClientFieldKind::RawBytes => quote! {
2612                    form = form.part(
2613                        #wire_name,
2614                        reqwest::multipart::Part::bytes(value.to_vec()),
2615                    );
2616                },
2617                MultipartClientFieldKind::Base64 => quote! {
2618                    use base64::Engine as _;
2619                    form = form.text(
2620                        #wire_name,
2621                        base64::engine::general_purpose::STANDARD.encode(value),
2622                    );
2623                },
2624                MultipartClientFieldKind::Base64UrlUnpadded => quote! {
2625                    use base64::Engine as _;
2626                    form = form.text(
2627                        #wire_name,
2628                        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value),
2629                    );
2630                },
2631                MultipartClientFieldKind::Text => quote! {
2632                    form = form.text(#wire_name, value.to_string());
2633                },
2634            };
2635            parts.push(if field.is_required {
2636                quote! {
2637                    let value = &request.#ident;
2638                    #add_value
2639                }
2640            } else {
2641                quote! {
2642                    if let Some(value) = &request.#ident {
2643                        #add_value
2644                    }
2645                }
2646            });
2647        }
2648
2649        quote! {
2650            let mut form = reqwest::multipart::Form::new();
2651            #(#parts)*
2652            req = req.multipart(form);
2653        }
2654    }
2655
2656    /// Generate request body serialization based on content type
2657    /// Emit statements that mutate `req` to apply the request body. Returns
2658    /// explicit zero-length framing for bodyless POST, PUT, and PATCH requests.
2659    /// Optional bodies (T11) gate the application on `Some(_)`; required bodies
2660    /// apply unconditionally.
2661    fn generate_request_body(&self, op: &OperationInfo, analysis: &SchemaAnalysis) -> TokenStream {
2662        let empty_request_framing = Self::generate_empty_request_framing(op);
2663        let Some(rb) = op.request_body.as_ref() else {
2664            return empty_request_framing;
2665        };
2666        use crate::analysis::RequestBodyContent;
2667        let required = op.request_body_required;
2668        let (ident, apply): (TokenStream, TokenStream) = match rb {
2669            RequestBodyContent::Json { media_type, .. } => (
2670                quote! { request },
2671                quote! {
2672                    req = req
2673                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
2674                        .header("content-type", #media_type);
2675                },
2676            ),
2677            RequestBodyContent::FormUrlEncoded { media_type, .. } => (
2678                quote! { request },
2679                quote! {
2680                    req = req
2681                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
2682                        .header("content-type", #media_type);
2683                },
2684            ),
2685            RequestBodyContent::Multipart {
2686                schema_name,
2687                validation_schema,
2688                ..
2689            } => (
2690                quote! { request },
2691                self.generate_typed_multipart_form(schema_name, validation_schema, analysis),
2692            ),
2693            RequestBodyContent::OctetStream { media_type } => (
2694                quote! { body },
2695                quote! {
2696                    req = req
2697                        .body(body)
2698                        .header("content-type", #media_type);
2699                },
2700            ),
2701            RequestBodyContent::Binary { media_type } => (
2702                quote! { body },
2703                quote! {
2704                    req = req
2705                        .body(body)
2706                        .header("content-type", #media_type);
2707                },
2708            ),
2709            RequestBodyContent::TextPlain { media_type } => (
2710                quote! { body },
2711                quote! {
2712                    req = req
2713                        .body(body)
2714                        .header("content-type", #media_type);
2715                },
2716            ),
2717            RequestBodyContent::Unsupported { media_types } => {
2718                let media_type = media_types
2719                    .iter()
2720                    .find(|media_type| !crate::openapi::is_wildcard_media_type(media_type))
2721                    .map(String::as_str);
2722                let Some(media_type) = media_type else {
2723                    let ranges = media_types.join(", ");
2724                    let message = format!(
2725                        "request body for operation `{}` declares only wildcard media ranges ({ranges}); a concrete Content-Type is required",
2726                        op.operation_id,
2727                    );
2728                    return if required {
2729                        quote! {
2730                            let _ = body;
2731                            return Err(HttpError::Config(#message.to_string()).into());
2732                        }
2733                    } else {
2734                        quote! {
2735                            if body.is_some() {
2736                                return Err(HttpError::Config(#message.to_string()).into());
2737                            }
2738                            #empty_request_framing
2739                        }
2740                    };
2741                };
2742                (
2743                    quote! { body },
2744                    quote! {
2745                        req = req
2746                            .body(body)
2747                            .header("content-type", #media_type);
2748                    },
2749                )
2750            }
2751            RequestBodyContent::SchemaLess { .. } => return empty_request_framing,
2752        };
2753        if required {
2754            apply
2755        } else {
2756            quote! {
2757                if let Some(#ident) = #ident {
2758                    #apply
2759                } else {
2760                    #empty_request_framing
2761                }
2762            }
2763        }
2764    }
2765
2766    /// Emit explicit HTTP/1.1 framing when an operation sends no request body.
2767    /// RFC 9110 recommends `Content-Length: 0` for methods that define request
2768    /// content semantics. Methods without those semantics intentionally remain
2769    /// unchanged.
2770    fn generate_empty_request_framing(op: &OperationInfo) -> TokenStream {
2771        if ["POST", "PUT", "PATCH"]
2772            .iter()
2773            .any(|method| op.method.eq_ignore_ascii_case(method))
2774        {
2775            quote! {
2776                req = req.header(reqwest::header::CONTENT_LENGTH, "0");
2777            }
2778        } else {
2779            quote! {}
2780        }
2781    }
2782
2783    /// Find the success (2xx) response schema name, if any.
2784    ///
2785    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
2786    /// so that endpoints like 204 No Content correctly return `()` instead of
2787    /// accidentally picking up the error schema (e.g. `BadRequestError`).
2788    fn get_success_response_schema<'a>(
2789        &self,
2790        op: &'a OperationInfo,
2791    ) -> Option<(&'a str, &'a String)> {
2792        op.response_schemas
2793            .get_key_value("200")
2794            .or_else(|| op.response_schemas.get_key_value("201"))
2795            .or_else(|| {
2796                op.response_schemas
2797                    .iter()
2798                    .find(|(code, _)| code.starts_with('2'))
2799            })
2800            .map(|(status, schema)| (status.as_str(), schema))
2801    }
2802
2803    fn get_success_response<'a>(
2804        &self,
2805        analysis: &'a SchemaAnalysis,
2806        op: &'a OperationInfo,
2807    ) -> ClientSuccessSelection<'a> {
2808        if let Some(responses) = analysis.operation_responses.get(&op.operation_id) {
2809            let mut candidates = Vec::new();
2810            for preferred in ["200", "201"] {
2811                if let Some((status, response)) = responses.get_key_value(preferred) {
2812                    candidates.push((status.as_str(), response));
2813                }
2814            }
2815            candidates.extend(
2816                responses
2817                    .iter()
2818                    .filter(|(status, _)| {
2819                        status.starts_with('2')
2820                            && status.as_str() != "200"
2821                            && status.as_str() != "201"
2822                    })
2823                    .map(|(status, response)| (status.as_str(), response)),
2824            );
2825
2826            let selected = candidates
2827                .iter()
2828                .copied()
2829                .find(|(_, response)| {
2830                    matches!(
2831                        Self::response_body(response),
2832                        ClientSuccessBody::Json(_)
2833                            | ClientSuccessBody::Text
2834                            | ClientSuccessBody::Binary
2835                    )
2836                })
2837                .or_else(|| {
2838                    candidates.iter().copied().find(|(_, response)| {
2839                        matches!(
2840                            Self::response_body(response),
2841                            ClientSuccessBody::EventStream
2842                        )
2843                    })
2844                })
2845                .or_else(|| candidates.first().copied());
2846
2847            if let Some((_, response)) = selected {
2848                let body = Self::response_body(response);
2849                let statuses = candidates
2850                    .iter()
2851                    .filter_map(|(status, candidate)| {
2852                        Self::success_bodies_are_compatible(body, Self::response_body(candidate))
2853                            .then_some(*status)
2854                    })
2855                    .collect();
2856                let accept = match &response.body {
2857                    Some(OperationResponseBody::Json { media_type, .. })
2858                    | Some(OperationResponseBody::Text { media_type }) => Some(media_type.as_str()),
2859                    Some(OperationResponseBody::Binary {
2860                        media_type,
2861                        wildcard,
2862                    }) => (!wildcard).then_some(media_type.as_str()),
2863                    None if response.schema_name.is_some() => {
2864                        response.media_type.as_deref().or(Some("application/json"))
2865                    }
2866                    None if response.supports_streaming => Some("text/event-stream"),
2867                    None => None,
2868                };
2869                return ClientSuccessSelection {
2870                    statuses,
2871                    body,
2872                    accept,
2873                };
2874            }
2875        }
2876
2877        if let Some((_status, schema_name)) = self.get_success_response_schema(op) {
2878            let statuses = op
2879                .response_schemas
2880                .iter()
2881                .filter_map(|(candidate_status, candidate_schema)| {
2882                    (candidate_status.starts_with('2') && candidate_schema == schema_name)
2883                        .then_some(candidate_status.as_str())
2884                })
2885                .collect();
2886            ClientSuccessSelection {
2887                statuses,
2888                body: ClientSuccessBody::Json(schema_name),
2889                accept: Some("application/json"),
2890            }
2891        } else if Self::returns_raw_event_stream(op) {
2892            ClientSuccessSelection {
2893                statuses: Vec::new(),
2894                body: ClientSuccessBody::EventStream,
2895                accept: Some("text/event-stream"),
2896            }
2897        } else {
2898            ClientSuccessSelection {
2899                statuses: Vec::new(),
2900                body: ClientSuccessBody::Empty,
2901                accept: None,
2902            }
2903        }
2904    }
2905
2906    fn response_body(response: &crate::analysis::OperationResponse) -> ClientSuccessBody<'_> {
2907        match &response.body {
2908            Some(OperationResponseBody::Json { schema_name, .. }) => {
2909                ClientSuccessBody::Json(schema_name)
2910            }
2911            Some(OperationResponseBody::Text { .. }) => ClientSuccessBody::Text,
2912            Some(OperationResponseBody::Binary { .. }) => ClientSuccessBody::Binary,
2913            None if response.schema_name.is_some() => {
2914                ClientSuccessBody::Json(response.schema_name.as_deref().unwrap_or_default())
2915            }
2916            None if response.supports_streaming => ClientSuccessBody::EventStream,
2917            None => ClientSuccessBody::Empty,
2918        }
2919    }
2920
2921    fn success_bodies_are_compatible(
2922        selected: ClientSuccessBody<'_>,
2923        candidate: ClientSuccessBody<'_>,
2924    ) -> bool {
2925        match (selected, candidate) {
2926            (ClientSuccessBody::Json(selected), ClientSuccessBody::Json(candidate)) => {
2927                selected == candidate
2928            }
2929            (ClientSuccessBody::Text, ClientSuccessBody::Text)
2930            | (ClientSuccessBody::Binary, ClientSuccessBody::Binary)
2931            | (ClientSuccessBody::EventStream, ClientSuccessBody::EventStream)
2932            | (ClientSuccessBody::Empty, ClientSuccessBody::Empty) => true,
2933            _ => false,
2934        }
2935    }
2936
2937    /// Get response type
2938    fn get_response_type(&self, analysis: &SchemaAnalysis, op: &OperationInfo) -> TokenStream {
2939        match self.get_success_response(analysis, op).body {
2940            ClientSuccessBody::Json(response_type) => {
2941                // Convert schema name to Rust type name (handles underscores, etc.)
2942                let rust_type_name = self.to_rust_type_name(response_type);
2943                let response_ident =
2944                    syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
2945                quote! { #response_ident }
2946            }
2947            ClientSuccessBody::Text => quote! { String },
2948            ClientSuccessBody::Binary => quote! { bytes::Bytes },
2949            ClientSuccessBody::EventStream => {
2950                quote! { impl futures_util::Stream<Item = Result<bytes::Bytes, reqwest::Error>> }
2951            }
2952            ClientSuccessBody::Empty => quote! { () },
2953        }
2954    }
2955
2956    fn success_status_guard(statuses: &[&str]) -> TokenStream {
2957        if statuses.is_empty() {
2958            return quote! { status.is_success() };
2959        }
2960        let guards = statuses
2961            .iter()
2962            .map(|status| Self::single_status_guard(status));
2963        quote! { false #( || #guards )* }
2964    }
2965
2966    fn single_status_guard(status: &str) -> TokenStream {
2967        match status {
2968            status if status.chars().all(|character| character.is_ascii_digit()) => {
2969                let status: u16 = status.parse().unwrap_or_default();
2970                quote! { status_code == #status }
2971            }
2972            status if matches!(status.as_bytes(), [b'1'..=b'5', b'X' | b'x', b'X' | b'x']) => {
2973                let class = u16::from(status.as_bytes()[0] - b'0');
2974                quote! { status_code / 100 == #class }
2975            }
2976            _ => quote! { status.is_success() },
2977        }
2978    }
2979
2980    /// True when the operation's success response carries `text/event-stream`
2981    /// and nothing this generator can model as a JSON body.
2982    ///
2983    /// These previously generated `-> Result<(), _>` and then called
2984    /// `response.text().await`, which on a live SSE stream never returns: the
2985    /// caller's task deadlocks rather than erroring (openapi-generator-x9v).
2986    /// The streaming signal was already detected in analysis and honored by the
2987    /// server generator; only the client ignored it.
2988    ///
2989    /// Operations declaring *both* a JSON body and `text/event-stream` keep
2990    /// their JSON contract here — those are the `stream: true` style endpoints
2991    /// covered by the explicit `[streaming]` configuration, and silently
2992    /// changing their return type would break existing callers.
2993    fn returns_raw_event_stream(op: &OperationInfo) -> bool {
2994        op.supports_streaming
2995    }
2996
2997    /// Generate error handling.
2998    ///
2999    /// Buffers non-streaming responses once, retaining both exact bytes and a
3000    /// lossy UTF-8 view for compatibility. Only the selected declared success
3001    /// status is parsed into the generated return type; other 2xx statuses are
3002    /// inspectable `ApiError`s rather than being fed to an incompatible parser.
3003    fn generate_error_handling(
3004        &self,
3005        op: &OperationInfo,
3006        success: ClientSuccessSelection<'_>,
3007    ) -> TokenStream {
3008        let op_error_type = self.op_error_type_token(op);
3009        let success_body = success.body;
3010        let success_status_guard = Self::success_status_guard(&success.statuses);
3011        let selected_status = if success.statuses.is_empty() {
3012            "any declared 2xx response".to_string()
3013        } else {
3014            success.statuses.join(", ")
3015        };
3016
3017        let success_branch = match success_body {
3018            ClientSuccessBody::Json(_) => quote! {
3019                match serde_json::from_str(&body_text) {
3020                    Ok(body) => Ok(body),
3021                    Err(e) => Err(ApiOpError::Api(ApiError {
3022                        status: status_code,
3023                        headers: headers,
3024                        body: body_text,
3025                        raw_body,
3026                        typed: None,
3027                        parse_error: Some(format!(
3028                            "failed to deserialize 2xx response body: {}",
3029                            e
3030                        )),
3031                    })),
3032                }
3033            },
3034            ClientSuccessBody::Text => quote! {
3035                let _ = raw_body;
3036                Ok(body_text)
3037            },
3038            ClientSuccessBody::Empty => quote! {
3039                let _ = body_text;
3040                let _ = raw_body;
3041                let _ = headers;
3042                Ok(())
3043            },
3044            ClientSuccessBody::Binary | ClientSuccessBody::EventStream => quote! {},
3045        };
3046
3047        let error_match_arms = self.generate_error_match_arms(op);
3048
3049        // Streaming success path: hand back the live byte stream instead of
3050        // buffering it. Reading an SSE body to a string blocks until the server
3051        // closes the connection, which is precisely what it will not do.
3052        // The error path still buffers — an error response is finite.
3053        if matches!(success_body, ClientSuccessBody::EventStream) {
3054            return quote! {
3055                let status = response.status();
3056                let status_code = status.as_u16();
3057                let headers = response.headers().clone();
3058
3059                if #success_status_guard {
3060                    Ok(response.bytes_stream())
3061                } else {
3062                    if status.is_success() {
3063                        return Err(ApiOpError::Api(ApiError {
3064                            status: status_code,
3065                            headers,
3066                            body: String::new(),
3067                            raw_body: Vec::new(),
3068                            typed: None,
3069                            parse_error: Some(format!(
3070                                "unexpected successful status {}; generated return type selects `{}`; live response body was not buffered",
3071                                status_code,
3072                                #selected_status,
3073                            )),
3074                        }));
3075                    }
3076                    let body_bytes = __read_bounded_response_body(
3077                        response,
3078                        self.max_response_body_bytes,
3079                    ).await?;
3080                    let raw_body = body_bytes;
3081                    let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3082                    let typed: Option<#op_error_type>;
3083                    let parse_error: Option<String>;
3084                    #error_match_arms
3085                    Err(ApiOpError::Api(ApiError {
3086                        status: status_code,
3087                        headers,
3088                        body: body_text,
3089                        raw_body,
3090                        typed,
3091                        parse_error,
3092                    }))
3093                }
3094            };
3095        }
3096
3097        if matches!(success_body, ClientSuccessBody::Binary) {
3098            return quote! {
3099                let status = response.status();
3100                let status_code = status.as_u16();
3101                let headers = response.headers().clone();
3102
3103                let body_bytes = __read_bounded_response_body(
3104                    response,
3105                    self.max_response_body_bytes,
3106                ).await?;
3107                if #success_status_guard {
3108                    Ok(bytes::Bytes::from(body_bytes))
3109                } else {
3110                    let raw_body = body_bytes;
3111                    let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3112                    if status.is_success() {
3113                        return Err(ApiOpError::Api(ApiError {
3114                            status: status_code,
3115                            headers,
3116                            body: body_text,
3117                            raw_body,
3118                            typed: None,
3119                            parse_error: Some(format!(
3120                                "unexpected successful status {}; generated return type selects `{}`",
3121                                status_code,
3122                                #selected_status,
3123                            )),
3124                        }));
3125                    }
3126                    let typed: Option<#op_error_type>;
3127                    let parse_error: Option<String>;
3128                    #error_match_arms
3129                    Err(ApiOpError::Api(ApiError {
3130                        status: status_code,
3131                        headers,
3132                        body: body_text,
3133                        raw_body,
3134                        typed,
3135                        parse_error,
3136                    }))
3137                }
3138            };
3139        }
3140
3141        quote! {
3142            let status = response.status();
3143            let status_code = status.as_u16();
3144            let headers = response.headers().clone();
3145            let body_bytes = __read_bounded_response_body(
3146                response,
3147                self.max_response_body_bytes,
3148            ).await?;
3149            let raw_body = body_bytes;
3150            let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3151
3152            if #success_status_guard {
3153                #success_branch
3154            } else if status.is_success() {
3155                Err(ApiOpError::Api(ApiError {
3156                    status: status_code,
3157                    headers,
3158                    body: body_text,
3159                    raw_body,
3160                    typed: None,
3161                    parse_error: Some(format!(
3162                        "unexpected successful status {}; generated return type selects `{}`",
3163                        status_code,
3164                        #selected_status,
3165                    )),
3166                }))
3167            } else {
3168                let typed: Option<#op_error_type>;
3169                let parse_error: Option<String>;
3170                #error_match_arms
3171                Err(ApiOpError::Api(ApiError {
3172                    status: status_code,
3173                    headers,
3174                    body: body_text,
3175                    raw_body,
3176                    typed,
3177                    parse_error,
3178                }))
3179            }
3180        }
3181    }
3182
3183    /// Generate the match arms that select which per-op error variant to
3184    /// deserialize the response body into based on the runtime status code.
3185    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
3186        let arms: Vec<TokenStream> = op
3187            .response_schemas
3188            .iter()
3189            .filter(|(code, _)| !code.starts_with('2'))
3190            .filter_map(|(code, schema)| {
3191                let variant_ident = Self::op_error_variant_ident(code);
3192                let payload_ty_name = self.to_rust_type_name(schema);
3193                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
3194                let enum_ident = self.op_error_enum_ident(op);
3195
3196                // T8: range-keyed responses (1XX/2XX/3XX/4XX/5XX) per OAS
3197                // 3.x §"Responses Object". Specific codes still take priority
3198                // (handled by ordering — concrete codes deserialize first
3199                // because the generic dispatch is a generic `_ if (range)`).
3200                let pattern = match code.as_str() {
3201                    "default" | "Default" => return None, // handled in fallback
3202                    other if other.chars().all(|c| c.is_ascii_digit()) => {
3203                        let n: u16 = other.parse().ok()?;
3204                        quote! { #n }
3205                    }
3206                    "1XX" | "1xx" => quote! { code if (100..=199).contains(&code) },
3207                    "2XX" | "2xx" => quote! { code if (200..=299).contains(&code) },
3208                    "3XX" | "3xx" => quote! { code if (300..=399).contains(&code) },
3209                    "4XX" | "4xx" => quote! { code if (400..=499).contains(&code) },
3210                    "5XX" | "5xx" => quote! { code if (500..=599).contains(&code) },
3211                    _ => return None,
3212                };
3213
3214                Some(quote! {
3215                    #pattern => {
3216                        match serde_json::from_str::<#payload_ty>(&body_text) {
3217                            Ok(v) => {
3218                                typed = Some(#enum_ident::#variant_ident(v));
3219                                parse_error = None;
3220                            }
3221                            Err(e) => {
3222                                typed = None;
3223                                parse_error = Some(e.to_string());
3224                            }
3225                        }
3226                    }
3227                })
3228            })
3229            .collect();
3230
3231        // Fallback for "default" or undeclared status codes: try to parse
3232        // as `serde_json::Value` for inspectability when the op's error
3233        // type is generic, otherwise leave typed = None.
3234        // Must mirror op_error_type_token: if op_error_type is the typed
3235        // enum (any non-2xx response, including `default`), the fallback arm
3236        // can't deserialize into `serde_json::Value` because `typed` is the
3237        // enum. Default to `typed = None` in that case.
3238        let has_typed_enum = op
3239            .response_schemas
3240            .iter()
3241            .any(|(code, _)| !code.starts_with('2'));
3242
3243        // A spec-declared `default` response is the catch-all arm's payload
3244        // type. Without this the generated enum carries a `Default(..)` variant
3245        // that nothing ever constructs, so a response matched only by `default`
3246        // — a perfectly parseable typed body — still surfaces as
3247        // `typed: None` and callers fall back to raw strings
3248        // (openapi-generator-nu7).
3249        let default_payload = op
3250            .response_schemas
3251            .iter()
3252            .find(|(code, _)| matches!(code.as_str(), "default" | "Default"))
3253            .map(|(_, schema)| self.to_rust_type_name(schema));
3254
3255        let default_arm = if let Some(payload_ty_name) = default_payload {
3256            let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
3257            let enum_ident = self.op_error_enum_ident(op);
3258            quote! {
3259                _ => {
3260                    match serde_json::from_str::<#payload_ty>(&body_text) {
3261                        Ok(v) => {
3262                            typed = Some(#enum_ident::Default(v));
3263                            parse_error = None;
3264                        }
3265                        Err(e) => {
3266                            typed = None;
3267                            parse_error = Some(e.to_string());
3268                        }
3269                    }
3270                }
3271            }
3272        } else if has_typed_enum {
3273            quote! {
3274                _ => {
3275                    typed = None;
3276                    parse_error = None;
3277                }
3278            }
3279        } else {
3280            // No typed enum — op_error_type is serde_json::Value.
3281            quote! {
3282                _ => {
3283                    match serde_json::from_str::<serde_json::Value>(&body_text) {
3284                        Ok(v) => {
3285                            typed = Some(v);
3286                            parse_error = None;
3287                        }
3288                        Err(e) => {
3289                            typed = None;
3290                            parse_error = Some(e.to_string());
3291                        }
3292                    }
3293                }
3294            }
3295        };
3296
3297        if arms.is_empty() {
3298            // No declared status arms — just the fallback.
3299            quote! {
3300                match status_code {
3301                    #default_arm
3302                }
3303            }
3304        } else {
3305            quote! {
3306                match status_code {
3307                    #(#arms)*
3308                    #default_arm
3309                }
3310            }
3311        }
3312    }
3313
3314    /// Generate URL construction with path parameter substitution
3315    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
3316        // Check if path has parameters (contains {...})
3317        if path.contains('{') {
3318            self.generate_url_with_params(path, op)
3319        } else {
3320            quote! {
3321                let request_url = format!("{}{}", self.base_url, #path);
3322            }
3323        }
3324    }
3325
3326    /// Generate URL with path parameters
3327    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
3328        // Find all path parameters in the operation.
3329        let path_params: Vec<_> = op
3330            .parameters
3331            .iter()
3332            .filter(|p| p.location == "path")
3333            .collect();
3334
3335        // T5: percent-encode each path-template variable per RFC3986 §3.3.
3336        // We build a positional-arg format string by walking the template
3337        // left-to-right and emitting one `{}` + one format arg per
3338        // placeholder occurrence. Cloudflare has paths like
3339        // `/accounts/{account_id}/.../accounts/{account_id}` — the same
3340        // variable appears twice. A naive `replace_all` produced two `{}`
3341        // placeholders but only one format arg (E0277). Per-occurrence
3342        // emission keeps them in sync.
3343        let mut format_string = String::with_capacity(path.len());
3344        let mut format_args: Vec<TokenStream> = Vec::new();
3345        let mut chars = path.chars().peekable();
3346        while let Some(c) = chars.next() {
3347            if c != '{' {
3348                format_string.push(c);
3349                continue;
3350            }
3351            // Read until the matching '}'.
3352            let mut name = String::new();
3353            while let Some(&n) = chars.peek() {
3354                chars.next();
3355                if n == '}' {
3356                    break;
3357                }
3358                name.push(n);
3359            }
3360            // Resolve to a path param. If no match, leave the placeholder
3361            // verbatim (real-world spec bug — this op shouldn't have made
3362            // it past analysis).
3363            let param = path_params.iter().find(|p| p.name == name);
3364            let Some(param) = param else {
3365                format_string.push('{');
3366                format_string.push_str(&name);
3367                format_string.push('}');
3368                continue;
3369            };
3370            format_string.push_str("{}");
3371            let param_name_snake = self.param_ident_str(param);
3372            let param_ident = Self::to_field_ident(&param_name_snake);
3373            if Self::param_uses_as_ref_str(param) {
3374                format_args.push(quote! {
3375                    __pct_encode_path_segment(#param_ident.as_ref())
3376                });
3377            } else {
3378                format_args.push(quote! {
3379                    __pct_encode_path_segment(&#param_ident.to_string())
3380                });
3381            }
3382        }
3383
3384        if format_args.is_empty() {
3385            quote! {
3386                let request_url = format!("{}{}", self.base_url, #path);
3387            }
3388        } else {
3389            quote! {
3390                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
3391            }
3392        }
3393    }
3394
3395    /// Resolve the Rust ident for a parameter. Prefers the disambiguated
3396    /// `rust_ident` set by the analyzer (which dedupes across the whole
3397    /// operation), falling back to a fresh sanitize of the wire name when
3398    /// no analyzer-side ident is present.
3399    pub(crate) fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String {
3400        if let Some(ident) = &param.rust_ident {
3401            // Apply the keyword-escape and self/super/crate dance the
3402            // sanitize fn does. The analyzer's base ident is already the
3403            // snake/kebab-aware shape; we only need post-processing.
3404            return self.escape_keyword_ident(ident);
3405        }
3406        self.sanitize_param_name(&param.name)
3407    }
3408
3409    fn escape_keyword_ident(&self, snake_case: &str) -> String {
3410        if matches!(snake_case, "self" | "super" | "crate" | "Self") {
3411            return format!("{snake_case}_param");
3412        }
3413        if Self::is_rust_keyword(snake_case) {
3414            format!("r#{snake_case}")
3415        } else {
3416            snake_case.to_string()
3417        }
3418    }
3419
3420    /// Sanitize a parameter name by escaping Rust reserved keywords with raw
3421    /// identifiers and disambiguating Twilio-style suffix operators
3422    /// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
3423    /// case to `start_time`).
3424    fn sanitize_param_name(&self, name: &str) -> String {
3425        // Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
3426        // filter-style query params; map them to `_lt` / `_gt` etc. so the
3427        // Rust ident is unique while the wire-level param name stays the
3428        // original string elsewhere in the codegen.
3429        let suffix = if name.ends_with("<=") {
3430            "_lte"
3431        } else if name.ends_with(">=") {
3432            "_gte"
3433        } else if name.ends_with('<') {
3434            "_lt"
3435        } else if name.ends_with('>') {
3436            "_gt"
3437        } else {
3438            ""
3439        };
3440        let stripped = name.trim_end_matches(['<', '>', '=']);
3441        let mut snake_case = stripped.to_snake_case();
3442        if snake_case.is_empty() {
3443            snake_case.push_str("parameter");
3444        } else if snake_case.starts_with(|character: char| character.is_ascii_digit()) {
3445            snake_case.insert(0, '_');
3446        }
3447        snake_case.push_str(suffix);
3448
3449        if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
3450            return format!("{snake_case}_param");
3451        }
3452        if Self::is_rust_keyword(&snake_case) {
3453            format!("r#{snake_case}")
3454        } else {
3455            snake_case
3456        }
3457    }
3458}