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", "cookie"] {
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 | RequestBodyContent::Unsupported { .. } => {
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            RequestBodyContent::SchemaLess { .. } => return None,
893        };
894        let body_type_name = self.to_rust_type_name(body_name);
895        let body_type = syn::Ident::new(&body_type_name, proc_macro2::Span::call_site());
896        let Some((resolved_name, resolved_schema)) =
897            self.resolve_reference_schema(body_name, analysis)
898        else {
899            return Some(BodyModelPlan {
900                body_ident,
901                body_type: quote! { #body_type },
902                required_construction: RequiredBodyConstruction::Whole,
903                optional_fields: Vec::new(),
904            });
905        };
906
907        let mut optional_fields = Vec::new();
908        let mut stack = std::collections::HashSet::new();
909        self.collect_optional_body_fields(
910            resolved_name,
911            Vec::new(),
912            analysis,
913            &mut stack,
914            &mut optional_fields,
915        );
916
917        let required_construction = match &resolved_schema.schema_type {
918            SchemaType::Object {
919                properties,
920                required,
921                additional_properties,
922            } if !self.is_discriminated_variant(resolved_name, analysis) => {
923                let emitted = self.emitted_object_properties(
924                    resolved_name,
925                    properties,
926                    required,
927                    additional_properties,
928                    analysis,
929                    None,
930                );
931                let required_fields: Vec<_> = emitted
932                    .iter()
933                    .filter(|field| field.is_required)
934                    .map(|field| BodyConstructorParam {
935                        preferred_ident: field.ident.clone(),
936                        value_type: field.field_type.clone(),
937                    })
938                    .collect();
939                if required_fields.is_empty() {
940                    RequiredBodyConstruction::Default
941                } else if emitted.iter().any(|field| !field.is_required)
942                    || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
943                {
944                    RequiredBodyConstruction::New(required_fields)
945                } else {
946                    RequiredBodyConstruction::Whole
947                }
948            }
949            _ => RequiredBodyConstruction::Whole,
950        };
951
952        Some(BodyModelPlan {
953            body_ident,
954            body_type: quote! { #body_type },
955            required_construction,
956            optional_fields,
957        })
958    }
959
960    fn resolve_reference_schema<'a>(
961        &self,
962        schema_name: &'a str,
963        analysis: &'a SchemaAnalysis,
964    ) -> Option<(&'a str, &'a crate::analysis::AnalyzedSchema)> {
965        let mut current = schema_name;
966        let mut visited = std::collections::HashSet::new();
967        loop {
968            if !visited.insert(current) {
969                return None;
970            }
971            let schema = analysis.schemas.get(current)?;
972            if let crate::analysis::SchemaType::Reference { target } = &schema.schema_type {
973                current = target;
974            } else {
975                return Some((current, schema));
976            }
977        }
978    }
979
980    fn collect_optional_body_fields(
981        &self,
982        schema_name: &str,
983        access_path: Vec<syn::Ident>,
984        analysis: &SchemaAnalysis,
985        stack: &mut std::collections::HashSet<String>,
986        output: &mut Vec<BodyFieldPlan>,
987    ) {
988        use crate::analysis::SchemaType;
989        if !stack.insert(schema_name.to_string()) {
990            return;
991        }
992        let Some(schema) = analysis.schemas.get(schema_name) else {
993            stack.remove(schema_name);
994            return;
995        };
996        match &schema.schema_type {
997            SchemaType::Reference { target } => {
998                self.collect_optional_body_fields(target, access_path, analysis, stack, output);
999            }
1000            SchemaType::Object {
1001                properties,
1002                required,
1003                additional_properties,
1004            } if !self.is_discriminated_variant(schema_name, analysis) => {
1005                for field in self.emitted_object_properties(
1006                    schema_name,
1007                    properties,
1008                    required,
1009                    additional_properties,
1010                    analysis,
1011                    None,
1012                ) {
1013                    if field.is_required {
1014                        continue;
1015                    }
1016                    let mut field_path = access_path.clone();
1017                    field_path.push(field.ident.clone());
1018                    output.push(BodyFieldPlan {
1019                        wire_name: field.wire_name.to_string(),
1020                        preferred_method_name: field.ident.to_string(),
1021                        value_ident: field.ident.clone(),
1022                        value_type: self.generate_property_base_type(
1023                            schema_name,
1024                            field.wire_name,
1025                            field.property,
1026                            analysis,
1027                        ),
1028                        access_path: field_path,
1029                    });
1030                }
1031            }
1032            SchemaType::Composition { schemas } => {
1033                for (index, schema_ref) in schemas.iter().enumerate() {
1034                    let mut nested_path = access_path.clone();
1035                    nested_path.push(format_ident!("part_{index}"));
1036                    self.collect_optional_body_fields(
1037                        &schema_ref.target,
1038                        nested_path,
1039                        analysis,
1040                        stack,
1041                        output,
1042                    );
1043                }
1044            }
1045            _ => {}
1046        }
1047        stack.remove(schema_name);
1048    }
1049
1050    fn is_discriminated_variant(&self, schema_name: &str, analysis: &SchemaAnalysis) -> bool {
1051        analysis.schemas.values().any(|schema| {
1052            matches!(
1053                &schema.schema_type,
1054                crate::analysis::SchemaType::DiscriminatedUnion { variants, .. }
1055                    if variants.iter().any(|variant| variant.type_name == schema_name)
1056            )
1057        })
1058    }
1059
1060    /// Emit inline enum types for parameters whose schema is `type: string`
1061    /// with `enum` or `const`. The generated enum implements `Display` so it
1062    /// drops into the existing `format!`-based path/query templating without
1063    /// any special-casing at the call site. See issue #10 follow-up.
1064    fn generate_param_enum_types(&self, operations: &[&OperationInfo]) -> TokenStream {
1065        let mut by_name: BTreeMap<String, &ParameterInfo> = BTreeMap::new();
1066        for op in operations {
1067            for param in &op.parameters {
1068                if param.enum_values.is_some() {
1069                    by_name.entry(param.rust_type.clone()).or_insert(param);
1070                }
1071            }
1072        }
1073
1074        if by_name.is_empty() {
1075            return quote! {};
1076        }
1077
1078        let defs: Vec<TokenStream> = by_name
1079            .values()
1080            .map(|param| self.generate_single_param_enum(param))
1081            .collect();
1082
1083        quote! { #(#defs)* }
1084    }
1085
1086    fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream {
1087        let Some(values) = param.enum_values.as_deref() else {
1088            return quote! {};
1089        };
1090
1091        let enum_ident = format_ident!("{}", param.rust_type);
1092
1093        // Dedupe variant names. Real-world specs use sort enums like
1094        // `["created_at", "-created_at"]` (descending prefix), and both
1095        // PascalCase to `CreatedAt`. Suffix collisions with `_2`/`_3`/…
1096        // while keeping each `serde(rename)` pointing at the original
1097        // wire string.
1098        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1099        let variant_names: Vec<String> = values
1100            .iter()
1101            .map(|value| {
1102                let base = self.to_rust_enum_variant(value);
1103                let mut chosen = base.clone();
1104                let mut suffix = 2;
1105                while !used.insert(chosen.clone()) {
1106                    chosen = format!("{base}_{suffix}");
1107                    suffix += 1;
1108                }
1109                chosen
1110            })
1111            .collect();
1112
1113        let variants: Vec<TokenStream> = values
1114            .iter()
1115            .zip(&variant_names)
1116            .map(|(value, name)| {
1117                let variant_ident = format_ident!("{}", name);
1118                quote! {
1119                    #[serde(rename = #value)]
1120                    #variant_ident,
1121                }
1122            })
1123            .collect();
1124
1125        let display_arms: Vec<TokenStream> = values
1126            .iter()
1127            .zip(&variant_names)
1128            .map(|(value, name)| {
1129                let variant_ident = format_ident!("{}", name);
1130                quote! { Self::#variant_ident => #value, }
1131            })
1132            .collect();
1133
1134        let doc = format!(
1135            "Allowed values for the `{}` {} parameter.",
1136            param.name, param.location
1137        );
1138
1139        quote! {
1140            #[doc = #doc]
1141            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
1142            pub enum #enum_ident {
1143                #(#variants)*
1144            }
1145
1146            impl #enum_ident {
1147                pub fn as_str(&self) -> &'static str {
1148                    match self {
1149                        #(#display_arms)*
1150                    }
1151                }
1152            }
1153
1154            impl std::fmt::Display for #enum_ident {
1155                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1156                    f.write_str(self.as_str())
1157                }
1158            }
1159
1160            impl AsRef<str> for #enum_ident {
1161                fn as_ref(&self) -> &str {
1162                    self.as_str()
1163                }
1164            }
1165        }
1166    }
1167
1168    /// Generate the per-operation typed error enum, if the op has any non-2xx
1169    /// responses with a body schema. Returns None when the op has no declared
1170    /// error bodies — those operations use `ApiOpError<serde_json::Value>` so
1171    /// the raw response body is still inspectable.
1172    fn generate_op_error_enum(&self, op: &OperationInfo) -> Option<TokenStream> {
1173        let variants: Vec<(String, String)> = op
1174            .response_schemas
1175            .iter()
1176            .filter(|(code, _)| !code.starts_with('2'))
1177            .map(|(code, schema)| (code.clone(), schema.clone()))
1178            .collect();
1179
1180        if variants.is_empty() {
1181            return None;
1182        }
1183
1184        let enum_ident = self.op_error_enum_ident(op);
1185        let variant_decls: Vec<TokenStream> = variants
1186            .iter()
1187            .map(|(code, schema)| {
1188                let variant_ident = Self::op_error_variant_ident(code);
1189                let payload_ty_name = self.to_rust_type_name(schema);
1190                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
1191                quote! { #variant_ident(#payload_ty) }
1192            })
1193            .collect();
1194
1195        let doc = format!(
1196            "Typed error responses for `{}`. One variant per declared non-2xx response.",
1197            op.operation_id
1198        );
1199
1200        Some(quote! {
1201            #[doc = #doc]
1202            #[derive(Debug, Clone)]
1203            pub enum #enum_ident {
1204                #(#variant_decls,)*
1205            }
1206        })
1207    }
1208
1209    /// Type name (Ident) for the per-op error enum, e.g. `ListTodosApiError`.
1210    fn op_error_enum_ident(&self, op: &OperationInfo) -> syn::Ident {
1211        use heck::ToPascalCase;
1212        let name = format!(
1213            "{}ApiError",
1214            op.operation_id.replace('.', "_").to_pascal_case()
1215        );
1216        syn::Ident::new(&name, proc_macro2::Span::call_site())
1217    }
1218
1219    /// Variant name for a status code: "400" → Status400, "default" → Default,
1220    /// "4XX" → Status4xx.
1221    fn op_error_variant_ident(status_code: &str) -> syn::Ident {
1222        let raw = match status_code {
1223            "default" | "Default" => "Default".to_string(),
1224            other if other.chars().all(|c| c.is_ascii_digit()) => format!("Status{other}"),
1225            other => format!("Status{}", other.to_ascii_lowercase()),
1226        };
1227        syn::Ident::new(&raw, proc_macro2::Span::call_site())
1228    }
1229
1230    /// Token stream for the type plugged into `ApiOpError<T>` for an op:
1231    /// either the per-op enum, or `serde_json::Value` for ops with no
1232    /// declared error body schemas.
1233    fn op_error_type_token(&self, op: &OperationInfo) -> TokenStream {
1234        if op
1235            .response_schemas
1236            .iter()
1237            .any(|(code, _)| !code.starts_with('2'))
1238        {
1239            let ident = self.op_error_enum_ident(op);
1240            quote! { #ident }
1241        } else {
1242            quote! { serde_json::Value }
1243        }
1244    }
1245
1246    /// Generate a single operation method
1247    fn generate_single_operation_method(&self, op: &OperationInfo) -> TokenStream {
1248        let method_name = self.get_method_name(op);
1249        let http_method_call = self.http_method_call(op);
1250        let path = &op.path;
1251        let request_param = self.generate_request_param(op);
1252        let request_body = self.generate_request_body(op);
1253        let query_params = self.generate_query_params(op);
1254        let header_params = self.generate_header_params(op);
1255        let cookie_params = self.generate_cookie_params(op);
1256        let auth_application = self.generate_auth_application();
1257        let response_type = self.get_response_type(op);
1258        let has_response_body = self.get_success_response_schema(op).is_some();
1259        let op_error_type = self.op_error_type_token(op);
1260        let error_handling = self.generate_error_handling(op, has_response_body);
1261        let url_construction = self.generate_url_construction(path, op);
1262        let doc_comment = self.generate_operation_doc_comment(op);
1263
1264        quote! {
1265            #doc_comment
1266            pub async fn #method_name(
1267                &self,
1268                #request_param
1269            ) -> Result<#response_type, ApiOpError<#op_error_type>> {
1270                #url_construction
1271
1272                let mut req = #http_method_call;
1273                #request_body
1274
1275                #query_params
1276                #header_params
1277                #cookie_params
1278
1279                // Apply configured authentication (T3). Was previously
1280                // hardcoded to bearer_auth regardless of GeneratorConfig.
1281                #auth_application
1282
1283                // Add custom headers
1284                for (name, value) in &self.custom_headers {
1285                    req = req.header(name, value);
1286                }
1287
1288                let response = req.send().await?;
1289                #error_handling
1290            }
1291        }
1292    }
1293
1294    /// T3: emit the auth-token application based on the configured AuthConfig.
1295    /// Default (no config) is Bearer on Authorization. ApiKey emits a custom
1296    /// header. Custom honors header_value_prefix.
1297    fn generate_auth_application(&self) -> TokenStream {
1298        use crate::http_config::AuthConfig;
1299        match &self.config().auth_config {
1300            Some(AuthConfig::Bearer { header_name }) if header_name == "Authorization" => quote! {
1301                if let Some(api_key) = &self.api_key {
1302                    req = req.bearer_auth(api_key);
1303                }
1304            },
1305            Some(AuthConfig::Bearer { header_name }) => {
1306                let h = header_name.clone();
1307                quote! {
1308                    if let Some(api_key) = &self.api_key {
1309                        req = req.header(#h, format!("Bearer {}", api_key));
1310                    }
1311                }
1312            }
1313            Some(AuthConfig::ApiKey { header_name }) => {
1314                let h = header_name.clone();
1315                quote! {
1316                    if let Some(api_key) = &self.api_key {
1317                        req = req.header(#h, api_key.as_str());
1318                    }
1319                }
1320            }
1321            Some(AuthConfig::Custom {
1322                header_name,
1323                header_value_prefix,
1324            }) => {
1325                let h = header_name.clone();
1326                let prefix = header_value_prefix.clone().unwrap_or_default();
1327                if prefix.is_empty() {
1328                    quote! {
1329                        if let Some(api_key) = &self.api_key {
1330                            req = req.header(#h, api_key.as_str());
1331                        }
1332                    }
1333                } else {
1334                    let format_str = format!("{}{{}}", prefix);
1335                    quote! {
1336                        if let Some(api_key) = &self.api_key {
1337                            req = req.header(#h, format!(#format_str, api_key));
1338                        }
1339                    }
1340                }
1341            }
1342            None => quote! {
1343                if let Some(api_key) = &self.api_key {
1344                    req = req.bearer_auth(api_key);
1345                }
1346            },
1347        }
1348    }
1349
1350    /// Generate header-parameter handling. Emits `req = req.header(name, ...)`
1351    /// for each `in: header` parameter — required headers unconditionally,
1352    /// optional ones gated on `Some(_)`.
1353    fn generate_header_params(&self, op: &OperationInfo) -> TokenStream {
1354        let header_params: Vec<_> = op
1355            .parameters
1356            .iter()
1357            .filter(|p| p.location == "header")
1358            .collect();
1359        if header_params.is_empty() {
1360            return quote! {};
1361        }
1362        let mut emit = Vec::new();
1363        for param in header_params {
1364            let param_name_snake = self.param_ident_str(param);
1365            let param_ident = Self::to_field_ident(&param_name_snake);
1366            let header_name = &param.name;
1367            if param.required {
1368                if Self::param_uses_as_ref_str(param) {
1369                    emit.push(quote! {
1370                        req = req.header(#header_name, #param_ident.as_ref());
1371                    });
1372                } else {
1373                    emit.push(quote! {
1374                        req = req.header(#header_name, #param_ident.to_string());
1375                    });
1376                }
1377            } else if Self::param_uses_as_ref_str(param) {
1378                emit.push(quote! {
1379                    if let Some(v) = #param_ident {
1380                        req = req.header(#header_name, v.as_ref());
1381                    }
1382                });
1383            } else {
1384                emit.push(quote! {
1385                    if let Some(v) = #param_ident {
1386                        req = req.header(#header_name, v.to_string());
1387                    }
1388                });
1389            }
1390        }
1391        quote! {
1392            #(#emit)*
1393        }
1394    }
1395
1396    fn generate_cookie_params(&self, op: &OperationInfo) -> TokenStream {
1397        let cookie_params: Vec<_> = op
1398            .parameters
1399            .iter()
1400            .filter(|parameter| parameter.location == "cookie")
1401            .collect();
1402        if cookie_params.is_empty() {
1403            return quote! {};
1404        }
1405        let mut emit = Vec::new();
1406        for parameter in cookie_params {
1407            let ident = Self::to_field_ident(&self.param_ident_str(parameter));
1408            let wire_name = parameter.name.as_str();
1409            if parameter.required {
1410                emit.push(quote! {
1411                    __cookie_fields.push(format!("{}={}", #wire_name, #ident));
1412                });
1413            } else {
1414                emit.push(quote! {
1415                    if let Some(value) = #ident {
1416                        __cookie_fields.push(format!("{}={}", #wire_name, value));
1417                    }
1418                });
1419            }
1420        }
1421        quote! {
1422            let mut __cookie_fields = Vec::new();
1423            #(#emit)*
1424            if !__cookie_fields.is_empty() {
1425                req = req.header(::reqwest::header::COOKIE, __cookie_fields.join("; "));
1426            }
1427        }
1428    }
1429
1430    /// Generate query parameter handling
1431    fn generate_query_params(&self, op: &OperationInfo) -> TokenStream {
1432        let query_params: Vec<_> = op
1433            .parameters
1434            .iter()
1435            .filter(|p| p.location == "query")
1436            .collect();
1437
1438        if query_params.is_empty() {
1439            return quote! {};
1440        }
1441
1442        let mut param_building = Vec::new();
1443        // Serialization applied on `req` directly, after the pair-vector
1444        // block: form-exploded objects and deepObject objects, whose keys
1445        // aren't the static parameter name.
1446        let mut req_appends = Vec::new();
1447
1448        for param in query_params {
1449            use crate::analysis::QuerySerialization;
1450
1451            // Use snake_case for Rust variable name with keyword escaping
1452            let param_name_snake = self.param_ident_str(param);
1453            let param_name = Self::to_field_ident(&param_name_snake);
1454
1455            // Use the original parameter name from OpenAPI spec as the query string key
1456            let param_key = &param.name;
1457
1458            match &param.query_serialization {
1459                Some(QuerySerialization::FormExplodedObject) => {
1460                    // Issue #27: reqwest serializes the struct through
1461                    // serde_urlencoded, so each property becomes its own
1462                    // `key=value` pair; the parameter's own name never
1463                    // appears in the query string (RFC 6570 form-explosion).
1464                    // `name[]=` is the shared zero-cardinality marker used to
1465                    // preserve Some(empty) and required-empty values.
1466                    let apply = quote! {
1467                        let __empty = match serde_json::to_value(&v)
1468                            .map_err(HttpError::serialization_error)?
1469                        {
1470                            serde_json::Value::Object(map) => map.is_empty(),
1471                            _ => false,
1472                        };
1473                        if __empty {
1474                            req = req.query(&[(format!("{}[]", #param_key), String::new())]);
1475                        } else {
1476                            req = req.query(&v);
1477                        }
1478                    };
1479                    if param.required {
1480                        req_appends.push(quote! {
1481                            {
1482                                let v = #param_name;
1483                                #apply
1484                            }
1485                        });
1486                    } else {
1487                        req_appends.push(quote! {
1488                            if let Some(v) = #param_name {
1489                                #apply
1490                            }
1491                        });
1492                    }
1493                    continue;
1494                }
1495                Some(QuerySerialization::DeepObject) => {
1496                    // `?filter[color]=red&filter[size]=5`. Property values
1497                    // stringify through their JSON form; Null (unset
1498                    // Option) properties are skipped.
1499                    let apply = quote! {
1500                        let map = match serde_json::to_value(&v)
1501                            .map_err(HttpError::serialization_error)?
1502                        {
1503                            serde_json::Value::Object(map) => map,
1504                            _ => return Err(HttpError::serialization_error(
1505                                format!("query parameter `{}` did not serialize as an object", #param_key)
1506                            ).into()),
1507                        };
1508                        let mut deep_params: Vec<(String, String)> = Vec::new();
1509                        for (k, val) in map {
1510                            let s = match val {
1511                                serde_json::Value::Null => continue,
1512                                serde_json::Value::String(s) => s,
1513                                other => other.to_string(),
1514                            };
1515                            deep_params.push((format!("{}[{}]", #param_key, k), s));
1516                        }
1517                        if deep_params.is_empty() {
1518                            deep_params.push((format!("{}[]", #param_key), String::new()));
1519                        }
1520                        req = req.query(&deep_params);
1521                    };
1522                    if param.required {
1523                        req_appends.push(quote! {
1524                            {
1525                                let v = #param_name;
1526                                #apply
1527                            }
1528                        });
1529                    } else {
1530                        req_appends.push(quote! {
1531                            if let Some(v) = #param_name {
1532                                #apply
1533                            }
1534                        });
1535                    }
1536                    continue;
1537                }
1538                Some(QuerySerialization::FormObject) => {
1539                    // `?filter=color,red,size,big` — one pair whose value is
1540                    // the comma-joined key,value list (RFC 6570 form,
1541                    // explode=false).
1542                    let apply = quote! {
1543                        let map = match serde_json::to_value(&v)
1544                            .map_err(HttpError::serialization_error)?
1545                        {
1546                            serde_json::Value::Object(map) => map,
1547                            _ => return Err(HttpError::serialization_error(
1548                                format!("query parameter `{}` did not serialize as an object", #param_key)
1549                            ).into()),
1550                        };
1551                        let mut parts: Vec<String> = Vec::new();
1552                        for (k, val) in map {
1553                            let s = match val {
1554                                serde_json::Value::Null => continue,
1555                                serde_json::Value::String(s) => s,
1556                                other => other.to_string(),
1557                            };
1558                            if k.contains(',') || s.contains(',') {
1559                                return Err(HttpError::serialization_error(
1560                                    format!(
1561                                        "query object `{}` contains a comma in key `{}`; use explode=true for lossless string values",
1562                                        #param_key,
1563                                        k,
1564                                    )
1565                                ).into());
1566                            }
1567                            parts.push(k);
1568                            parts.push(s);
1569                        }
1570                        if parts.is_empty() {
1571                            query_params.push((
1572                                format!("{}[]", #param_key),
1573                                String::new(),
1574                            ));
1575                        } else {
1576                            query_params.push((#param_key.to_string(), parts.join(",")));
1577                        }
1578                    };
1579                    if param.required {
1580                        param_building.push(quote! {
1581                            {
1582                                let v = #param_name;
1583                                #apply
1584                            }
1585                        });
1586                    } else {
1587                        param_building.push(quote! {
1588                            if let Some(v) = #param_name {
1589                                #apply
1590                            }
1591                        });
1592                    }
1593                    continue;
1594                }
1595                Some(QuerySerialization::FormExplodedArray { .. }) => {
1596                    // `?tags=a&tags=b` — one pair per element.
1597                    if param.required {
1598                        param_building.push(quote! {
1599                            if #param_name.is_empty() {
1600                                query_params.push((
1601                                    format!("{}[]", #param_key),
1602                                    String::new(),
1603                                ));
1604                            } else {
1605                                for item in #param_name {
1606                                    query_params.push((#param_key.to_string(), item.to_string()));
1607                                }
1608                            }
1609                        });
1610                    } else {
1611                        param_building.push(quote! {
1612                            if let Some(v) = #param_name {
1613                                if v.is_empty() {
1614                                    query_params.push((
1615                                        format!("{}[]", #param_key),
1616                                        String::new(),
1617                                    ));
1618                                } else {
1619                                    for item in v {
1620                                        query_params.push((#param_key.to_string(), item.to_string()));
1621                                    }
1622                                }
1623                            }
1624                        });
1625                    }
1626                    continue;
1627                }
1628                Some(QuerySerialization::FormArray { .. }) => {
1629                    // `?tags=a,b,c` — one comma-joined pair. Empty vectors
1630                    // use the shared `tags[]=` zero-cardinality marker.
1631                    let apply = quote! {
1632                        if v.is_empty() {
1633                            query_params.push((
1634                                format!("{}[]", #param_key),
1635                                String::new(),
1636                            ));
1637                        } else {
1638                            let mut parts = Vec::with_capacity(v.len());
1639                            for item in &v {
1640                                let item = item.to_string();
1641                                if item.contains(',') {
1642                                    return Err(HttpError::serialization_error(
1643                                        format!(
1644                                            "query array `{}` contains a comma; use explode=true for lossless string values",
1645                                            #param_key,
1646                                        )
1647                                    ).into());
1648                                }
1649                                parts.push(item);
1650                            }
1651                            query_params.push((
1652                                #param_key.to_string(),
1653                                parts.join(","),
1654                            ));
1655                        }
1656                    };
1657                    if param.required {
1658                        param_building.push(quote! {
1659                            {
1660                                let v = #param_name;
1661                                #apply
1662                            }
1663                        });
1664                    } else {
1665                        param_building.push(quote! {
1666                            if let Some(v) = #param_name {
1667                                #apply
1668                            }
1669                        });
1670                    }
1671                    continue;
1672                }
1673                Some(QuerySerialization::Unsupported { .. }) => {}
1674                None => {}
1675            }
1676
1677            if param.required {
1678                // Required parameters: always add
1679                if Self::param_uses_as_ref_str(param) {
1680                    param_building.push(quote! {
1681                        query_params.push((#param_key.to_string(), #param_name.as_ref().to_string()));
1682                    });
1683                } else {
1684                    param_building.push(quote! {
1685                        query_params.push((#param_key.to_string(), #param_name.to_string()));
1686                    });
1687                }
1688            } else {
1689                // Optional parameters: add only if Some
1690                if Self::param_uses_as_ref_str(param) {
1691                    param_building.push(quote! {
1692                        if let Some(v) = #param_name {
1693                            query_params.push((#param_key.to_string(), v.as_ref().to_string()));
1694                        }
1695                    });
1696                } else {
1697                    param_building.push(quote! {
1698                        if let Some(v) = #param_name {
1699                            query_params.push((#param_key.to_string(), v.to_string()));
1700                        }
1701                    });
1702                }
1703            }
1704        }
1705
1706        // Ops whose query params all serialize on `req` directly skip the
1707        // pair-vector block entirely.
1708        let pairs_block = if param_building.is_empty() {
1709            quote! {}
1710        } else {
1711            quote! {
1712                {
1713                    let mut query_params: Vec<(String, String)> = Vec::new();
1714                    #(#param_building)*
1715                    if !query_params.is_empty() {
1716                        req = req.query(&query_params);
1717                    }
1718                }
1719            }
1720        };
1721
1722        quote! {
1723            // Add query parameters
1724            #pairs_block
1725            #(#req_appends)*
1726        }
1727    }
1728
1729    /// Generate the rustdoc block for an operation, surfacing summary,
1730    /// description, the HTTP method+path, and any tags from the OAS spec
1731    /// (T13). Also marks the method `#[deprecated]` if the operation is.
1732    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
1733        let method = op.method.to_uppercase();
1734        let path = &op.path;
1735        let mut docs: Vec<String> = Vec::new();
1736        if let Some(s) = &op.summary {
1737            if !s.is_empty() {
1738                docs.push(s.clone());
1739                docs.push(String::new());
1740            }
1741        }
1742        if let Some(d) = &op.description {
1743            if !d.is_empty() {
1744                for line in d.lines() {
1745                    docs.push(line.to_string());
1746                }
1747                docs.push(String::new());
1748            }
1749        }
1750        docs.push(format!("`{} {}`", method, path));
1751        let doc_attrs: Vec<TokenStream> = docs
1752            .iter()
1753            .map(|line| {
1754                let prefixed = if line.is_empty() {
1755                    String::new()
1756                } else {
1757                    format!(" {line}")
1758                };
1759                quote! { #[doc = #prefixed] }
1760            })
1761            .collect();
1762        quote! { #(#doc_attrs)* }
1763    }
1764
1765    /// Get the method name from the operation
1766    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
1767        let name = if !op.operation_id.is_empty() {
1768            op.operation_id.to_snake_case()
1769        } else {
1770            // Fallback: generate from HTTP method and path
1771            format!(
1772                "{}_{}",
1773                op.method,
1774                op.path.replace('/', "_").replace(['{', '}'], "")
1775            )
1776            .to_snake_case()
1777        };
1778
1779        syn::Ident::new(&name, proc_macro2::Span::call_site())
1780    }
1781
1782    /// Build the request-builder expression for the operation's HTTP method.
1783    /// Named reqwest methods (`.get`/`.post`/…) are used where available;
1784    /// OPTIONS and TRACE go through `Client::request(Method::OPTIONS, _)` since
1785    /// reqwest doesn't expose those as named methods.
1786    fn http_method_call(&self, op: &OperationInfo) -> TokenStream {
1787        match op.method.to_uppercase().as_str() {
1788            "GET" => quote! { self.http_client.get(request_url) },
1789            "POST" => quote! { self.http_client.post(request_url) },
1790            "PUT" => quote! { self.http_client.put(request_url) },
1791            "DELETE" => quote! { self.http_client.delete(request_url) },
1792            "PATCH" => quote! { self.http_client.patch(request_url) },
1793            "HEAD" => quote! { self.http_client.head(request_url) },
1794            "OPTIONS" => quote! {
1795                self.http_client.request(reqwest::Method::OPTIONS, request_url)
1796            },
1797            "TRACE" => quote! {
1798                self.http_client.request(reqwest::Method::TRACE, request_url)
1799            },
1800            // D1: 3.2 `QUERY` verb + any custom verb from
1801            // PathItem.additionalOperations. reqwest's Method::from_bytes
1802            // accepts arbitrary uppercase tokens that match the RFC7230
1803            // method grammar.
1804            other => {
1805                let upper = other.to_string();
1806                quote! {
1807                    self.http_client.request(
1808                        reqwest::Method::from_bytes(#upper.as_bytes())
1809                            .expect("invalid HTTP method"),
1810                        request_url,
1811                    )
1812                }
1813            }
1814        }
1815    }
1816
1817    /// Generate request parameters including path, query, header, and request body.
1818    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
1819        let mut params = Vec::new();
1820        // Dedup parameter Rust idents within this method signature. Real-world
1821        // specs sometimes declare two parameters that sanitize to the same
1822        // snake_case name (modern-treasury declared `name` twice across
1823        // different param objects). Suffixing with `_2`, `_3`, … keeps each
1824        // parameter accessible while preserving the original wire-level name
1825        // (which is used elsewhere as the query/path/header key).
1826        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1827        let mut unique_param_ident = |raw: String| -> syn::Ident {
1828            let mut chosen = raw.clone();
1829            let mut suffix = 2;
1830            while !used.insert(chosen.clone()) {
1831                chosen = format!("{raw}_{suffix}");
1832                suffix += 1;
1833            }
1834            Self::to_field_ident(&chosen)
1835        };
1836
1837        // Add path parameters
1838        for param in &op.parameters {
1839            if param.location == "path" {
1840                let param_name_snake = self.param_ident_str(param);
1841                let param_name = unique_param_ident(param_name_snake);
1842                let param_type = self.get_param_rust_type(param);
1843                params.push(quote! { #param_name: #param_type });
1844            }
1845        }
1846
1847        // Add query parameters (all as Option<T>)
1848        for param in &op.parameters {
1849            if param.location == "query" {
1850                let param_name_snake = self.param_ident_str(param);
1851                let param_name = unique_param_ident(param_name_snake);
1852                let param_type = self.get_param_rust_type(param);
1853
1854                // Query parameters should be Option unless explicitly required
1855                if param.required {
1856                    params.push(quote! { #param_name: #param_type });
1857                } else {
1858                    params.push(quote! { #param_name: Option<#param_type> });
1859                }
1860            }
1861        }
1862
1863        // Add header parameters. Required headers are bare; optional ones are
1864        // Option<T>. Per OAS 3.x §"Parameter Object", header names matching
1865        // `Accept`, `Content-Type`, and `Authorization` are forbidden — those
1866        // are described by other mechanisms — but we leave that validation to
1867        // analysis.
1868        for param in &op.parameters {
1869            if param.location == "header" {
1870                let param_name_snake = self.param_ident_str(param);
1871                let param_name = unique_param_ident(param_name_snake);
1872                let param_type = self.get_param_rust_type(param);
1873                if param.required {
1874                    params.push(quote! { #param_name: #param_type });
1875                } else {
1876                    params.push(quote! { #param_name: Option<#param_type> });
1877                }
1878            }
1879        }
1880
1881        for param in &op.parameters {
1882            if param.location == "cookie" {
1883                let param_name_snake = self.param_ident_str(param);
1884                let param_name = unique_param_ident(param_name_snake);
1885                let param_type = self.get_param_rust_type(param);
1886                if param.required {
1887                    params.push(quote! { #param_name: #param_type });
1888                } else {
1889                    params.push(quote! { #param_name: Option<#param_type> });
1890                }
1891            }
1892        }
1893
1894        // Add request body parameter based on content type. Optional bodies
1895        // (`requestBody.required` is false or absent) become `Option<T>` per T11.
1896        if let Some(ref rb) = op.request_body {
1897            use crate::analysis::RequestBodyContent;
1898            if matches!(rb, RequestBodyContent::SchemaLess { .. }) {
1899                return if params.is_empty() {
1900                    quote! {}
1901                } else {
1902                    quote! { #(#params),* }
1903                };
1904            }
1905            let required = op.request_body_required;
1906            let body_type = match rb {
1907                RequestBodyContent::Json { schema_name, .. }
1908                | RequestBodyContent::FormUrlEncoded { schema_name, .. } => {
1909                    let rust_type_name = self.to_rust_type_name(schema_name);
1910                    let request_ident =
1911                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
1912                    quote! { #request_ident }
1913                }
1914                RequestBodyContent::Multipart => quote! { reqwest::multipart::Form },
1915                RequestBodyContent::OctetStream => quote! { Vec<u8> },
1916                RequestBodyContent::TextPlain => quote! { String },
1917                RequestBodyContent::Unsupported { .. } => quote! { Vec<u8> },
1918                RequestBodyContent::SchemaLess { .. } => unreachable!(
1919                    "schema-less request bodies preserve the historical client signature"
1920                ),
1921            };
1922            let body_ident = match rb {
1923                RequestBodyContent::Multipart => quote! { form },
1924                RequestBodyContent::OctetStream
1925                | RequestBodyContent::TextPlain
1926                | RequestBodyContent::Unsupported { .. } => quote! { body },
1927                RequestBodyContent::SchemaLess { .. } => unreachable!(
1928                    "schema-less request bodies preserve the historical client signature"
1929                ),
1930                _ => quote! { request },
1931            };
1932            if required {
1933                params.push(quote! { #body_ident: #body_type });
1934            } else {
1935                params.push(quote! { #body_ident: Option<#body_type> });
1936            }
1937        }
1938
1939        if params.is_empty() {
1940            quote! {}
1941        } else {
1942            quote! { #(#params),* }
1943        }
1944    }
1945
1946    /// Get the Rust type for a parameter
1947    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
1948        if Self::param_has_impl_as_ref_type(param) {
1949            quote! { impl AsRef<str> }
1950        } else {
1951            self.get_param_owned_rust_type(param)
1952        }
1953    }
1954
1955    /// Owned parameter type shared by client-builder storage and generated
1956    /// server extraction. [`ParameterInfo::query_serialization`] is the
1957    /// authoritative projection for typed query objects and arrays.
1958    pub(crate) fn get_param_owned_rust_type(
1959        &self,
1960        param: &crate::analysis::ParameterInfo,
1961    ) -> TokenStream {
1962        use crate::analysis::QuerySerialization;
1963        // Typed form-style arrays take Vec<item> (openapi-generator-anu).
1964        // Scalars parse as-is (they may be type paths from [type_mappings]);
1965        // enum refs are raw schema names and go through the same
1966        // to_rust_type_name sanitization as every other schema reference
1967        // (cloudflare has enum schemas like `resource-sharing_resource_type`).
1968        if let Some(
1969            QuerySerialization::FormExplodedArray { item_type }
1970            | QuerySerialization::FormArray { item_type },
1971        ) = &param.query_serialization
1972        {
1973            use crate::analysis::ArrayItemType;
1974            let item_ty: syn::Type = match item_type {
1975                ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type)
1976                    .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")),
1977                ArrayItemType::EnumRef(schema_name) => {
1978                    let rust_name = self.to_rust_type_name(schema_name);
1979                    syn::parse_str(&rust_name)
1980                        .unwrap_or_else(|_| panic!("invalid enum item type `{rust_name}`"))
1981                }
1982            };
1983            return quote! { Vec<#item_ty> };
1984        }
1985        // T10: $ref-typed parameters used to lose their type because we only
1986        // consulted `rust_type` (which stays "String"). Now: prefer the
1987        // resolved schema reference if present.
1988        if let Some(ref schema_name) = param.schema_ref {
1989            let rust_name = self.to_rust_type_name(schema_name);
1990            let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site());
1991            return quote! { #ident };
1992        }
1993        syn::parse_str::<syn::Type>(&param.rust_type)
1994            .map(|ty| quote! { #ty })
1995            .unwrap_or_else(|_| {
1996                let type_ident = syn::Ident::new(&param.rust_type, proc_macro2::Span::call_site());
1997                quote! { #type_ident }
1998            })
1999    }
2000
2001    /// True when the parameter's compile-time type is `impl AsRef<str>` and
2002    /// we should call `.as_ref()` on it before stringifying. False for any
2003    /// $ref-resolved type (T10) or non-String primitive — those just call
2004    /// `.to_string()`.
2005    fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
2006        param.schema_ref.is_none() && param.rust_type == "String"
2007    }
2008
2009    /// Generate request body serialization based on content type
2010    /// Emit statements that mutate `req` to apply the request body. Returns
2011    /// `quote!{}` if the operation has no body. Optional bodies (T11) gate the
2012    /// application on `Some(_)`; required bodies apply unconditionally.
2013    fn generate_request_body(&self, op: &OperationInfo) -> TokenStream {
2014        let Some(rb) = op.request_body.as_ref() else {
2015            return quote! {};
2016        };
2017        use crate::analysis::RequestBodyContent;
2018        let required = op.request_body_required;
2019        let (ident, apply): (TokenStream, TokenStream) = match rb {
2020            RequestBodyContent::Json { media_type, .. } => (
2021                quote! { request },
2022                quote! {
2023                    req = req
2024                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
2025                        .header("content-type", #media_type);
2026                },
2027            ),
2028            RequestBodyContent::FormUrlEncoded { media_type, .. } => (
2029                quote! { request },
2030                quote! {
2031                    req = req
2032                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
2033                        .header("content-type", #media_type);
2034                },
2035            ),
2036            RequestBodyContent::Multipart => (
2037                quote! { form },
2038                quote! {
2039                    req = req.multipart(form);
2040                },
2041            ),
2042            RequestBodyContent::OctetStream => (
2043                quote! { body },
2044                quote! {
2045                    req = req
2046                        .body(body)
2047                        .header("content-type", "application/octet-stream");
2048                },
2049            ),
2050            RequestBodyContent::TextPlain => (
2051                quote! { body },
2052                quote! {
2053                    req = req
2054                        .body(body)
2055                        .header("content-type", "text/plain");
2056                },
2057            ),
2058            RequestBodyContent::Unsupported { media_types } => {
2059                let media_type = media_types
2060                    .first()
2061                    .map(String::as_str)
2062                    .unwrap_or("application/octet-stream");
2063                (
2064                    quote! { body },
2065                    quote! {
2066                        req = req
2067                            .body(body)
2068                            .header("content-type", #media_type);
2069                    },
2070                )
2071            }
2072            RequestBodyContent::SchemaLess { .. } => return quote! {},
2073        };
2074        if required {
2075            apply
2076        } else {
2077            quote! {
2078                if let Some(#ident) = #ident {
2079                    #apply
2080                }
2081            }
2082        }
2083    }
2084
2085    /// Find the success (2xx) response schema name, if any.
2086    ///
2087    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
2088    /// so that endpoints like 204 No Content correctly return `()` instead of
2089    /// accidentally picking up the error schema (e.g. `BadRequestError`).
2090    fn get_success_response_schema<'a>(&self, op: &'a OperationInfo) -> Option<&'a String> {
2091        op.response_schemas
2092            .get("200")
2093            .or_else(|| op.response_schemas.get("201"))
2094            .or_else(|| {
2095                op.response_schemas
2096                    .iter()
2097                    .find(|(code, _)| code.starts_with('2'))
2098                    .map(|(_, v)| v)
2099            })
2100    }
2101
2102    /// Get response type
2103    fn get_response_type(&self, op: &OperationInfo) -> TokenStream {
2104        if let Some(response_type) = self.get_success_response_schema(op) {
2105            // Convert schema name to Rust type name (handles underscores, etc.)
2106            let rust_type_name = self.to_rust_type_name(response_type);
2107            let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
2108            quote! { #response_ident }
2109        } else {
2110            quote! { () }
2111        }
2112    }
2113
2114    /// Generate error handling.
2115    ///
2116    /// Always reads the response body to a string before attempting any typed
2117    /// deserialization, so the raw body and headers are preserved on the error
2118    /// path even when JSON parsing fails. On 2xx the body is parsed into the
2119    /// success type; on non-2xx the body is parsed into the matching variant
2120    /// of the per-operation error enum (when one is declared) and wrapped in
2121    /// `ApiError<E>`.
2122    fn generate_error_handling(&self, op: &OperationInfo, has_response_body: bool) -> TokenStream {
2123        let op_error_type = self.op_error_type_token(op);
2124
2125        let success_branch = if has_response_body {
2126            quote! {
2127                match serde_json::from_str(&body_text) {
2128                    Ok(body) => Ok(body),
2129                    Err(e) => Err(ApiOpError::Api(ApiError {
2130                        status: status_code,
2131                        headers: headers,
2132                        body: body_text,
2133                        typed: None,
2134                        parse_error: Some(format!(
2135                            "failed to deserialize 2xx response body: {}",
2136                            e
2137                        )),
2138                    })),
2139                }
2140            }
2141        } else {
2142            quote! {
2143                let _ = body_text;
2144                let _ = headers;
2145                Ok(())
2146            }
2147        };
2148
2149        let error_match_arms = self.generate_error_match_arms(op);
2150
2151        quote! {
2152            let status = response.status();
2153            let status_code = status.as_u16();
2154            let headers = response.headers().clone();
2155            let body_text = response.text().await
2156                .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?;
2157
2158            if status.is_success() {
2159                #success_branch
2160            } else {
2161                let typed: Option<#op_error_type>;
2162                let parse_error: Option<String>;
2163                #error_match_arms
2164                Err(ApiOpError::Api(ApiError {
2165                    status: status_code,
2166                    headers,
2167                    body: body_text,
2168                    typed,
2169                    parse_error,
2170                }))
2171            }
2172        }
2173    }
2174
2175    /// Generate the match arms that select which per-op error variant to
2176    /// deserialize the response body into based on the runtime status code.
2177    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
2178        let arms: Vec<TokenStream> = op
2179            .response_schemas
2180            .iter()
2181            .filter(|(code, _)| !code.starts_with('2'))
2182            .filter_map(|(code, schema)| {
2183                let variant_ident = Self::op_error_variant_ident(code);
2184                let payload_ty_name = self.to_rust_type_name(schema);
2185                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
2186                let enum_ident = self.op_error_enum_ident(op);
2187
2188                // T8: range-keyed responses (1XX/2XX/3XX/4XX/5XX) per OAS
2189                // 3.x §"Responses Object". Specific codes still take priority
2190                // (handled by ordering — concrete codes deserialize first
2191                // because the generic dispatch is a generic `_ if (range)`).
2192                let pattern = match code.as_str() {
2193                    "default" | "Default" => return None, // handled in fallback
2194                    other if other.chars().all(|c| c.is_ascii_digit()) => {
2195                        let n: u16 = other.parse().ok()?;
2196                        quote! { #n }
2197                    }
2198                    "1XX" | "1xx" => quote! { code if (100..=199).contains(&code) },
2199                    "2XX" | "2xx" => quote! { code if (200..=299).contains(&code) },
2200                    "3XX" | "3xx" => quote! { code if (300..=399).contains(&code) },
2201                    "4XX" | "4xx" => quote! { code if (400..=499).contains(&code) },
2202                    "5XX" | "5xx" => quote! { code if (500..=599).contains(&code) },
2203                    _ => return None,
2204                };
2205
2206                Some(quote! {
2207                    #pattern => {
2208                        match serde_json::from_str::<#payload_ty>(&body_text) {
2209                            Ok(v) => {
2210                                typed = Some(#enum_ident::#variant_ident(v));
2211                                parse_error = None;
2212                            }
2213                            Err(e) => {
2214                                typed = None;
2215                                parse_error = Some(e.to_string());
2216                            }
2217                        }
2218                    }
2219                })
2220            })
2221            .collect();
2222
2223        // Fallback for "default" or undeclared status codes: try to parse
2224        // as `serde_json::Value` for inspectability when the op's error
2225        // type is generic, otherwise leave typed = None.
2226        // Must mirror op_error_type_token: if op_error_type is the typed
2227        // enum (any non-2xx response, including `default`), the fallback arm
2228        // can't deserialize into `serde_json::Value` because `typed` is the
2229        // enum. Default to `typed = None` in that case.
2230        let has_typed_enum = op
2231            .response_schemas
2232            .iter()
2233            .any(|(code, _)| !code.starts_with('2'));
2234
2235        let default_arm = if has_typed_enum {
2236            quote! {
2237                _ => {
2238                    typed = None;
2239                    parse_error = None;
2240                }
2241            }
2242        } else {
2243            // No typed enum — op_error_type is serde_json::Value.
2244            quote! {
2245                _ => {
2246                    match serde_json::from_str::<serde_json::Value>(&body_text) {
2247                        Ok(v) => {
2248                            typed = Some(v);
2249                            parse_error = None;
2250                        }
2251                        Err(e) => {
2252                            typed = None;
2253                            parse_error = Some(e.to_string());
2254                        }
2255                    }
2256                }
2257            }
2258        };
2259
2260        if arms.is_empty() {
2261            // No declared status arms — just the fallback.
2262            quote! {
2263                match status_code {
2264                    #default_arm
2265                }
2266            }
2267        } else {
2268            quote! {
2269                match status_code {
2270                    #(#arms)*
2271                    #default_arm
2272                }
2273            }
2274        }
2275    }
2276
2277    /// Generate URL construction with path parameter substitution
2278    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
2279        // Check if path has parameters (contains {...})
2280        if path.contains('{') {
2281            self.generate_url_with_params(path, op)
2282        } else {
2283            quote! {
2284                let request_url = format!("{}{}", self.base_url, #path);
2285            }
2286        }
2287    }
2288
2289    /// Generate URL with path parameters
2290    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
2291        // Find all path parameters in the operation.
2292        let path_params: Vec<_> = op
2293            .parameters
2294            .iter()
2295            .filter(|p| p.location == "path")
2296            .collect();
2297
2298        // T5: percent-encode each path-template variable per RFC3986 §3.3.
2299        // We build a positional-arg format string by walking the template
2300        // left-to-right and emitting one `{}` + one format arg per
2301        // placeholder occurrence. Cloudflare has paths like
2302        // `/accounts/{account_id}/.../accounts/{account_id}` — the same
2303        // variable appears twice. A naive `replace_all` produced two `{}`
2304        // placeholders but only one format arg (E0277). Per-occurrence
2305        // emission keeps them in sync.
2306        let mut format_string = String::with_capacity(path.len());
2307        let mut format_args: Vec<TokenStream> = Vec::new();
2308        let mut chars = path.chars().peekable();
2309        while let Some(c) = chars.next() {
2310            if c != '{' {
2311                format_string.push(c);
2312                continue;
2313            }
2314            // Read until the matching '}'.
2315            let mut name = String::new();
2316            while let Some(&n) = chars.peek() {
2317                chars.next();
2318                if n == '}' {
2319                    break;
2320                }
2321                name.push(n);
2322            }
2323            // Resolve to a path param. If no match, leave the placeholder
2324            // verbatim (real-world spec bug — this op shouldn't have made
2325            // it past analysis).
2326            let param = path_params.iter().find(|p| p.name == name);
2327            let Some(param) = param else {
2328                format_string.push('{');
2329                format_string.push_str(&name);
2330                format_string.push('}');
2331                continue;
2332            };
2333            format_string.push_str("{}");
2334            let param_name_snake = self.param_ident_str(param);
2335            let param_ident = Self::to_field_ident(&param_name_snake);
2336            if Self::param_uses_as_ref_str(param) {
2337                format_args.push(quote! {
2338                    __pct_encode_path_segment(#param_ident.as_ref())
2339                });
2340            } else {
2341                format_args.push(quote! {
2342                    __pct_encode_path_segment(&#param_ident.to_string())
2343                });
2344            }
2345        }
2346
2347        if format_args.is_empty() {
2348            quote! {
2349                let request_url = format!("{}{}", self.base_url, #path);
2350            }
2351        } else {
2352            quote! {
2353                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
2354            }
2355        }
2356    }
2357
2358    /// Resolve the Rust ident for a parameter. Prefers the disambiguated
2359    /// `rust_ident` set by the analyzer (which dedupes across the whole
2360    /// operation), falling back to a fresh sanitize of the wire name when
2361    /// no analyzer-side ident is present.
2362    pub(crate) fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String {
2363        if let Some(ident) = &param.rust_ident {
2364            // Apply the keyword-escape and self/super/crate dance the
2365            // sanitize fn does. The analyzer's base ident is already the
2366            // snake/kebab-aware shape; we only need post-processing.
2367            return self.escape_keyword_ident(ident);
2368        }
2369        self.sanitize_param_name(&param.name)
2370    }
2371
2372    fn escape_keyword_ident(&self, snake_case: &str) -> String {
2373        if matches!(snake_case, "self" | "super" | "crate" | "Self") {
2374            return format!("{snake_case}_param");
2375        }
2376        if Self::is_rust_keyword(snake_case) {
2377            format!("r#{snake_case}")
2378        } else {
2379            snake_case.to_string()
2380        }
2381    }
2382
2383    /// Sanitize a parameter name by escaping Rust reserved keywords with raw
2384    /// identifiers and disambiguating Twilio-style suffix operators
2385    /// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
2386    /// case to `start_time`).
2387    fn sanitize_param_name(&self, name: &str) -> String {
2388        // Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
2389        // filter-style query params; map them to `_lt` / `_gt` etc. so the
2390        // Rust ident is unique while the wire-level param name stays the
2391        // original string elsewhere in the codegen.
2392        let suffix = if name.ends_with("<=") {
2393            "_lte"
2394        } else if name.ends_with(">=") {
2395            "_gte"
2396        } else if name.ends_with('<') {
2397            "_lt"
2398        } else if name.ends_with('>') {
2399            "_gt"
2400        } else {
2401            ""
2402        };
2403        let stripped = name.trim_end_matches(['<', '>', '=']);
2404        let mut snake_case = stripped.to_snake_case();
2405        if snake_case.is_empty() {
2406            snake_case.push_str("parameter");
2407        } else if snake_case.starts_with(|character: char| character.is_ascii_digit()) {
2408            snake_case.insert(0, '_');
2409        }
2410        snake_case.push_str(suffix);
2411
2412        if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
2413            return format!("{snake_case}_param");
2414        }
2415        if Self::is_rust_keyword(&snake_case) {
2416            format!("r#{snake_case}")
2417        } else {
2418            snake_case
2419        }
2420    }
2421}