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