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