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