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(¶m.name);
538 let param_name = Self::to_field_ident(¶m_name_snake);
539
540 // Use the original parameter name from OpenAPI spec as the query string key
541 let param_key = ¶m.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(¶m.name);
634 let param_name = Self::to_field_ident(¶m_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(¶m.name);
644 let param_name = Self::to_field_ident(¶m_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 = ¶m.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(¶m.name);
955 let param_ident = Self::to_field_ident(¶m_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}