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                ..
993            } if !self.is_discriminated_variant(resolved_name, analysis) => {
994                let emitted = self.emitted_object_properties(
995                    resolved_name,
996                    properties,
997                    required,
998                    additional_properties,
999                    analysis,
1000                    None,
1001                );
1002                let required_fields: Vec<_> = emitted
1003                    .iter()
1004                    .filter(|field| field.is_required)
1005                    .map(|field| BodyConstructorParam {
1006                        preferred_ident: field.ident.clone(),
1007                        value_type: field.field_type.clone(),
1008                    })
1009                    .collect();
1010                if required_fields.is_empty() {
1011                    RequiredBodyConstruction::Default
1012                } else if emitted.iter().any(|field| !field.is_required)
1013                    || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
1014                {
1015                    RequiredBodyConstruction::New(required_fields)
1016                } else {
1017                    RequiredBodyConstruction::Whole
1018                }
1019            }
1020            _ => RequiredBodyConstruction::Whole,
1021        };
1022
1023        Some(BodyModelPlan {
1024            body_ident,
1025            body_type: quote! { #body_type },
1026            required_construction,
1027            optional_fields,
1028        })
1029    }
1030
1031    fn resolve_reference_schema<'a>(
1032        &self,
1033        schema_name: &'a str,
1034        analysis: &'a SchemaAnalysis,
1035    ) -> Option<(&'a str, &'a crate::analysis::AnalyzedSchema)> {
1036        let mut current = schema_name;
1037        let mut visited = std::collections::HashSet::new();
1038        loop {
1039            if !visited.insert(current) {
1040                return None;
1041            }
1042            let schema = analysis.schemas.get(current)?;
1043            if let crate::analysis::SchemaType::Reference { target } = &schema.schema_type {
1044                current = target;
1045            } else {
1046                return Some((current, schema));
1047            }
1048        }
1049    }
1050
1051    fn collect_optional_body_fields(
1052        &self,
1053        schema_name: &str,
1054        access_path: Vec<syn::Ident>,
1055        analysis: &SchemaAnalysis,
1056        stack: &mut std::collections::HashSet<String>,
1057        output: &mut Vec<BodyFieldPlan>,
1058    ) {
1059        use crate::analysis::SchemaType;
1060        if !stack.insert(schema_name.to_string()) {
1061            return;
1062        }
1063        let Some(schema) = analysis.schemas.get(schema_name) else {
1064            stack.remove(schema_name);
1065            return;
1066        };
1067        match &schema.schema_type {
1068            SchemaType::Reference { target } => {
1069                self.collect_optional_body_fields(target, access_path, analysis, stack, output);
1070            }
1071            SchemaType::Object {
1072                properties,
1073                required,
1074                additional_properties,
1075                ..
1076            } if !self.is_discriminated_variant(schema_name, analysis) => {
1077                for field in self.emitted_object_properties(
1078                    schema_name,
1079                    properties,
1080                    required,
1081                    additional_properties,
1082                    analysis,
1083                    None,
1084                ) {
1085                    if field.is_required {
1086                        continue;
1087                    }
1088                    let mut field_path = access_path.clone();
1089                    field_path.push(field.ident.clone());
1090                    output.push(BodyFieldPlan {
1091                        wire_name: field.wire_name.to_string(),
1092                        preferred_method_name: field.ident.to_string(),
1093                        value_ident: field.ident.clone(),
1094                        value_type: self.generate_property_base_type(
1095                            schema_name,
1096                            field.wire_name,
1097                            field.property,
1098                            analysis,
1099                        ),
1100                        access_path: field_path,
1101                    });
1102                }
1103            }
1104            SchemaType::Composition { schemas } => {
1105                for (index, schema_ref) in schemas.iter().enumerate() {
1106                    let mut nested_path = access_path.clone();
1107                    nested_path.push(format_ident!("part_{index}"));
1108                    self.collect_optional_body_fields(
1109                        &schema_ref.target,
1110                        nested_path,
1111                        analysis,
1112                        stack,
1113                        output,
1114                    );
1115                }
1116            }
1117            _ => {}
1118        }
1119        stack.remove(schema_name);
1120    }
1121
1122    fn is_discriminated_variant(&self, schema_name: &str, analysis: &SchemaAnalysis) -> bool {
1123        analysis.schemas.values().any(|schema| {
1124            matches!(
1125                &schema.schema_type,
1126                crate::analysis::SchemaType::DiscriminatedUnion { variants, .. }
1127                    if variants.iter().any(|variant| variant.type_name == schema_name)
1128            )
1129        })
1130    }
1131
1132    /// Emit inline enum types for parameters whose schema is `type: string`
1133    /// with `enum` or `const`. The generated enum implements `Display` so it
1134    /// drops into the existing `format!`-based path/query templating without
1135    /// any special-casing at the call site. See issue #10 follow-up.
1136    fn generate_param_enum_types(&self, operations: &[&OperationInfo]) -> TokenStream {
1137        let mut by_name: BTreeMap<String, &ParameterInfo> = BTreeMap::new();
1138        for op in operations {
1139            for param in &op.parameters {
1140                if param.enum_values.is_some() {
1141                    by_name.entry(param.rust_type.clone()).or_insert(param);
1142                }
1143            }
1144        }
1145
1146        if by_name.is_empty() {
1147            return quote! {};
1148        }
1149
1150        let defs: Vec<TokenStream> = by_name
1151            .values()
1152            .map(|param| self.generate_single_param_enum(param))
1153            .collect();
1154
1155        quote! { #(#defs)* }
1156    }
1157
1158    fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream {
1159        let Some(values) = param.enum_values.as_deref() else {
1160            return quote! {};
1161        };
1162
1163        let enum_ident = format_ident!("{}", param.rust_type);
1164
1165        // Dedupe variant names. Real-world specs use sort enums like
1166        // `["created_at", "-created_at"]` (descending prefix), and both
1167        // PascalCase to `CreatedAt`. Suffix collisions with `_2`/`_3`/…
1168        // while keeping each `serde(rename)` pointing at the original
1169        // wire string.
1170        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1171        // `x-enum-varnames` wins over the naming heuristic when the spec
1172        // supplies it — the whole point of the extension is that the author
1173        // knows better than a transformation of the wire string. Schema-level
1174        // enums already honored it; parameter enums did not, so the same spec
1175        // produced different variant names depending on where its enum lived.
1176        // Suffix disambiguation still applies, since nothing stops a spec from
1177        // declaring two names that collide once converted to an identifier.
1178        let variant_names: Vec<String> = values
1179            .iter()
1180            .enumerate()
1181            .map(|(index, value)| {
1182                let base = param
1183                    .enum_varnames
1184                    .as_ref()
1185                    .and_then(|names| names.get(index))
1186                    .map(|name| self.to_rust_enum_variant(name))
1187                    .unwrap_or_else(|| self.to_rust_enum_variant(value));
1188                let mut chosen = base.clone();
1189                let mut suffix = 2;
1190                while !used.insert(chosen.clone()) {
1191                    chosen = format!("{base}_{suffix}");
1192                    suffix += 1;
1193                }
1194                chosen
1195            })
1196            .collect();
1197
1198        let variants: Vec<TokenStream> = values
1199            .iter()
1200            .zip(&variant_names)
1201            .map(|(value, name)| {
1202                let variant_ident = format_ident!("{}", name);
1203                quote! {
1204                    #[serde(rename = #value)]
1205                    #variant_ident,
1206                }
1207            })
1208            .collect();
1209
1210        let display_arms: Vec<TokenStream> = values
1211            .iter()
1212            .zip(&variant_names)
1213            .map(|(value, name)| {
1214                let variant_ident = format_ident!("{}", name);
1215                quote! { Self::#variant_ident => #value, }
1216            })
1217            .collect();
1218
1219        let doc = format!(
1220            "Allowed values for the `{}` {} parameter.",
1221            param.name, param.location
1222        );
1223
1224        quote! {
1225            #[doc = #doc]
1226            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
1227            pub enum #enum_ident {
1228                #(#variants)*
1229            }
1230
1231            impl #enum_ident {
1232                pub fn as_str(&self) -> &'static str {
1233                    match self {
1234                        #(#display_arms)*
1235                    }
1236                }
1237            }
1238
1239            impl std::fmt::Display for #enum_ident {
1240                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1241                    f.write_str(self.as_str())
1242                }
1243            }
1244
1245            impl AsRef<str> for #enum_ident {
1246                fn as_ref(&self) -> &str {
1247                    self.as_str()
1248                }
1249            }
1250        }
1251    }
1252
1253    /// Generate the per-operation typed error enum, if the op has any non-2xx
1254    /// responses with a body schema. Returns None when the op has no declared
1255    /// error bodies — those operations use `ApiOpError<serde_json::Value>` so
1256    /// the raw response body is still inspectable.
1257    fn generate_op_error_enum(&self, op: &OperationInfo) -> Option<TokenStream> {
1258        let variants: Vec<(String, String)> = op
1259            .response_schemas
1260            .iter()
1261            .filter(|(code, _)| !code.starts_with('2'))
1262            .map(|(code, schema)| (code.clone(), schema.clone()))
1263            .collect();
1264
1265        if variants.is_empty() {
1266            return None;
1267        }
1268
1269        let enum_ident = self.op_error_enum_ident(op);
1270        let variant_decls: Vec<TokenStream> = variants
1271            .iter()
1272            .map(|(code, schema)| {
1273                let variant_ident = Self::op_error_variant_ident(code);
1274                let payload_ty_name = self.to_rust_type_name(schema);
1275                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
1276                quote! { #variant_ident(#payload_ty) }
1277            })
1278            .collect();
1279
1280        let doc = format!(
1281            "Typed error responses for `{}`. One variant per declared non-2xx response.",
1282            op.operation_id
1283        );
1284
1285        Some(quote! {
1286            #[doc = #doc]
1287            #[derive(Debug, Clone)]
1288            pub enum #enum_ident {
1289                #(#variant_decls,)*
1290            }
1291        })
1292    }
1293
1294    /// Type name (Ident) for the per-op error enum, e.g. `ListTodosApiError`.
1295    fn op_error_enum_ident(&self, op: &OperationInfo) -> syn::Ident {
1296        use heck::ToPascalCase;
1297        let name = format!(
1298            "{}ApiError",
1299            op.operation_id.replace('.', "_").to_pascal_case()
1300        );
1301        syn::Ident::new(&name, proc_macro2::Span::call_site())
1302    }
1303
1304    /// Variant name for a status code: "400" → Status400, "default" → Default,
1305    /// "4XX" → Status4xx.
1306    fn op_error_variant_ident(status_code: &str) -> syn::Ident {
1307        let raw = match status_code {
1308            "default" | "Default" => "Default".to_string(),
1309            other if other.chars().all(|c| c.is_ascii_digit()) => format!("Status{other}"),
1310            other => format!("Status{}", other.to_ascii_lowercase()),
1311        };
1312        syn::Ident::new(&raw, proc_macro2::Span::call_site())
1313    }
1314
1315    /// Token stream for the type plugged into `ApiOpError<T>` for an op:
1316    /// either the per-op enum, or `serde_json::Value` for ops with no
1317    /// declared error body schemas.
1318    fn op_error_type_token(&self, op: &OperationInfo) -> TokenStream {
1319        if op
1320            .response_schemas
1321            .iter()
1322            .any(|(code, _)| !code.starts_with('2'))
1323        {
1324            let ident = self.op_error_enum_ident(op);
1325            quote! { #ident }
1326        } else {
1327            quote! { serde_json::Value }
1328        }
1329    }
1330
1331    /// Generate a single operation method
1332    fn generate_single_operation_method(
1333        &self,
1334        analysis: &SchemaAnalysis,
1335        op: &OperationInfo,
1336    ) -> TokenStream {
1337        let method_name = self.get_method_name(op);
1338        let http_method_call = self.http_method_call(op);
1339        let path = &op.path;
1340        let request_param = self.generate_request_param(op);
1341        let request_body = self.generate_request_body(op, analysis);
1342        let query_params = self.generate_query_params(op);
1343        let header_params = self.generate_header_params(op);
1344        let cookie_params = self.generate_cookie_params(op);
1345        let auth_application = self.generate_auth_application();
1346        let success = self.get_success_response(analysis, op);
1347        let response_type = self.get_response_type(analysis, op);
1348        let op_error_type = self.op_error_type_token(op);
1349        let accept = success.accept;
1350        let error_handling = self.generate_error_handling(op, success);
1351        let (custom_headers, accept_header) = if let Some(media_type) = accept {
1352            (
1353                quote! {
1354                    for (name, value) in &self.custom_headers {
1355                        if !name.eq_ignore_ascii_case("accept") {
1356                            req = req.header(name, value);
1357                        }
1358                    }
1359                },
1360                quote! {
1361                    req = req.header(reqwest::header::ACCEPT, #media_type);
1362                },
1363            )
1364        } else {
1365            (
1366                quote! {
1367                    for (name, value) in &self.custom_headers {
1368                        req = req.header(name, value);
1369                    }
1370                },
1371                TokenStream::new(),
1372            )
1373        };
1374        let url_construction = self.generate_url_construction(path, op);
1375        let doc_comment = self.generate_operation_doc_comment(op);
1376
1377        quote! {
1378            #doc_comment
1379            pub async fn #method_name(
1380                &self,
1381                #request_param
1382            ) -> Result<#response_type, ApiOpError<#op_error_type>> {
1383                #url_construction
1384
1385                let mut req = #http_method_call;
1386                #request_body
1387
1388                #query_params
1389                #header_params
1390                #cookie_params
1391
1392                // Apply configured authentication (T3). Was previously
1393                // hardcoded to bearer_auth regardless of GeneratorConfig.
1394                #auth_application
1395
1396                // Add custom headers
1397                #custom_headers
1398
1399                // Keep content negotiation aligned with the generated return type,
1400                // replacing any custom Accept value for this operation.
1401                #accept_header
1402
1403                let response = req.send().await?;
1404                #error_handling
1405            }
1406        }
1407    }
1408
1409    /// T3: emit the auth-token application based on the configured AuthConfig.
1410    /// Default (no config) is Bearer on Authorization. ApiKey emits a custom
1411    /// header. Custom honors header_value_prefix.
1412    fn generate_auth_application(&self) -> TokenStream {
1413        use crate::http_config::AuthConfig;
1414        match &self.config().auth_config {
1415            Some(AuthConfig::Bearer { header_name }) if header_name == "Authorization" => quote! {
1416                if let Some(api_key) = &self.api_key {
1417                    req = req.bearer_auth(api_key);
1418                }
1419            },
1420            Some(AuthConfig::Bearer { header_name }) => {
1421                let h = header_name.clone();
1422                quote! {
1423                    if let Some(api_key) = &self.api_key {
1424                        req = req.header(#h, format!("Bearer {}", api_key));
1425                    }
1426                }
1427            }
1428            Some(AuthConfig::ApiKey { header_name }) => {
1429                let h = header_name.clone();
1430                quote! {
1431                    if let Some(api_key) = &self.api_key {
1432                        req = req.header(#h, api_key.as_str());
1433                    }
1434                }
1435            }
1436            Some(AuthConfig::Custom {
1437                header_name,
1438                header_value_prefix,
1439            }) => {
1440                let h = header_name.clone();
1441                let prefix = header_value_prefix.clone().unwrap_or_default();
1442                if prefix.is_empty() {
1443                    quote! {
1444                        if let Some(api_key) = &self.api_key {
1445                            req = req.header(#h, api_key.as_str());
1446                        }
1447                    }
1448                } else {
1449                    let format_str = format!("{}{{}}", prefix);
1450                    quote! {
1451                        if let Some(api_key) = &self.api_key {
1452                            req = req.header(#h, format!(#format_str, api_key));
1453                        }
1454                    }
1455                }
1456            }
1457            None => quote! {
1458                if let Some(api_key) = &self.api_key {
1459                    req = req.bearer_auth(api_key);
1460                }
1461            },
1462        }
1463    }
1464
1465    /// Generate header-parameter handling. Emits `req = req.header(name, ...)`
1466    /// for each `in: header` parameter — required headers unconditionally,
1467    /// optional ones gated on `Some(_)`.
1468    fn generate_header_params(&self, op: &OperationInfo) -> TokenStream {
1469        let header_params: Vec<_> = op
1470            .parameters
1471            .iter()
1472            .filter(|p| p.location == "header")
1473            .collect();
1474        if header_params.is_empty() {
1475            return quote! {};
1476        }
1477        let mut emit = Vec::new();
1478        for param in header_params {
1479            let param_name_snake = self.param_ident_str(param);
1480            let param_ident = Self::to_field_ident(&param_name_snake);
1481            let header_name = &param.name;
1482            if matches!(
1483                param.query_serialization,
1484                Some(crate::analysis::QuerySerialization::SimpleHeaderArray { .. })
1485            ) {
1486                let encode = quote! {
1487                    v.iter().map(::std::string::ToString::to_string).collect::<Vec<_>>().join(",")
1488                };
1489                if param.required {
1490                    emit.push(quote! {
1491                        let v = #param_ident;
1492                        req = req.header(#header_name, #encode);
1493                    });
1494                } else {
1495                    emit.push(quote! {
1496                        if let Some(v) = #param_ident {
1497                            req = req.header(#header_name, #encode);
1498                        }
1499                    });
1500                }
1501                continue;
1502            }
1503            if param.required {
1504                if Self::param_uses_as_ref_str(param) {
1505                    emit.push(quote! {
1506                        req = req.header(#header_name, #param_ident.as_ref());
1507                    });
1508                } else {
1509                    emit.push(quote! {
1510                        req = req.header(#header_name, #param_ident.to_string());
1511                    });
1512                }
1513            } else if Self::param_uses_as_ref_str(param) {
1514                emit.push(quote! {
1515                    if let Some(v) = #param_ident {
1516                        req = req.header(#header_name, v.as_ref());
1517                    }
1518                });
1519            } else {
1520                emit.push(quote! {
1521                    if let Some(v) = #param_ident {
1522                        req = req.header(#header_name, v.to_string());
1523                    }
1524                });
1525            }
1526        }
1527        quote! {
1528            #(#emit)*
1529        }
1530    }
1531
1532    fn generate_cookie_params(&self, op: &OperationInfo) -> TokenStream {
1533        let cookie_params: Vec<_> = op
1534            .parameters
1535            .iter()
1536            .filter(|parameter| parameter.location == "cookie")
1537            .collect();
1538        if cookie_params.is_empty() {
1539            return quote! {};
1540        }
1541        let mut emit = Vec::new();
1542        for parameter in cookie_params {
1543            let ident = Self::to_field_ident(&self.param_ident_str(parameter));
1544            let wire_name = parameter.name.as_str();
1545            if parameter.required {
1546                emit.push(quote! {
1547                    __cookie_fields.push(format!("{}={}", #wire_name, #ident));
1548                });
1549            } else {
1550                emit.push(quote! {
1551                    if let Some(value) = #ident {
1552                        __cookie_fields.push(format!("{}={}", #wire_name, value));
1553                    }
1554                });
1555            }
1556        }
1557        quote! {
1558            let mut __cookie_fields = Vec::new();
1559            #(#emit)*
1560            if !__cookie_fields.is_empty() {
1561                req = req.header(::reqwest::header::COOKIE, __cookie_fields.join("; "));
1562            }
1563        }
1564    }
1565
1566    /// Generate query parameter handling
1567    fn generate_query_params(&self, op: &OperationInfo) -> TokenStream {
1568        let query_params: Vec<_> = op
1569            .parameters
1570            .iter()
1571            .filter(|p| p.location == "query")
1572            .collect();
1573
1574        if query_params.is_empty() {
1575            return quote! {};
1576        }
1577
1578        let mut param_building = Vec::new();
1579        // Serialization applied on `req` directly, after the pair-vector
1580        // block: form-exploded objects and deepObject objects, whose keys
1581        // aren't the static parameter name.
1582        let mut req_appends = Vec::new();
1583
1584        for param in query_params {
1585            use crate::analysis::QuerySerialization;
1586
1587            // Use snake_case for Rust variable name with keyword escaping
1588            let param_name_snake = self.param_ident_str(param);
1589            let param_name = Self::to_field_ident(&param_name_snake);
1590
1591            // Use the original parameter name from OpenAPI spec as the query string key
1592            let param_key = &param.name;
1593
1594            match &param.query_serialization {
1595                Some(QuerySerialization::FormExplodedNestedObject { properties }) => {
1596                    let emit_properties = properties.iter().map(|property| {
1597                        let wire_name = property.wire_name.as_str();
1598                        let field_ident = CodeGenerator::to_field_ident(
1599                            &self.to_rust_field_name(wire_name),
1600                        );
1601                        match &property.value_type {
1602                            crate::analysis::QueryStructPropertyType::Scalar(_) => quote! {
1603                                let value = serde_json::to_value(&v.#field_ident)
1604                                    .map_err(HttpError::serialization_error)?;
1605                                if !value.is_null() {
1606                                    let value = match value {
1607                                        serde_json::Value::String(value) => value,
1608                                        serde_json::Value::Bool(value) => value.to_string(),
1609                                        serde_json::Value::Number(value) => value.to_string(),
1610                                        _ => return Err(HttpError::serialization_error(
1611                                            format!("query field `{}` did not serialize as a scalar", #wire_name)
1612                                        ).into()),
1613                                    };
1614                                    nested_params.push((format!("{}.{}", #param_key, #wire_name), value));
1615                                }
1616                            },
1617                            crate::analysis::QueryStructPropertyType::Object { properties } => {
1618                                let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>();
1619                                quote! {
1620                                    let value = serde_json::to_value(&v.#field_ident)
1621                                        .map_err(HttpError::serialization_error)?;
1622                                    if !value.is_null() {
1623                                        let serde_json::Value::Object(object) = value else {
1624                                            return Err(HttpError::serialization_error(format!("query field `{}` did not serialize as an object", #wire_name)).into());
1625                                        };
1626                                        if object.is_empty() {
1627                                            nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
1628                                        } else {
1629                                            for leaf in [#(#leaves),*] {
1630                                                let Some(value) = object.get(leaf) else { continue };
1631                                                if value.is_null() { continue; }
1632                                                let value = match value {
1633                                                    serde_json::Value::String(value) => value.clone(),
1634                                                    serde_json::Value::Bool(value) => value.to_string(),
1635                                                    serde_json::Value::Number(value) => value.to_string(),
1636                                                    _ => return Err(HttpError::serialization_error(format!("query field `{}` contained a non-scalar leaf", #wire_name)).into()),
1637                                                };
1638                                                nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, leaf), value));
1639                                            }
1640                                        }
1641                                    }
1642                                }
1643                            }
1644                            crate::analysis::QueryStructPropertyType::Array { item_type } => {
1645                                let nested_properties = match item_type {
1646                                    crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => Some(
1647                                        properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>()
1648                                    ),
1649                                    _ => None,
1650                                };
1651                                if let Some(nested_properties) = nested_properties {
1652                                    quote! {
1653                                        let values = serde_json::to_value(&v.#field_ident)
1654                                            .map_err(HttpError::serialization_error)?;
1655                                        if !values.is_null() {
1656                                            let serde_json::Value::Array(values) = values else {
1657                                                return Err(HttpError::serialization_error(
1658                                                    format!("query field `{}` did not serialize as an array", #wire_name)
1659                                                ).into());
1660                                            };
1661                                            if values.is_empty() {
1662                                                nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
1663                                            }
1664                                            for (index, value) in values.into_iter().enumerate() {
1665                                                let serde_json::Value::Object(object) = value else {
1666                                                    return Err(HttpError::serialization_error(
1667                                                        format!("query field `{}` contained a non-object item", #wire_name)
1668                                                    ).into());
1669                                                };
1670                                                for leaf in [#(#nested_properties),*] {
1671                                                    let Some(value) = object.get(leaf) else { continue };
1672                                                    if value.is_null() { continue; }
1673                                                    let value = match value {
1674                                                        serde_json::Value::String(value) => value.clone(),
1675                                                        serde_json::Value::Bool(value) => value.to_string(),
1676                                                        serde_json::Value::Number(value) => value.to_string(),
1677                                                        _ => return Err(HttpError::serialization_error(
1678                                                            format!("query field `{}` contained a non-scalar leaf", #wire_name)
1679                                                        ).into()),
1680                                                    };
1681                                                    nested_params.push((format!("{}.{}.{}.{}", #param_key, #wire_name, index + 1, leaf), value));
1682                                                }
1683                                            }
1684                                        }
1685                                    }
1686                                } else {
1687                                    quote! {
1688                                        let values = serde_json::to_value(&v.#field_ident)
1689                                            .map_err(HttpError::serialization_error)?;
1690                                        if !values.is_null() {
1691                                            let serde_json::Value::Array(values) = values else {
1692                                                return Err(HttpError::serialization_error(
1693                                                    format!("query field `{}` did not serialize as an array", #wire_name)
1694                                                ).into());
1695                                            };
1696                                            if values.is_empty() {
1697                                                nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
1698                                            }
1699                                            for (index, value) in values.into_iter().enumerate() {
1700                                                let value = match value {
1701                                                    serde_json::Value::String(value) => value,
1702                                                    serde_json::Value::Bool(value) => value.to_string(),
1703                                                    serde_json::Value::Number(value) => value.to_string(),
1704                                                    _ => return Err(HttpError::serialization_error(
1705                                                        format!("query field `{}` contained a non-scalar item", #wire_name)
1706                                                    ).into()),
1707                                                };
1708                                                nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, index + 1), value));
1709                                            }
1710                                        }
1711                                    }
1712                                }
1713                            }
1714                        }
1715                    }).collect::<Vec<_>>();
1716                    let apply = quote! {
1717                        let mut nested_params: Vec<(String, String)> = Vec::new();
1718                        #(#emit_properties)*
1719                        if nested_params.is_empty() {
1720                            nested_params.push((format!("{}[]", #param_key), String::new()));
1721                        }
1722                        req = req.query(&nested_params);
1723                    };
1724                    if param.required {
1725                        req_appends.push(quote! {{ let v = #param_name; #apply }});
1726                    } else {
1727                        req_appends.push(quote! { if let Some(v) = #param_name { #apply } });
1728                    }
1729                    continue;
1730                }
1731                Some(QuerySerialization::FormExplodedObject) => {
1732                    // Issue #27: reqwest serializes the struct through
1733                    // serde_urlencoded, so each property becomes its own
1734                    // `key=value` pair; the parameter's own name never
1735                    // appears in the query string (RFC 6570 form-explosion).
1736                    // `name[]=` is the shared zero-cardinality marker used to
1737                    // preserve Some(empty) and required-empty values.
1738                    let apply = quote! {
1739                        let __empty = match serde_json::to_value(&v)
1740                            .map_err(HttpError::serialization_error)?
1741                        {
1742                            serde_json::Value::Object(map) => map.is_empty(),
1743                            _ => false,
1744                        };
1745                        if __empty {
1746                            req = req.query(&[(format!("{}[]", #param_key), String::new())]);
1747                        } else {
1748                            req = req.query(&v);
1749                        }
1750                    };
1751                    if param.required {
1752                        req_appends.push(quote! {
1753                            {
1754                                let v = #param_name;
1755                                #apply
1756                            }
1757                        });
1758                    } else {
1759                        req_appends.push(quote! {
1760                            if let Some(v) = #param_name {
1761                                #apply
1762                            }
1763                        });
1764                    }
1765                    continue;
1766                }
1767                Some(QuerySerialization::DeepObject) => {
1768                    // `?filter[color]=red&filter[size]=5`. Property values
1769                    // stringify through their JSON form; Null (unset
1770                    // Option) properties are skipped.
1771                    let apply = quote! {
1772                        let map = match serde_json::to_value(&v)
1773                            .map_err(HttpError::serialization_error)?
1774                        {
1775                            serde_json::Value::Object(map) => map,
1776                            _ => return Err(HttpError::serialization_error(
1777                                format!("query parameter `{}` did not serialize as an object", #param_key)
1778                            ).into()),
1779                        };
1780                        let mut deep_params: Vec<(String, String)> = Vec::new();
1781                        for (k, val) in map {
1782                            let s = match val {
1783                                serde_json::Value::Null => continue,
1784                                serde_json::Value::String(s) => s,
1785                                other => other.to_string(),
1786                            };
1787                            deep_params.push((format!("{}[{}]", #param_key, k), s));
1788                        }
1789                        if deep_params.is_empty() {
1790                            deep_params.push((format!("{}[]", #param_key), String::new()));
1791                        }
1792                        req = req.query(&deep_params);
1793                    };
1794                    if param.required {
1795                        req_appends.push(quote! {
1796                            {
1797                                let v = #param_name;
1798                                #apply
1799                            }
1800                        });
1801                    } else {
1802                        req_appends.push(quote! {
1803                            if let Some(v) = #param_name {
1804                                #apply
1805                            }
1806                        });
1807                    }
1808                    continue;
1809                }
1810                Some(QuerySerialization::FormObject) => {
1811                    // `?filter=color,red,size,big` — one pair whose value is
1812                    // the comma-joined key,value list (RFC 6570 form,
1813                    // explode=false).
1814                    let apply = quote! {
1815                        let map = match serde_json::to_value(&v)
1816                            .map_err(HttpError::serialization_error)?
1817                        {
1818                            serde_json::Value::Object(map) => map,
1819                            _ => return Err(HttpError::serialization_error(
1820                                format!("query parameter `{}` did not serialize as an object", #param_key)
1821                            ).into()),
1822                        };
1823                        let mut parts: Vec<String> = Vec::new();
1824                        for (k, val) in map {
1825                            let s = match val {
1826                                serde_json::Value::Null => continue,
1827                                serde_json::Value::String(s) => s,
1828                                other => other.to_string(),
1829                            };
1830                            if k.contains(',') || s.contains(',') {
1831                                return Err(HttpError::serialization_error(
1832                                    format!(
1833                                        "query object `{}` contains a comma in key `{}`; use explode=true for lossless string values",
1834                                        #param_key,
1835                                        k,
1836                                    )
1837                                ).into());
1838                            }
1839                            parts.push(k);
1840                            parts.push(s);
1841                        }
1842                        if parts.is_empty() {
1843                            query_params.push((
1844                                format!("{}[]", #param_key),
1845                                String::new(),
1846                            ));
1847                        } else {
1848                            query_params.push((#param_key.to_string(), parts.join(",")));
1849                        }
1850                    };
1851                    if param.required {
1852                        param_building.push(quote! {
1853                            {
1854                                let v = #param_name;
1855                                #apply
1856                            }
1857                        });
1858                    } else {
1859                        param_building.push(quote! {
1860                            if let Some(v) = #param_name {
1861                                #apply
1862                            }
1863                        });
1864                    }
1865                    continue;
1866                }
1867                Some(QuerySerialization::FormExplodedArray { item_type }) => {
1868                    // `?tags=a&tags=b` — one pair per element; flat structures
1869                    // expand AWS query-protocol style as `?tags.1.Key=k&tags.1.Value=v`.
1870                    let struct_properties = match item_type {
1871                        crate::analysis::ArrayItemType::FlatStructRef { properties, .. }
1872                        | crate::analysis::ArrayItemType::NestedStructRef { properties, .. } => {
1873                            Some(properties.clone())
1874                        }
1875                        _ => None,
1876                    };
1877                    let emit_items = if let Some(properties) = struct_properties {
1878                        let pushes = properties
1879                            .iter()
1880                            .map(|property| {
1881                                let wire_name = &property.wire_name;
1882                                // Wire names such as `Type` land on struct
1883                                // fields via the same keyword-escaping the
1884                                // model generator uses (`r#type`).
1885                                let field_ident = CodeGenerator::to_field_ident(
1886                                    &self.to_rust_field_name(wire_name),
1887                                );
1888                                match &property.value_type {
1889                                    crate::analysis::QueryStructPropertyType::Scalar(_) => quote! {
1890                                        let value = serde_json::to_value(&item.#field_ident)
1891                                            .map_err(HttpError::serialization_error)?;
1892                                        if !value.is_null() {
1893                                            let value = match value {
1894                                                serde_json::Value::String(value) => value,
1895                                                serde_json::Value::Bool(value) => value.to_string(),
1896                                                serde_json::Value::Number(value) => value.to_string(),
1897                                                _ => return Err(HttpError::serialization_error(
1898                                                    format!("query field `{}.{}` did not serialize as a scalar", #param_key, #wire_name)
1899                                                ).into()),
1900                                            };
1901                                            query_params.push((
1902                                                format!("{}.{}.{}", #param_key, index, #wire_name),
1903                                                value,
1904                                            ));
1905                                        }
1906                                    },
1907                                    crate::analysis::QueryStructPropertyType::Object { properties } => {
1908                                        let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>();
1909                                        quote! {
1910                                            let value = serde_json::to_value(&item.#field_ident)
1911                                                .map_err(HttpError::serialization_error)?;
1912                                            if !value.is_null() {
1913                                                let serde_json::Value::Object(object) = value else {
1914                                                    return Err(HttpError::serialization_error(format!("query field `{}.{}` did not serialize as an object", #param_key, #wire_name)).into());
1915                                                };
1916                                                if object.is_empty() {
1917                                                    query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
1918                                                }
1919                                                for leaf in [#(#leaves),*] {
1920                                                    let Some(value) = object.get(leaf) else { continue };
1921                                                    if value.is_null() { continue; }
1922                                                    let value = match value {
1923                                                        serde_json::Value::String(value) => value.clone(),
1924                                                        serde_json::Value::Bool(value) => value.to_string(),
1925                                                        serde_json::Value::Number(value) => value.to_string(),
1926                                                        _ => return Err(HttpError::serialization_error(format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name)).into()),
1927                                                    };
1928                                                    query_params.push((format!("{}.{}.{}.{}", #param_key, index, #wire_name, leaf), value));
1929                                                }
1930                                            }
1931                                        }
1932                                    }
1933                                    crate::analysis::QueryStructPropertyType::Array { item_type } => {
1934                                        let nested_properties = match item_type {
1935                                            crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => {
1936                                                Some(properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>())
1937                                            }
1938                                            _ => None,
1939                                        };
1940                                        if let Some(nested_properties) = nested_properties {
1941                                            quote! {
1942                                                let values = serde_json::to_value(&item.#field_ident)
1943                                                    .map_err(HttpError::serialization_error)?;
1944                                                if !values.is_null() {
1945                                                    let serde_json::Value::Array(values) = values else {
1946                                                        return Err(HttpError::serialization_error(
1947                                                            format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name)
1948                                                        ).into());
1949                                                    };
1950                                                    if values.is_empty() {
1951                                                        query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
1952                                                    }
1953                                                    for (nested_index, value) in values.into_iter().enumerate() {
1954                                                        let serde_json::Value::Object(object) = value else {
1955                                                            return Err(HttpError::serialization_error(
1956                                                                format!("query field `{}.{}` contained a non-object item", #param_key, #wire_name)
1957                                                            ).into());
1958                                                        };
1959                                                        if object.is_empty() {
1960                                                            query_params.push((format!("{}.{}.{}.{}[]", #param_key, index, #wire_name, nested_index + 1), String::new()));
1961                                                        }
1962                                                        for nested_wire_name in [#(#nested_properties),*] {
1963                                                            let Some(value) = object.get(nested_wire_name) else { continue };
1964                                                            if value.is_null() { continue; }
1965                                                            let value = match value {
1966                                                                serde_json::Value::String(value) => value.clone(),
1967                                                                serde_json::Value::Bool(value) => value.to_string(),
1968                                                                serde_json::Value::Number(value) => value.to_string(),
1969                                                                _ => return Err(HttpError::serialization_error(
1970                                                                    format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name)
1971                                                                ).into()),
1972                                                            };
1973                                                            query_params.push((
1974                                                                format!("{}.{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1, nested_wire_name),
1975                                                                value,
1976                                                            ));
1977                                                        }
1978                                                    }
1979                                                }
1980                                            }
1981                                        } else {
1982                                            quote! {
1983                                                let values = serde_json::to_value(&item.#field_ident)
1984                                                    .map_err(HttpError::serialization_error)?;
1985                                                if !values.is_null() {
1986                                                    let serde_json::Value::Array(values) = values else {
1987                                                        return Err(HttpError::serialization_error(
1988                                                            format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name)
1989                                                        ).into());
1990                                                    };
1991                                                    if values.is_empty() {
1992                                                        query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
1993                                                    }
1994                                                    for (nested_index, value) in values.into_iter().enumerate() {
1995                                                        let value = match value {
1996                                                            serde_json::Value::String(value) => value,
1997                                                            serde_json::Value::Bool(value) => value.to_string(),
1998                                                            serde_json::Value::Number(value) => value.to_string(),
1999                                                            _ => return Err(HttpError::serialization_error(
2000                                                                format!("query field `{}.{}` contained a non-scalar item", #param_key, #wire_name)
2001                                                            ).into()),
2002                                                        };
2003                                                        query_params.push((
2004                                                            format!("{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1),
2005                                                            value,
2006                                                        ));
2007                                                    }
2008                                                }
2009                                            }
2010                                        }
2011                                    }
2012                                }
2013                            })
2014                            .collect::<Vec<_>>();
2015                        quote! {
2016                            for (index, item) in v.iter().enumerate() {
2017                                let index = index + 1;
2018                                let item_start_len = query_params.len();
2019                                #(#pushes)*
2020                                if query_params.len() == item_start_len {
2021                                    query_params.push((format!("{}.{}[]", #param_key, index), String::new()));
2022                                }
2023                            }
2024                        }
2025                    } else {
2026                        quote! {
2027                            for item in v {
2028                                query_params.push((#param_key.to_string(), item.to_string()));
2029                            }
2030                        }
2031                    };
2032                    if param.required {
2033                        param_building.push(quote! {
2034                            let v = #param_name;
2035                            if v.is_empty() {
2036                                query_params.push((
2037                                    format!("{}[]", #param_key),
2038                                    String::new(),
2039                                ));
2040                            } else {
2041                                #emit_items
2042                            }
2043                        });
2044                    } else {
2045                        param_building.push(quote! {
2046                            if let Some(v) = #param_name {
2047                                if v.is_empty() {
2048                                    query_params.push((
2049                                        format!("{}[]", #param_key),
2050                                        String::new(),
2051                                    ));
2052                                } else {
2053                                    #emit_items
2054                                }
2055                            }
2056                        });
2057                    }
2058                    continue;
2059                }
2060                Some(QuerySerialization::FormArray { .. }) => {
2061                    // `?tags=a,b,c` — one comma-joined pair. Empty vectors
2062                    // use the shared `tags[]=` zero-cardinality marker.
2063                    let apply = quote! {
2064                        if v.is_empty() {
2065                            query_params.push((
2066                                format!("{}[]", #param_key),
2067                                String::new(),
2068                            ));
2069                        } else {
2070                            let mut parts = Vec::with_capacity(v.len());
2071                            for item in &v {
2072                                let item = item.to_string();
2073                                if item.contains(',') {
2074                                    return Err(HttpError::serialization_error(
2075                                        format!(
2076                                            "query array `{}` contains a comma; use explode=true for lossless string values",
2077                                            #param_key,
2078                                        )
2079                                    ).into());
2080                                }
2081                                parts.push(item);
2082                            }
2083                            query_params.push((
2084                                #param_key.to_string(),
2085                                parts.join(","),
2086                            ));
2087                        }
2088                    };
2089                    if param.required {
2090                        param_building.push(quote! {
2091                            {
2092                                let v = #param_name;
2093                                #apply
2094                            }
2095                        });
2096                    } else {
2097                        param_building.push(quote! {
2098                            if let Some(v) = #param_name {
2099                                #apply
2100                            }
2101                        });
2102                    }
2103                    continue;
2104                }
2105                Some(
2106                    QuerySerialization::Unsupported { .. }
2107                    | QuerySerialization::SimpleHeaderArray { .. },
2108                ) => {}
2109                None => {}
2110            }
2111
2112            if param.required {
2113                // Required parameters: always add
2114                if Self::param_uses_as_ref_str(param) {
2115                    param_building.push(quote! {
2116                        query_params.push((#param_key.to_string(), #param_name.as_ref().to_string()));
2117                    });
2118                } else {
2119                    param_building.push(quote! {
2120                        query_params.push((#param_key.to_string(), #param_name.to_string()));
2121                    });
2122                }
2123            } else {
2124                // Optional parameters: add only if Some
2125                if Self::param_uses_as_ref_str(param) {
2126                    param_building.push(quote! {
2127                        if let Some(v) = #param_name {
2128                            query_params.push((#param_key.to_string(), v.as_ref().to_string()));
2129                        }
2130                    });
2131                } else {
2132                    param_building.push(quote! {
2133                        if let Some(v) = #param_name {
2134                            query_params.push((#param_key.to_string(), v.to_string()));
2135                        }
2136                    });
2137                }
2138            }
2139        }
2140
2141        // Ops whose query params all serialize on `req` directly skip the
2142        // pair-vector block entirely.
2143        let pairs_block = if param_building.is_empty() {
2144            quote! {}
2145        } else {
2146            quote! {
2147                {
2148                    let mut query_params: Vec<(String, String)> = Vec::new();
2149                    #(#param_building)*
2150                    if !query_params.is_empty() {
2151                        req = req.query(&query_params);
2152                    }
2153                }
2154            }
2155        };
2156
2157        quote! {
2158            // Add query parameters
2159            #pairs_block
2160            #(#req_appends)*
2161        }
2162    }
2163
2164    /// Generate the rustdoc block for an operation, surfacing summary,
2165    /// description, the HTTP method+path, and any tags from the OAS spec
2166    /// (T13). Also marks the method `#[deprecated]` if the operation is.
2167    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
2168        let method = op.method.to_uppercase();
2169        let path = &op.path;
2170        let mut docs: Vec<String> = Vec::new();
2171        if let Some(s) = &op.summary {
2172            if !s.is_empty() {
2173                docs.push(s.clone());
2174                docs.push(String::new());
2175            }
2176        }
2177        if let Some(d) = &op.description {
2178            if !d.is_empty() {
2179                for line in d.lines() {
2180                    docs.push(line.to_string());
2181                }
2182                docs.push(String::new());
2183            }
2184        }
2185        docs.push(format!("`{} {}`", method, path));
2186        let doc_attrs: Vec<TokenStream> = docs
2187            .iter()
2188            .map(|line| {
2189                let prefixed = if line.is_empty() {
2190                    String::new()
2191                } else {
2192                    format!(" {line}")
2193                };
2194                quote! { #[doc = #prefixed] }
2195            })
2196            .collect();
2197        quote! { #(#doc_attrs)* }
2198    }
2199
2200    /// Get the method name from the operation
2201    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
2202        let name = if !op.operation_id.is_empty() {
2203            op.operation_id.to_snake_case()
2204        } else {
2205            // Fallback: generate from HTTP method and path
2206            format!(
2207                "{}_{}",
2208                op.method,
2209                op.path.replace('/', "_").replace(['{', '}'], "")
2210            )
2211            .to_snake_case()
2212        };
2213
2214        syn::Ident::new(&name, proc_macro2::Span::call_site())
2215    }
2216
2217    /// Build the request-builder expression for the operation's HTTP method.
2218    /// Named reqwest methods (`.get`/`.post`/…) are used where available;
2219    /// OPTIONS and TRACE go through `Client::request(Method::OPTIONS, _)` since
2220    /// reqwest doesn't expose those as named methods.
2221    fn http_method_call(&self, op: &OperationInfo) -> TokenStream {
2222        match op.method.to_uppercase().as_str() {
2223            "GET" => quote! { self.http_client.get(request_url) },
2224            "POST" => quote! { self.http_client.post(request_url) },
2225            "PUT" => quote! { self.http_client.put(request_url) },
2226            "DELETE" => quote! { self.http_client.delete(request_url) },
2227            "PATCH" => quote! { self.http_client.patch(request_url) },
2228            "HEAD" => quote! { self.http_client.head(request_url) },
2229            "OPTIONS" => quote! {
2230                self.http_client.request(reqwest::Method::OPTIONS, request_url)
2231            },
2232            "TRACE" => quote! {
2233                self.http_client.request(reqwest::Method::TRACE, request_url)
2234            },
2235            // D1: 3.2 `QUERY` verb + any custom verb from
2236            // PathItem.additionalOperations. reqwest's Method::from_bytes
2237            // accepts arbitrary uppercase tokens that match the RFC7230
2238            // method grammar.
2239            other => {
2240                let upper = other.to_string();
2241                quote! {
2242                    self.http_client.request(
2243                        reqwest::Method::from_bytes(#upper.as_bytes())
2244                            .expect("invalid HTTP method"),
2245                        request_url,
2246                    )
2247                }
2248            }
2249        }
2250    }
2251
2252    /// Generate request parameters including path, query, header, and request body.
2253    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
2254        let mut params = Vec::new();
2255        // Dedup parameter Rust idents within this method signature. Real-world
2256        // specs sometimes declare two parameters that sanitize to the same
2257        // snake_case name (modern-treasury declared `name` twice across
2258        // different param objects). Suffixing with `_2`, `_3`, … keeps each
2259        // parameter accessible while preserving the original wire-level name
2260        // (which is used elsewhere as the query/path/header key).
2261        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
2262        let mut unique_param_ident = |raw: String| -> syn::Ident {
2263            let mut chosen = raw.clone();
2264            let mut suffix = 2;
2265            while !used.insert(chosen.clone()) {
2266                chosen = format!("{raw}_{suffix}");
2267                suffix += 1;
2268            }
2269            Self::to_field_ident(&chosen)
2270        };
2271
2272        // Add path parameters
2273        for param in &op.parameters {
2274            if param.location == "path" {
2275                let param_name_snake = self.param_ident_str(param);
2276                let param_name = unique_param_ident(param_name_snake);
2277                let param_type = self.get_param_rust_type(param);
2278                params.push(quote! { #param_name: #param_type });
2279            }
2280        }
2281
2282        // Add query parameters (all as Option<T>)
2283        for param in &op.parameters {
2284            if param.location == "query" {
2285                let param_name_snake = self.param_ident_str(param);
2286                let param_name = unique_param_ident(param_name_snake);
2287                let param_type = self.get_param_rust_type(param);
2288
2289                // Query parameters should be Option unless explicitly required
2290                if param.required {
2291                    params.push(quote! { #param_name: #param_type });
2292                } else {
2293                    params.push(quote! { #param_name: Option<#param_type> });
2294                }
2295            }
2296        }
2297
2298        // Add header parameters. Required headers are bare; optional ones are
2299        // Option<T>. Per OAS 3.x §"Parameter Object", header names matching
2300        // `Accept`, `Content-Type`, and `Authorization` are forbidden — those
2301        // are described by other mechanisms — but we leave that validation to
2302        // analysis.
2303        for param in &op.parameters {
2304            if param.location == "header" {
2305                let param_name_snake = self.param_ident_str(param);
2306                let param_name = unique_param_ident(param_name_snake);
2307                let param_type = self.get_param_rust_type(param);
2308                if param.required {
2309                    params.push(quote! { #param_name: #param_type });
2310                } else {
2311                    params.push(quote! { #param_name: Option<#param_type> });
2312                }
2313            }
2314        }
2315
2316        for param in &op.parameters {
2317            if param.location == "cookie" {
2318                let param_name_snake = self.param_ident_str(param);
2319                let param_name = unique_param_ident(param_name_snake);
2320                let param_type = self.get_param_rust_type(param);
2321                if param.required {
2322                    params.push(quote! { #param_name: #param_type });
2323                } else {
2324                    params.push(quote! { #param_name: Option<#param_type> });
2325                }
2326            }
2327        }
2328
2329        // Add request body parameter based on content type. Optional bodies
2330        // (`requestBody.required` is false or absent) become `Option<T>` per T11.
2331        if let Some(ref rb) = op.request_body {
2332            use crate::analysis::RequestBodyContent;
2333            if matches!(rb, RequestBodyContent::SchemaLess { .. }) {
2334                return if params.is_empty() {
2335                    quote! {}
2336                } else {
2337                    quote! { #(#params),* }
2338                };
2339            }
2340            let required = op.request_body_required;
2341            let body_type = match rb {
2342                RequestBodyContent::Json { schema_name, .. }
2343                | RequestBodyContent::FormUrlEncoded { schema_name, .. }
2344                | RequestBodyContent::Multipart { schema_name, .. } => {
2345                    let rust_type_name = self.to_rust_type_name(schema_name);
2346                    let request_ident =
2347                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
2348                    quote! { #request_ident }
2349                }
2350
2351                RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. } => {
2352                    quote! { Vec<u8> }
2353                }
2354                RequestBodyContent::TextPlain { .. } => quote! { String },
2355                RequestBodyContent::Unsupported { .. } => quote! { Vec<u8> },
2356                RequestBodyContent::SchemaLess { .. } => unreachable!(
2357                    "schema-less request bodies preserve the historical client signature"
2358                ),
2359            };
2360            let body_ident = match rb {
2361                RequestBodyContent::OctetStream { .. }
2362                | RequestBodyContent::Binary { .. }
2363                | RequestBodyContent::TextPlain { .. }
2364                | RequestBodyContent::Unsupported { .. } => quote! { body },
2365                RequestBodyContent::SchemaLess { .. } => unreachable!(
2366                    "schema-less request bodies preserve the historical client signature"
2367                ),
2368                _ => quote! { request },
2369            };
2370            if required {
2371                params.push(quote! { #body_ident: #body_type });
2372            } else {
2373                params.push(quote! { #body_ident: Option<#body_type> });
2374            }
2375        }
2376
2377        if params.is_empty() {
2378            quote! {}
2379        } else {
2380            quote! { #(#params),* }
2381        }
2382    }
2383
2384    /// Get the Rust type for a parameter
2385    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
2386        if Self::param_has_impl_as_ref_type(param) {
2387            quote! { impl AsRef<str> }
2388        } else {
2389            self.get_param_owned_rust_type(param)
2390        }
2391    }
2392
2393    /// Owned parameter type shared by client-builder storage and generated
2394    /// server extraction. [`ParameterInfo::query_serialization`] is the
2395    /// authoritative projection for typed query objects and arrays.
2396    pub(crate) fn get_param_owned_rust_type(
2397        &self,
2398        param: &crate::analysis::ParameterInfo,
2399    ) -> TokenStream {
2400        use crate::analysis::QuerySerialization;
2401        // Typed form-style arrays take Vec<item> (openapi-generator-anu).
2402        // Scalars parse as-is (they may be type paths from [type_mappings]);
2403        // schema refs are raw schema names and go through the same
2404        // to_rust_type_name sanitization as every other schema reference
2405        // (cloudflare has enum schemas like `resource-sharing_resource_type`).
2406        if let Some(
2407            QuerySerialization::FormExplodedArray { item_type }
2408            | QuerySerialization::FormArray { item_type }
2409            | QuerySerialization::SimpleHeaderArray { item_type },
2410        ) = &param.query_serialization
2411        {
2412            use crate::analysis::ArrayItemType;
2413            let item_ty: syn::Type = match item_type {
2414                ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type)
2415                    .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")),
2416                ArrayItemType::SchemaRef(schema_name) => {
2417                    let rust_name = self.to_rust_type_name(schema_name);
2418                    syn::parse_str(&rust_name)
2419                        .unwrap_or_else(|_| panic!("invalid schema item type `{rust_name}`"))
2420                }
2421                ArrayItemType::FlatStructRef { schema_name, .. } => {
2422                    let rust_name = self.to_rust_type_name(schema_name);
2423                    syn::parse_str(&rust_name)
2424                        .unwrap_or_else(|_| panic!("invalid struct item type `{rust_name}`"))
2425                }
2426                ArrayItemType::NestedStructRef { schema_name, .. } => {
2427                    let rust_name = self.to_rust_type_name(schema_name);
2428                    syn::parse_str(&rust_name)
2429                        .unwrap_or_else(|_| panic!("invalid nested struct item type `{rust_name}`"))
2430                }
2431            };
2432            return quote! { Vec<#item_ty> };
2433        }
2434        // T10: $ref-typed parameters used to lose their type because we only
2435        // consulted `rust_type` (which stays "String"). Now: prefer the
2436        // resolved schema reference if present.
2437        if let Some(ref schema_name) = param.schema_ref {
2438            let rust_name = self.to_rust_type_name(schema_name);
2439            let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site());
2440            return quote! { #ident };
2441        }
2442        syn::parse_str::<syn::Type>(&param.rust_type)
2443            .map(|ty| quote! { #ty })
2444            .unwrap_or_else(|_| {
2445                let type_ident = syn::Ident::new(&param.rust_type, proc_macro2::Span::call_site());
2446                quote! { #type_ident }
2447            })
2448    }
2449
2450    /// True when the parameter's compile-time type is `impl AsRef<str>` and
2451    /// we should call `.as_ref()` on it before stringifying. False for any
2452    /// $ref-resolved type (T10) or non-String primitive — those just call
2453    /// `.to_string()`.
2454    fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
2455        param.schema_ref.is_none() && param.rust_type == "String"
2456    }
2457
2458    fn resolve_multipart_wire_schema<'a>(
2459        schema: &'a serde_json::Value,
2460        analysis: &'a SchemaAnalysis,
2461        visited: &mut std::collections::HashSet<String>,
2462    ) -> Option<&'a serde_json::Value> {
2463        let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) else {
2464            return Some(schema);
2465        };
2466        let name = reference.strip_prefix("#/components/schemas/")?;
2467        if !visited.insert(name.to_string()) {
2468            return None;
2469        }
2470        let resolved = analysis.validation_context.component_schemas.get(name)?;
2471        Self::resolve_multipart_wire_schema(resolved, analysis, visited)
2472    }
2473
2474    fn multipart_client_field_kind(
2475        schema_type: &crate::analysis::SchemaType,
2476        analysis: &SchemaAnalysis,
2477        visited: &mut std::collections::HashSet<String>,
2478    ) -> Option<MultipartClientFieldKind> {
2479        match schema_type {
2480            crate::analysis::SchemaType::Primitive {
2481                rust_type,
2482                serde_with,
2483            } => {
2484                let rust_type = rust_type.replace(' ', "");
2485                if rust_type == "bytes::Bytes" {
2486                    Some(MultipartClientFieldKind::RawBytes)
2487                } else if serde_with
2488                    .as_deref()
2489                    .is_some_and(|codec| codec.contains("base64_url"))
2490                {
2491                    Some(MultipartClientFieldKind::Base64UrlUnpadded)
2492                } else if serde_with
2493                    .as_deref()
2494                    .is_some_and(|codec| codec.contains("base64"))
2495                    || rust_type == "Vec<u8>"
2496                {
2497                    Some(MultipartClientFieldKind::Base64)
2498                } else {
2499                    Some(MultipartClientFieldKind::Text)
2500                }
2501            }
2502            crate::analysis::SchemaType::StringEnum { .. }
2503            | crate::analysis::SchemaType::ExtensibleEnum { .. } => {
2504                Some(MultipartClientFieldKind::Text)
2505            }
2506            crate::analysis::SchemaType::Reference { target } => {
2507                if !visited.insert(target.clone()) {
2508                    return None;
2509                }
2510                analysis.schemas.get(target).and_then(|schema| {
2511                    Self::multipart_client_field_kind(&schema.schema_type, analysis, visited)
2512                })
2513            }
2514            _ => None,
2515        }
2516    }
2517
2518    fn generate_typed_multipart_form(
2519        &self,
2520        schema_name: &str,
2521        validation_schema: &serde_json::Value,
2522        analysis: &SchemaAnalysis,
2523    ) -> TokenStream {
2524        use crate::analysis::{ObjectAdditionalProperties, SchemaType};
2525
2526        let Some((resolved_name, resolved_schema)) =
2527            self.resolve_reference_schema(schema_name, analysis)
2528        else {
2529            let message = format!(
2530                "multipart request schema `{schema_name}` could not be resolved during generation"
2531            );
2532            return quote! {
2533                return Err(HttpError::Config(#message.to_string()).into());
2534            };
2535        };
2536        let SchemaType::Object {
2537            properties,
2538            required,
2539            additional_properties,
2540            ..
2541        } = &resolved_schema.schema_type
2542        else {
2543            let message =
2544                format!("multipart request schema `{schema_name}` must resolve to an object");
2545            return quote! {
2546                return Err(HttpError::Config(#message.to_string()).into());
2547            };
2548        };
2549        let wire_schema = Self::resolve_multipart_wire_schema(
2550            validation_schema,
2551            analysis,
2552            &mut std::collections::HashSet::new(),
2553        );
2554        let wire_properties = wire_schema
2555            .and_then(|schema| schema.get("properties"))
2556            .and_then(serde_json::Value::as_object);
2557        let fields = self.emitted_object_properties(
2558            resolved_name,
2559            properties,
2560            required,
2561            additional_properties,
2562            analysis,
2563            None,
2564        );
2565        if matches!(
2566            additional_properties,
2567            ObjectAdditionalProperties::Typed { .. }
2568        ) {
2569            let message = format!(
2570                "multipart request schema `{schema_name}` cannot contain typed additional properties"
2571            );
2572            return quote! {
2573                return Err(HttpError::Config(#message.to_string()).into());
2574            };
2575        }
2576
2577        let mut parts = Vec::new();
2578        for field in fields {
2579            let wire_name = field.wire_name;
2580            let ident = field.ident;
2581            let wire_format = wire_properties
2582                .and_then(|properties| properties.get(wire_name))
2583                .and_then(|schema| {
2584                    Self::resolve_multipart_wire_schema(
2585                        schema,
2586                        analysis,
2587                        &mut std::collections::HashSet::new(),
2588                    )
2589                })
2590                .and_then(|schema| schema.get("format"))
2591                .and_then(serde_json::Value::as_str);
2592            let kind = if wire_format == Some("binary") {
2593                match self.config().types.binary {
2594                    crate::type_mapping::BinaryStrategy::String => MultipartClientFieldKind::Text,
2595                    crate::type_mapping::BinaryStrategy::Bytes
2596                    | crate::type_mapping::BinaryStrategy::VecU8 => {
2597                        MultipartClientFieldKind::RawBytes
2598                    }
2599                }
2600            } else if let Some(kind) = Self::multipart_client_field_kind(
2601                &field.property.schema_type,
2602                analysis,
2603                &mut std::collections::HashSet::new(),
2604            ) {
2605                kind
2606            } else {
2607                let message =
2608                    format!("multipart field `{wire_name}` must be binary or a scalar text field");
2609                return quote! {
2610                    return Err(HttpError::Config(#message.to_string()).into());
2611                };
2612            };
2613            let add_value = match kind {
2614                MultipartClientFieldKind::RawBytes => quote! {
2615                    form = form.part(
2616                        #wire_name,
2617                        reqwest::multipart::Part::bytes(value.to_vec()),
2618                    );
2619                },
2620                MultipartClientFieldKind::Base64 => quote! {
2621                    use base64::Engine as _;
2622                    form = form.text(
2623                        #wire_name,
2624                        base64::engine::general_purpose::STANDARD.encode(value),
2625                    );
2626                },
2627                MultipartClientFieldKind::Base64UrlUnpadded => quote! {
2628                    use base64::Engine as _;
2629                    form = form.text(
2630                        #wire_name,
2631                        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value),
2632                    );
2633                },
2634                MultipartClientFieldKind::Text => quote! {
2635                    form = form.text(#wire_name, value.to_string());
2636                },
2637            };
2638            parts.push(if field.is_required {
2639                quote! {
2640                    let value = &request.#ident;
2641                    #add_value
2642                }
2643            } else {
2644                quote! {
2645                    if let Some(value) = &request.#ident {
2646                        #add_value
2647                    }
2648                }
2649            });
2650        }
2651
2652        quote! {
2653            let mut form = reqwest::multipart::Form::new();
2654            #(#parts)*
2655            req = req.multipart(form);
2656        }
2657    }
2658
2659    /// Generate request body serialization based on content type
2660    /// Emit statements that mutate `req` to apply the request body. Returns
2661    /// explicit zero-length framing for bodyless POST, PUT, and PATCH requests.
2662    /// Optional bodies (T11) gate the application on `Some(_)`; required bodies
2663    /// apply unconditionally.
2664    fn generate_request_body(&self, op: &OperationInfo, analysis: &SchemaAnalysis) -> TokenStream {
2665        let empty_request_framing = Self::generate_empty_request_framing(op);
2666        let Some(rb) = op.request_body.as_ref() else {
2667            return empty_request_framing;
2668        };
2669        use crate::analysis::RequestBodyContent;
2670        let required = op.request_body_required;
2671        let (ident, apply): (TokenStream, TokenStream) = match rb {
2672            RequestBodyContent::Json { media_type, .. } => (
2673                quote! { request },
2674                quote! {
2675                    req = req
2676                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
2677                        .header("content-type", #media_type);
2678                },
2679            ),
2680            RequestBodyContent::FormUrlEncoded { media_type, .. } => (
2681                quote! { request },
2682                quote! {
2683                    req = req
2684                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
2685                        .header("content-type", #media_type);
2686                },
2687            ),
2688            RequestBodyContent::Multipart {
2689                schema_name,
2690                validation_schema,
2691                ..
2692            } => (
2693                quote! { request },
2694                self.generate_typed_multipart_form(schema_name, validation_schema, analysis),
2695            ),
2696            RequestBodyContent::OctetStream { media_type } => (
2697                quote! { body },
2698                quote! {
2699                    req = req
2700                        .body(body)
2701                        .header("content-type", #media_type);
2702                },
2703            ),
2704            RequestBodyContent::Binary { media_type } => (
2705                quote! { body },
2706                quote! {
2707                    req = req
2708                        .body(body)
2709                        .header("content-type", #media_type);
2710                },
2711            ),
2712            RequestBodyContent::TextPlain { media_type } => (
2713                quote! { body },
2714                quote! {
2715                    req = req
2716                        .body(body)
2717                        .header("content-type", #media_type);
2718                },
2719            ),
2720            RequestBodyContent::Unsupported { media_types } => {
2721                let media_type = media_types
2722                    .iter()
2723                    .find(|media_type| !crate::openapi::is_wildcard_media_type(media_type))
2724                    .map(String::as_str);
2725                let Some(media_type) = media_type else {
2726                    let ranges = media_types.join(", ");
2727                    let message = format!(
2728                        "request body for operation `{}` declares only wildcard media ranges ({ranges}); a concrete Content-Type is required",
2729                        op.operation_id,
2730                    );
2731                    return if required {
2732                        quote! {
2733                            let _ = body;
2734                            return Err(HttpError::Config(#message.to_string()).into());
2735                        }
2736                    } else {
2737                        quote! {
2738                            if body.is_some() {
2739                                return Err(HttpError::Config(#message.to_string()).into());
2740                            }
2741                            #empty_request_framing
2742                        }
2743                    };
2744                };
2745                (
2746                    quote! { body },
2747                    quote! {
2748                        req = req
2749                            .body(body)
2750                            .header("content-type", #media_type);
2751                    },
2752                )
2753            }
2754            RequestBodyContent::SchemaLess { .. } => return empty_request_framing,
2755        };
2756        if required {
2757            apply
2758        } else {
2759            quote! {
2760                if let Some(#ident) = #ident {
2761                    #apply
2762                } else {
2763                    #empty_request_framing
2764                }
2765            }
2766        }
2767    }
2768
2769    /// Emit explicit HTTP/1.1 framing when an operation sends no request body.
2770    /// RFC 9110 recommends `Content-Length: 0` for methods that define request
2771    /// content semantics. Methods without those semantics intentionally remain
2772    /// unchanged.
2773    fn generate_empty_request_framing(op: &OperationInfo) -> TokenStream {
2774        if ["POST", "PUT", "PATCH"]
2775            .iter()
2776            .any(|method| op.method.eq_ignore_ascii_case(method))
2777        {
2778            quote! {
2779                req = req.header(reqwest::header::CONTENT_LENGTH, "0");
2780            }
2781        } else {
2782            quote! {}
2783        }
2784    }
2785
2786    /// Find the success (2xx) response schema name, if any.
2787    ///
2788    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
2789    /// so that endpoints like 204 No Content correctly return `()` instead of
2790    /// accidentally picking up the error schema (e.g. `BadRequestError`).
2791    fn get_success_response_schema<'a>(
2792        &self,
2793        op: &'a OperationInfo,
2794    ) -> Option<(&'a str, &'a String)> {
2795        op.response_schemas
2796            .get_key_value("200")
2797            .or_else(|| op.response_schemas.get_key_value("201"))
2798            .or_else(|| {
2799                op.response_schemas
2800                    .iter()
2801                    .find(|(code, _)| code.starts_with('2'))
2802            })
2803            .map(|(status, schema)| (status.as_str(), schema))
2804    }
2805
2806    fn get_success_response<'a>(
2807        &self,
2808        analysis: &'a SchemaAnalysis,
2809        op: &'a OperationInfo,
2810    ) -> ClientSuccessSelection<'a> {
2811        if let Some(responses) = analysis.operation_responses.get(&op.operation_id) {
2812            let mut candidates = Vec::new();
2813            for preferred in ["200", "201"] {
2814                if let Some((status, response)) = responses.get_key_value(preferred) {
2815                    candidates.push((status.as_str(), response));
2816                }
2817            }
2818            candidates.extend(
2819                responses
2820                    .iter()
2821                    .filter(|(status, _)| {
2822                        status.starts_with('2')
2823                            && status.as_str() != "200"
2824                            && status.as_str() != "201"
2825                    })
2826                    .map(|(status, response)| (status.as_str(), response)),
2827            );
2828
2829            let selected = candidates
2830                .iter()
2831                .copied()
2832                .find(|(_, response)| {
2833                    matches!(
2834                        Self::response_body(response),
2835                        ClientSuccessBody::Json(_)
2836                            | ClientSuccessBody::Text
2837                            | ClientSuccessBody::Binary
2838                    )
2839                })
2840                .or_else(|| {
2841                    candidates.iter().copied().find(|(_, response)| {
2842                        matches!(
2843                            Self::response_body(response),
2844                            ClientSuccessBody::EventStream
2845                        )
2846                    })
2847                })
2848                .or_else(|| candidates.first().copied());
2849
2850            if let Some((_, response)) = selected {
2851                let body = Self::response_body(response);
2852                let statuses = candidates
2853                    .iter()
2854                    .filter_map(|(status, candidate)| {
2855                        Self::success_bodies_are_compatible(body, Self::response_body(candidate))
2856                            .then_some(*status)
2857                    })
2858                    .collect();
2859                let accept = match &response.body {
2860                    Some(OperationResponseBody::Json { media_type, .. })
2861                    | Some(OperationResponseBody::Text { media_type }) => Some(media_type.as_str()),
2862                    Some(OperationResponseBody::Binary {
2863                        media_type,
2864                        wildcard,
2865                    }) => (!wildcard).then_some(media_type.as_str()),
2866                    None if response.schema_name.is_some() => {
2867                        response.media_type.as_deref().or(Some("application/json"))
2868                    }
2869                    None if response.supports_streaming => Some("text/event-stream"),
2870                    None => None,
2871                };
2872                return ClientSuccessSelection {
2873                    statuses,
2874                    body,
2875                    accept,
2876                };
2877            }
2878        }
2879
2880        if let Some((_status, schema_name)) = self.get_success_response_schema(op) {
2881            let statuses = op
2882                .response_schemas
2883                .iter()
2884                .filter_map(|(candidate_status, candidate_schema)| {
2885                    (candidate_status.starts_with('2') && candidate_schema == schema_name)
2886                        .then_some(candidate_status.as_str())
2887                })
2888                .collect();
2889            ClientSuccessSelection {
2890                statuses,
2891                body: ClientSuccessBody::Json(schema_name),
2892                accept: Some("application/json"),
2893            }
2894        } else if Self::returns_raw_event_stream(op) {
2895            ClientSuccessSelection {
2896                statuses: Vec::new(),
2897                body: ClientSuccessBody::EventStream,
2898                accept: Some("text/event-stream"),
2899            }
2900        } else {
2901            ClientSuccessSelection {
2902                statuses: Vec::new(),
2903                body: ClientSuccessBody::Empty,
2904                accept: None,
2905            }
2906        }
2907    }
2908
2909    fn response_body(response: &crate::analysis::OperationResponse) -> ClientSuccessBody<'_> {
2910        match &response.body {
2911            Some(OperationResponseBody::Json { schema_name, .. }) => {
2912                ClientSuccessBody::Json(schema_name)
2913            }
2914            Some(OperationResponseBody::Text { .. }) => ClientSuccessBody::Text,
2915            Some(OperationResponseBody::Binary { .. }) => ClientSuccessBody::Binary,
2916            None if response.schema_name.is_some() => {
2917                ClientSuccessBody::Json(response.schema_name.as_deref().unwrap_or_default())
2918            }
2919            None if response.supports_streaming => ClientSuccessBody::EventStream,
2920            None => ClientSuccessBody::Empty,
2921        }
2922    }
2923
2924    fn success_bodies_are_compatible(
2925        selected: ClientSuccessBody<'_>,
2926        candidate: ClientSuccessBody<'_>,
2927    ) -> bool {
2928        match (selected, candidate) {
2929            (ClientSuccessBody::Json(selected), ClientSuccessBody::Json(candidate)) => {
2930                selected == candidate
2931            }
2932            (ClientSuccessBody::Text, ClientSuccessBody::Text)
2933            | (ClientSuccessBody::Binary, ClientSuccessBody::Binary)
2934            | (ClientSuccessBody::EventStream, ClientSuccessBody::EventStream)
2935            | (ClientSuccessBody::Empty, ClientSuccessBody::Empty) => true,
2936            _ => false,
2937        }
2938    }
2939
2940    /// Get response type
2941    fn get_response_type(&self, analysis: &SchemaAnalysis, op: &OperationInfo) -> TokenStream {
2942        match self.get_success_response(analysis, op).body {
2943            ClientSuccessBody::Json(response_type) => {
2944                // Convert schema name to Rust type name (handles underscores, etc.)
2945                let rust_type_name = self.to_rust_type_name(response_type);
2946                let response_ident =
2947                    syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
2948                quote! { #response_ident }
2949            }
2950            ClientSuccessBody::Text => quote! { String },
2951            ClientSuccessBody::Binary => quote! { bytes::Bytes },
2952            ClientSuccessBody::EventStream => {
2953                quote! { impl futures_util::Stream<Item = Result<bytes::Bytes, reqwest::Error>> }
2954            }
2955            ClientSuccessBody::Empty => quote! { () },
2956        }
2957    }
2958
2959    fn success_status_guard(statuses: &[&str]) -> TokenStream {
2960        if statuses.is_empty() {
2961            return quote! { status.is_success() };
2962        }
2963        let guards = statuses
2964            .iter()
2965            .map(|status| Self::single_status_guard(status));
2966        quote! { false #( || #guards )* }
2967    }
2968
2969    fn single_status_guard(status: &str) -> TokenStream {
2970        match status {
2971            status if status.chars().all(|character| character.is_ascii_digit()) => {
2972                let status: u16 = status.parse().unwrap_or_default();
2973                quote! { status_code == #status }
2974            }
2975            status if matches!(status.as_bytes(), [b'1'..=b'5', b'X' | b'x', b'X' | b'x']) => {
2976                let class = u16::from(status.as_bytes()[0] - b'0');
2977                quote! { status_code / 100 == #class }
2978            }
2979            _ => quote! { status.is_success() },
2980        }
2981    }
2982
2983    /// True when the operation's success response carries `text/event-stream`
2984    /// and nothing this generator can model as a JSON body.
2985    ///
2986    /// These previously generated `-> Result<(), _>` and then called
2987    /// `response.text().await`, which on a live SSE stream never returns: the
2988    /// caller's task deadlocks rather than erroring (openapi-generator-x9v).
2989    /// The streaming signal was already detected in analysis and honored by the
2990    /// server generator; only the client ignored it.
2991    ///
2992    /// Operations declaring *both* a JSON body and `text/event-stream` keep
2993    /// their JSON contract here — those are the `stream: true` style endpoints
2994    /// covered by the explicit `[streaming]` configuration, and silently
2995    /// changing their return type would break existing callers.
2996    fn returns_raw_event_stream(op: &OperationInfo) -> bool {
2997        op.supports_streaming
2998    }
2999
3000    /// Generate error handling.
3001    ///
3002    /// Buffers non-streaming responses once, retaining both exact bytes and a
3003    /// lossy UTF-8 view for compatibility. Only the selected declared success
3004    /// status is parsed into the generated return type; other 2xx statuses are
3005    /// inspectable `ApiError`s rather than being fed to an incompatible parser.
3006    fn generate_error_handling(
3007        &self,
3008        op: &OperationInfo,
3009        success: ClientSuccessSelection<'_>,
3010    ) -> TokenStream {
3011        let op_error_type = self.op_error_type_token(op);
3012        let success_body = success.body;
3013        let success_status_guard = Self::success_status_guard(&success.statuses);
3014        let selected_status = if success.statuses.is_empty() {
3015            "any declared 2xx response".to_string()
3016        } else {
3017            success.statuses.join(", ")
3018        };
3019
3020        let success_branch = match success_body {
3021            ClientSuccessBody::Json(_) => quote! {
3022                match serde_json::from_str(&body_text) {
3023                    Ok(body) => Ok(body),
3024                    Err(e) => Err(ApiOpError::Api(ApiError {
3025                        status: status_code,
3026                        headers: headers,
3027                        body: body_text,
3028                        raw_body,
3029                        typed: None,
3030                        parse_error: Some(format!(
3031                            "failed to deserialize 2xx response body: {}",
3032                            e
3033                        )),
3034                    })),
3035                }
3036            },
3037            ClientSuccessBody::Text => quote! {
3038                let _ = raw_body;
3039                Ok(body_text)
3040            },
3041            ClientSuccessBody::Empty => quote! {
3042                let _ = body_text;
3043                let _ = raw_body;
3044                let _ = headers;
3045                Ok(())
3046            },
3047            ClientSuccessBody::Binary | ClientSuccessBody::EventStream => quote! {},
3048        };
3049
3050        let error_match_arms = self.generate_error_match_arms(op);
3051
3052        // Streaming success path: hand back the live byte stream instead of
3053        // buffering it. Reading an SSE body to a string blocks until the server
3054        // closes the connection, which is precisely what it will not do.
3055        // The error path still buffers — an error response is finite.
3056        if matches!(success_body, ClientSuccessBody::EventStream) {
3057            return quote! {
3058                let status = response.status();
3059                let status_code = status.as_u16();
3060                let headers = response.headers().clone();
3061
3062                if #success_status_guard {
3063                    Ok(response.bytes_stream())
3064                } else {
3065                    if status.is_success() {
3066                        return Err(ApiOpError::Api(ApiError {
3067                            status: status_code,
3068                            headers,
3069                            body: String::new(),
3070                            raw_body: Vec::new(),
3071                            typed: None,
3072                            parse_error: Some(format!(
3073                                "unexpected successful status {}; generated return type selects `{}`; live response body was not buffered",
3074                                status_code,
3075                                #selected_status,
3076                            )),
3077                        }));
3078                    }
3079                    let body_bytes = __read_bounded_response_body(
3080                        response,
3081                        self.max_response_body_bytes,
3082                    ).await?;
3083                    let raw_body = body_bytes;
3084                    let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3085                    let typed: Option<#op_error_type>;
3086                    let parse_error: Option<String>;
3087                    #error_match_arms
3088                    Err(ApiOpError::Api(ApiError {
3089                        status: status_code,
3090                        headers,
3091                        body: body_text,
3092                        raw_body,
3093                        typed,
3094                        parse_error,
3095                    }))
3096                }
3097            };
3098        }
3099
3100        if matches!(success_body, ClientSuccessBody::Binary) {
3101            return quote! {
3102                let status = response.status();
3103                let status_code = status.as_u16();
3104                let headers = response.headers().clone();
3105
3106                let body_bytes = __read_bounded_response_body(
3107                    response,
3108                    self.max_response_body_bytes,
3109                ).await?;
3110                if #success_status_guard {
3111                    Ok(bytes::Bytes::from(body_bytes))
3112                } else {
3113                    let raw_body = body_bytes;
3114                    let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3115                    if status.is_success() {
3116                        return Err(ApiOpError::Api(ApiError {
3117                            status: status_code,
3118                            headers,
3119                            body: body_text,
3120                            raw_body,
3121                            typed: None,
3122                            parse_error: Some(format!(
3123                                "unexpected successful status {}; generated return type selects `{}`",
3124                                status_code,
3125                                #selected_status,
3126                            )),
3127                        }));
3128                    }
3129                    let typed: Option<#op_error_type>;
3130                    let parse_error: Option<String>;
3131                    #error_match_arms
3132                    Err(ApiOpError::Api(ApiError {
3133                        status: status_code,
3134                        headers,
3135                        body: body_text,
3136                        raw_body,
3137                        typed,
3138                        parse_error,
3139                    }))
3140                }
3141            };
3142        }
3143
3144        quote! {
3145            let status = response.status();
3146            let status_code = status.as_u16();
3147            let headers = response.headers().clone();
3148            let body_bytes = __read_bounded_response_body(
3149                response,
3150                self.max_response_body_bytes,
3151            ).await?;
3152            let raw_body = body_bytes;
3153            let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3154
3155            if #success_status_guard {
3156                #success_branch
3157            } else if status.is_success() {
3158                Err(ApiOpError::Api(ApiError {
3159                    status: status_code,
3160                    headers,
3161                    body: body_text,
3162                    raw_body,
3163                    typed: None,
3164                    parse_error: Some(format!(
3165                        "unexpected successful status {}; generated return type selects `{}`",
3166                        status_code,
3167                        #selected_status,
3168                    )),
3169                }))
3170            } else {
3171                let typed: Option<#op_error_type>;
3172                let parse_error: Option<String>;
3173                #error_match_arms
3174                Err(ApiOpError::Api(ApiError {
3175                    status: status_code,
3176                    headers,
3177                    body: body_text,
3178                    raw_body,
3179                    typed,
3180                    parse_error,
3181                }))
3182            }
3183        }
3184    }
3185
3186    /// Generate the match arms that select which per-op error variant to
3187    /// deserialize the response body into based on the runtime status code.
3188    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
3189        let arms: Vec<TokenStream> = op
3190            .response_schemas
3191            .iter()
3192            .filter(|(code, _)| !code.starts_with('2'))
3193            .filter_map(|(code, schema)| {
3194                let variant_ident = Self::op_error_variant_ident(code);
3195                let payload_ty_name = self.to_rust_type_name(schema);
3196                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
3197                let enum_ident = self.op_error_enum_ident(op);
3198
3199                // T8: range-keyed responses (1XX/2XX/3XX/4XX/5XX) per OAS
3200                // 3.x §"Responses Object". Specific codes still take priority
3201                // (handled by ordering — concrete codes deserialize first
3202                // because the generic dispatch is a generic `_ if (range)`).
3203                let pattern = match code.as_str() {
3204                    "default" | "Default" => return None, // handled in fallback
3205                    other if other.chars().all(|c| c.is_ascii_digit()) => {
3206                        let n: u16 = other.parse().ok()?;
3207                        quote! { #n }
3208                    }
3209                    "1XX" | "1xx" => quote! { code if (100..=199).contains(&code) },
3210                    "2XX" | "2xx" => quote! { code if (200..=299).contains(&code) },
3211                    "3XX" | "3xx" => quote! { code if (300..=399).contains(&code) },
3212                    "4XX" | "4xx" => quote! { code if (400..=499).contains(&code) },
3213                    "5XX" | "5xx" => quote! { code if (500..=599).contains(&code) },
3214                    _ => return None,
3215                };
3216
3217                Some(quote! {
3218                    #pattern => {
3219                        match serde_json::from_str::<#payload_ty>(&body_text) {
3220                            Ok(v) => {
3221                                typed = Some(#enum_ident::#variant_ident(v));
3222                                parse_error = None;
3223                            }
3224                            Err(e) => {
3225                                typed = None;
3226                                parse_error = Some(e.to_string());
3227                            }
3228                        }
3229                    }
3230                })
3231            })
3232            .collect();
3233
3234        // Fallback for "default" or undeclared status codes: try to parse
3235        // as `serde_json::Value` for inspectability when the op's error
3236        // type is generic, otherwise leave typed = None.
3237        // Must mirror op_error_type_token: if op_error_type is the typed
3238        // enum (any non-2xx response, including `default`), the fallback arm
3239        // can't deserialize into `serde_json::Value` because `typed` is the
3240        // enum. Default to `typed = None` in that case.
3241        let has_typed_enum = op
3242            .response_schemas
3243            .iter()
3244            .any(|(code, _)| !code.starts_with('2'));
3245
3246        // A spec-declared `default` response is the catch-all arm's payload
3247        // type. Without this the generated enum carries a `Default(..)` variant
3248        // that nothing ever constructs, so a response matched only by `default`
3249        // — a perfectly parseable typed body — still surfaces as
3250        // `typed: None` and callers fall back to raw strings
3251        // (openapi-generator-nu7).
3252        let default_payload = op
3253            .response_schemas
3254            .iter()
3255            .find(|(code, _)| matches!(code.as_str(), "default" | "Default"))
3256            .map(|(_, schema)| self.to_rust_type_name(schema));
3257
3258        let default_arm = if let Some(payload_ty_name) = default_payload {
3259            let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
3260            let enum_ident = self.op_error_enum_ident(op);
3261            quote! {
3262                _ => {
3263                    match serde_json::from_str::<#payload_ty>(&body_text) {
3264                        Ok(v) => {
3265                            typed = Some(#enum_ident::Default(v));
3266                            parse_error = None;
3267                        }
3268                        Err(e) => {
3269                            typed = None;
3270                            parse_error = Some(e.to_string());
3271                        }
3272                    }
3273                }
3274            }
3275        } else if has_typed_enum {
3276            quote! {
3277                _ => {
3278                    typed = None;
3279                    parse_error = None;
3280                }
3281            }
3282        } else {
3283            // No typed enum — op_error_type is serde_json::Value.
3284            quote! {
3285                _ => {
3286                    match serde_json::from_str::<serde_json::Value>(&body_text) {
3287                        Ok(v) => {
3288                            typed = Some(v);
3289                            parse_error = None;
3290                        }
3291                        Err(e) => {
3292                            typed = None;
3293                            parse_error = Some(e.to_string());
3294                        }
3295                    }
3296                }
3297            }
3298        };
3299
3300        if arms.is_empty() {
3301            // No declared status arms — just the fallback.
3302            quote! {
3303                match status_code {
3304                    #default_arm
3305                }
3306            }
3307        } else {
3308            quote! {
3309                match status_code {
3310                    #(#arms)*
3311                    #default_arm
3312                }
3313            }
3314        }
3315    }
3316
3317    /// Generate URL construction with path parameter substitution
3318    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
3319        // Check if path has parameters (contains {...})
3320        if path.contains('{') {
3321            self.generate_url_with_params(path, op)
3322        } else {
3323            quote! {
3324                let request_url = format!("{}{}", self.base_url, #path);
3325            }
3326        }
3327    }
3328
3329    /// Generate URL with path parameters
3330    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
3331        // Find all path parameters in the operation.
3332        let path_params: Vec<_> = op
3333            .parameters
3334            .iter()
3335            .filter(|p| p.location == "path")
3336            .collect();
3337
3338        // T5: percent-encode each path-template variable per RFC3986 §3.3.
3339        // We build a positional-arg format string by walking the template
3340        // left-to-right and emitting one `{}` + one format arg per
3341        // placeholder occurrence. Cloudflare has paths like
3342        // `/accounts/{account_id}/.../accounts/{account_id}` — the same
3343        // variable appears twice. A naive `replace_all` produced two `{}`
3344        // placeholders but only one format arg (E0277). Per-occurrence
3345        // emission keeps them in sync.
3346        let mut format_string = String::with_capacity(path.len());
3347        let mut format_args: Vec<TokenStream> = Vec::new();
3348        let mut chars = path.chars().peekable();
3349        while let Some(c) = chars.next() {
3350            if c != '{' {
3351                format_string.push(c);
3352                continue;
3353            }
3354            // Read until the matching '}'.
3355            let mut name = String::new();
3356            while let Some(&n) = chars.peek() {
3357                chars.next();
3358                if n == '}' {
3359                    break;
3360                }
3361                name.push(n);
3362            }
3363            // Resolve to a path param. If no match, leave the placeholder
3364            // verbatim (real-world spec bug — this op shouldn't have made
3365            // it past analysis).
3366            let param = path_params.iter().find(|p| p.name == name);
3367            let Some(param) = param else {
3368                format_string.push('{');
3369                format_string.push_str(&name);
3370                format_string.push('}');
3371                continue;
3372            };
3373            format_string.push_str("{}");
3374            let param_name_snake = self.param_ident_str(param);
3375            let param_ident = Self::to_field_ident(&param_name_snake);
3376            if Self::param_uses_as_ref_str(param) {
3377                format_args.push(quote! {
3378                    __pct_encode_path_segment(#param_ident.as_ref())
3379                });
3380            } else {
3381                format_args.push(quote! {
3382                    __pct_encode_path_segment(&#param_ident.to_string())
3383                });
3384            }
3385        }
3386
3387        if format_args.is_empty() {
3388            quote! {
3389                let request_url = format!("{}{}", self.base_url, #path);
3390            }
3391        } else {
3392            quote! {
3393                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
3394            }
3395        }
3396    }
3397
3398    /// Resolve the Rust ident for a parameter. Prefers the disambiguated
3399    /// `rust_ident` set by the analyzer (which dedupes across the whole
3400    /// operation), falling back to a fresh sanitize of the wire name when
3401    /// no analyzer-side ident is present.
3402    pub(crate) fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String {
3403        if let Some(ident) = &param.rust_ident {
3404            // Apply the keyword-escape and self/super/crate dance the
3405            // sanitize fn does. The analyzer's base ident is already the
3406            // snake/kebab-aware shape; we only need post-processing.
3407            return self.escape_keyword_ident(ident);
3408        }
3409        self.sanitize_param_name(&param.name)
3410    }
3411
3412    fn escape_keyword_ident(&self, snake_case: &str) -> String {
3413        if matches!(snake_case, "self" | "super" | "crate" | "Self") {
3414            return format!("{snake_case}_param");
3415        }
3416        if Self::is_rust_keyword(snake_case) {
3417            format!("r#{snake_case}")
3418        } else {
3419            snake_case.to_string()
3420        }
3421    }
3422
3423    /// Sanitize a parameter name by escaping Rust reserved keywords with raw
3424    /// identifiers and disambiguating Twilio-style suffix operators
3425    /// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
3426    /// case to `start_time`).
3427    fn sanitize_param_name(&self, name: &str) -> String {
3428        // Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
3429        // filter-style query params; map them to `_lt` / `_gt` etc. so the
3430        // Rust ident is unique while the wire-level param name stays the
3431        // original string elsewhere in the codegen.
3432        let suffix = if name.ends_with("<=") {
3433            "_lte"
3434        } else if name.ends_with(">=") {
3435            "_gte"
3436        } else if name.ends_with('<') {
3437            "_lt"
3438        } else if name.ends_with('>') {
3439            "_gt"
3440        } else {
3441            ""
3442        };
3443        let stripped = name.trim_end_matches(['<', '>', '=']);
3444        let mut snake_case = stripped.to_snake_case();
3445        if snake_case.is_empty() {
3446            snake_case.push_str("parameter");
3447        } else if snake_case.starts_with(|character: char| character.is_ascii_digit()) {
3448            snake_case.insert(0, '_');
3449        }
3450        snake_case.push_str(suffix);
3451
3452        if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
3453            return format!("{snake_case}_param");
3454        }
3455        if Self::is_rust_keyword(&snake_case) {
3456            format!("r#{snake_case}")
3457        } else {
3458            snake_case
3459        }
3460    }
3461}