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, ParameterInfo, SchemaAnalysis};
151use crate::generator::CodeGenerator;
152use heck::{ToPascalCase, ToSnakeCase};
153use proc_macro2::TokenStream;
154use quote::{format_ident, quote};
155use std::collections::BTreeMap;
156
157struct AllocatedOperationParam<'a> {
158    param: &'a ParameterInfo,
159    ident: syn::Ident,
160}
161
162#[derive(Clone)]
163struct BodyFieldPlan {
164    wire_name: String,
165    preferred_method_name: String,
166    value_ident: syn::Ident,
167    value_type: TokenStream,
168    access_path: Vec<syn::Ident>,
169}
170
171enum RequiredBodyConstruction {
172    Default,
173    New(Vec<BodyConstructorParam>),
174    Whole,
175}
176
177struct BodyConstructorParam {
178    preferred_ident: syn::Ident,
179    value_type: TokenStream,
180}
181
182struct BodyModelPlan {
183    body_ident: syn::Ident,
184    body_type: TokenStream,
185    required_construction: RequiredBodyConstruction,
186    optional_fields: Vec<BodyFieldPlan>,
187}
188
189impl CodeGenerator {
190    /// Generate the HTTP client struct with middleware support
191    pub fn generate_http_client_struct(&self) -> TokenStream {
192        let has_retry = self.config().retry_config.is_some();
193        let has_tracing = self.config().tracing_enabled;
194
195        // Generate RetryConfig struct if needed
196        let retry_config_struct = if has_retry {
197            quote! {
198                /// Retry configuration for HTTP requests
199                #[derive(Debug, Clone)]
200                pub struct RetryConfig {
201                    pub max_retries: u32,
202                    pub initial_delay_ms: u64,
203                    pub max_delay_ms: u64,
204                }
205
206                impl Default for RetryConfig {
207                    fn default() -> Self {
208                        Self {
209                            max_retries: 3,
210                            initial_delay_ms: 500,
211                            max_delay_ms: 16000,
212                        }
213                    }
214                }
215            }
216        } else {
217            quote! {}
218        };
219
220        // Generate the main HttpClient struct
221        let client_struct = quote! {
222            use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
223            use std::collections::BTreeMap;
224
225            /// HTTP client for making API requests
226            #[derive(Clone)]
227            pub struct HttpClient {
228                base_url: String,
229                api_key: Option<String>,
230                http_client: ClientWithMiddleware,
231                custom_headers: BTreeMap<String, String>,
232            }
233        };
234
235        // Generate constructor
236        let constructor = self.generate_constructor(has_retry, has_tracing);
237
238        // Generate builder methods
239        let builder_methods = self.generate_builder_methods();
240
241        // Generate Default implementation
242        let default_impl = quote! {
243            impl Default for HttpClient {
244                fn default() -> Self {
245                    Self::new()
246                }
247            }
248        };
249
250        // Path-segment percent encoder, used by url construction (T5).
251        // Encodes per RFC3986 §3.3: only ALPHA, DIGIT, and `-._~` pass through;
252        // everything else becomes `%XX`.
253        let path_encoder = quote! {
254            fn __pct_encode_path_segment(s: &str) -> String {
255                let mut out = String::with_capacity(s.len());
256                for &b in s.as_bytes() {
257                    match b {
258                        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
259                            out.push(b as char);
260                        }
261                        _ => {
262                            out.push('%');
263                            out.push_str(&format!("{:02X}", b));
264                        }
265                    }
266                }
267                out
268            }
269        };
270
271        // Combine all parts
272        quote! {
273            #retry_config_struct
274            #client_struct
275
276            impl HttpClient {
277                #constructor
278                #builder_methods
279            }
280
281            #default_impl
282            #path_encoder
283        }
284    }
285
286    /// Generate the constructor method
287    fn generate_constructor(&self, has_retry: bool, has_tracing: bool) -> TokenStream {
288        let retry_param = if has_retry {
289            quote! { retry_config: Option<RetryConfig>, }
290        } else {
291            quote! {}
292        };
293
294        let tracing_param = if has_tracing {
295            quote! { enable_tracing: bool, }
296        } else {
297            quote! {}
298        };
299
300        let retry_middleware = if has_retry {
301            quote! {
302                if let Some(config) = retry_config {
303                    use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
304
305                    let retry_policy = ExponentialBackoff::builder()
306                        .retry_bounds(
307                            std::time::Duration::from_millis(config.initial_delay_ms),
308                            std::time::Duration::from_millis(config.max_delay_ms),
309                        )
310                        .build_with_max_retries(config.max_retries);
311
312                    let retry_middleware = RetryTransientMiddleware::new_with_policy(retry_policy);
313                    client_builder = client_builder.with(retry_middleware);
314                }
315            }
316        } else {
317            quote! {}
318        };
319
320        let tracing_middleware = if has_tracing {
321            quote! {
322                if enable_tracing {
323                    use reqwest_tracing::TracingMiddleware;
324                    client_builder = client_builder.with(TracingMiddleware::default());
325                }
326            }
327        } else {
328            quote! {}
329        };
330
331        let default_constructor = if has_retry && has_tracing {
332            quote! {
333                /// Create a new HTTP client with default configuration
334                pub fn new() -> Self {
335                    Self::with_config(None, true)
336                }
337            }
338        } else if has_retry {
339            quote! {
340                /// Create a new HTTP client with default configuration
341                pub fn new() -> Self {
342                    Self::with_config(None)
343                }
344            }
345        } else if has_tracing {
346            quote! {
347                /// Create a new HTTP client with default configuration
348                pub fn new() -> Self {
349                    Self::with_config(true)
350                }
351            }
352        } else {
353            quote! {
354                /// Create a new HTTP client with default configuration
355                pub fn new() -> Self {
356                    let reqwest_client = reqwest::Client::new();
357                    let client_builder = ClientBuilder::new(reqwest_client);
358                    let http_client = client_builder.build();
359
360                    Self {
361                        base_url: String::new(),
362                        api_key: None,
363                        http_client,
364                        custom_headers: BTreeMap::new(),
365                    }
366                }
367            }
368        };
369
370        if has_retry || has_tracing {
371            quote! {
372                #default_constructor
373
374                /// Create a new HTTP client with custom configuration
375                pub fn with_config(#retry_param #tracing_param) -> Self {
376                    let reqwest_client = reqwest::Client::new();
377                    let mut client_builder = ClientBuilder::new(reqwest_client);
378
379                    #tracing_middleware
380                    #retry_middleware
381
382                    let http_client = client_builder.build();
383
384                    Self {
385                        base_url: String::new(),
386                        api_key: None,
387                        http_client,
388                        custom_headers: BTreeMap::new(),
389                    }
390                }
391            }
392        } else {
393            default_constructor
394        }
395    }
396
397    /// Generate builder methods for configuration
398    fn generate_builder_methods(&self) -> TokenStream {
399        quote! {
400            /// Set the base URL for all requests
401            pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
402                self.base_url = base_url.into();
403                self
404            }
405
406            /// Set the API key for authentication
407            pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
408                self.api_key = Some(api_key.into());
409                self
410            }
411
412            /// Add a custom header to all requests
413            pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
414                self.custom_headers.insert(name.into(), value.into());
415                self
416            }
417
418            /// Add multiple custom headers
419            pub fn with_headers(mut self, headers: BTreeMap<String, String>) -> Self {
420                self.custom_headers.extend(headers);
421                self
422            }
423        }
424    }
425
426    /// Generate HTTP operation methods for the client.
427    ///
428    /// Emits per-operation typed error enums (one variant per declared non-2xx
429    /// response with a body schema) BEFORE the `impl HttpClient` block so the
430    /// generated method signatures can reference them. This low-level helper
431    /// intentionally emits every analyzed operation; use
432    /// [`Self::generate_http_client`] or [`Self::generate_all`] to honor the
433    /// configured `[client].operations` scope.
434    pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream {
435        let operations: Vec<&OperationInfo> = analysis.operations.values().collect();
436        self.generate_operation_methods_for(analysis, &operations)
437    }
438
439    /// Generate every operation-owned client artifact from one resolved
440    /// operation slice. This keeps methods, parameter enums, and typed error
441    /// enums in lockstep for selective clients.
442    pub(crate) fn generate_operation_methods_for(
443        &self,
444        analysis: &SchemaAnalysis,
445        operations: &[&OperationInfo],
446    ) -> TokenStream {
447        let param_enums = self.generate_param_enum_types(operations);
448
449        let op_error_enums: Vec<TokenStream> = operations
450            .iter()
451            .copied()
452            .filter_map(|op| self.generate_op_error_enum(op))
453            .collect();
454
455        let methods: Vec<TokenStream> = operations
456            .iter()
457            .copied()
458            .map(|op| self.generate_single_operation_method(op))
459            .collect();
460
461        let (operation_builders, builder_entries) =
462            self.generate_operation_builders(analysis, operations);
463
464        quote! {
465            #param_enums
466
467            #(#op_error_enums)*
468
469            #(#operation_builders)*
470
471            impl HttpClient {
472                #(#methods)*
473                #(#builder_entries)*
474            }
475        }
476    }
477
478    fn generate_operation_builders(
479        &self,
480        analysis: &SchemaAnalysis,
481        operations: &[&OperationInfo],
482    ) -> (Vec<TokenStream>, Vec<TokenStream>) {
483        if !self.config().builders.enabled {
484            return (Vec::new(), Vec::new());
485        }
486
487        let mut used_entry_methods: std::collections::HashSet<String> = operations
488            .iter()
489            .map(|operation| self.get_method_name(operation).to_string())
490            .collect();
491        let mut used_type_names = std::collections::HashSet::new();
492        for schema_name in analysis.schemas.keys() {
493            let rust_name = self.to_rust_type_name(schema_name);
494            used_type_names.insert(rust_name.clone());
495            // Request-model builders live in `types.rs` and are imported by
496            // glob into the client module. Reserve their conventional names
497            // so operation builders cannot create ambiguous re-exports.
498            used_type_names.insert(format!("{rust_name}Builder"));
499        }
500        used_type_names.insert("HttpClient".to_string());
501        used_type_names.insert("ApiOpError".to_string());
502        used_type_names.extend(
503            [
504                "ClientBuilder",
505                "ClientWithMiddleware",
506                "RetryConfig",
507                "HttpError",
508                "BTreeMap",
509            ]
510            .into_iter()
511            .map(str::to_string),
512        );
513        for operation in operations {
514            used_type_names.insert(self.op_error_enum_ident(operation).to_string());
515            used_type_names.extend(
516                operation
517                    .parameters
518                    .iter()
519                    .filter(|parameter| parameter.enum_values.is_some())
520                    .map(|parameter| parameter.rust_type.clone()),
521            );
522        }
523
524        let mut definitions = Vec::new();
525        let mut entries = Vec::new();
526        for operation in operations {
527            let allocated_params = self.allocated_operation_params(operation);
528            let body_plan = self.body_model_plan(operation, analysis);
529            let optional_param_count = allocated_params
530                .iter()
531                .filter(|allocated| !Self::builder_param_is_required(allocated.param))
532                .count();
533            let optional_body_count =
534                usize::from(operation.request_body.is_some() && !operation.request_body_required);
535            let body_field_count = body_plan
536                .as_ref()
537                .filter(|plan| {
538                    operation.request_body_required
539                        || matches!(
540                            &plan.required_construction,
541                            RequiredBodyConstruction::Default
542                        )
543                })
544                .map_or(0, |plan| plan.optional_fields.len());
545            let optional_count = optional_param_count + optional_body_count + body_field_count;
546            if optional_count <= self.config().builders.threshold {
547                continue;
548            }
549
550            let flat_method = self.get_method_name(operation);
551            let entry_base = format!("{flat_method}_builder");
552            let entry_name = Self::allocate_name(&entry_base, &mut used_entry_methods);
553            let entry_ident = Self::to_field_ident(&entry_name);
554
555            let builder_base = format!("{}Builder", flat_method.to_string().to_pascal_case());
556            let builder_name = Self::allocate_type_name(&builder_base, &mut used_type_names);
557            let builder_ident = format_ident!("{builder_name}");
558
559            let (definition, entry) = self.generate_single_operation_builder(
560                operation,
561                &allocated_params,
562                body_plan,
563                &flat_method,
564                &entry_ident,
565                &builder_ident,
566            );
567            definitions.push(definition);
568            entries.push(entry);
569        }
570
571        (definitions, entries)
572    }
573
574    fn generate_single_operation_builder(
575        &self,
576        operation: &OperationInfo,
577        allocated_params: &[AllocatedOperationParam<'_>],
578        body_plan: Option<BodyModelPlan>,
579        flat_method: &syn::Ident,
580        entry_ident: &syn::Ident,
581        builder_ident: &syn::Ident,
582    ) -> (TokenStream, TokenStream) {
583        let mut fields = vec![quote! { client: &'a HttpClient }];
584        let mut entry_parameters = Vec::new();
585        let mut initializers = vec![quote! { client: self }];
586        let mut call_arguments = Vec::new();
587        let mut setters = Vec::new();
588        let mut used_entry_params = std::collections::HashSet::new();
589        let mut used_methods = std::collections::HashSet::from(["send".to_string()]);
590
591        for allocated in allocated_params {
592            let field_ident = &allocated.ident;
593            let storage_type = self.builder_param_storage_type(allocated.param);
594            if Self::builder_param_is_required(allocated.param) {
595                fields.push(quote! { #field_ident: #storage_type });
596                let entry_name =
597                    Self::allocate_name(&field_ident.to_string(), &mut used_entry_params);
598                let entry_param = Self::to_field_ident(&entry_name);
599                if Self::param_has_impl_as_ref_type(allocated.param) {
600                    entry_parameters.push(quote! { #entry_param: impl Into<String> });
601                    initializers.push(quote! { #field_ident: #entry_param.into() });
602                } else {
603                    entry_parameters.push(quote! { #entry_param: #storage_type });
604                    initializers.push(quote! { #field_ident: #entry_param });
605                }
606            } else {
607                fields.push(quote! { #field_ident: Option<#storage_type> });
608                initializers.push(quote! { #field_ident: None });
609                let setter_ident =
610                    Self::allocate_builder_method(&field_ident.to_string(), &mut used_methods);
611                let wire_name = &allocated.param.name;
612                let assignment = if Self::param_has_impl_as_ref_type(allocated.param) {
613                    quote! { self.#field_ident = Some(#field_ident.into()); }
614                } else {
615                    quote! { self.#field_ident = Some(#field_ident); }
616                };
617                let setter_type = if Self::param_has_impl_as_ref_type(allocated.param) {
618                    quote! { impl Into<String> }
619                } else {
620                    storage_type.clone()
621                };
622                setters.push(quote! {
623                    #[doc = concat!("Set the optional `", #wire_name, "` operation parameter.")]
624                    #[must_use]
625                    pub fn #setter_ident(mut self, #field_ident: #setter_type) -> Self {
626                        #assignment
627                        self
628                    }
629                });
630            }
631            call_arguments.push(quote! { self.#field_ident });
632        }
633
634        if let Some(body_plan) = body_plan {
635            let BodyModelPlan {
636                body_ident,
637                body_type,
638                required_construction,
639                optional_fields,
640            } = body_plan;
641            let can_initialize_optional_body =
642                matches!(&required_construction, RequiredBodyConstruction::Default);
643            if operation.request_body_required {
644                fields.push(quote! { #body_ident: #body_type });
645                match required_construction {
646                    RequiredBodyConstruction::Default => {
647                        initializers.push(quote! { #body_ident: Default::default() });
648                    }
649                    RequiredBodyConstruction::New(constructor_params) => {
650                        let mut constructor_args = Vec::new();
651                        for constructor in constructor_params {
652                            let preferred = constructor.preferred_ident.to_string();
653                            let entry_name =
654                                Self::allocate_name(&preferred, &mut used_entry_params);
655                            let entry_param = Self::to_field_ident(&entry_name);
656                            let value_type = constructor.value_type;
657                            entry_parameters.push(quote! { #entry_param: #value_type });
658                            constructor_args.push(entry_param);
659                        }
660                        initializers.push(quote! {
661                            #body_ident: #body_type::new(#(#constructor_args),*)
662                        });
663                    }
664                    RequiredBodyConstruction::Whole => {
665                        let entry_name =
666                            Self::allocate_name(&body_ident.to_string(), &mut used_entry_params);
667                        let entry_param = Self::to_field_ident(&entry_name);
668                        entry_parameters.push(quote! { #entry_param: #body_type });
669                        initializers.push(quote! { #body_ident: #entry_param });
670                    }
671                }
672            } else {
673                fields.push(quote! { #body_ident: Option<#body_type> });
674                initializers.push(quote! { #body_ident: None });
675            }
676
677            let body_setter =
678                Self::allocate_builder_method(&body_ident.to_string(), &mut used_methods);
679            let body_assignment = if operation.request_body_required {
680                quote! { self.#body_ident = #body_ident; }
681            } else {
682                quote! { self.#body_ident = Some(#body_ident); }
683            };
684            setters.push(quote! {
685                /// Replace the complete request body.
686                #[must_use]
687                pub fn #body_setter(mut self, #body_ident: #body_type) -> Self {
688                    #body_assignment
689                    self
690                }
691            });
692
693            if operation.request_body_required || can_initialize_optional_body {
694                for field in optional_fields {
695                    let setter_ident = Self::allocate_builder_method(
696                        &field.preferred_method_name,
697                        &mut used_methods,
698                    );
699                    let value_ident = field.value_ident;
700                    let value_type = field.value_type;
701                    let wire_name = field.wire_name;
702                    let access_path = field.access_path;
703                    let assignment = if operation.request_body_required {
704                        let mut target = quote! { self.#body_ident };
705                        for access in &access_path {
706                            target = quote! { #target.#access };
707                        }
708                        quote! { #target = Some(#value_ident); }
709                    } else {
710                        let mut target = quote! { request };
711                        for access in &access_path {
712                            target = quote! { #target.#access };
713                        }
714                        quote! {
715                            let request = self.#body_ident.get_or_insert_with(Default::default);
716                            #target = Some(#value_ident);
717                        }
718                    };
719                    setters.push(quote! {
720                        #[doc = concat!("Set the optional request-body field `", #wire_name, "`.")]
721                        #[must_use]
722                        pub fn #setter_ident(mut self, #value_ident: #value_type) -> Self {
723                            #assignment
724                            self
725                        }
726                    });
727                }
728            }
729            call_arguments.push(quote! { self.#body_ident });
730        }
731
732        let response_type = self.get_response_type(operation);
733        let error_type = self.op_error_type_token(operation);
734        let operation_id = &operation.operation_id;
735        let definition = quote! {
736            #[doc = concat!("Additive request builder for `", #operation_id, "`.")]
737            #[must_use]
738            pub struct #builder_ident<'a> {
739                #(#fields,)*
740            }
741
742            impl<'a> #builder_ident<'a> {
743                #(#setters)*
744
745                /// Send the request through the existing flat operation method.
746                pub async fn send(self) -> Result<#response_type, ApiOpError<#error_type>> {
747                    self.client.#flat_method(#(#call_arguments),*).await
748                }
749            }
750        };
751        let entry = quote! {
752            #[doc = concat!("Start an additive builder for `", #operation_id, "`.")]
753            pub fn #entry_ident(
754                &self,
755                #(#entry_parameters),*
756            ) -> #builder_ident<'_> {
757                #builder_ident {
758                    #(#initializers,)*
759                }
760            }
761        };
762        (definition, entry)
763    }
764
765    fn allocate_name(base: &str, used: &mut std::collections::HashSet<String>) -> String {
766        let mut candidate = base.to_string();
767        let mut suffix = 2;
768        while !used.insert(candidate.clone()) {
769            candidate = format!("{base}_{suffix}");
770            suffix += 1;
771        }
772        candidate
773    }
774
775    fn allocate_type_name(base: &str, used: &mut std::collections::HashSet<String>) -> String {
776        if used.insert(base.to_string()) {
777            return base.to_string();
778        }
779
780        let mut suffix = 2;
781        loop {
782            let candidate = format!("{base}{suffix}");
783            if used.insert(candidate.clone()) {
784                return candidate;
785            }
786            suffix += 1;
787        }
788    }
789
790    fn allocate_builder_method(
791        preferred: &str,
792        used: &mut std::collections::HashSet<String>,
793    ) -> syn::Ident {
794        let plain = preferred.strip_prefix("r#").unwrap_or(preferred);
795        let base = if used.contains(preferred) {
796            format!("with_{plain}")
797        } else {
798            preferred.to_string()
799        };
800        let allocated = Self::allocate_name(&base, used);
801        Self::to_field_ident(&allocated)
802    }
803
804    fn allocated_operation_params<'a>(
805        &self,
806        operation: &'a OperationInfo,
807    ) -> Vec<AllocatedOperationParam<'a>> {
808        // Builder-internal storage uses these names. Operation parameters are
809        // positional when delegated to the flat method, so suffixing only the
810        // builder field is safe and prevents duplicate struct fields.
811        let mut used = std::collections::HashSet::from([
812            "client".to_string(),
813            "request".to_string(),
814            "form".to_string(),
815            "body".to_string(),
816        ]);
817        let mut allocated = Vec::new();
818        for location in ["path", "query", "header"] {
819            for parameter in &operation.parameters {
820                if parameter.location != location {
821                    continue;
822                }
823                let raw = self.param_ident_str(parameter);
824                let chosen = Self::allocate_name(&raw, &mut used);
825                allocated.push(AllocatedOperationParam {
826                    param: parameter,
827                    ident: Self::to_field_ident(&chosen),
828                });
829            }
830        }
831        allocated
832    }
833
834    fn builder_param_is_required(parameter: &ParameterInfo) -> bool {
835        // The existing flat signature always emits path parameters as bare
836        // values. Invalid real-world specs sometimes omit `required: true`;
837        // mirror the flat contract so builder delegation remains type-correct.
838        parameter.location == "path" || parameter.required
839    }
840
841    fn builder_param_storage_type(&self, parameter: &ParameterInfo) -> TokenStream {
842        self.get_param_owned_rust_type(parameter)
843    }
844
845    fn param_has_impl_as_ref_type(parameter: &ParameterInfo) -> bool {
846        !matches!(
847            &parameter.query_serialization,
848            Some(
849                crate::analysis::QuerySerialization::FormExplodedArray { .. }
850                    | crate::analysis::QuerySerialization::FormArray { .. }
851            )
852        ) && Self::param_uses_as_ref_str(parameter)
853    }
854
855    fn body_model_plan(
856        &self,
857        operation: &OperationInfo,
858        analysis: &SchemaAnalysis,
859    ) -> Option<BodyModelPlan> {
860        use crate::analysis::{ObjectAdditionalProperties, RequestBodyContent, SchemaType};
861
862        let request_body = operation.request_body.as_ref()?;
863        let (body_name, body_ident) = match request_body {
864            RequestBodyContent::Json { schema_name }
865            | RequestBodyContent::FormUrlEncoded { schema_name } => {
866                (schema_name.as_str(), format_ident!("request"))
867            }
868            RequestBodyContent::Multipart => {
869                return Some(BodyModelPlan {
870                    body_ident: format_ident!("form"),
871                    body_type: quote! { reqwest::multipart::Form },
872                    required_construction: RequiredBodyConstruction::Whole,
873                    optional_fields: Vec::new(),
874                });
875            }
876            RequestBodyContent::OctetStream => {
877                return Some(BodyModelPlan {
878                    body_ident: format_ident!("body"),
879                    body_type: quote! { Vec<u8> },
880                    required_construction: RequiredBodyConstruction::Whole,
881                    optional_fields: Vec::new(),
882                });
883            }
884            RequestBodyContent::TextPlain => {
885                return Some(BodyModelPlan {
886                    body_ident: format_ident!("body"),
887                    body_type: quote! { String },
888                    required_construction: RequiredBodyConstruction::Whole,
889                    optional_fields: Vec::new(),
890                });
891            }
892        };
893        let body_type_name = self.to_rust_type_name(body_name);
894        let body_type = syn::Ident::new(&body_type_name, proc_macro2::Span::call_site());
895        let Some((resolved_name, resolved_schema)) =
896            self.resolve_reference_schema(body_name, analysis)
897        else {
898            return Some(BodyModelPlan {
899                body_ident,
900                body_type: quote! { #body_type },
901                required_construction: RequiredBodyConstruction::Whole,
902                optional_fields: Vec::new(),
903            });
904        };
905
906        let mut optional_fields = Vec::new();
907        let mut stack = std::collections::HashSet::new();
908        self.collect_optional_body_fields(
909            resolved_name,
910            Vec::new(),
911            analysis,
912            &mut stack,
913            &mut optional_fields,
914        );
915
916        let required_construction = match &resolved_schema.schema_type {
917            SchemaType::Object {
918                properties,
919                required,
920                additional_properties,
921            } if !self.is_discriminated_variant(resolved_name, analysis) => {
922                let emitted = self.emitted_object_properties(
923                    resolved_name,
924                    properties,
925                    required,
926                    additional_properties,
927                    analysis,
928                    None,
929                );
930                let required_fields: Vec<_> = emitted
931                    .iter()
932                    .filter(|field| field.is_required)
933                    .map(|field| BodyConstructorParam {
934                        preferred_ident: field.ident.clone(),
935                        value_type: field.field_type.clone(),
936                    })
937                    .collect();
938                if required_fields.is_empty() {
939                    RequiredBodyConstruction::Default
940                } else if emitted.iter().any(|field| !field.is_required)
941                    || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
942                {
943                    RequiredBodyConstruction::New(required_fields)
944                } else {
945                    RequiredBodyConstruction::Whole
946                }
947            }
948            _ => RequiredBodyConstruction::Whole,
949        };
950
951        Some(BodyModelPlan {
952            body_ident,
953            body_type: quote! { #body_type },
954            required_construction,
955            optional_fields,
956        })
957    }
958
959    fn resolve_reference_schema<'a>(
960        &self,
961        schema_name: &'a str,
962        analysis: &'a SchemaAnalysis,
963    ) -> Option<(&'a str, &'a crate::analysis::AnalyzedSchema)> {
964        let mut current = schema_name;
965        let mut visited = std::collections::HashSet::new();
966        loop {
967            if !visited.insert(current) {
968                return None;
969            }
970            let schema = analysis.schemas.get(current)?;
971            if let crate::analysis::SchemaType::Reference { target } = &schema.schema_type {
972                current = target;
973            } else {
974                return Some((current, schema));
975            }
976        }
977    }
978
979    fn collect_optional_body_fields(
980        &self,
981        schema_name: &str,
982        access_path: Vec<syn::Ident>,
983        analysis: &SchemaAnalysis,
984        stack: &mut std::collections::HashSet<String>,
985        output: &mut Vec<BodyFieldPlan>,
986    ) {
987        use crate::analysis::SchemaType;
988        if !stack.insert(schema_name.to_string()) {
989            return;
990        }
991        let Some(schema) = analysis.schemas.get(schema_name) else {
992            stack.remove(schema_name);
993            return;
994        };
995        match &schema.schema_type {
996            SchemaType::Reference { target } => {
997                self.collect_optional_body_fields(target, access_path, analysis, stack, output);
998            }
999            SchemaType::Object {
1000                properties,
1001                required,
1002                additional_properties,
1003            } if !self.is_discriminated_variant(schema_name, analysis) => {
1004                for field in self.emitted_object_properties(
1005                    schema_name,
1006                    properties,
1007                    required,
1008                    additional_properties,
1009                    analysis,
1010                    None,
1011                ) {
1012                    if field.is_required {
1013                        continue;
1014                    }
1015                    let mut field_path = access_path.clone();
1016                    field_path.push(field.ident.clone());
1017                    output.push(BodyFieldPlan {
1018                        wire_name: field.wire_name.to_string(),
1019                        preferred_method_name: field.ident.to_string(),
1020                        value_ident: field.ident.clone(),
1021                        value_type: self.generate_property_base_type(
1022                            schema_name,
1023                            field.wire_name,
1024                            field.property,
1025                            analysis,
1026                        ),
1027                        access_path: field_path,
1028                    });
1029                }
1030            }
1031            SchemaType::Composition { schemas } => {
1032                for (index, schema_ref) in schemas.iter().enumerate() {
1033                    let mut nested_path = access_path.clone();
1034                    nested_path.push(format_ident!("part_{index}"));
1035                    self.collect_optional_body_fields(
1036                        &schema_ref.target,
1037                        nested_path,
1038                        analysis,
1039                        stack,
1040                        output,
1041                    );
1042                }
1043            }
1044            _ => {}
1045        }
1046        stack.remove(schema_name);
1047    }
1048
1049    fn is_discriminated_variant(&self, schema_name: &str, analysis: &SchemaAnalysis) -> bool {
1050        analysis.schemas.values().any(|schema| {
1051            matches!(
1052                &schema.schema_type,
1053                crate::analysis::SchemaType::DiscriminatedUnion { variants, .. }
1054                    if variants.iter().any(|variant| variant.type_name == schema_name)
1055            )
1056        })
1057    }
1058
1059    /// Emit inline enum types for parameters whose schema is `type: string`
1060    /// with `enum` or `const`. The generated enum implements `Display` so it
1061    /// drops into the existing `format!`-based path/query templating without
1062    /// any special-casing at the call site. See issue #10 follow-up.
1063    fn generate_param_enum_types(&self, operations: &[&OperationInfo]) -> TokenStream {
1064        let mut by_name: BTreeMap<String, &ParameterInfo> = BTreeMap::new();
1065        for op in operations {
1066            for param in &op.parameters {
1067                if param.enum_values.is_some() {
1068                    by_name.entry(param.rust_type.clone()).or_insert(param);
1069                }
1070            }
1071        }
1072
1073        if by_name.is_empty() {
1074            return quote! {};
1075        }
1076
1077        let defs: Vec<TokenStream> = by_name
1078            .values()
1079            .map(|param| self.generate_single_param_enum(param))
1080            .collect();
1081
1082        quote! { #(#defs)* }
1083    }
1084
1085    fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream {
1086        let Some(values) = param.enum_values.as_deref() else {
1087            return quote! {};
1088        };
1089
1090        let enum_ident = format_ident!("{}", param.rust_type);
1091
1092        // Dedupe variant names. Real-world specs use sort enums like
1093        // `["created_at", "-created_at"]` (descending prefix), and both
1094        // PascalCase to `CreatedAt`. Suffix collisions with `_2`/`_3`/…
1095        // while keeping each `serde(rename)` pointing at the original
1096        // wire string.
1097        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1098        let variant_names: Vec<String> = values
1099            .iter()
1100            .map(|value| {
1101                let base = self.to_rust_enum_variant(value);
1102                let mut chosen = base.clone();
1103                let mut suffix = 2;
1104                while !used.insert(chosen.clone()) {
1105                    chosen = format!("{base}_{suffix}");
1106                    suffix += 1;
1107                }
1108                chosen
1109            })
1110            .collect();
1111
1112        let variants: Vec<TokenStream> = values
1113            .iter()
1114            .zip(&variant_names)
1115            .map(|(value, name)| {
1116                let variant_ident = format_ident!("{}", name);
1117                quote! {
1118                    #[serde(rename = #value)]
1119                    #variant_ident,
1120                }
1121            })
1122            .collect();
1123
1124        let display_arms: Vec<TokenStream> = values
1125            .iter()
1126            .zip(&variant_names)
1127            .map(|(value, name)| {
1128                let variant_ident = format_ident!("{}", name);
1129                quote! { Self::#variant_ident => #value, }
1130            })
1131            .collect();
1132
1133        let doc = format!(
1134            "Allowed values for the `{}` {} parameter.",
1135            param.name, param.location
1136        );
1137
1138        quote! {
1139            #[doc = #doc]
1140            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
1141            pub enum #enum_ident {
1142                #(#variants)*
1143            }
1144
1145            impl #enum_ident {
1146                pub fn as_str(&self) -> &'static str {
1147                    match self {
1148                        #(#display_arms)*
1149                    }
1150                }
1151            }
1152
1153            impl std::fmt::Display for #enum_ident {
1154                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1155                    f.write_str(self.as_str())
1156                }
1157            }
1158
1159            impl AsRef<str> for #enum_ident {
1160                fn as_ref(&self) -> &str {
1161                    self.as_str()
1162                }
1163            }
1164        }
1165    }
1166
1167    /// Generate the per-operation typed error enum, if the op has any non-2xx
1168    /// responses with a body schema. Returns None when the op has no declared
1169    /// error bodies — those operations use `ApiOpError<serde_json::Value>` so
1170    /// the raw response body is still inspectable.
1171    fn generate_op_error_enum(&self, op: &OperationInfo) -> Option<TokenStream> {
1172        let variants: Vec<(String, String)> = op
1173            .response_schemas
1174            .iter()
1175            .filter(|(code, _)| !code.starts_with('2'))
1176            .map(|(code, schema)| (code.clone(), schema.clone()))
1177            .collect();
1178
1179        if variants.is_empty() {
1180            return None;
1181        }
1182
1183        let enum_ident = self.op_error_enum_ident(op);
1184        let variant_decls: Vec<TokenStream> = variants
1185            .iter()
1186            .map(|(code, schema)| {
1187                let variant_ident = Self::op_error_variant_ident(code);
1188                let payload_ty_name = self.to_rust_type_name(schema);
1189                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
1190                quote! { #variant_ident(#payload_ty) }
1191            })
1192            .collect();
1193
1194        let doc = format!(
1195            "Typed error responses for `{}`. One variant per declared non-2xx response.",
1196            op.operation_id
1197        );
1198
1199        Some(quote! {
1200            #[doc = #doc]
1201            #[derive(Debug, Clone)]
1202            pub enum #enum_ident {
1203                #(#variant_decls,)*
1204            }
1205        })
1206    }
1207
1208    /// Type name (Ident) for the per-op error enum, e.g. `ListTodosApiError`.
1209    fn op_error_enum_ident(&self, op: &OperationInfo) -> syn::Ident {
1210        use heck::ToPascalCase;
1211        let name = format!(
1212            "{}ApiError",
1213            op.operation_id.replace('.', "_").to_pascal_case()
1214        );
1215        syn::Ident::new(&name, proc_macro2::Span::call_site())
1216    }
1217
1218    /// Variant name for a status code: "400" → Status400, "default" → Default,
1219    /// "4XX" → Status4xx.
1220    fn op_error_variant_ident(status_code: &str) -> syn::Ident {
1221        let raw = match status_code {
1222            "default" | "Default" => "Default".to_string(),
1223            other if other.chars().all(|c| c.is_ascii_digit()) => format!("Status{other}"),
1224            other => format!("Status{}", other.to_ascii_lowercase()),
1225        };
1226        syn::Ident::new(&raw, proc_macro2::Span::call_site())
1227    }
1228
1229    /// Token stream for the type plugged into `ApiOpError<T>` for an op:
1230    /// either the per-op enum, or `serde_json::Value` for ops with no
1231    /// declared error body schemas.
1232    fn op_error_type_token(&self, op: &OperationInfo) -> TokenStream {
1233        if op
1234            .response_schemas
1235            .iter()
1236            .any(|(code, _)| !code.starts_with('2'))
1237        {
1238            let ident = self.op_error_enum_ident(op);
1239            quote! { #ident }
1240        } else {
1241            quote! { serde_json::Value }
1242        }
1243    }
1244
1245    /// Generate a single operation method
1246    fn generate_single_operation_method(&self, op: &OperationInfo) -> TokenStream {
1247        let method_name = self.get_method_name(op);
1248        let http_method_call = self.http_method_call(op);
1249        let path = &op.path;
1250        let request_param = self.generate_request_param(op);
1251        let request_body = self.generate_request_body(op);
1252        let query_params = self.generate_query_params(op);
1253        let header_params = self.generate_header_params(op);
1254        let auth_application = self.generate_auth_application();
1255        let response_type = self.get_response_type(op);
1256        let has_response_body = self.get_success_response_schema(op).is_some();
1257        let op_error_type = self.op_error_type_token(op);
1258        let error_handling = self.generate_error_handling(op, has_response_body);
1259        let url_construction = self.generate_url_construction(path, op);
1260        let doc_comment = self.generate_operation_doc_comment(op);
1261
1262        quote! {
1263            #doc_comment
1264            pub async fn #method_name(
1265                &self,
1266                #request_param
1267            ) -> Result<#response_type, ApiOpError<#op_error_type>> {
1268                #url_construction
1269
1270                let mut req = #http_method_call;
1271                #request_body
1272
1273                #query_params
1274                #header_params
1275
1276                // Apply configured authentication (T3). Was previously
1277                // hardcoded to bearer_auth regardless of GeneratorConfig.
1278                #auth_application
1279
1280                // Add custom headers
1281                for (name, value) in &self.custom_headers {
1282                    req = req.header(name, value);
1283                }
1284
1285                let response = req.send().await?;
1286                #error_handling
1287            }
1288        }
1289    }
1290
1291    /// T3: emit the auth-token application based on the configured AuthConfig.
1292    /// Default (no config) is Bearer on Authorization. ApiKey emits a custom
1293    /// header. Custom honors header_value_prefix.
1294    fn generate_auth_application(&self) -> TokenStream {
1295        use crate::http_config::AuthConfig;
1296        match &self.config().auth_config {
1297            Some(AuthConfig::Bearer { header_name }) if header_name == "Authorization" => quote! {
1298                if let Some(api_key) = &self.api_key {
1299                    req = req.bearer_auth(api_key);
1300                }
1301            },
1302            Some(AuthConfig::Bearer { header_name }) => {
1303                let h = header_name.clone();
1304                quote! {
1305                    if let Some(api_key) = &self.api_key {
1306                        req = req.header(#h, format!("Bearer {}", api_key));
1307                    }
1308                }
1309            }
1310            Some(AuthConfig::ApiKey { header_name }) => {
1311                let h = header_name.clone();
1312                quote! {
1313                    if let Some(api_key) = &self.api_key {
1314                        req = req.header(#h, api_key.as_str());
1315                    }
1316                }
1317            }
1318            Some(AuthConfig::Custom {
1319                header_name,
1320                header_value_prefix,
1321            }) => {
1322                let h = header_name.clone();
1323                let prefix = header_value_prefix.clone().unwrap_or_default();
1324                if prefix.is_empty() {
1325                    quote! {
1326                        if let Some(api_key) = &self.api_key {
1327                            req = req.header(#h, api_key.as_str());
1328                        }
1329                    }
1330                } else {
1331                    let format_str = format!("{}{{}}", prefix);
1332                    quote! {
1333                        if let Some(api_key) = &self.api_key {
1334                            req = req.header(#h, format!(#format_str, api_key));
1335                        }
1336                    }
1337                }
1338            }
1339            None => quote! {
1340                if let Some(api_key) = &self.api_key {
1341                    req = req.bearer_auth(api_key);
1342                }
1343            },
1344        }
1345    }
1346
1347    /// Generate header-parameter handling. Emits `req = req.header(name, ...)`
1348    /// for each `in: header` parameter — required headers unconditionally,
1349    /// optional ones gated on `Some(_)`.
1350    fn generate_header_params(&self, op: &OperationInfo) -> TokenStream {
1351        let header_params: Vec<_> = op
1352            .parameters
1353            .iter()
1354            .filter(|p| p.location == "header")
1355            .collect();
1356        if header_params.is_empty() {
1357            return quote! {};
1358        }
1359        let mut emit = Vec::new();
1360        for param in header_params {
1361            let param_name_snake = self.param_ident_str(param);
1362            let param_ident = Self::to_field_ident(&param_name_snake);
1363            let header_name = &param.name;
1364            if param.required {
1365                if Self::param_uses_as_ref_str(param) {
1366                    emit.push(quote! {
1367                        req = req.header(#header_name, #param_ident.as_ref());
1368                    });
1369                } else {
1370                    emit.push(quote! {
1371                        req = req.header(#header_name, #param_ident.to_string());
1372                    });
1373                }
1374            } else if Self::param_uses_as_ref_str(param) {
1375                emit.push(quote! {
1376                    if let Some(v) = #param_ident {
1377                        req = req.header(#header_name, v.as_ref());
1378                    }
1379                });
1380            } else {
1381                emit.push(quote! {
1382                    if let Some(v) = #param_ident {
1383                        req = req.header(#header_name, v.to_string());
1384                    }
1385                });
1386            }
1387        }
1388        quote! {
1389            #(#emit)*
1390        }
1391    }
1392
1393    /// Generate query parameter handling
1394    fn generate_query_params(&self, op: &OperationInfo) -> TokenStream {
1395        let query_params: Vec<_> = op
1396            .parameters
1397            .iter()
1398            .filter(|p| p.location == "query")
1399            .collect();
1400
1401        if query_params.is_empty() {
1402            return quote! {};
1403        }
1404
1405        let mut param_building = Vec::new();
1406        // Serialization applied on `req` directly, after the pair-vector
1407        // block: form-exploded objects and deepObject objects, whose keys
1408        // aren't the static parameter name.
1409        let mut req_appends = Vec::new();
1410
1411        for param in query_params {
1412            use crate::analysis::QuerySerialization;
1413
1414            // Use snake_case for Rust variable name with keyword escaping
1415            let param_name_snake = self.param_ident_str(param);
1416            let param_name = Self::to_field_ident(&param_name_snake);
1417
1418            // Use the original parameter name from OpenAPI spec as the query string key
1419            let param_key = &param.name;
1420
1421            match &param.query_serialization {
1422                Some(QuerySerialization::FormExplodedObject) => {
1423                    // Issue #27: reqwest serializes the struct through
1424                    // serde_urlencoded, so each property becomes its own
1425                    // `key=value` pair; the parameter's own name never
1426                    // appears in the query string (RFC 6570 form-explosion).
1427                    // `name[]=` is the shared zero-cardinality marker used to
1428                    // preserve Some(empty) and required-empty values.
1429                    let apply = quote! {
1430                        let __empty = match serde_json::to_value(&v)
1431                            .map_err(HttpError::serialization_error)?
1432                        {
1433                            serde_json::Value::Object(map) => map.is_empty(),
1434                            _ => false,
1435                        };
1436                        if __empty {
1437                            req = req.query(&[(format!("{}[]", #param_key), String::new())]);
1438                        } else {
1439                            req = req.query(&v);
1440                        }
1441                    };
1442                    if param.required {
1443                        req_appends.push(quote! {
1444                            {
1445                                let v = #param_name;
1446                                #apply
1447                            }
1448                        });
1449                    } else {
1450                        req_appends.push(quote! {
1451                            if let Some(v) = #param_name {
1452                                #apply
1453                            }
1454                        });
1455                    }
1456                    continue;
1457                }
1458                Some(QuerySerialization::DeepObject) => {
1459                    // `?filter[color]=red&filter[size]=5`. Property values
1460                    // stringify through their JSON form; Null (unset
1461                    // Option) properties are skipped.
1462                    let apply = quote! {
1463                        let map = match serde_json::to_value(&v)
1464                            .map_err(HttpError::serialization_error)?
1465                        {
1466                            serde_json::Value::Object(map) => map,
1467                            _ => return Err(HttpError::serialization_error(
1468                                format!("query parameter `{}` did not serialize as an object", #param_key)
1469                            ).into()),
1470                        };
1471                        let mut deep_params: Vec<(String, String)> = Vec::new();
1472                        for (k, val) in map {
1473                            let s = match val {
1474                                serde_json::Value::Null => continue,
1475                                serde_json::Value::String(s) => s,
1476                                other => other.to_string(),
1477                            };
1478                            deep_params.push((format!("{}[{}]", #param_key, k), s));
1479                        }
1480                        if deep_params.is_empty() {
1481                            deep_params.push((format!("{}[]", #param_key), String::new()));
1482                        }
1483                        req = req.query(&deep_params);
1484                    };
1485                    if param.required {
1486                        req_appends.push(quote! {
1487                            {
1488                                let v = #param_name;
1489                                #apply
1490                            }
1491                        });
1492                    } else {
1493                        req_appends.push(quote! {
1494                            if let Some(v) = #param_name {
1495                                #apply
1496                            }
1497                        });
1498                    }
1499                    continue;
1500                }
1501                Some(QuerySerialization::FormObject) => {
1502                    // `?filter=color,red,size,big` — one pair whose value is
1503                    // the comma-joined key,value list (RFC 6570 form,
1504                    // explode=false).
1505                    let apply = quote! {
1506                        let map = match serde_json::to_value(&v)
1507                            .map_err(HttpError::serialization_error)?
1508                        {
1509                            serde_json::Value::Object(map) => map,
1510                            _ => return Err(HttpError::serialization_error(
1511                                format!("query parameter `{}` did not serialize as an object", #param_key)
1512                            ).into()),
1513                        };
1514                        let mut parts: Vec<String> = Vec::new();
1515                        for (k, val) in map {
1516                            let s = match val {
1517                                serde_json::Value::Null => continue,
1518                                serde_json::Value::String(s) => s,
1519                                other => other.to_string(),
1520                            };
1521                            if k.contains(',') || s.contains(',') {
1522                                return Err(HttpError::serialization_error(
1523                                    format!(
1524                                        "query object `{}` contains a comma in key `{}`; use explode=true for lossless string values",
1525                                        #param_key,
1526                                        k,
1527                                    )
1528                                ).into());
1529                            }
1530                            parts.push(k);
1531                            parts.push(s);
1532                        }
1533                        if parts.is_empty() {
1534                            query_params.push((
1535                                format!("{}[]", #param_key),
1536                                String::new(),
1537                            ));
1538                        } else {
1539                            query_params.push((#param_key.to_string(), parts.join(",")));
1540                        }
1541                    };
1542                    if param.required {
1543                        param_building.push(quote! {
1544                            {
1545                                let v = #param_name;
1546                                #apply
1547                            }
1548                        });
1549                    } else {
1550                        param_building.push(quote! {
1551                            if let Some(v) = #param_name {
1552                                #apply
1553                            }
1554                        });
1555                    }
1556                    continue;
1557                }
1558                Some(QuerySerialization::FormExplodedArray { .. }) => {
1559                    // `?tags=a&tags=b` — one pair per element.
1560                    if param.required {
1561                        param_building.push(quote! {
1562                            if #param_name.is_empty() {
1563                                query_params.push((
1564                                    format!("{}[]", #param_key),
1565                                    String::new(),
1566                                ));
1567                            } else {
1568                                for item in #param_name {
1569                                    query_params.push((#param_key.to_string(), item.to_string()));
1570                                }
1571                            }
1572                        });
1573                    } else {
1574                        param_building.push(quote! {
1575                            if let Some(v) = #param_name {
1576                                if v.is_empty() {
1577                                    query_params.push((
1578                                        format!("{}[]", #param_key),
1579                                        String::new(),
1580                                    ));
1581                                } else {
1582                                    for item in v {
1583                                        query_params.push((#param_key.to_string(), item.to_string()));
1584                                    }
1585                                }
1586                            }
1587                        });
1588                    }
1589                    continue;
1590                }
1591                Some(QuerySerialization::FormArray { .. }) => {
1592                    // `?tags=a,b,c` — one comma-joined pair. Empty vectors
1593                    // use the shared `tags[]=` zero-cardinality marker.
1594                    let apply = quote! {
1595                        if v.is_empty() {
1596                            query_params.push((
1597                                format!("{}[]", #param_key),
1598                                String::new(),
1599                            ));
1600                        } else {
1601                            let mut parts = Vec::with_capacity(v.len());
1602                            for item in &v {
1603                                let item = item.to_string();
1604                                if item.contains(',') {
1605                                    return Err(HttpError::serialization_error(
1606                                        format!(
1607                                            "query array `{}` contains a comma; use explode=true for lossless string values",
1608                                            #param_key,
1609                                        )
1610                                    ).into());
1611                                }
1612                                parts.push(item);
1613                            }
1614                            query_params.push((
1615                                #param_key.to_string(),
1616                                parts.join(","),
1617                            ));
1618                        }
1619                    };
1620                    if param.required {
1621                        param_building.push(quote! {
1622                            {
1623                                let v = #param_name;
1624                                #apply
1625                            }
1626                        });
1627                    } else {
1628                        param_building.push(quote! {
1629                            if let Some(v) = #param_name {
1630                                #apply
1631                            }
1632                        });
1633                    }
1634                    continue;
1635                }
1636                Some(QuerySerialization::Unsupported { .. }) => {}
1637                None => {}
1638            }
1639
1640            if param.required {
1641                // Required parameters: always add
1642                if Self::param_uses_as_ref_str(param) {
1643                    param_building.push(quote! {
1644                        query_params.push((#param_key.to_string(), #param_name.as_ref().to_string()));
1645                    });
1646                } else {
1647                    param_building.push(quote! {
1648                        query_params.push((#param_key.to_string(), #param_name.to_string()));
1649                    });
1650                }
1651            } else {
1652                // Optional parameters: add only if Some
1653                if Self::param_uses_as_ref_str(param) {
1654                    param_building.push(quote! {
1655                        if let Some(v) = #param_name {
1656                            query_params.push((#param_key.to_string(), v.as_ref().to_string()));
1657                        }
1658                    });
1659                } else {
1660                    param_building.push(quote! {
1661                        if let Some(v) = #param_name {
1662                            query_params.push((#param_key.to_string(), v.to_string()));
1663                        }
1664                    });
1665                }
1666            }
1667        }
1668
1669        // Ops whose query params all serialize on `req` directly skip the
1670        // pair-vector block entirely.
1671        let pairs_block = if param_building.is_empty() {
1672            quote! {}
1673        } else {
1674            quote! {
1675                {
1676                    let mut query_params: Vec<(String, String)> = Vec::new();
1677                    #(#param_building)*
1678                    if !query_params.is_empty() {
1679                        req = req.query(&query_params);
1680                    }
1681                }
1682            }
1683        };
1684
1685        quote! {
1686            // Add query parameters
1687            #pairs_block
1688            #(#req_appends)*
1689        }
1690    }
1691
1692    /// Generate the rustdoc block for an operation, surfacing summary,
1693    /// description, the HTTP method+path, and any tags from the OAS spec
1694    /// (T13). Also marks the method `#[deprecated]` if the operation is.
1695    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
1696        let method = op.method.to_uppercase();
1697        let path = &op.path;
1698        let mut docs: Vec<String> = Vec::new();
1699        if let Some(s) = &op.summary {
1700            if !s.is_empty() {
1701                docs.push(s.clone());
1702                docs.push(String::new());
1703            }
1704        }
1705        if let Some(d) = &op.description {
1706            if !d.is_empty() {
1707                for line in d.lines() {
1708                    docs.push(line.to_string());
1709                }
1710                docs.push(String::new());
1711            }
1712        }
1713        docs.push(format!("`{} {}`", method, path));
1714        let doc_attrs: Vec<TokenStream> = docs
1715            .iter()
1716            .map(|line| {
1717                let prefixed = if line.is_empty() {
1718                    String::new()
1719                } else {
1720                    format!(" {line}")
1721                };
1722                quote! { #[doc = #prefixed] }
1723            })
1724            .collect();
1725        quote! { #(#doc_attrs)* }
1726    }
1727
1728    /// Get the method name from the operation
1729    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
1730        let name = if !op.operation_id.is_empty() {
1731            op.operation_id.to_snake_case()
1732        } else {
1733            // Fallback: generate from HTTP method and path
1734            format!(
1735                "{}_{}",
1736                op.method,
1737                op.path.replace('/', "_").replace(['{', '}'], "")
1738            )
1739            .to_snake_case()
1740        };
1741
1742        syn::Ident::new(&name, proc_macro2::Span::call_site())
1743    }
1744
1745    /// Build the request-builder expression for the operation's HTTP method.
1746    /// Named reqwest methods (`.get`/`.post`/…) are used where available;
1747    /// OPTIONS and TRACE go through `Client::request(Method::OPTIONS, _)` since
1748    /// reqwest doesn't expose those as named methods.
1749    fn http_method_call(&self, op: &OperationInfo) -> TokenStream {
1750        match op.method.to_uppercase().as_str() {
1751            "GET" => quote! { self.http_client.get(request_url) },
1752            "POST" => quote! { self.http_client.post(request_url) },
1753            "PUT" => quote! { self.http_client.put(request_url) },
1754            "DELETE" => quote! { self.http_client.delete(request_url) },
1755            "PATCH" => quote! { self.http_client.patch(request_url) },
1756            "HEAD" => quote! { self.http_client.head(request_url) },
1757            "OPTIONS" => quote! {
1758                self.http_client.request(reqwest::Method::OPTIONS, request_url)
1759            },
1760            "TRACE" => quote! {
1761                self.http_client.request(reqwest::Method::TRACE, request_url)
1762            },
1763            // D1: 3.2 `QUERY` verb + any custom verb from
1764            // PathItem.additionalOperations. reqwest's Method::from_bytes
1765            // accepts arbitrary uppercase tokens that match the RFC7230
1766            // method grammar.
1767            other => {
1768                let upper = other.to_string();
1769                quote! {
1770                    self.http_client.request(
1771                        reqwest::Method::from_bytes(#upper.as_bytes())
1772                            .expect("invalid HTTP method"),
1773                        request_url,
1774                    )
1775                }
1776            }
1777        }
1778    }
1779
1780    /// Generate request parameters including path, query, header, and request body.
1781    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
1782        let mut params = Vec::new();
1783        // Dedup parameter Rust idents within this method signature. Real-world
1784        // specs sometimes declare two parameters that sanitize to the same
1785        // snake_case name (modern-treasury declared `name` twice across
1786        // different param objects). Suffixing with `_2`, `_3`, … keeps each
1787        // parameter accessible while preserving the original wire-level name
1788        // (which is used elsewhere as the query/path/header key).
1789        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1790        let mut unique_param_ident = |raw: String| -> syn::Ident {
1791            let mut chosen = raw.clone();
1792            let mut suffix = 2;
1793            while !used.insert(chosen.clone()) {
1794                chosen = format!("{raw}_{suffix}");
1795                suffix += 1;
1796            }
1797            Self::to_field_ident(&chosen)
1798        };
1799
1800        // Add path parameters
1801        for param in &op.parameters {
1802            if param.location == "path" {
1803                let param_name_snake = self.param_ident_str(param);
1804                let param_name = unique_param_ident(param_name_snake);
1805                let param_type = self.get_param_rust_type(param);
1806                params.push(quote! { #param_name: #param_type });
1807            }
1808        }
1809
1810        // Add query parameters (all as Option<T>)
1811        for param in &op.parameters {
1812            if param.location == "query" {
1813                let param_name_snake = self.param_ident_str(param);
1814                let param_name = unique_param_ident(param_name_snake);
1815                let param_type = self.get_param_rust_type(param);
1816
1817                // Query parameters should be Option unless explicitly required
1818                if param.required {
1819                    params.push(quote! { #param_name: #param_type });
1820                } else {
1821                    params.push(quote! { #param_name: Option<#param_type> });
1822                }
1823            }
1824        }
1825
1826        // Add header parameters. Required headers are bare; optional ones are
1827        // Option<T>. Per OAS 3.x §"Parameter Object", header names matching
1828        // `Accept`, `Content-Type`, and `Authorization` are forbidden — those
1829        // are described by other mechanisms — but we leave that validation to
1830        // analysis.
1831        for param in &op.parameters {
1832            if param.location == "header" {
1833                let param_name_snake = self.param_ident_str(param);
1834                let param_name = unique_param_ident(param_name_snake);
1835                let param_type = self.get_param_rust_type(param);
1836                if param.required {
1837                    params.push(quote! { #param_name: #param_type });
1838                } else {
1839                    params.push(quote! { #param_name: Option<#param_type> });
1840                }
1841            }
1842        }
1843
1844        // Add request body parameter based on content type. Optional bodies
1845        // (`requestBody.required` is false or absent) become `Option<T>` per T11.
1846        if let Some(ref rb) = op.request_body {
1847            use crate::analysis::RequestBodyContent;
1848            let required = op.request_body_required;
1849            let body_type = match rb {
1850                RequestBodyContent::Json { schema_name }
1851                | RequestBodyContent::FormUrlEncoded { schema_name } => {
1852                    let rust_type_name = self.to_rust_type_name(schema_name);
1853                    let request_ident =
1854                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
1855                    quote! { #request_ident }
1856                }
1857                RequestBodyContent::Multipart => quote! { reqwest::multipart::Form },
1858                RequestBodyContent::OctetStream => quote! { Vec<u8> },
1859                RequestBodyContent::TextPlain => quote! { String },
1860            };
1861            let body_ident = match rb {
1862                RequestBodyContent::Multipart => quote! { form },
1863                RequestBodyContent::OctetStream | RequestBodyContent::TextPlain => quote! { body },
1864                _ => quote! { request },
1865            };
1866            if required {
1867                params.push(quote! { #body_ident: #body_type });
1868            } else {
1869                params.push(quote! { #body_ident: Option<#body_type> });
1870            }
1871        }
1872
1873        if params.is_empty() {
1874            quote! {}
1875        } else {
1876            quote! { #(#params),* }
1877        }
1878    }
1879
1880    /// Get the Rust type for a parameter
1881    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
1882        if Self::param_has_impl_as_ref_type(param) {
1883            quote! { impl AsRef<str> }
1884        } else {
1885            self.get_param_owned_rust_type(param)
1886        }
1887    }
1888
1889    /// Owned parameter type shared by client-builder storage and generated
1890    /// server extraction. [`ParameterInfo::query_serialization`] is the
1891    /// authoritative projection for typed query objects and arrays.
1892    pub(crate) fn get_param_owned_rust_type(
1893        &self,
1894        param: &crate::analysis::ParameterInfo,
1895    ) -> TokenStream {
1896        use crate::analysis::QuerySerialization;
1897        // Typed form-style arrays take Vec<item> (openapi-generator-anu).
1898        // Scalars parse as-is (they may be type paths from [type_mappings]);
1899        // enum refs are raw schema names and go through the same
1900        // to_rust_type_name sanitization as every other schema reference
1901        // (cloudflare has enum schemas like `resource-sharing_resource_type`).
1902        if let Some(
1903            QuerySerialization::FormExplodedArray { item_type }
1904            | QuerySerialization::FormArray { item_type },
1905        ) = &param.query_serialization
1906        {
1907            use crate::analysis::ArrayItemType;
1908            let item_ty: syn::Type = match item_type {
1909                ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type)
1910                    .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")),
1911                ArrayItemType::EnumRef(schema_name) => {
1912                    let rust_name = self.to_rust_type_name(schema_name);
1913                    syn::parse_str(&rust_name)
1914                        .unwrap_or_else(|_| panic!("invalid enum item type `{rust_name}`"))
1915                }
1916            };
1917            return quote! { Vec<#item_ty> };
1918        }
1919        // T10: $ref-typed parameters used to lose their type because we only
1920        // consulted `rust_type` (which stays "String"). Now: prefer the
1921        // resolved schema reference if present.
1922        if let Some(ref schema_name) = param.schema_ref {
1923            let rust_name = self.to_rust_type_name(schema_name);
1924            let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site());
1925            return quote! { #ident };
1926        }
1927        syn::parse_str::<syn::Type>(&param.rust_type)
1928            .map(|ty| quote! { #ty })
1929            .unwrap_or_else(|_| {
1930                let type_ident = syn::Ident::new(&param.rust_type, proc_macro2::Span::call_site());
1931                quote! { #type_ident }
1932            })
1933    }
1934
1935    /// True when the parameter's compile-time type is `impl AsRef<str>` and
1936    /// we should call `.as_ref()` on it before stringifying. False for any
1937    /// $ref-resolved type (T10) or non-String primitive — those just call
1938    /// `.to_string()`.
1939    fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
1940        param.schema_ref.is_none() && param.rust_type == "String"
1941    }
1942
1943    /// Generate request body serialization based on content type
1944    /// Emit statements that mutate `req` to apply the request body. Returns
1945    /// `quote!{}` if the operation has no body. Optional bodies (T11) gate the
1946    /// application on `Some(_)`; required bodies apply unconditionally.
1947    fn generate_request_body(&self, op: &OperationInfo) -> TokenStream {
1948        let Some(rb) = op.request_body.as_ref() else {
1949            return quote! {};
1950        };
1951        use crate::analysis::RequestBodyContent;
1952        let required = op.request_body_required;
1953        let (ident, apply): (TokenStream, TokenStream) = match rb {
1954            RequestBodyContent::Json { .. } => (
1955                quote! { request },
1956                quote! {
1957                    req = req
1958                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
1959                        .header("content-type", "application/json");
1960                },
1961            ),
1962            RequestBodyContent::FormUrlEncoded { .. } => (
1963                quote! { request },
1964                quote! {
1965                    req = req
1966                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
1967                        .header("content-type", "application/x-www-form-urlencoded");
1968                },
1969            ),
1970            RequestBodyContent::Multipart => (
1971                quote! { form },
1972                quote! {
1973                    req = req.multipart(form);
1974                },
1975            ),
1976            RequestBodyContent::OctetStream => (
1977                quote! { body },
1978                quote! {
1979                    req = req
1980                        .body(body)
1981                        .header("content-type", "application/octet-stream");
1982                },
1983            ),
1984            RequestBodyContent::TextPlain => (
1985                quote! { body },
1986                quote! {
1987                    req = req
1988                        .body(body)
1989                        .header("content-type", "text/plain");
1990                },
1991            ),
1992        };
1993        if required {
1994            apply
1995        } else {
1996            quote! {
1997                if let Some(#ident) = #ident {
1998                    #apply
1999                }
2000            }
2001        }
2002    }
2003
2004    /// Find the success (2xx) response schema name, if any.
2005    ///
2006    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
2007    /// so that endpoints like 204 No Content correctly return `()` instead of
2008    /// accidentally picking up the error schema (e.g. `BadRequestError`).
2009    fn get_success_response_schema<'a>(&self, op: &'a OperationInfo) -> Option<&'a String> {
2010        op.response_schemas
2011            .get("200")
2012            .or_else(|| op.response_schemas.get("201"))
2013            .or_else(|| {
2014                op.response_schemas
2015                    .iter()
2016                    .find(|(code, _)| code.starts_with('2'))
2017                    .map(|(_, v)| v)
2018            })
2019    }
2020
2021    /// Get response type
2022    fn get_response_type(&self, op: &OperationInfo) -> TokenStream {
2023        if let Some(response_type) = self.get_success_response_schema(op) {
2024            // Convert schema name to Rust type name (handles underscores, etc.)
2025            let rust_type_name = self.to_rust_type_name(response_type);
2026            let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
2027            quote! { #response_ident }
2028        } else {
2029            quote! { () }
2030        }
2031    }
2032
2033    /// Generate error handling.
2034    ///
2035    /// Always reads the response body to a string before attempting any typed
2036    /// deserialization, so the raw body and headers are preserved on the error
2037    /// path even when JSON parsing fails. On 2xx the body is parsed into the
2038    /// success type; on non-2xx the body is parsed into the matching variant
2039    /// of the per-operation error enum (when one is declared) and wrapped in
2040    /// `ApiError<E>`.
2041    fn generate_error_handling(&self, op: &OperationInfo, has_response_body: bool) -> TokenStream {
2042        let op_error_type = self.op_error_type_token(op);
2043
2044        let success_branch = if has_response_body {
2045            quote! {
2046                match serde_json::from_str(&body_text) {
2047                    Ok(body) => Ok(body),
2048                    Err(e) => Err(ApiOpError::Api(ApiError {
2049                        status: status_code,
2050                        headers: headers,
2051                        body: body_text,
2052                        typed: None,
2053                        parse_error: Some(format!(
2054                            "failed to deserialize 2xx response body: {}",
2055                            e
2056                        )),
2057                    })),
2058                }
2059            }
2060        } else {
2061            quote! {
2062                let _ = body_text;
2063                let _ = headers;
2064                Ok(())
2065            }
2066        };
2067
2068        let error_match_arms = self.generate_error_match_arms(op);
2069
2070        quote! {
2071            let status = response.status();
2072            let status_code = status.as_u16();
2073            let headers = response.headers().clone();
2074            let body_text = response.text().await
2075                .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?;
2076
2077            if status.is_success() {
2078                #success_branch
2079            } else {
2080                let typed: Option<#op_error_type>;
2081                let parse_error: Option<String>;
2082                #error_match_arms
2083                Err(ApiOpError::Api(ApiError {
2084                    status: status_code,
2085                    headers,
2086                    body: body_text,
2087                    typed,
2088                    parse_error,
2089                }))
2090            }
2091        }
2092    }
2093
2094    /// Generate the match arms that select which per-op error variant to
2095    /// deserialize the response body into based on the runtime status code.
2096    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
2097        let arms: Vec<TokenStream> = op
2098            .response_schemas
2099            .iter()
2100            .filter(|(code, _)| !code.starts_with('2'))
2101            .filter_map(|(code, schema)| {
2102                let variant_ident = Self::op_error_variant_ident(code);
2103                let payload_ty_name = self.to_rust_type_name(schema);
2104                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
2105                let enum_ident = self.op_error_enum_ident(op);
2106
2107                // T8: range-keyed responses (1XX/2XX/3XX/4XX/5XX) per OAS
2108                // 3.x §"Responses Object". Specific codes still take priority
2109                // (handled by ordering — concrete codes deserialize first
2110                // because the generic dispatch is a generic `_ if (range)`).
2111                let pattern = match code.as_str() {
2112                    "default" | "Default" => return None, // handled in fallback
2113                    other if other.chars().all(|c| c.is_ascii_digit()) => {
2114                        let n: u16 = other.parse().ok()?;
2115                        quote! { #n }
2116                    }
2117                    "1XX" | "1xx" => quote! { code if (100..=199).contains(&code) },
2118                    "2XX" | "2xx" => quote! { code if (200..=299).contains(&code) },
2119                    "3XX" | "3xx" => quote! { code if (300..=399).contains(&code) },
2120                    "4XX" | "4xx" => quote! { code if (400..=499).contains(&code) },
2121                    "5XX" | "5xx" => quote! { code if (500..=599).contains(&code) },
2122                    _ => return None,
2123                };
2124
2125                Some(quote! {
2126                    #pattern => {
2127                        match serde_json::from_str::<#payload_ty>(&body_text) {
2128                            Ok(v) => {
2129                                typed = Some(#enum_ident::#variant_ident(v));
2130                                parse_error = None;
2131                            }
2132                            Err(e) => {
2133                                typed = None;
2134                                parse_error = Some(e.to_string());
2135                            }
2136                        }
2137                    }
2138                })
2139            })
2140            .collect();
2141
2142        // Fallback for "default" or undeclared status codes: try to parse
2143        // as `serde_json::Value` for inspectability when the op's error
2144        // type is generic, otherwise leave typed = None.
2145        // Must mirror op_error_type_token: if op_error_type is the typed
2146        // enum (any non-2xx response, including `default`), the fallback arm
2147        // can't deserialize into `serde_json::Value` because `typed` is the
2148        // enum. Default to `typed = None` in that case.
2149        let has_typed_enum = op
2150            .response_schemas
2151            .iter()
2152            .any(|(code, _)| !code.starts_with('2'));
2153
2154        let default_arm = if has_typed_enum {
2155            quote! {
2156                _ => {
2157                    typed = None;
2158                    parse_error = None;
2159                }
2160            }
2161        } else {
2162            // No typed enum — op_error_type is serde_json::Value.
2163            quote! {
2164                _ => {
2165                    match serde_json::from_str::<serde_json::Value>(&body_text) {
2166                        Ok(v) => {
2167                            typed = Some(v);
2168                            parse_error = None;
2169                        }
2170                        Err(e) => {
2171                            typed = None;
2172                            parse_error = Some(e.to_string());
2173                        }
2174                    }
2175                }
2176            }
2177        };
2178
2179        if arms.is_empty() {
2180            // No declared status arms — just the fallback.
2181            quote! {
2182                match status_code {
2183                    #default_arm
2184                }
2185            }
2186        } else {
2187            quote! {
2188                match status_code {
2189                    #(#arms)*
2190                    #default_arm
2191                }
2192            }
2193        }
2194    }
2195
2196    /// Generate URL construction with path parameter substitution
2197    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
2198        // Check if path has parameters (contains {...})
2199        if path.contains('{') {
2200            self.generate_url_with_params(path, op)
2201        } else {
2202            quote! {
2203                let request_url = format!("{}{}", self.base_url, #path);
2204            }
2205        }
2206    }
2207
2208    /// Generate URL with path parameters
2209    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
2210        // Find all path parameters in the operation.
2211        let path_params: Vec<_> = op
2212            .parameters
2213            .iter()
2214            .filter(|p| p.location == "path")
2215            .collect();
2216
2217        // T5: percent-encode each path-template variable per RFC3986 §3.3.
2218        // We build a positional-arg format string by walking the template
2219        // left-to-right and emitting one `{}` + one format arg per
2220        // placeholder occurrence. Cloudflare has paths like
2221        // `/accounts/{account_id}/.../accounts/{account_id}` — the same
2222        // variable appears twice. A naive `replace_all` produced two `{}`
2223        // placeholders but only one format arg (E0277). Per-occurrence
2224        // emission keeps them in sync.
2225        let mut format_string = String::with_capacity(path.len());
2226        let mut format_args: Vec<TokenStream> = Vec::new();
2227        let mut chars = path.chars().peekable();
2228        while let Some(c) = chars.next() {
2229            if c != '{' {
2230                format_string.push(c);
2231                continue;
2232            }
2233            // Read until the matching '}'.
2234            let mut name = String::new();
2235            while let Some(&n) = chars.peek() {
2236                chars.next();
2237                if n == '}' {
2238                    break;
2239                }
2240                name.push(n);
2241            }
2242            // Resolve to a path param. If no match, leave the placeholder
2243            // verbatim (real-world spec bug — this op shouldn't have made
2244            // it past analysis).
2245            let param = path_params.iter().find(|p| p.name == name);
2246            let Some(param) = param else {
2247                format_string.push('{');
2248                format_string.push_str(&name);
2249                format_string.push('}');
2250                continue;
2251            };
2252            format_string.push_str("{}");
2253            let param_name_snake = self.param_ident_str(param);
2254            let param_ident = Self::to_field_ident(&param_name_snake);
2255            if Self::param_uses_as_ref_str(param) {
2256                format_args.push(quote! {
2257                    __pct_encode_path_segment(#param_ident.as_ref())
2258                });
2259            } else {
2260                format_args.push(quote! {
2261                    __pct_encode_path_segment(&#param_ident.to_string())
2262                });
2263            }
2264        }
2265
2266        if format_args.is_empty() {
2267            quote! {
2268                let request_url = format!("{}{}", self.base_url, #path);
2269            }
2270        } else {
2271            quote! {
2272                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
2273            }
2274        }
2275    }
2276
2277    /// Resolve the Rust ident for a parameter. Prefers the disambiguated
2278    /// `rust_ident` set by the analyzer (which dedupes across the whole
2279    /// operation), falling back to a fresh sanitize of the wire name when
2280    /// no analyzer-side ident is present.
2281    pub(crate) fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String {
2282        if let Some(ident) = &param.rust_ident {
2283            // Apply the keyword-escape and self/super/crate dance the
2284            // sanitize fn does. The analyzer's base ident is already the
2285            // snake/kebab-aware shape; we only need post-processing.
2286            return self.escape_keyword_ident(ident);
2287        }
2288        self.sanitize_param_name(&param.name)
2289    }
2290
2291    fn escape_keyword_ident(&self, snake_case: &str) -> String {
2292        if matches!(snake_case, "self" | "super" | "crate" | "Self") {
2293            return format!("{snake_case}_param");
2294        }
2295        if Self::is_rust_keyword(snake_case) {
2296            format!("r#{snake_case}")
2297        } else {
2298            snake_case.to_string()
2299        }
2300    }
2301
2302    /// Sanitize a parameter name by escaping Rust reserved keywords with raw
2303    /// identifiers and disambiguating Twilio-style suffix operators
2304    /// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
2305    /// case to `start_time`).
2306    fn sanitize_param_name(&self, name: &str) -> String {
2307        // Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
2308        // filter-style query params; map them to `_lt` / `_gt` etc. so the
2309        // Rust ident is unique while the wire-level param name stays the
2310        // original string elsewhere in the codegen.
2311        let suffix = if name.ends_with("<=") {
2312            "_lte"
2313        } else if name.ends_with(">=") {
2314            "_gte"
2315        } else if name.ends_with('<') {
2316            "_lt"
2317        } else if name.ends_with('>') {
2318            "_gt"
2319        } else {
2320            ""
2321        };
2322        let stripped = name.trim_end_matches(['<', '>', '=']);
2323        let mut snake_case = stripped.to_snake_case();
2324        snake_case.push_str(suffix);
2325
2326        if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
2327            return format!("{snake_case}_param");
2328        }
2329        if Self::is_rust_keyword(&snake_case) {
2330            format!("r#{snake_case}")
2331        } else {
2332            snake_case
2333        }
2334    }
2335}