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
773        for param in query_params {
774            // Use snake_case for Rust variable name with keyword escaping
775            let param_name_snake = self.param_ident_str(param);
776            let param_name = Self::to_field_ident(&param_name_snake);
777
778            // Use the original parameter name from OpenAPI spec as the query string key
779            let param_key = &param.name;
780
781            if param.required {
782                // Required parameters: always add
783                if Self::param_uses_as_ref_str(param) {
784                    param_building.push(quote! {
785                        query_params.push((#param_key, #param_name.as_ref().to_string()));
786                    });
787                } else {
788                    param_building.push(quote! {
789                        query_params.push((#param_key, #param_name.to_string()));
790                    });
791                }
792            } else {
793                // Optional parameters: add only if Some
794                if Self::param_uses_as_ref_str(param) {
795                    param_building.push(quote! {
796                        if let Some(v) = #param_name {
797                            query_params.push((#param_key, v.as_ref().to_string()));
798                        }
799                    });
800                } else {
801                    param_building.push(quote! {
802                        if let Some(v) = #param_name {
803                            query_params.push((#param_key, v.to_string()));
804                        }
805                    });
806                }
807            }
808        }
809
810        quote! {
811            // Add query parameters
812            {
813                let mut query_params: Vec<(&str, String)> = Vec::new();
814                #(#param_building)*
815                if !query_params.is_empty() {
816                    req = req.query(&query_params);
817                }
818            }
819        }
820    }
821
822    /// Generate the rustdoc block for an operation, surfacing summary,
823    /// description, the HTTP method+path, and any tags from the OAS spec
824    /// (T13). Also marks the method `#[deprecated]` if the operation is.
825    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
826        let method = op.method.to_uppercase();
827        let path = &op.path;
828        let mut docs: Vec<String> = Vec::new();
829        if let Some(s) = &op.summary {
830            if !s.is_empty() {
831                docs.push(s.clone());
832                docs.push(String::new());
833            }
834        }
835        if let Some(d) = &op.description {
836            if !d.is_empty() {
837                for line in d.lines() {
838                    docs.push(line.to_string());
839                }
840                docs.push(String::new());
841            }
842        }
843        docs.push(format!("`{} {}`", method, path));
844        let doc_attrs: Vec<TokenStream> = docs
845            .iter()
846            .map(|line| {
847                let prefixed = if line.is_empty() {
848                    String::new()
849                } else {
850                    format!(" {line}")
851                };
852                quote! { #[doc = #prefixed] }
853            })
854            .collect();
855        quote! { #(#doc_attrs)* }
856    }
857
858    /// Get the method name from the operation
859    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
860        let name = if !op.operation_id.is_empty() {
861            op.operation_id.to_snake_case()
862        } else {
863            // Fallback: generate from HTTP method and path
864            format!(
865                "{}_{}",
866                op.method,
867                op.path.replace('/', "_").replace(['{', '}'], "")
868            )
869            .to_snake_case()
870        };
871
872        syn::Ident::new(&name, proc_macro2::Span::call_site())
873    }
874
875    /// Build the request-builder expression for the operation's HTTP method.
876    /// Named reqwest methods (`.get`/`.post`/…) are used where available;
877    /// OPTIONS and TRACE go through `Client::request(Method::OPTIONS, _)` since
878    /// reqwest doesn't expose those as named methods.
879    fn http_method_call(&self, op: &OperationInfo) -> TokenStream {
880        match op.method.to_uppercase().as_str() {
881            "GET" => quote! { self.http_client.get(request_url) },
882            "POST" => quote! { self.http_client.post(request_url) },
883            "PUT" => quote! { self.http_client.put(request_url) },
884            "DELETE" => quote! { self.http_client.delete(request_url) },
885            "PATCH" => quote! { self.http_client.patch(request_url) },
886            "HEAD" => quote! { self.http_client.head(request_url) },
887            "OPTIONS" => quote! {
888                self.http_client.request(reqwest::Method::OPTIONS, request_url)
889            },
890            "TRACE" => quote! {
891                self.http_client.request(reqwest::Method::TRACE, request_url)
892            },
893            // D1: 3.2 `QUERY` verb + any custom verb from
894            // PathItem.additionalOperations. reqwest's Method::from_bytes
895            // accepts arbitrary uppercase tokens that match the RFC7230
896            // method grammar.
897            other => {
898                let upper = other.to_string();
899                quote! {
900                    self.http_client.request(
901                        reqwest::Method::from_bytes(#upper.as_bytes())
902                            .expect("invalid HTTP method"),
903                        request_url,
904                    )
905                }
906            }
907        }
908    }
909
910    /// Generate request parameters including path, query, header, and request body.
911    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
912        let mut params = Vec::new();
913        // Dedup parameter Rust idents within this method signature. Real-world
914        // specs sometimes declare two parameters that sanitize to the same
915        // snake_case name (modern-treasury declared `name` twice across
916        // different param objects). Suffixing with `_2`, `_3`, … keeps each
917        // parameter accessible while preserving the original wire-level name
918        // (which is used elsewhere as the query/path/header key).
919        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
920        let mut unique_param_ident = |raw: String| -> syn::Ident {
921            let mut chosen = raw.clone();
922            let mut suffix = 2;
923            while !used.insert(chosen.clone()) {
924                chosen = format!("{raw}_{suffix}");
925                suffix += 1;
926            }
927            Self::to_field_ident(&chosen)
928        };
929
930        // Add path parameters
931        for param in &op.parameters {
932            if param.location == "path" {
933                let param_name_snake = self.param_ident_str(param);
934                let param_name = unique_param_ident(param_name_snake);
935                let param_type = self.get_param_rust_type(param);
936                params.push(quote! { #param_name: #param_type });
937            }
938        }
939
940        // Add query parameters (all as Option<T>)
941        for param in &op.parameters {
942            if param.location == "query" {
943                let param_name_snake = self.param_ident_str(param);
944                let param_name = unique_param_ident(param_name_snake);
945                let param_type = self.get_param_rust_type(param);
946
947                // Query parameters should be Option unless explicitly required
948                if param.required {
949                    params.push(quote! { #param_name: #param_type });
950                } else {
951                    params.push(quote! { #param_name: Option<#param_type> });
952                }
953            }
954        }
955
956        // Add header parameters. Required headers are bare; optional ones are
957        // Option<T>. Per OAS 3.x §"Parameter Object", header names matching
958        // `Accept`, `Content-Type`, and `Authorization` are forbidden — those
959        // are described by other mechanisms — but we leave that validation to
960        // analysis.
961        for param in &op.parameters {
962            if param.location == "header" {
963                let param_name_snake = self.param_ident_str(param);
964                let param_name = unique_param_ident(param_name_snake);
965                let param_type = self.get_param_rust_type(param);
966                if param.required {
967                    params.push(quote! { #param_name: #param_type });
968                } else {
969                    params.push(quote! { #param_name: Option<#param_type> });
970                }
971            }
972        }
973
974        // Add request body parameter based on content type. Optional bodies
975        // (`requestBody.required` is false or absent) become `Option<T>` per T11.
976        if let Some(ref rb) = op.request_body {
977            use crate::analysis::RequestBodyContent;
978            let required = op.request_body_required;
979            let body_type = match rb {
980                RequestBodyContent::Json { schema_name }
981                | RequestBodyContent::FormUrlEncoded { schema_name } => {
982                    let rust_type_name = self.to_rust_type_name(schema_name);
983                    let request_ident =
984                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
985                    quote! { #request_ident }
986                }
987                RequestBodyContent::Multipart => quote! { reqwest::multipart::Form },
988                RequestBodyContent::OctetStream => quote! { Vec<u8> },
989                RequestBodyContent::TextPlain => quote! { String },
990            };
991            let body_ident = match rb {
992                RequestBodyContent::Multipart => quote! { form },
993                RequestBodyContent::OctetStream | RequestBodyContent::TextPlain => quote! { body },
994                _ => quote! { request },
995            };
996            if required {
997                params.push(quote! { #body_ident: #body_type });
998            } else {
999                params.push(quote! { #body_ident: Option<#body_type> });
1000            }
1001        }
1002
1003        if params.is_empty() {
1004            quote! {}
1005        } else {
1006            quote! { #(#params),* }
1007        }
1008    }
1009
1010    /// Get the Rust type for a parameter
1011    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
1012        // T10: $ref-typed parameters used to lose their type because we only
1013        // consulted `rust_type` (which stays "String"). Now: prefer the
1014        // resolved schema reference if present.
1015        if let Some(ref schema_name) = param.schema_ref {
1016            let rust_name = self.to_rust_type_name(schema_name);
1017            let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site());
1018            return quote! { #ident };
1019        }
1020        let type_str = &param.rust_type;
1021        match type_str.as_str() {
1022            "String" => quote! { impl AsRef<str> },
1023            "i64" => quote! { i64 },
1024            "i32" => quote! { i32 },
1025            "f64" => quote! { f64 },
1026            "bool" => quote! { bool },
1027            _ => {
1028                let type_ident = syn::Ident::new(type_str, proc_macro2::Span::call_site());
1029                quote! { #type_ident }
1030            }
1031        }
1032    }
1033
1034    /// True when the parameter's compile-time type is `impl AsRef<str>` and
1035    /// we should call `.as_ref()` on it before stringifying. False for any
1036    /// $ref-resolved type (T10) or non-String primitive — those just call
1037    /// `.to_string()`.
1038    fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
1039        param.schema_ref.is_none() && param.rust_type == "String"
1040    }
1041
1042    /// Generate request body serialization based on content type
1043    /// Emit statements that mutate `req` to apply the request body. Returns
1044    /// `quote!{}` if the operation has no body. Optional bodies (T11) gate the
1045    /// application on `Some(_)`; required bodies apply unconditionally.
1046    fn generate_request_body(&self, op: &OperationInfo) -> TokenStream {
1047        let Some(rb) = op.request_body.as_ref() else {
1048            return quote! {};
1049        };
1050        use crate::analysis::RequestBodyContent;
1051        let required = op.request_body_required;
1052        let (ident, apply): (TokenStream, TokenStream) = match rb {
1053            RequestBodyContent::Json { .. } => (
1054                quote! { request },
1055                quote! {
1056                    req = req
1057                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
1058                        .header("content-type", "application/json");
1059                },
1060            ),
1061            RequestBodyContent::FormUrlEncoded { .. } => (
1062                quote! { request },
1063                quote! {
1064                    req = req
1065                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
1066                        .header("content-type", "application/x-www-form-urlencoded");
1067                },
1068            ),
1069            RequestBodyContent::Multipart => (
1070                quote! { form },
1071                quote! {
1072                    req = req.multipart(form);
1073                },
1074            ),
1075            RequestBodyContent::OctetStream => (
1076                quote! { body },
1077                quote! {
1078                    req = req
1079                        .body(body)
1080                        .header("content-type", "application/octet-stream");
1081                },
1082            ),
1083            RequestBodyContent::TextPlain => (
1084                quote! { body },
1085                quote! {
1086                    req = req
1087                        .body(body)
1088                        .header("content-type", "text/plain");
1089                },
1090            ),
1091        };
1092        if required {
1093            apply
1094        } else {
1095            quote! {
1096                if let Some(#ident) = #ident {
1097                    #apply
1098                }
1099            }
1100        }
1101    }
1102
1103    /// Find the success (2xx) response schema name, if any.
1104    ///
1105    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
1106    /// so that endpoints like 204 No Content correctly return `()` instead of
1107    /// accidentally picking up the error schema (e.g. `BadRequestError`).
1108    fn get_success_response_schema<'a>(&self, op: &'a OperationInfo) -> Option<&'a String> {
1109        op.response_schemas
1110            .get("200")
1111            .or_else(|| op.response_schemas.get("201"))
1112            .or_else(|| {
1113                op.response_schemas
1114                    .iter()
1115                    .find(|(code, _)| code.starts_with('2'))
1116                    .map(|(_, v)| v)
1117            })
1118    }
1119
1120    /// Get response type
1121    fn get_response_type(&self, op: &OperationInfo) -> TokenStream {
1122        if let Some(response_type) = self.get_success_response_schema(op) {
1123            // Convert schema name to Rust type name (handles underscores, etc.)
1124            let rust_type_name = self.to_rust_type_name(response_type);
1125            let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
1126            quote! { #response_ident }
1127        } else {
1128            quote! { () }
1129        }
1130    }
1131
1132    /// Generate error handling.
1133    ///
1134    /// Always reads the response body to a string before attempting any typed
1135    /// deserialization, so the raw body and headers are preserved on the error
1136    /// path even when JSON parsing fails. On 2xx the body is parsed into the
1137    /// success type; on non-2xx the body is parsed into the matching variant
1138    /// of the per-operation error enum (when one is declared) and wrapped in
1139    /// `ApiError<E>`.
1140    fn generate_error_handling(&self, op: &OperationInfo, has_response_body: bool) -> TokenStream {
1141        let op_error_type = self.op_error_type_token(op);
1142
1143        let success_branch = if has_response_body {
1144            quote! {
1145                match serde_json::from_str(&body_text) {
1146                    Ok(body) => Ok(body),
1147                    Err(e) => Err(ApiOpError::Api(ApiError {
1148                        status: status_code,
1149                        headers: headers,
1150                        body: body_text,
1151                        typed: None,
1152                        parse_error: Some(format!(
1153                            "failed to deserialize 2xx response body: {}",
1154                            e
1155                        )),
1156                    })),
1157                }
1158            }
1159        } else {
1160            quote! {
1161                let _ = body_text;
1162                let _ = headers;
1163                Ok(())
1164            }
1165        };
1166
1167        let error_match_arms = self.generate_error_match_arms(op);
1168
1169        quote! {
1170            let status = response.status();
1171            let status_code = status.as_u16();
1172            let headers = response.headers().clone();
1173            let body_text = response.text().await
1174                .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?;
1175
1176            if status.is_success() {
1177                #success_branch
1178            } else {
1179                let typed: Option<#op_error_type>;
1180                let parse_error: Option<String>;
1181                #error_match_arms
1182                Err(ApiOpError::Api(ApiError {
1183                    status: status_code,
1184                    headers,
1185                    body: body_text,
1186                    typed,
1187                    parse_error,
1188                }))
1189            }
1190        }
1191    }
1192
1193    /// Generate the match arms that select which per-op error variant to
1194    /// deserialize the response body into based on the runtime status code.
1195    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
1196        let arms: Vec<TokenStream> = op
1197            .response_schemas
1198            .iter()
1199            .filter(|(code, _)| !code.starts_with('2'))
1200            .filter_map(|(code, schema)| {
1201                let variant_ident = Self::op_error_variant_ident(code);
1202                let payload_ty_name = self.to_rust_type_name(schema);
1203                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
1204                let enum_ident = self.op_error_enum_ident(op);
1205
1206                // T8: range-keyed responses (1XX/2XX/3XX/4XX/5XX) per OAS
1207                // 3.x §"Responses Object". Specific codes still take priority
1208                // (handled by ordering — concrete codes deserialize first
1209                // because the generic dispatch is a generic `_ if (range)`).
1210                let pattern = match code.as_str() {
1211                    "default" | "Default" => return None, // handled in fallback
1212                    other if other.chars().all(|c| c.is_ascii_digit()) => {
1213                        let n: u16 = other.parse().ok()?;
1214                        quote! { #n }
1215                    }
1216                    "1XX" | "1xx" => quote! { code if (100..=199).contains(&code) },
1217                    "2XX" | "2xx" => quote! { code if (200..=299).contains(&code) },
1218                    "3XX" | "3xx" => quote! { code if (300..=399).contains(&code) },
1219                    "4XX" | "4xx" => quote! { code if (400..=499).contains(&code) },
1220                    "5XX" | "5xx" => quote! { code if (500..=599).contains(&code) },
1221                    _ => return None,
1222                };
1223
1224                Some(quote! {
1225                    #pattern => {
1226                        match serde_json::from_str::<#payload_ty>(&body_text) {
1227                            Ok(v) => {
1228                                typed = Some(#enum_ident::#variant_ident(v));
1229                                parse_error = None;
1230                            }
1231                            Err(e) => {
1232                                typed = None;
1233                                parse_error = Some(e.to_string());
1234                            }
1235                        }
1236                    }
1237                })
1238            })
1239            .collect();
1240
1241        // Fallback for "default" or undeclared status codes: try to parse
1242        // as `serde_json::Value` for inspectability when the op's error
1243        // type is generic, otherwise leave typed = None.
1244        // Must mirror op_error_type_token: if op_error_type is the typed
1245        // enum (any non-2xx response, including `default`), the fallback arm
1246        // can't deserialize into `serde_json::Value` because `typed` is the
1247        // enum. Default to `typed = None` in that case.
1248        let has_typed_enum = op
1249            .response_schemas
1250            .iter()
1251            .any(|(code, _)| !code.starts_with('2'));
1252
1253        let default_arm = if has_typed_enum {
1254            quote! {
1255                _ => {
1256                    typed = None;
1257                    parse_error = None;
1258                }
1259            }
1260        } else {
1261            // No typed enum — op_error_type is serde_json::Value.
1262            quote! {
1263                _ => {
1264                    match serde_json::from_str::<serde_json::Value>(&body_text) {
1265                        Ok(v) => {
1266                            typed = Some(v);
1267                            parse_error = None;
1268                        }
1269                        Err(e) => {
1270                            typed = None;
1271                            parse_error = Some(e.to_string());
1272                        }
1273                    }
1274                }
1275            }
1276        };
1277
1278        if arms.is_empty() {
1279            // No declared status arms — just the fallback.
1280            quote! {
1281                match status_code {
1282                    #default_arm
1283                }
1284            }
1285        } else {
1286            quote! {
1287                match status_code {
1288                    #(#arms)*
1289                    #default_arm
1290                }
1291            }
1292        }
1293    }
1294
1295    /// Generate URL construction with path parameter substitution
1296    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
1297        // Check if path has parameters (contains {...})
1298        if path.contains('{') {
1299            self.generate_url_with_params(path, op)
1300        } else {
1301            quote! {
1302                let request_url = format!("{}{}", self.base_url, #path);
1303            }
1304        }
1305    }
1306
1307    /// Generate URL with path parameters
1308    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
1309        // Find all path parameters in the operation.
1310        let path_params: Vec<_> = op
1311            .parameters
1312            .iter()
1313            .filter(|p| p.location == "path")
1314            .collect();
1315
1316        // T5: percent-encode each path-template variable per RFC3986 §3.3.
1317        // We build a positional-arg format string by walking the template
1318        // left-to-right and emitting one `{}` + one format arg per
1319        // placeholder occurrence. Cloudflare has paths like
1320        // `/accounts/{account_id}/.../accounts/{account_id}` — the same
1321        // variable appears twice. A naive `replace_all` produced two `{}`
1322        // placeholders but only one format arg (E0277). Per-occurrence
1323        // emission keeps them in sync.
1324        let mut format_string = String::with_capacity(path.len());
1325        let mut format_args: Vec<TokenStream> = Vec::new();
1326        let mut chars = path.chars().peekable();
1327        while let Some(c) = chars.next() {
1328            if c != '{' {
1329                format_string.push(c);
1330                continue;
1331            }
1332            // Read until the matching '}'.
1333            let mut name = String::new();
1334            while let Some(&n) = chars.peek() {
1335                chars.next();
1336                if n == '}' {
1337                    break;
1338                }
1339                name.push(n);
1340            }
1341            // Resolve to a path param. If no match, leave the placeholder
1342            // verbatim (real-world spec bug — this op shouldn't have made
1343            // it past analysis).
1344            let param = path_params.iter().find(|p| p.name == name);
1345            let Some(param) = param else {
1346                format_string.push('{');
1347                format_string.push_str(&name);
1348                format_string.push('}');
1349                continue;
1350            };
1351            format_string.push_str("{}");
1352            let param_name_snake = self.param_ident_str(param);
1353            let param_ident = Self::to_field_ident(&param_name_snake);
1354            if Self::param_uses_as_ref_str(param) {
1355                format_args.push(quote! {
1356                    __pct_encode_path_segment(#param_ident.as_ref())
1357                });
1358            } else {
1359                format_args.push(quote! {
1360                    __pct_encode_path_segment(&#param_ident.to_string())
1361                });
1362            }
1363        }
1364
1365        if format_args.is_empty() {
1366            quote! {
1367                let request_url = format!("{}{}", self.base_url, #path);
1368            }
1369        } else {
1370            quote! {
1371                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
1372            }
1373        }
1374    }
1375
1376    /// Resolve the Rust ident for a parameter. Prefers the disambiguated
1377    /// `rust_ident` set by the analyzer (which dedupes across the whole
1378    /// operation), falling back to a fresh sanitize of the wire name when
1379    /// no analyzer-side ident is present.
1380    fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String {
1381        if let Some(ident) = &param.rust_ident {
1382            // Apply the keyword-escape and self/super/crate dance the
1383            // sanitize fn does. The analyzer's base ident is already the
1384            // snake/kebab-aware shape; we only need post-processing.
1385            return self.escape_keyword_ident(ident);
1386        }
1387        self.sanitize_param_name(&param.name)
1388    }
1389
1390    fn escape_keyword_ident(&self, snake_case: &str) -> String {
1391        if matches!(snake_case, "self" | "super" | "crate" | "Self") {
1392            return format!("{snake_case}_param");
1393        }
1394        if Self::is_rust_keyword(snake_case) {
1395            format!("r#{snake_case}")
1396        } else {
1397            snake_case.to_string()
1398        }
1399    }
1400
1401    /// Sanitize a parameter name by escaping Rust reserved keywords with raw
1402    /// identifiers and disambiguating Twilio-style suffix operators
1403    /// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
1404    /// case to `start_time`).
1405    fn sanitize_param_name(&self, name: &str) -> String {
1406        // Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
1407        // filter-style query params; map them to `_lt` / `_gt` etc. so the
1408        // Rust ident is unique while the wire-level param name stays the
1409        // original string elsewhere in the codegen.
1410        let suffix = if name.ends_with("<=") {
1411            "_lte"
1412        } else if name.ends_with(">=") {
1413            "_gte"
1414        } else if name.ends_with('<') {
1415            "_lt"
1416        } else if name.ends_with('>') {
1417            "_gt"
1418        } else {
1419            ""
1420        };
1421        let stripped = name.trim_end_matches(['<', '>', '=']);
1422        let mut snake_case = stripped.to_snake_case();
1423        snake_case.push_str(suffix);
1424
1425        if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
1426            return format!("{snake_case}_param");
1427        }
1428        if Self::is_rust_keyword(&snake_case) {
1429            format!("r#{snake_case}")
1430        } else {
1431            snake_case
1432        }
1433    }
1434}