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