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::ToSnakeCase;
153use proc_macro2::TokenStream;
154use quote::{format_ident, quote};
155use std::collections::BTreeMap;
156
157impl CodeGenerator {
158    /// Generate the HTTP client struct with middleware support
159    pub fn generate_http_client_struct(&self) -> TokenStream {
160        let has_retry = self.config().retry_config.is_some();
161        let has_tracing = self.config().tracing_enabled;
162
163        // Generate RetryConfig struct if needed
164        let retry_config_struct = if has_retry {
165            quote! {
166                /// Retry configuration for HTTP requests
167                #[derive(Debug, Clone)]
168                pub struct RetryConfig {
169                    pub max_retries: u32,
170                    pub initial_delay_ms: u64,
171                    pub max_delay_ms: u64,
172                }
173
174                impl Default for RetryConfig {
175                    fn default() -> Self {
176                        Self {
177                            max_retries: 3,
178                            initial_delay_ms: 500,
179                            max_delay_ms: 16000,
180                        }
181                    }
182                }
183            }
184        } else {
185            quote! {}
186        };
187
188        // Generate the main HttpClient struct
189        let client_struct = quote! {
190            use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
191            use std::collections::BTreeMap;
192
193            /// HTTP client for making API requests
194            #[derive(Clone)]
195            pub struct HttpClient {
196                base_url: String,
197                api_key: Option<String>,
198                http_client: ClientWithMiddleware,
199                custom_headers: BTreeMap<String, String>,
200            }
201        };
202
203        // Generate constructor
204        let constructor = self.generate_constructor(has_retry, has_tracing);
205
206        // Generate builder methods
207        let builder_methods = self.generate_builder_methods();
208
209        // Generate Default implementation
210        let default_impl = quote! {
211            impl Default for HttpClient {
212                fn default() -> Self {
213                    Self::new()
214                }
215            }
216        };
217
218        // Path-segment percent encoder, used by url construction (T5).
219        // Encodes per RFC3986 §3.3: only ALPHA, DIGIT, and `-._~` pass through;
220        // everything else becomes `%XX`.
221        let path_encoder = quote! {
222            fn __pct_encode_path_segment(s: &str) -> String {
223                let mut out = String::with_capacity(s.len());
224                for &b in s.as_bytes() {
225                    match b {
226                        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
227                            out.push(b as char);
228                        }
229                        _ => {
230                            out.push('%');
231                            out.push_str(&format!("{:02X}", b));
232                        }
233                    }
234                }
235                out
236            }
237        };
238
239        // Combine all parts
240        quote! {
241            #retry_config_struct
242            #client_struct
243
244            impl HttpClient {
245                #constructor
246                #builder_methods
247            }
248
249            #default_impl
250            #path_encoder
251        }
252    }
253
254    /// Generate the constructor method
255    fn generate_constructor(&self, has_retry: bool, has_tracing: bool) -> TokenStream {
256        let retry_param = if has_retry {
257            quote! { retry_config: Option<RetryConfig>, }
258        } else {
259            quote! {}
260        };
261
262        let tracing_param = if has_tracing {
263            quote! { enable_tracing: bool, }
264        } else {
265            quote! {}
266        };
267
268        let retry_middleware = if has_retry {
269            quote! {
270                if let Some(config) = retry_config {
271                    use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
272
273                    let retry_policy = ExponentialBackoff::builder()
274                        .retry_bounds(
275                            std::time::Duration::from_millis(config.initial_delay_ms),
276                            std::time::Duration::from_millis(config.max_delay_ms),
277                        )
278                        .build_with_max_retries(config.max_retries);
279
280                    let retry_middleware = RetryTransientMiddleware::new_with_policy(retry_policy);
281                    client_builder = client_builder.with(retry_middleware);
282                }
283            }
284        } else {
285            quote! {}
286        };
287
288        let tracing_middleware = if has_tracing {
289            quote! {
290                if enable_tracing {
291                    use reqwest_tracing::TracingMiddleware;
292                    client_builder = client_builder.with(TracingMiddleware::default());
293                }
294            }
295        } else {
296            quote! {}
297        };
298
299        let default_constructor = if has_retry && has_tracing {
300            quote! {
301                /// Create a new HTTP client with default configuration
302                pub fn new() -> Self {
303                    Self::with_config(None, true)
304                }
305            }
306        } else if has_retry {
307            quote! {
308                /// Create a new HTTP client with default configuration
309                pub fn new() -> Self {
310                    Self::with_config(None)
311                }
312            }
313        } else if has_tracing {
314            quote! {
315                /// Create a new HTTP client with default configuration
316                pub fn new() -> Self {
317                    Self::with_config(true)
318                }
319            }
320        } else {
321            quote! {
322                /// Create a new HTTP client with default configuration
323                pub fn new() -> Self {
324                    let reqwest_client = reqwest::Client::new();
325                    let client_builder = ClientBuilder::new(reqwest_client);
326                    let http_client = client_builder.build();
327
328                    Self {
329                        base_url: String::new(),
330                        api_key: None,
331                        http_client,
332                        custom_headers: BTreeMap::new(),
333                    }
334                }
335            }
336        };
337
338        if has_retry || has_tracing {
339            quote! {
340                #default_constructor
341
342                /// Create a new HTTP client with custom configuration
343                pub fn with_config(#retry_param #tracing_param) -> Self {
344                    let reqwest_client = reqwest::Client::new();
345                    let mut client_builder = ClientBuilder::new(reqwest_client);
346
347                    #tracing_middleware
348                    #retry_middleware
349
350                    let http_client = client_builder.build();
351
352                    Self {
353                        base_url: String::new(),
354                        api_key: None,
355                        http_client,
356                        custom_headers: BTreeMap::new(),
357                    }
358                }
359            }
360        } else {
361            default_constructor
362        }
363    }
364
365    /// Generate builder methods for configuration
366    fn generate_builder_methods(&self) -> TokenStream {
367        quote! {
368            /// Set the base URL for all requests
369            pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
370                self.base_url = base_url.into();
371                self
372            }
373
374            /// Set the API key for authentication
375            pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
376                self.api_key = Some(api_key.into());
377                self
378            }
379
380            /// Add a custom header to all requests
381            pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
382                self.custom_headers.insert(name.into(), value.into());
383                self
384            }
385
386            /// Add multiple custom headers
387            pub fn with_headers(mut self, headers: BTreeMap<String, String>) -> Self {
388                self.custom_headers.extend(headers);
389                self
390            }
391        }
392    }
393
394    /// Generate HTTP operation methods for the client.
395    ///
396    /// Emits per-operation typed error enums (one variant per declared non-2xx
397    /// response with a body schema) BEFORE the `impl HttpClient` block so the
398    /// generated method signatures can reference them.
399    pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream {
400        let param_enums = self.generate_param_enum_types(analysis);
401
402        let op_error_enums: Vec<TokenStream> = analysis
403            .operations
404            .values()
405            .filter_map(|op| self.generate_op_error_enum(op))
406            .collect();
407
408        let methods: Vec<TokenStream> = analysis
409            .operations
410            .values()
411            .map(|op| self.generate_single_operation_method(op))
412            .collect();
413
414        quote! {
415            #param_enums
416
417            #(#op_error_enums)*
418
419            impl HttpClient {
420                #(#methods)*
421            }
422        }
423    }
424
425    /// Emit inline enum types for parameters whose schema is `type: string`
426    /// with `enum` or `const`. The generated enum implements `Display` so it
427    /// drops into the existing `format!`-based path/query templating without
428    /// any special-casing at the call site. See issue #10 follow-up.
429    fn generate_param_enum_types(&self, analysis: &SchemaAnalysis) -> TokenStream {
430        let mut by_name: BTreeMap<String, &ParameterInfo> = BTreeMap::new();
431        for op in analysis.operations.values() {
432            for param in &op.parameters {
433                if param.enum_values.is_some() {
434                    by_name.entry(param.rust_type.clone()).or_insert(param);
435                }
436            }
437        }
438
439        if by_name.is_empty() {
440            return quote! {};
441        }
442
443        let defs: Vec<TokenStream> = by_name
444            .values()
445            .map(|param| self.generate_single_param_enum(param))
446            .collect();
447
448        quote! { #(#defs)* }
449    }
450
451    fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream {
452        let Some(values) = param.enum_values.as_deref() else {
453            return quote! {};
454        };
455
456        let enum_ident = format_ident!("{}", param.rust_type);
457
458        // Dedupe variant names. Real-world specs use sort enums like
459        // `["created_at", "-created_at"]` (descending prefix), and both
460        // PascalCase to `CreatedAt`. Suffix collisions with `_2`/`_3`/…
461        // while keeping each `serde(rename)` pointing at the original
462        // wire string.
463        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
464        let variant_names: Vec<String> = values
465            .iter()
466            .map(|value| {
467                let base = self.to_rust_enum_variant(value);
468                let mut chosen = base.clone();
469                let mut suffix = 2;
470                while !used.insert(chosen.clone()) {
471                    chosen = format!("{base}_{suffix}");
472                    suffix += 1;
473                }
474                chosen
475            })
476            .collect();
477
478        let variants: Vec<TokenStream> = values
479            .iter()
480            .zip(&variant_names)
481            .map(|(value, name)| {
482                let variant_ident = format_ident!("{}", name);
483                quote! {
484                    #[serde(rename = #value)]
485                    #variant_ident,
486                }
487            })
488            .collect();
489
490        let display_arms: Vec<TokenStream> = values
491            .iter()
492            .zip(&variant_names)
493            .map(|(value, name)| {
494                let variant_ident = format_ident!("{}", name);
495                quote! { Self::#variant_ident => #value, }
496            })
497            .collect();
498
499        let doc = format!(
500            "Allowed values for the `{}` {} parameter.",
501            param.name, param.location
502        );
503
504        quote! {
505            #[doc = #doc]
506            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
507            pub enum #enum_ident {
508                #(#variants)*
509            }
510
511            impl #enum_ident {
512                pub fn as_str(&self) -> &'static str {
513                    match self {
514                        #(#display_arms)*
515                    }
516                }
517            }
518
519            impl std::fmt::Display for #enum_ident {
520                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521                    f.write_str(self.as_str())
522                }
523            }
524
525            impl AsRef<str> for #enum_ident {
526                fn as_ref(&self) -> &str {
527                    self.as_str()
528                }
529            }
530        }
531    }
532
533    /// Generate the per-operation typed error enum, if the op has any non-2xx
534    /// responses with a body schema. Returns None when the op has no declared
535    /// error bodies — those operations use `ApiOpError<serde_json::Value>` so
536    /// the raw response body is still inspectable.
537    fn generate_op_error_enum(&self, op: &OperationInfo) -> Option<TokenStream> {
538        let variants: Vec<(String, String)> = op
539            .response_schemas
540            .iter()
541            .filter(|(code, _)| !code.starts_with('2'))
542            .map(|(code, schema)| (code.clone(), schema.clone()))
543            .collect();
544
545        if variants.is_empty() {
546            return None;
547        }
548
549        let enum_ident = self.op_error_enum_ident(op);
550        let variant_decls: Vec<TokenStream> = variants
551            .iter()
552            .map(|(code, schema)| {
553                let variant_ident = Self::op_error_variant_ident(code);
554                let payload_ty_name = self.to_rust_type_name(schema);
555                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
556                quote! { #variant_ident(#payload_ty) }
557            })
558            .collect();
559
560        let doc = format!(
561            "Typed error responses for `{}`. One variant per declared non-2xx response.",
562            op.operation_id
563        );
564
565        Some(quote! {
566            #[doc = #doc]
567            #[derive(Debug, Clone)]
568            pub enum #enum_ident {
569                #(#variant_decls,)*
570            }
571        })
572    }
573
574    /// Type name (Ident) for the per-op error enum, e.g. `ListTodosApiError`.
575    fn op_error_enum_ident(&self, op: &OperationInfo) -> syn::Ident {
576        use heck::ToPascalCase;
577        let name = format!(
578            "{}ApiError",
579            op.operation_id.replace('.', "_").to_pascal_case()
580        );
581        syn::Ident::new(&name, proc_macro2::Span::call_site())
582    }
583
584    /// Variant name for a status code: "400" → Status400, "default" → Default,
585    /// "4XX" → Status4xx.
586    fn op_error_variant_ident(status_code: &str) -> syn::Ident {
587        let raw = match status_code {
588            "default" | "Default" => "Default".to_string(),
589            other if other.chars().all(|c| c.is_ascii_digit()) => format!("Status{other}"),
590            other => format!("Status{}", other.to_ascii_lowercase()),
591        };
592        syn::Ident::new(&raw, proc_macro2::Span::call_site())
593    }
594
595    /// Token stream for the type plugged into `ApiOpError<T>` for an op:
596    /// either the per-op enum, or `serde_json::Value` for ops with no
597    /// declared error body schemas.
598    fn op_error_type_token(&self, op: &OperationInfo) -> TokenStream {
599        if op
600            .response_schemas
601            .iter()
602            .any(|(code, _)| !code.starts_with('2'))
603        {
604            let ident = self.op_error_enum_ident(op);
605            quote! { #ident }
606        } else {
607            quote! { serde_json::Value }
608        }
609    }
610
611    /// Generate a single operation method
612    fn generate_single_operation_method(&self, op: &OperationInfo) -> TokenStream {
613        let method_name = self.get_method_name(op);
614        let http_method_call = self.http_method_call(op);
615        let path = &op.path;
616        let request_param = self.generate_request_param(op);
617        let request_body = self.generate_request_body(op);
618        let query_params = self.generate_query_params(op);
619        let header_params = self.generate_header_params(op);
620        let auth_application = self.generate_auth_application();
621        let response_type = self.get_response_type(op);
622        let has_response_body = self.get_success_response_schema(op).is_some();
623        let op_error_type = self.op_error_type_token(op);
624        let error_handling = self.generate_error_handling(op, has_response_body);
625        let url_construction = self.generate_url_construction(path, op);
626        let doc_comment = self.generate_operation_doc_comment(op);
627
628        quote! {
629            #doc_comment
630            pub async fn #method_name(
631                &self,
632                #request_param
633            ) -> Result<#response_type, ApiOpError<#op_error_type>> {
634                #url_construction
635
636                let mut req = #http_method_call;
637                #request_body
638
639                #query_params
640                #header_params
641
642                // Apply configured authentication (T3). Was previously
643                // hardcoded to bearer_auth regardless of GeneratorConfig.
644                #auth_application
645
646                // Add custom headers
647                for (name, value) in &self.custom_headers {
648                    req = req.header(name, value);
649                }
650
651                let response = req.send().await?;
652                #error_handling
653            }
654        }
655    }
656
657    /// T3: emit the auth-token application based on the configured AuthConfig.
658    /// Default (no config) is Bearer on Authorization. ApiKey emits a custom
659    /// header. Custom honors header_value_prefix.
660    fn generate_auth_application(&self) -> TokenStream {
661        use crate::http_config::AuthConfig;
662        match &self.config().auth_config {
663            Some(AuthConfig::Bearer { header_name }) if header_name == "Authorization" => quote! {
664                if let Some(api_key) = &self.api_key {
665                    req = req.bearer_auth(api_key);
666                }
667            },
668            Some(AuthConfig::Bearer { header_name }) => {
669                let h = header_name.clone();
670                quote! {
671                    if let Some(api_key) = &self.api_key {
672                        req = req.header(#h, format!("Bearer {}", api_key));
673                    }
674                }
675            }
676            Some(AuthConfig::ApiKey { header_name }) => {
677                let h = header_name.clone();
678                quote! {
679                    if let Some(api_key) = &self.api_key {
680                        req = req.header(#h, api_key.as_str());
681                    }
682                }
683            }
684            Some(AuthConfig::Custom {
685                header_name,
686                header_value_prefix,
687            }) => {
688                let h = header_name.clone();
689                let prefix = header_value_prefix.clone().unwrap_or_default();
690                if prefix.is_empty() {
691                    quote! {
692                        if let Some(api_key) = &self.api_key {
693                            req = req.header(#h, api_key.as_str());
694                        }
695                    }
696                } else {
697                    let format_str = format!("{}{{}}", prefix);
698                    quote! {
699                        if let Some(api_key) = &self.api_key {
700                            req = req.header(#h, format!(#format_str, api_key));
701                        }
702                    }
703                }
704            }
705            None => quote! {
706                if let Some(api_key) = &self.api_key {
707                    req = req.bearer_auth(api_key);
708                }
709            },
710        }
711    }
712
713    /// Generate header-parameter handling. Emits `req = req.header(name, ...)`
714    /// for each `in: header` parameter — required headers unconditionally,
715    /// optional ones gated on `Some(_)`.
716    fn generate_header_params(&self, op: &OperationInfo) -> TokenStream {
717        let header_params: Vec<_> = op
718            .parameters
719            .iter()
720            .filter(|p| p.location == "header")
721            .collect();
722        if header_params.is_empty() {
723            return quote! {};
724        }
725        let mut emit = Vec::new();
726        for param in header_params {
727            let param_name_snake = self.param_ident_str(param);
728            let param_ident = Self::to_field_ident(&param_name_snake);
729            let header_name = &param.name;
730            if param.required {
731                if Self::param_uses_as_ref_str(param) {
732                    emit.push(quote! {
733                        req = req.header(#header_name, #param_ident.as_ref());
734                    });
735                } else {
736                    emit.push(quote! {
737                        req = req.header(#header_name, #param_ident.to_string());
738                    });
739                }
740            } else if Self::param_uses_as_ref_str(param) {
741                emit.push(quote! {
742                    if let Some(v) = #param_ident {
743                        req = req.header(#header_name, v.as_ref());
744                    }
745                });
746            } else {
747                emit.push(quote! {
748                    if let Some(v) = #param_ident {
749                        req = req.header(#header_name, v.to_string());
750                    }
751                });
752            }
753        }
754        quote! {
755            #(#emit)*
756        }
757    }
758
759    /// Generate query parameter handling
760    fn generate_query_params(&self, op: &OperationInfo) -> TokenStream {
761        let query_params: Vec<_> = op
762            .parameters
763            .iter()
764            .filter(|p| p.location == "query")
765            .collect();
766
767        if query_params.is_empty() {
768            return quote! {};
769        }
770
771        let mut param_building = Vec::new();
772        // Serialization applied on `req` directly, after the pair-vector
773        // block: form-exploded objects and deepObject objects, whose keys
774        // aren't the static parameter name.
775        let mut req_appends = Vec::new();
776
777        for param in query_params {
778            use crate::analysis::QuerySerialization;
779
780            // Use snake_case for Rust variable name with keyword escaping
781            let param_name_snake = self.param_ident_str(param);
782            let param_name = Self::to_field_ident(&param_name_snake);
783
784            // Use the original parameter name from OpenAPI spec as the query string key
785            let param_key = &param.name;
786
787            match &param.query_serialization {
788                Some(QuerySerialization::FormExplodedObject) => {
789                    // Issue #27: reqwest serializes the struct through
790                    // serde_urlencoded, so each property becomes its own
791                    // `key=value` pair; the parameter's own name never
792                    // appears in the query string (RFC 6570 form-explosion).
793                    if param.required {
794                        req_appends.push(quote! {
795                            req = req.query(&#param_name);
796                        });
797                    } else {
798                        req_appends.push(quote! {
799                            if let Some(v) = #param_name {
800                                req = req.query(&v);
801                            }
802                        });
803                    }
804                    continue;
805                }
806                Some(QuerySerialization::DeepObject) => {
807                    // `?filter[color]=red&filter[size]=5`. Property values
808                    // stringify through their JSON form; Null (unset
809                    // Option) properties are skipped.
810                    let apply = quote! {
811                        if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(&v) {
812                            let mut deep_params: Vec<(String, String)> = Vec::new();
813                            for (k, val) in map {
814                                let s = match val {
815                                    serde_json::Value::Null => continue,
816                                    serde_json::Value::String(s) => s,
817                                    other => other.to_string(),
818                                };
819                                deep_params.push((format!("{}[{}]", #param_key, k), s));
820                            }
821                            if !deep_params.is_empty() {
822                                req = req.query(&deep_params);
823                            }
824                        }
825                    };
826                    if param.required {
827                        req_appends.push(quote! {
828                            {
829                                let v = #param_name;
830                                #apply
831                            }
832                        });
833                    } else {
834                        req_appends.push(quote! {
835                            if let Some(v) = #param_name {
836                                #apply
837                            }
838                        });
839                    }
840                    continue;
841                }
842                Some(QuerySerialization::FormObject) => {
843                    // `?filter=color,red,size,big` — one pair whose value is
844                    // the comma-joined key,value list (RFC 6570 form,
845                    // explode=false).
846                    let apply = quote! {
847                        if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(&v) {
848                            let mut parts: Vec<String> = Vec::new();
849                            for (k, val) in map {
850                                let s = match val {
851                                    serde_json::Value::Null => continue,
852                                    serde_json::Value::String(s) => s,
853                                    other => other.to_string(),
854                                };
855                                parts.push(k);
856                                parts.push(s);
857                            }
858                            if !parts.is_empty() {
859                                query_params.push((#param_key, parts.join(",")));
860                            }
861                        }
862                    };
863                    if param.required {
864                        param_building.push(quote! {
865                            {
866                                let v = #param_name;
867                                #apply
868                            }
869                        });
870                    } else {
871                        param_building.push(quote! {
872                            if let Some(v) = #param_name {
873                                #apply
874                            }
875                        });
876                    }
877                    continue;
878                }
879                Some(QuerySerialization::FormExplodedArray { .. }) => {
880                    // `?tags=a&tags=b` — one pair per element.
881                    if param.required {
882                        param_building.push(quote! {
883                            for item in #param_name {
884                                query_params.push((#param_key, item.to_string()));
885                            }
886                        });
887                    } else {
888                        param_building.push(quote! {
889                            if let Some(v) = #param_name {
890                                for item in v {
891                                    query_params.push((#param_key, item.to_string()));
892                                }
893                            }
894                        });
895                    }
896                    continue;
897                }
898                Some(QuerySerialization::FormArray { .. }) => {
899                    // `?tags=a,b,c` — one comma-joined pair. Empty vectors
900                    // are omitted entirely.
901                    let apply = quote! {
902                        if !v.is_empty() {
903                            query_params.push((
904                                #param_key,
905                                v.iter()
906                                    .map(|item| item.to_string())
907                                    .collect::<Vec<String>>()
908                                    .join(","),
909                            ));
910                        }
911                    };
912                    if param.required {
913                        param_building.push(quote! {
914                            {
915                                let v = #param_name;
916                                #apply
917                            }
918                        });
919                    } else {
920                        param_building.push(quote! {
921                            if let Some(v) = #param_name {
922                                #apply
923                            }
924                        });
925                    }
926                    continue;
927                }
928                None => {}
929            }
930
931            if param.required {
932                // Required parameters: always add
933                if Self::param_uses_as_ref_str(param) {
934                    param_building.push(quote! {
935                        query_params.push((#param_key, #param_name.as_ref().to_string()));
936                    });
937                } else {
938                    param_building.push(quote! {
939                        query_params.push((#param_key, #param_name.to_string()));
940                    });
941                }
942            } else {
943                // Optional parameters: add only if Some
944                if Self::param_uses_as_ref_str(param) {
945                    param_building.push(quote! {
946                        if let Some(v) = #param_name {
947                            query_params.push((#param_key, v.as_ref().to_string()));
948                        }
949                    });
950                } else {
951                    param_building.push(quote! {
952                        if let Some(v) = #param_name {
953                            query_params.push((#param_key, v.to_string()));
954                        }
955                    });
956                }
957            }
958        }
959
960        // Ops whose query params all serialize on `req` directly skip the
961        // pair-vector block entirely.
962        let pairs_block = if param_building.is_empty() {
963            quote! {}
964        } else {
965            quote! {
966                {
967                    let mut query_params: Vec<(&str, String)> = Vec::new();
968                    #(#param_building)*
969                    if !query_params.is_empty() {
970                        req = req.query(&query_params);
971                    }
972                }
973            }
974        };
975
976        quote! {
977            // Add query parameters
978            #pairs_block
979            #(#req_appends)*
980        }
981    }
982
983    /// Generate the rustdoc block for an operation, surfacing summary,
984    /// description, the HTTP method+path, and any tags from the OAS spec
985    /// (T13). Also marks the method `#[deprecated]` if the operation is.
986    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
987        let method = op.method.to_uppercase();
988        let path = &op.path;
989        let mut docs: Vec<String> = Vec::new();
990        if let Some(s) = &op.summary {
991            if !s.is_empty() {
992                docs.push(s.clone());
993                docs.push(String::new());
994            }
995        }
996        if let Some(d) = &op.description {
997            if !d.is_empty() {
998                for line in d.lines() {
999                    docs.push(line.to_string());
1000                }
1001                docs.push(String::new());
1002            }
1003        }
1004        docs.push(format!("`{} {}`", method, path));
1005        let doc_attrs: Vec<TokenStream> = docs
1006            .iter()
1007            .map(|line| {
1008                let prefixed = if line.is_empty() {
1009                    String::new()
1010                } else {
1011                    format!(" {line}")
1012                };
1013                quote! { #[doc = #prefixed] }
1014            })
1015            .collect();
1016        quote! { #(#doc_attrs)* }
1017    }
1018
1019    /// Get the method name from the operation
1020    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
1021        let name = if !op.operation_id.is_empty() {
1022            op.operation_id.to_snake_case()
1023        } else {
1024            // Fallback: generate from HTTP method and path
1025            format!(
1026                "{}_{}",
1027                op.method,
1028                op.path.replace('/', "_").replace(['{', '}'], "")
1029            )
1030            .to_snake_case()
1031        };
1032
1033        syn::Ident::new(&name, proc_macro2::Span::call_site())
1034    }
1035
1036    /// Build the request-builder expression for the operation's HTTP method.
1037    /// Named reqwest methods (`.get`/`.post`/…) are used where available;
1038    /// OPTIONS and TRACE go through `Client::request(Method::OPTIONS, _)` since
1039    /// reqwest doesn't expose those as named methods.
1040    fn http_method_call(&self, op: &OperationInfo) -> TokenStream {
1041        match op.method.to_uppercase().as_str() {
1042            "GET" => quote! { self.http_client.get(request_url) },
1043            "POST" => quote! { self.http_client.post(request_url) },
1044            "PUT" => quote! { self.http_client.put(request_url) },
1045            "DELETE" => quote! { self.http_client.delete(request_url) },
1046            "PATCH" => quote! { self.http_client.patch(request_url) },
1047            "HEAD" => quote! { self.http_client.head(request_url) },
1048            "OPTIONS" => quote! {
1049                self.http_client.request(reqwest::Method::OPTIONS, request_url)
1050            },
1051            "TRACE" => quote! {
1052                self.http_client.request(reqwest::Method::TRACE, request_url)
1053            },
1054            // D1: 3.2 `QUERY` verb + any custom verb from
1055            // PathItem.additionalOperations. reqwest's Method::from_bytes
1056            // accepts arbitrary uppercase tokens that match the RFC7230
1057            // method grammar.
1058            other => {
1059                let upper = other.to_string();
1060                quote! {
1061                    self.http_client.request(
1062                        reqwest::Method::from_bytes(#upper.as_bytes())
1063                            .expect("invalid HTTP method"),
1064                        request_url,
1065                    )
1066                }
1067            }
1068        }
1069    }
1070
1071    /// Generate request parameters including path, query, header, and request body.
1072    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
1073        let mut params = Vec::new();
1074        // Dedup parameter Rust idents within this method signature. Real-world
1075        // specs sometimes declare two parameters that sanitize to the same
1076        // snake_case name (modern-treasury declared `name` twice across
1077        // different param objects). Suffixing with `_2`, `_3`, … keeps each
1078        // parameter accessible while preserving the original wire-level name
1079        // (which is used elsewhere as the query/path/header key).
1080        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1081        let mut unique_param_ident = |raw: String| -> syn::Ident {
1082            let mut chosen = raw.clone();
1083            let mut suffix = 2;
1084            while !used.insert(chosen.clone()) {
1085                chosen = format!("{raw}_{suffix}");
1086                suffix += 1;
1087            }
1088            Self::to_field_ident(&chosen)
1089        };
1090
1091        // Add path parameters
1092        for param in &op.parameters {
1093            if param.location == "path" {
1094                let param_name_snake = self.param_ident_str(param);
1095                let param_name = unique_param_ident(param_name_snake);
1096                let param_type = self.get_param_rust_type(param);
1097                params.push(quote! { #param_name: #param_type });
1098            }
1099        }
1100
1101        // Add query parameters (all as Option<T>)
1102        for param in &op.parameters {
1103            if param.location == "query" {
1104                let param_name_snake = self.param_ident_str(param);
1105                let param_name = unique_param_ident(param_name_snake);
1106                let param_type = self.get_param_rust_type(param);
1107
1108                // Query parameters should be Option unless explicitly required
1109                if param.required {
1110                    params.push(quote! { #param_name: #param_type });
1111                } else {
1112                    params.push(quote! { #param_name: Option<#param_type> });
1113                }
1114            }
1115        }
1116
1117        // Add header parameters. Required headers are bare; optional ones are
1118        // Option<T>. Per OAS 3.x §"Parameter Object", header names matching
1119        // `Accept`, `Content-Type`, and `Authorization` are forbidden — those
1120        // are described by other mechanisms — but we leave that validation to
1121        // analysis.
1122        for param in &op.parameters {
1123            if param.location == "header" {
1124                let param_name_snake = self.param_ident_str(param);
1125                let param_name = unique_param_ident(param_name_snake);
1126                let param_type = self.get_param_rust_type(param);
1127                if param.required {
1128                    params.push(quote! { #param_name: #param_type });
1129                } else {
1130                    params.push(quote! { #param_name: Option<#param_type> });
1131                }
1132            }
1133        }
1134
1135        // Add request body parameter based on content type. Optional bodies
1136        // (`requestBody.required` is false or absent) become `Option<T>` per T11.
1137        if let Some(ref rb) = op.request_body {
1138            use crate::analysis::RequestBodyContent;
1139            let required = op.request_body_required;
1140            let body_type = match rb {
1141                RequestBodyContent::Json { schema_name }
1142                | RequestBodyContent::FormUrlEncoded { schema_name } => {
1143                    let rust_type_name = self.to_rust_type_name(schema_name);
1144                    let request_ident =
1145                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
1146                    quote! { #request_ident }
1147                }
1148                RequestBodyContent::Multipart => quote! { reqwest::multipart::Form },
1149                RequestBodyContent::OctetStream => quote! { Vec<u8> },
1150                RequestBodyContent::TextPlain => quote! { String },
1151            };
1152            let body_ident = match rb {
1153                RequestBodyContent::Multipart => quote! { form },
1154                RequestBodyContent::OctetStream | RequestBodyContent::TextPlain => quote! { body },
1155                _ => quote! { request },
1156            };
1157            if required {
1158                params.push(quote! { #body_ident: #body_type });
1159            } else {
1160                params.push(quote! { #body_ident: Option<#body_type> });
1161            }
1162        }
1163
1164        if params.is_empty() {
1165            quote! {}
1166        } else {
1167            quote! { #(#params),* }
1168        }
1169    }
1170
1171    /// Get the Rust type for a parameter
1172    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
1173        use crate::analysis::QuerySerialization;
1174        // Typed form-style arrays take Vec<item> (openapi-generator-anu).
1175        // Scalars parse as-is (they may be type paths from [type_mappings]);
1176        // enum refs are raw schema names and go through the same
1177        // to_rust_type_name sanitization as every other schema reference
1178        // (cloudflare has enum schemas like `resource-sharing_resource_type`).
1179        if let Some(
1180            QuerySerialization::FormExplodedArray { item_type }
1181            | QuerySerialization::FormArray { item_type },
1182        ) = &param.query_serialization
1183        {
1184            use crate::analysis::ArrayItemType;
1185            let item_ty: syn::Type = match item_type {
1186                ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type)
1187                    .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")),
1188                ArrayItemType::EnumRef(schema_name) => {
1189                    let rust_name = self.to_rust_type_name(schema_name);
1190                    syn::parse_str(&rust_name)
1191                        .unwrap_or_else(|_| panic!("invalid enum item type `{rust_name}`"))
1192                }
1193            };
1194            return quote! { Vec<#item_ty> };
1195        }
1196        // T10: $ref-typed parameters used to lose their type because we only
1197        // consulted `rust_type` (which stays "String"). Now: prefer the
1198        // resolved schema reference if present.
1199        if let Some(ref schema_name) = param.schema_ref {
1200            let rust_name = self.to_rust_type_name(schema_name);
1201            let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site());
1202            return quote! { #ident };
1203        }
1204        let type_str = &param.rust_type;
1205        match type_str.as_str() {
1206            "String" => quote! { impl AsRef<str> },
1207            "i64" => quote! { i64 },
1208            "i32" => quote! { i32 },
1209            "f64" => quote! { f64 },
1210            "bool" => quote! { bool },
1211            _ => {
1212                let type_ident = syn::Ident::new(type_str, proc_macro2::Span::call_site());
1213                quote! { #type_ident }
1214            }
1215        }
1216    }
1217
1218    /// True when the parameter's compile-time type is `impl AsRef<str>` and
1219    /// we should call `.as_ref()` on it before stringifying. False for any
1220    /// $ref-resolved type (T10) or non-String primitive — those just call
1221    /// `.to_string()`.
1222    fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
1223        param.schema_ref.is_none() && param.rust_type == "String"
1224    }
1225
1226    /// Generate request body serialization based on content type
1227    /// Emit statements that mutate `req` to apply the request body. Returns
1228    /// `quote!{}` if the operation has no body. Optional bodies (T11) gate the
1229    /// application on `Some(_)`; required bodies apply unconditionally.
1230    fn generate_request_body(&self, op: &OperationInfo) -> TokenStream {
1231        let Some(rb) = op.request_body.as_ref() else {
1232            return quote! {};
1233        };
1234        use crate::analysis::RequestBodyContent;
1235        let required = op.request_body_required;
1236        let (ident, apply): (TokenStream, TokenStream) = match rb {
1237            RequestBodyContent::Json { .. } => (
1238                quote! { request },
1239                quote! {
1240                    req = req
1241                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
1242                        .header("content-type", "application/json");
1243                },
1244            ),
1245            RequestBodyContent::FormUrlEncoded { .. } => (
1246                quote! { request },
1247                quote! {
1248                    req = req
1249                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
1250                        .header("content-type", "application/x-www-form-urlencoded");
1251                },
1252            ),
1253            RequestBodyContent::Multipart => (
1254                quote! { form },
1255                quote! {
1256                    req = req.multipart(form);
1257                },
1258            ),
1259            RequestBodyContent::OctetStream => (
1260                quote! { body },
1261                quote! {
1262                    req = req
1263                        .body(body)
1264                        .header("content-type", "application/octet-stream");
1265                },
1266            ),
1267            RequestBodyContent::TextPlain => (
1268                quote! { body },
1269                quote! {
1270                    req = req
1271                        .body(body)
1272                        .header("content-type", "text/plain");
1273                },
1274            ),
1275        };
1276        if required {
1277            apply
1278        } else {
1279            quote! {
1280                if let Some(#ident) = #ident {
1281                    #apply
1282                }
1283            }
1284        }
1285    }
1286
1287    /// Find the success (2xx) response schema name, if any.
1288    ///
1289    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
1290    /// so that endpoints like 204 No Content correctly return `()` instead of
1291    /// accidentally picking up the error schema (e.g. `BadRequestError`).
1292    fn get_success_response_schema<'a>(&self, op: &'a OperationInfo) -> Option<&'a String> {
1293        op.response_schemas
1294            .get("200")
1295            .or_else(|| op.response_schemas.get("201"))
1296            .or_else(|| {
1297                op.response_schemas
1298                    .iter()
1299                    .find(|(code, _)| code.starts_with('2'))
1300                    .map(|(_, v)| v)
1301            })
1302    }
1303
1304    /// Get response type
1305    fn get_response_type(&self, op: &OperationInfo) -> TokenStream {
1306        if let Some(response_type) = self.get_success_response_schema(op) {
1307            // Convert schema name to Rust type name (handles underscores, etc.)
1308            let rust_type_name = self.to_rust_type_name(response_type);
1309            let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
1310            quote! { #response_ident }
1311        } else {
1312            quote! { () }
1313        }
1314    }
1315
1316    /// Generate error handling.
1317    ///
1318    /// Always reads the response body to a string before attempting any typed
1319    /// deserialization, so the raw body and headers are preserved on the error
1320    /// path even when JSON parsing fails. On 2xx the body is parsed into the
1321    /// success type; on non-2xx the body is parsed into the matching variant
1322    /// of the per-operation error enum (when one is declared) and wrapped in
1323    /// `ApiError<E>`.
1324    fn generate_error_handling(&self, op: &OperationInfo, has_response_body: bool) -> TokenStream {
1325        let op_error_type = self.op_error_type_token(op);
1326
1327        let success_branch = if has_response_body {
1328            quote! {
1329                match serde_json::from_str(&body_text) {
1330                    Ok(body) => Ok(body),
1331                    Err(e) => Err(ApiOpError::Api(ApiError {
1332                        status: status_code,
1333                        headers: headers,
1334                        body: body_text,
1335                        typed: None,
1336                        parse_error: Some(format!(
1337                            "failed to deserialize 2xx response body: {}",
1338                            e
1339                        )),
1340                    })),
1341                }
1342            }
1343        } else {
1344            quote! {
1345                let _ = body_text;
1346                let _ = headers;
1347                Ok(())
1348            }
1349        };
1350
1351        let error_match_arms = self.generate_error_match_arms(op);
1352
1353        quote! {
1354            let status = response.status();
1355            let status_code = status.as_u16();
1356            let headers = response.headers().clone();
1357            let body_text = response.text().await
1358                .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?;
1359
1360            if status.is_success() {
1361                #success_branch
1362            } else {
1363                let typed: Option<#op_error_type>;
1364                let parse_error: Option<String>;
1365                #error_match_arms
1366                Err(ApiOpError::Api(ApiError {
1367                    status: status_code,
1368                    headers,
1369                    body: body_text,
1370                    typed,
1371                    parse_error,
1372                }))
1373            }
1374        }
1375    }
1376
1377    /// Generate the match arms that select which per-op error variant to
1378    /// deserialize the response body into based on the runtime status code.
1379    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
1380        let arms: Vec<TokenStream> = op
1381            .response_schemas
1382            .iter()
1383            .filter(|(code, _)| !code.starts_with('2'))
1384            .filter_map(|(code, schema)| {
1385                let variant_ident = Self::op_error_variant_ident(code);
1386                let payload_ty_name = self.to_rust_type_name(schema);
1387                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
1388                let enum_ident = self.op_error_enum_ident(op);
1389
1390                // T8: range-keyed responses (1XX/2XX/3XX/4XX/5XX) per OAS
1391                // 3.x §"Responses Object". Specific codes still take priority
1392                // (handled by ordering — concrete codes deserialize first
1393                // because the generic dispatch is a generic `_ if (range)`).
1394                let pattern = match code.as_str() {
1395                    "default" | "Default" => return None, // handled in fallback
1396                    other if other.chars().all(|c| c.is_ascii_digit()) => {
1397                        let n: u16 = other.parse().ok()?;
1398                        quote! { #n }
1399                    }
1400                    "1XX" | "1xx" => quote! { code if (100..=199).contains(&code) },
1401                    "2XX" | "2xx" => quote! { code if (200..=299).contains(&code) },
1402                    "3XX" | "3xx" => quote! { code if (300..=399).contains(&code) },
1403                    "4XX" | "4xx" => quote! { code if (400..=499).contains(&code) },
1404                    "5XX" | "5xx" => quote! { code if (500..=599).contains(&code) },
1405                    _ => return None,
1406                };
1407
1408                Some(quote! {
1409                    #pattern => {
1410                        match serde_json::from_str::<#payload_ty>(&body_text) {
1411                            Ok(v) => {
1412                                typed = Some(#enum_ident::#variant_ident(v));
1413                                parse_error = None;
1414                            }
1415                            Err(e) => {
1416                                typed = None;
1417                                parse_error = Some(e.to_string());
1418                            }
1419                        }
1420                    }
1421                })
1422            })
1423            .collect();
1424
1425        // Fallback for "default" or undeclared status codes: try to parse
1426        // as `serde_json::Value` for inspectability when the op's error
1427        // type is generic, otherwise leave typed = None.
1428        // Must mirror op_error_type_token: if op_error_type is the typed
1429        // enum (any non-2xx response, including `default`), the fallback arm
1430        // can't deserialize into `serde_json::Value` because `typed` is the
1431        // enum. Default to `typed = None` in that case.
1432        let has_typed_enum = op
1433            .response_schemas
1434            .iter()
1435            .any(|(code, _)| !code.starts_with('2'));
1436
1437        let default_arm = if has_typed_enum {
1438            quote! {
1439                _ => {
1440                    typed = None;
1441                    parse_error = None;
1442                }
1443            }
1444        } else {
1445            // No typed enum — op_error_type is serde_json::Value.
1446            quote! {
1447                _ => {
1448                    match serde_json::from_str::<serde_json::Value>(&body_text) {
1449                        Ok(v) => {
1450                            typed = Some(v);
1451                            parse_error = None;
1452                        }
1453                        Err(e) => {
1454                            typed = None;
1455                            parse_error = Some(e.to_string());
1456                        }
1457                    }
1458                }
1459            }
1460        };
1461
1462        if arms.is_empty() {
1463            // No declared status arms — just the fallback.
1464            quote! {
1465                match status_code {
1466                    #default_arm
1467                }
1468            }
1469        } else {
1470            quote! {
1471                match status_code {
1472                    #(#arms)*
1473                    #default_arm
1474                }
1475            }
1476        }
1477    }
1478
1479    /// Generate URL construction with path parameter substitution
1480    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
1481        // Check if path has parameters (contains {...})
1482        if path.contains('{') {
1483            self.generate_url_with_params(path, op)
1484        } else {
1485            quote! {
1486                let request_url = format!("{}{}", self.base_url, #path);
1487            }
1488        }
1489    }
1490
1491    /// Generate URL with path parameters
1492    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
1493        // Find all path parameters in the operation.
1494        let path_params: Vec<_> = op
1495            .parameters
1496            .iter()
1497            .filter(|p| p.location == "path")
1498            .collect();
1499
1500        // T5: percent-encode each path-template variable per RFC3986 §3.3.
1501        // We build a positional-arg format string by walking the template
1502        // left-to-right and emitting one `{}` + one format arg per
1503        // placeholder occurrence. Cloudflare has paths like
1504        // `/accounts/{account_id}/.../accounts/{account_id}` — the same
1505        // variable appears twice. A naive `replace_all` produced two `{}`
1506        // placeholders but only one format arg (E0277). Per-occurrence
1507        // emission keeps them in sync.
1508        let mut format_string = String::with_capacity(path.len());
1509        let mut format_args: Vec<TokenStream> = Vec::new();
1510        let mut chars = path.chars().peekable();
1511        while let Some(c) = chars.next() {
1512            if c != '{' {
1513                format_string.push(c);
1514                continue;
1515            }
1516            // Read until the matching '}'.
1517            let mut name = String::new();
1518            while let Some(&n) = chars.peek() {
1519                chars.next();
1520                if n == '}' {
1521                    break;
1522                }
1523                name.push(n);
1524            }
1525            // Resolve to a path param. If no match, leave the placeholder
1526            // verbatim (real-world spec bug — this op shouldn't have made
1527            // it past analysis).
1528            let param = path_params.iter().find(|p| p.name == name);
1529            let Some(param) = param else {
1530                format_string.push('{');
1531                format_string.push_str(&name);
1532                format_string.push('}');
1533                continue;
1534            };
1535            format_string.push_str("{}");
1536            let param_name_snake = self.param_ident_str(param);
1537            let param_ident = Self::to_field_ident(&param_name_snake);
1538            if Self::param_uses_as_ref_str(param) {
1539                format_args.push(quote! {
1540                    __pct_encode_path_segment(#param_ident.as_ref())
1541                });
1542            } else {
1543                format_args.push(quote! {
1544                    __pct_encode_path_segment(&#param_ident.to_string())
1545                });
1546            }
1547        }
1548
1549        if format_args.is_empty() {
1550            quote! {
1551                let request_url = format!("{}{}", self.base_url, #path);
1552            }
1553        } else {
1554            quote! {
1555                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
1556            }
1557        }
1558    }
1559
1560    /// Resolve the Rust ident for a parameter. Prefers the disambiguated
1561    /// `rust_ident` set by the analyzer (which dedupes across the whole
1562    /// operation), falling back to a fresh sanitize of the wire name when
1563    /// no analyzer-side ident is present.
1564    fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String {
1565        if let Some(ident) = &param.rust_ident {
1566            // Apply the keyword-escape and self/super/crate dance the
1567            // sanitize fn does. The analyzer's base ident is already the
1568            // snake/kebab-aware shape; we only need post-processing.
1569            return self.escape_keyword_ident(ident);
1570        }
1571        self.sanitize_param_name(&param.name)
1572    }
1573
1574    fn escape_keyword_ident(&self, snake_case: &str) -> String {
1575        if matches!(snake_case, "self" | "super" | "crate" | "Self") {
1576            return format!("{snake_case}_param");
1577        }
1578        if Self::is_rust_keyword(snake_case) {
1579            format!("r#{snake_case}")
1580        } else {
1581            snake_case.to_string()
1582        }
1583    }
1584
1585    /// Sanitize a parameter name by escaping Rust reserved keywords with raw
1586    /// identifiers and disambiguating Twilio-style suffix operators
1587    /// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
1588    /// case to `start_time`).
1589    fn sanitize_param_name(&self, name: &str) -> String {
1590        // Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
1591        // filter-style query params; map them to `_lt` / `_gt` etc. so the
1592        // Rust ident is unique while the wire-level param name stays the
1593        // original string elsewhere in the codegen.
1594        let suffix = if name.ends_with("<=") {
1595            "_lte"
1596        } else if name.ends_with(">=") {
1597            "_gte"
1598        } else if name.ends_with('<') {
1599            "_lt"
1600        } else if name.ends_with('>') {
1601            "_gt"
1602        } else {
1603            ""
1604        };
1605        let stripped = name.trim_end_matches(['<', '>', '=']);
1606        let mut snake_case = stripped.to_snake_case();
1607        snake_case.push_str(suffix);
1608
1609        if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
1610            return format!("{snake_case}_param");
1611        }
1612        if Self::is_rust_keyword(&snake_case) {
1613            format!("r#{snake_case}")
1614        } else {
1615            snake_case
1616        }
1617    }
1618}