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, SchemaAnalysis};
151use crate::generator::CodeGenerator;
152use heck::ToSnakeCase;
153use proc_macro2::TokenStream;
154use quote::quote;
155
156impl CodeGenerator {
157    /// Generate the HTTP client struct with middleware support
158    pub fn generate_http_client_struct(&self) -> TokenStream {
159        let has_retry = self.config().retry_config.is_some();
160        let has_tracing = self.config().tracing_enabled;
161
162        // Generate RetryConfig struct if needed
163        let retry_config_struct = if has_retry {
164            quote! {
165                /// Retry configuration for HTTP requests
166                #[derive(Debug, Clone)]
167                pub struct RetryConfig {
168                    pub max_retries: u32,
169                    pub initial_delay_ms: u64,
170                    pub max_delay_ms: u64,
171                }
172
173                impl Default for RetryConfig {
174                    fn default() -> Self {
175                        Self {
176                            max_retries: 3,
177                            initial_delay_ms: 500,
178                            max_delay_ms: 16000,
179                        }
180                    }
181                }
182            }
183        } else {
184            quote! {}
185        };
186
187        // Generate the main HttpClient struct
188        let client_struct = quote! {
189            use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
190            use std::collections::BTreeMap;
191
192            /// HTTP client for making API requests
193            #[derive(Clone)]
194            pub struct HttpClient {
195                base_url: String,
196                api_key: Option<String>,
197                http_client: ClientWithMiddleware,
198                custom_headers: BTreeMap<String, String>,
199            }
200        };
201
202        // Generate constructor
203        let constructor = self.generate_constructor(has_retry, has_tracing);
204
205        // Generate builder methods
206        let builder_methods = self.generate_builder_methods();
207
208        // Generate Default implementation
209        let default_impl = quote! {
210            impl Default for HttpClient {
211                fn default() -> Self {
212                    Self::new()
213                }
214            }
215        };
216
217        // Combine all parts
218        quote! {
219            #retry_config_struct
220            #client_struct
221
222            impl HttpClient {
223                #constructor
224                #builder_methods
225            }
226
227            #default_impl
228        }
229    }
230
231    /// Generate the constructor method
232    fn generate_constructor(&self, has_retry: bool, has_tracing: bool) -> TokenStream {
233        let retry_param = if has_retry {
234            quote! { retry_config: Option<RetryConfig>, }
235        } else {
236            quote! {}
237        };
238
239        let tracing_param = if has_tracing {
240            quote! { enable_tracing: bool, }
241        } else {
242            quote! {}
243        };
244
245        let retry_middleware = if has_retry {
246            quote! {
247                if let Some(config) = retry_config {
248                    use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
249
250                    let retry_policy = ExponentialBackoff::builder()
251                        .retry_bounds(
252                            std::time::Duration::from_millis(config.initial_delay_ms),
253                            std::time::Duration::from_millis(config.max_delay_ms),
254                        )
255                        .build_with_max_retries(config.max_retries);
256
257                    let retry_middleware = RetryTransientMiddleware::new_with_policy(retry_policy);
258                    client_builder = client_builder.with(retry_middleware);
259                }
260            }
261        } else {
262            quote! {}
263        };
264
265        let tracing_middleware = if has_tracing {
266            quote! {
267                if enable_tracing {
268                    use reqwest_tracing::TracingMiddleware;
269                    client_builder = client_builder.with(TracingMiddleware::default());
270                }
271            }
272        } else {
273            quote! {}
274        };
275
276        let default_constructor = if has_retry && has_tracing {
277            quote! {
278                /// Create a new HTTP client with default configuration
279                pub fn new() -> Self {
280                    Self::with_config(None, true)
281                }
282            }
283        } else if has_retry {
284            quote! {
285                /// Create a new HTTP client with default configuration
286                pub fn new() -> Self {
287                    Self::with_config(None)
288                }
289            }
290        } else if has_tracing {
291            quote! {
292                /// Create a new HTTP client with default configuration
293                pub fn new() -> Self {
294                    Self::with_config(true)
295                }
296            }
297        } else {
298            quote! {
299                /// Create a new HTTP client with default configuration
300                pub fn new() -> Self {
301                    let reqwest_client = reqwest::Client::new();
302                    let client_builder = ClientBuilder::new(reqwest_client);
303                    let http_client = client_builder.build();
304
305                    Self {
306                        base_url: String::new(),
307                        api_key: None,
308                        http_client,
309                        custom_headers: BTreeMap::new(),
310                    }
311                }
312            }
313        };
314
315        if has_retry || has_tracing {
316            quote! {
317                #default_constructor
318
319                /// Create a new HTTP client with custom configuration
320                pub fn with_config(#retry_param #tracing_param) -> Self {
321                    let reqwest_client = reqwest::Client::new();
322                    let mut client_builder = ClientBuilder::new(reqwest_client);
323
324                    #tracing_middleware
325                    #retry_middleware
326
327                    let http_client = client_builder.build();
328
329                    Self {
330                        base_url: String::new(),
331                        api_key: None,
332                        http_client,
333                        custom_headers: BTreeMap::new(),
334                    }
335                }
336            }
337        } else {
338            default_constructor
339        }
340    }
341
342    /// Generate builder methods for configuration
343    fn generate_builder_methods(&self) -> TokenStream {
344        quote! {
345            /// Set the base URL for all requests
346            pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
347                self.base_url = base_url.into();
348                self
349            }
350
351            /// Set the API key for authentication
352            pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
353                self.api_key = Some(api_key.into());
354                self
355            }
356
357            /// Add a custom header to all requests
358            pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
359                self.custom_headers.insert(name.into(), value.into());
360                self
361            }
362
363            /// Add multiple custom headers
364            pub fn with_headers(mut self, headers: BTreeMap<String, String>) -> Self {
365                self.custom_headers.extend(headers);
366                self
367            }
368        }
369    }
370
371    /// Generate HTTP operation methods for the client.
372    ///
373    /// Emits per-operation typed error enums (one variant per declared non-2xx
374    /// response with a body schema) BEFORE the `impl HttpClient` block so the
375    /// generated method signatures can reference them.
376    pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream {
377        let op_error_enums: Vec<TokenStream> = analysis
378            .operations
379            .values()
380            .filter_map(|op| self.generate_op_error_enum(op))
381            .collect();
382
383        let methods: Vec<TokenStream> = analysis
384            .operations
385            .values()
386            .map(|op| self.generate_single_operation_method(op))
387            .collect();
388
389        quote! {
390            #(#op_error_enums)*
391
392            impl HttpClient {
393                #(#methods)*
394            }
395        }
396    }
397
398    /// Generate the per-operation typed error enum, if the op has any non-2xx
399    /// responses with a body schema. Returns None when the op has no declared
400    /// error bodies — those operations use `ApiOpError<serde_json::Value>` so
401    /// the raw response body is still inspectable.
402    fn generate_op_error_enum(&self, op: &OperationInfo) -> Option<TokenStream> {
403        let variants: Vec<(String, String)> = op
404            .response_schemas
405            .iter()
406            .filter(|(code, _)| !code.starts_with('2'))
407            .map(|(code, schema)| (code.clone(), schema.clone()))
408            .collect();
409
410        if variants.is_empty() {
411            return None;
412        }
413
414        let enum_ident = self.op_error_enum_ident(op);
415        let variant_decls: Vec<TokenStream> = variants
416            .iter()
417            .map(|(code, schema)| {
418                let variant_ident = Self::op_error_variant_ident(code);
419                let payload_ty_name = self.to_rust_type_name(schema);
420                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
421                quote! { #variant_ident(#payload_ty) }
422            })
423            .collect();
424
425        let doc = format!(
426            "Typed error responses for `{}`. One variant per declared non-2xx response.",
427            op.operation_id
428        );
429
430        Some(quote! {
431            #[doc = #doc]
432            #[derive(Debug, Clone)]
433            pub enum #enum_ident {
434                #(#variant_decls,)*
435            }
436        })
437    }
438
439    /// Type name (Ident) for the per-op error enum, e.g. `ListTodosApiError`.
440    fn op_error_enum_ident(&self, op: &OperationInfo) -> syn::Ident {
441        use heck::ToPascalCase;
442        let name = format!(
443            "{}ApiError",
444            op.operation_id.replace('.', "_").to_pascal_case()
445        );
446        syn::Ident::new(&name, proc_macro2::Span::call_site())
447    }
448
449    /// Variant name for a status code: "400" → Status400, "default" → Default,
450    /// "4XX" → Status4xx.
451    fn op_error_variant_ident(status_code: &str) -> syn::Ident {
452        let raw = match status_code {
453            "default" | "Default" => "Default".to_string(),
454            other if other.chars().all(|c| c.is_ascii_digit()) => format!("Status{other}"),
455            other => format!("Status{}", other.to_ascii_lowercase()),
456        };
457        syn::Ident::new(&raw, proc_macro2::Span::call_site())
458    }
459
460    /// Token stream for the type plugged into `ApiOpError<T>` for an op:
461    /// either the per-op enum, or `serde_json::Value` for ops with no
462    /// declared error body schemas.
463    fn op_error_type_token(&self, op: &OperationInfo) -> TokenStream {
464        if op
465            .response_schemas
466            .iter()
467            .any(|(code, _)| !code.starts_with('2'))
468        {
469            let ident = self.op_error_enum_ident(op);
470            quote! { #ident }
471        } else {
472            quote! { serde_json::Value }
473        }
474    }
475
476    /// Generate a single operation method
477    fn generate_single_operation_method(&self, op: &OperationInfo) -> TokenStream {
478        let method_name = self.get_method_name(op);
479        let http_method = self.get_http_method(op);
480        let path = &op.path;
481        let request_param = self.generate_request_param(op);
482        let request_body = self.generate_request_body(op);
483        let query_params = self.generate_query_params(op);
484        let response_type = self.get_response_type(op);
485        let has_response_body = self.get_success_response_schema(op).is_some();
486        let op_error_type = self.op_error_type_token(op);
487        let error_handling = self.generate_error_handling(op, has_response_body);
488        let url_construction = self.generate_url_construction(path, op);
489        let doc_comment = self.generate_operation_doc_comment(op);
490
491        quote! {
492            #doc_comment
493            pub async fn #method_name(
494                &self,
495                #request_param
496            ) -> Result<#response_type, ApiOpError<#op_error_type>> {
497                #url_construction
498
499                let mut req = self.http_client
500                    .#http_method(request_url)
501                    #request_body;
502
503                #query_params
504
505                // Add API key if configured
506                if let Some(api_key) = &self.api_key {
507                    req = req.bearer_auth(api_key);
508                }
509
510                // Add custom headers
511                for (name, value) in &self.custom_headers {
512                    req = req.header(name, value);
513                }
514
515                let response = req.send().await?;
516                #error_handling
517            }
518        }
519    }
520
521    /// Generate query parameter handling
522    fn generate_query_params(&self, op: &OperationInfo) -> TokenStream {
523        let query_params: Vec<_> = op
524            .parameters
525            .iter()
526            .filter(|p| p.location == "query")
527            .collect();
528
529        if query_params.is_empty() {
530            return quote! {};
531        }
532
533        let mut param_building = Vec::new();
534
535        for param in query_params {
536            // Use snake_case for Rust variable name with keyword escaping
537            let param_name_snake = self.sanitize_param_name(&param.name);
538            let param_name = Self::to_field_ident(&param_name_snake);
539
540            // Use the original parameter name from OpenAPI spec as the query string key
541            let param_key = &param.name;
542
543            if param.required {
544                // Required parameters: always add
545                if param.rust_type == "String" {
546                    param_building.push(quote! {
547                        query_params.push((#param_key, #param_name.as_ref().to_string()));
548                    });
549                } else {
550                    param_building.push(quote! {
551                        query_params.push((#param_key, #param_name.to_string()));
552                    });
553                }
554            } else {
555                // Optional parameters: add only if Some
556                if param.rust_type == "String" {
557                    param_building.push(quote! {
558                        if let Some(v) = #param_name {
559                            query_params.push((#param_key, v.as_ref().to_string()));
560                        }
561                    });
562                } else {
563                    param_building.push(quote! {
564                        if let Some(v) = #param_name {
565                            query_params.push((#param_key, v.to_string()));
566                        }
567                    });
568                }
569            }
570        }
571
572        quote! {
573            // Add query parameters
574            {
575                let mut query_params: Vec<(&str, String)> = Vec::new();
576                #(#param_building)*
577                if !query_params.is_empty() {
578                    req = req.query(&query_params);
579                }
580            }
581        }
582    }
583
584    /// Generate documentation comment for the operation
585    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
586        let method = op.method.to_uppercase();
587        let path = &op.path;
588        let doc = format!("{} {}", method, path);
589
590        quote! {
591            #[doc = #doc]
592        }
593    }
594
595    /// Get the method name from the operation
596    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
597        let name = if !op.operation_id.is_empty() {
598            op.operation_id.to_snake_case()
599        } else {
600            // Fallback: generate from HTTP method and path
601            format!(
602                "{}_{}",
603                op.method,
604                op.path.replace('/', "_").replace(['{', '}'], "")
605            )
606            .to_snake_case()
607        };
608
609        syn::Ident::new(&name, proc_macro2::Span::call_site())
610    }
611
612    /// Get the HTTP method
613    fn get_http_method(&self, op: &OperationInfo) -> syn::Ident {
614        let method = match op.method.to_uppercase().as_str() {
615            "GET" => "get",
616            "POST" => "post",
617            "PUT" => "put",
618            "DELETE" => "delete",
619            "PATCH" => "patch",
620            _ => "get", // Default fallback
621        };
622
623        syn::Ident::new(method, proc_macro2::Span::call_site())
624    }
625
626    /// Generate request parameters including path parameters, query parameters, and request body
627    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
628        let mut params = Vec::new();
629
630        // Add path parameters
631        for param in &op.parameters {
632            if param.location == "path" {
633                let param_name_snake = self.sanitize_param_name(&param.name);
634                let param_name = Self::to_field_ident(&param_name_snake);
635                let param_type = self.get_param_rust_type(param);
636                params.push(quote! { #param_name: #param_type });
637            }
638        }
639
640        // Add query parameters (all as Option<T>)
641        for param in &op.parameters {
642            if param.location == "query" {
643                let param_name_snake = self.sanitize_param_name(&param.name);
644                let param_name = Self::to_field_ident(&param_name_snake);
645                let param_type = self.get_param_rust_type(param);
646
647                // Query parameters should be Option unless explicitly required
648                if param.required {
649                    params.push(quote! { #param_name: #param_type });
650                } else {
651                    params.push(quote! { #param_name: Option<#param_type> });
652                }
653            }
654        }
655
656        // Add request body parameter based on content type
657        if let Some(ref rb) = op.request_body {
658            use crate::analysis::RequestBodyContent;
659            match rb {
660                RequestBodyContent::Json { schema_name }
661                | RequestBodyContent::FormUrlEncoded { schema_name } => {
662                    let rust_type_name = self.to_rust_type_name(schema_name);
663                    let request_ident =
664                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
665                    params.push(quote! { request: #request_ident });
666                }
667                RequestBodyContent::Multipart => {
668                    params.push(quote! { form: reqwest::multipart::Form });
669                }
670                RequestBodyContent::OctetStream => {
671                    params.push(quote! { body: Vec<u8> });
672                }
673                RequestBodyContent::TextPlain => {
674                    params.push(quote! { body: String });
675                }
676            }
677        }
678
679        if params.is_empty() {
680            quote! {}
681        } else {
682            quote! { #(#params),* }
683        }
684    }
685
686    /// Get the Rust type for a parameter
687    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
688        let type_str = &param.rust_type;
689        match type_str.as_str() {
690            "String" => quote! { impl AsRef<str> },
691            "i64" => quote! { i64 },
692            "i32" => quote! { i32 },
693            "f64" => quote! { f64 },
694            "bool" => quote! { bool },
695            _ => {
696                let type_ident = syn::Ident::new(type_str, proc_macro2::Span::call_site());
697                quote! { #type_ident }
698            }
699        }
700    }
701
702    /// Generate request body serialization based on content type
703    fn generate_request_body(&self, op: &OperationInfo) -> TokenStream {
704        if let Some(ref rb) = op.request_body {
705            use crate::analysis::RequestBodyContent;
706            match rb {
707                RequestBodyContent::Json { .. } => {
708                    quote! {
709                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
710                        .header("content-type", "application/json")
711                    }
712                }
713                RequestBodyContent::FormUrlEncoded { .. } => {
714                    quote! {
715                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
716                        .header("content-type", "application/x-www-form-urlencoded")
717                    }
718                }
719                RequestBodyContent::Multipart => {
720                    quote! {
721                        .multipart(form)
722                    }
723                }
724                RequestBodyContent::OctetStream => {
725                    quote! {
726                        .body(body)
727                        .header("content-type", "application/octet-stream")
728                    }
729                }
730                RequestBodyContent::TextPlain => {
731                    quote! {
732                        .body(body)
733                        .header("content-type", "text/plain")
734                    }
735                }
736            }
737        } else {
738            quote! {}
739        }
740    }
741
742    /// Find the success (2xx) response schema name, if any.
743    ///
744    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
745    /// so that endpoints like 204 No Content correctly return `()` instead of
746    /// accidentally picking up the error schema (e.g. `BadRequestError`).
747    fn get_success_response_schema<'a>(&self, op: &'a OperationInfo) -> Option<&'a String> {
748        op.response_schemas
749            .get("200")
750            .or_else(|| op.response_schemas.get("201"))
751            .or_else(|| {
752                op.response_schemas
753                    .iter()
754                    .find(|(code, _)| code.starts_with('2'))
755                    .map(|(_, v)| v)
756            })
757    }
758
759    /// Get response type
760    fn get_response_type(&self, op: &OperationInfo) -> TokenStream {
761        if let Some(response_type) = self.get_success_response_schema(op) {
762            // Convert schema name to Rust type name (handles underscores, etc.)
763            let rust_type_name = self.to_rust_type_name(response_type);
764            let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
765            quote! { #response_ident }
766        } else {
767            quote! { () }
768        }
769    }
770
771    /// Generate error handling.
772    ///
773    /// Always reads the response body to a string before attempting any typed
774    /// deserialization, so the raw body and headers are preserved on the error
775    /// path even when JSON parsing fails. On 2xx the body is parsed into the
776    /// success type; on non-2xx the body is parsed into the matching variant
777    /// of the per-operation error enum (when one is declared) and wrapped in
778    /// `ApiError<E>`.
779    fn generate_error_handling(&self, op: &OperationInfo, has_response_body: bool) -> TokenStream {
780        let op_error_type = self.op_error_type_token(op);
781
782        let success_branch = if has_response_body {
783            quote! {
784                match serde_json::from_str(&body_text) {
785                    Ok(body) => Ok(body),
786                    Err(e) => Err(ApiOpError::Api(ApiError {
787                        status: status_code,
788                        headers: headers,
789                        body: body_text,
790                        typed: None,
791                        parse_error: Some(format!(
792                            "failed to deserialize 2xx response body: {}",
793                            e
794                        )),
795                    })),
796                }
797            }
798        } else {
799            quote! {
800                let _ = body_text;
801                let _ = headers;
802                Ok(())
803            }
804        };
805
806        let error_match_arms = self.generate_error_match_arms(op);
807
808        quote! {
809            let status = response.status();
810            let status_code = status.as_u16();
811            let headers = response.headers().clone();
812            let body_text = response.text().await
813                .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?;
814
815            if status.is_success() {
816                #success_branch
817            } else {
818                let typed: Option<#op_error_type>;
819                let parse_error: Option<String>;
820                #error_match_arms
821                Err(ApiOpError::Api(ApiError {
822                    status: status_code,
823                    headers,
824                    body: body_text,
825                    typed,
826                    parse_error,
827                }))
828            }
829        }
830    }
831
832    /// Generate the match arms that select which per-op error variant to
833    /// deserialize the response body into based on the runtime status code.
834    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
835        let arms: Vec<TokenStream> = op
836            .response_schemas
837            .iter()
838            .filter(|(code, _)| !code.starts_with('2'))
839            .filter_map(|(code, schema)| {
840                let variant_ident = Self::op_error_variant_ident(code);
841                let payload_ty_name = self.to_rust_type_name(schema);
842                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
843                let enum_ident = self.op_error_enum_ident(op);
844
845                let pattern = match code.as_str() {
846                    "default" | "Default" => return None, // handled in fallback
847                    other if other.chars().all(|c| c.is_ascii_digit()) => {
848                        let n: u16 = other.parse().ok()?;
849                        quote! { #n }
850                    }
851                    // Range like "4XX" / "5XX" — fall through to generic
852                    // for now; declared-range handling is a follow-up.
853                    _ => return None,
854                };
855
856                Some(quote! {
857                    #pattern => {
858                        match serde_json::from_str::<#payload_ty>(&body_text) {
859                            Ok(v) => {
860                                typed = Some(#enum_ident::#variant_ident(v));
861                                parse_error = None;
862                            }
863                            Err(e) => {
864                                typed = None;
865                                parse_error = Some(e.to_string());
866                            }
867                        }
868                    }
869                })
870            })
871            .collect();
872
873        // Fallback for "default" or undeclared status codes: try to parse
874        // as `serde_json::Value` for inspectability when the op's error
875        // type is generic, otherwise leave typed = None.
876        let has_typed_enum = op.response_schemas.iter().any(|(code, _)| {
877            !code.starts_with('2') && !matches!(code.as_str(), "default" | "Default")
878        });
879
880        let default_arm = if has_typed_enum {
881            quote! {
882                _ => {
883                    typed = None;
884                    parse_error = None;
885                }
886            }
887        } else {
888            // No typed enum — op_error_type is serde_json::Value.
889            quote! {
890                _ => {
891                    match serde_json::from_str::<serde_json::Value>(&body_text) {
892                        Ok(v) => {
893                            typed = Some(v);
894                            parse_error = None;
895                        }
896                        Err(e) => {
897                            typed = None;
898                            parse_error = Some(e.to_string());
899                        }
900                    }
901                }
902            }
903        };
904
905        if arms.is_empty() {
906            // No declared status arms — just the fallback.
907            quote! {
908                match status_code {
909                    #default_arm
910                }
911            }
912        } else {
913            quote! {
914                match status_code {
915                    #(#arms)*
916                    #default_arm
917                }
918            }
919        }
920    }
921
922    /// Generate URL construction with path parameter substitution
923    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
924        // Check if path has parameters (contains {...})
925        if path.contains('{') {
926            self.generate_url_with_params(path, op)
927        } else {
928            quote! {
929                let request_url = format!("{}{}", self.base_url, #path);
930            }
931        }
932    }
933
934    /// Generate URL with path parameters
935    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
936        // Parse path to find all parameter placeholders
937        let mut format_string = path.to_string();
938        let mut format_args = Vec::new();
939
940        // Find all path parameters in the operation
941        let path_params: Vec<_> = op
942            .parameters
943            .iter()
944            .filter(|p| p.location == "path")
945            .collect();
946
947        // Replace {paramName} with {} and collect parameter names for format args
948        for param in &path_params {
949            let placeholder = format!("{{{}}}", param.name);
950            if format_string.contains(&placeholder) {
951                format_string = format_string.replace(&placeholder, "{}");
952
953                // Use snake_case for the Rust variable name with keyword escaping
954                let param_name_snake = self.sanitize_param_name(&param.name);
955                let param_ident = Self::to_field_ident(&param_name_snake);
956
957                // Use .as_ref() for string types to handle impl AsRef<str>
958                if param.rust_type == "String" {
959                    format_args.push(quote! { #param_ident.as_ref() });
960                } else {
961                    format_args.push(quote! { #param_ident });
962                }
963            }
964        }
965
966        if format_args.is_empty() {
967            // No path parameters found, use simple format
968            quote! {
969                let request_url = format!("{}{}", self.base_url, #path);
970            }
971        } else {
972            // Build format call with path parameters
973            quote! {
974                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
975            }
976        }
977    }
978
979    /// Sanitize a parameter name by escaping Rust reserved keywords with raw identifiers
980    fn sanitize_param_name(&self, name: &str) -> String {
981        let snake_case = name.to_snake_case();
982        if Self::is_rust_keyword(&snake_case) {
983            format!("r#{snake_case}")
984        } else {
985            snake_case
986        }
987    }
988}