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_client(client: reqwest::Client) -> Self { /* ... */ }
38//!     pub fn with_config(retry_config: Option<RetryConfig>, enable_tracing: bool) -> Self { /* ... */ }
39//!     pub fn with_base_url(self, base_url: String) -> Self { /* ... */ }
40//!     pub fn with_api_key(self, api_key: String) -> Self { /* ... */ }
41//!     pub fn with_header(self, key: String, value: String) -> Self { /* ... */ }
42//!
43//!     // Generated operation methods
44//!     pub async fn list_items(&self) -> Result<ItemList, HttpError> { /* ... */ }
45//!     pub async fn create_item(&self, request: CreateItemRequest) -> Result<Item, HttpError> { /* ... */ }
46//!     pub async fn get_item(&self, id: impl AsRef<str>) -> Result<Item, HttpError> { /* ... */ }
47//! }
48//! ```
49//!
50//! # Middleware Stack
51//!
52//! The generated client uses `reqwest-middleware` to build a composable middleware stack:
53//!
54//! 1. **Tracing Middleware** (optional, enabled by default)
55//!    - Logs HTTP requests/responses
56//!    - Creates spans for distributed tracing
57//!    - Integrates with `tracing` ecosystem
58//!
59//! 2. **Retry Middleware** (optional, configured via TOML)
60//!    - Exponential backoff retry policy
61//!    - Automatically retries transient errors (429, 500, 502, 503, 504)
62//!    - Configurable max retries and delay bounds
63//!
64//! # Configuration
65//!
66//! ## Via TOML
67//!
68//! ```toml
69//! [http_client]
70//! base_url = "https://api.example.com"
71//! timeout_seconds = 30
72//!
73//! [http_client.retry]
74//! max_retries = 3
75//! initial_delay_ms = 500
76//! max_delay_ms = 16000
77//!
78//! [http_client.tracing]
79//! enabled = true
80//! ```
81//!
82//! ## Via Rust API
83//!
84//! ```no_run
85//! use openapi_to_rust::{GeneratorConfig, http_config::*};
86//! use std::path::PathBuf;
87//!
88//! let config = GeneratorConfig {
89//!     spec_path: PathBuf::from("openapi.json"),
90//!     enable_async_client: true,
91//!     retry_config: Some(RetryConfig {
92//!         max_retries: 3,
93//!         initial_delay_ms: 500,
94//!         max_delay_ms: 16000,
95//!     }),
96//!     tracing_enabled: true,
97//!     // ... other fields
98//!     ..Default::default()
99//! };
100//! ```
101//!
102//! # Generated Client Usage
103//!
104//! ```rust,ignore
105//! use crate::generated::client::HttpClient;
106//!
107//! #[tokio::main]
108//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
109//!     // Create client with retry and tracing
110//!     let client = HttpClient::new()
111//!         .with_base_url("https://api.example.com".to_string())
112//!         .with_api_key("your-api-key".to_string())
113//!         .with_header("X-Custom-Header".to_string(), "value".to_string());
114//!
115//!     // Make API calls - retries happen automatically
116//!     let items = client.list_items().await?;
117//!     println!("Found {} items", items.items.len());
118//!
119//!     Ok(())
120//! }
121//! ```
122//!
123//! # HTTP Method Support
124//!
125//! The generator supports all standard HTTP methods:
126//! - `GET` - List and retrieve operations
127//! - `POST` - Create operations
128//! - `PUT` - Full update operations
129//! - `PATCH` - Partial update operations
130//! - `DELETE` - Delete operations
131//!
132//! # Error Handling
133//!
134//! All generated methods return `Result<T, HttpError>` where `HttpError` provides:
135//! - Detailed error information
136//! - Retry detection via `is_retryable()`
137//! - Error categorization (client errors, server errors)
138//!
139//! See [`http_error`](crate::http_error) module for details.
140//!
141//! # Implementation Details
142//!
143//! The generator uses the following approach:
144//! 1. Analyzes OpenAPI operations to extract HTTP methods, paths, parameters
145//! 2. Generates typed request/response handling
146//! 3. Creates method signatures with proper parameter types
147//! 4. Generates path parameter substitution
148//! 5. Handles query parameters and request bodies
149//! 6. Configures middleware stack based on generator config
150
151use crate::analysis::{
152    OperationInfo, OperationRepresentation, OperationResponseBody, ParameterInfo, SchemaAnalysis,
153};
154use crate::generator::CodeGenerator;
155use heck::{ToPascalCase, ToSnakeCase};
156use proc_macro2::TokenStream;
157use quote::{format_ident, quote};
158use std::collections::BTreeMap;
159
160struct AllocatedOperationParam<'a> {
161    param: &'a ParameterInfo,
162    ident: syn::Ident,
163}
164
165#[derive(Clone)]
166struct BodyFieldPlan {
167    wire_name: String,
168    preferred_method_name: String,
169    value_ident: syn::Ident,
170    value_type: TokenStream,
171    access_path: Vec<syn::Ident>,
172    tri_state: bool,
173}
174
175#[derive(Clone, Copy)]
176enum MultipartClientFieldKind {
177    RawBytes,
178    Base64,
179    Base64UrlUnpadded,
180    Text,
181    /// An object, or a union of them, sent as one JSON part.
182    Json,
183}
184
185#[derive(Clone, Copy)]
186enum ClientSuccessBody<'a> {
187    Json(&'a str),
188    Text,
189    Binary,
190    EventStream,
191    ParsedSse,
192    Empty,
193}
194
195#[derive(Clone)]
196struct ClientSuccessSelection<'a> {
197    statuses: Vec<&'a str>,
198    excluded_statuses: Vec<&'a str>,
199    body: ClientSuccessBody<'a>,
200    accept: Option<&'a str>,
201}
202
203/// Final flat client binding shared by rendering, builders and metadata.
204pub(crate) struct ClientMethodPlan<'a> {
205    pub operation: &'a OperationInfo,
206    pub method_ident: syn::Ident,
207    pub request_parameters: TokenStream,
208    pub generics: TokenStream,
209    pub response_type: TokenStream,
210    pub error_type: TokenStream,
211    pub response: ClientResponsePlan,
212    pub builder_method_ident: Option<syn::Ident>,
213    pub builder_type_ident: Option<syn::Ident>,
214    success: ClientSuccessSelection<'a>,
215    validate_content_type: bool,
216}
217
218pub(crate) struct ClientResponsePlan {
219    pub statuses: Vec<String>,
220    pub excluded_statuses: Vec<String>,
221    pub media_type: Option<String>,
222    pub kind: String,
223    pub consumption: String,
224}
225
226enum RequiredBodyConstruction {
227    Default,
228    New(Vec<BodyConstructorParam>),
229    Whole,
230}
231
232struct BodyConstructorParam {
233    preferred_ident: syn::Ident,
234    value_type: TokenStream,
235}
236
237struct BodyModelPlan {
238    body_ident: syn::Ident,
239    body_type: TokenStream,
240    required_construction: RequiredBodyConstruction,
241    optional_fields: Vec<BodyFieldPlan>,
242}
243
244impl CodeGenerator {
245    /// Generate the HTTP client struct with middleware support
246    pub fn generate_http_client_struct(&self) -> TokenStream {
247        let has_retry = self.config().retry_config.is_some();
248        let has_tracing = self.config().tracing_enabled;
249
250        // Generate RetryConfig struct if needed
251        let retry_config_struct = if has_retry {
252            quote! {
253                /// Retry configuration for HTTP requests
254                #[derive(Debug, Clone)]
255                pub struct RetryConfig {
256                    pub max_retries: u32,
257                    pub initial_delay_ms: u64,
258                    pub max_delay_ms: u64,
259                }
260
261                impl Default for RetryConfig {
262                    fn default() -> Self {
263                        Self {
264                            max_retries: 3,
265                            initial_delay_ms: 500,
266                            max_delay_ms: 16000,
267                        }
268                    }
269                }
270            }
271        } else {
272            quote! {}
273        };
274
275        // Generate the main HttpClient struct
276        let client_struct = quote! {
277            use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
278            use std::collections::BTreeMap;
279
280            /// Default upper bound for any response body buffered in memory.
281            pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024;
282
283            /// HTTP client for making API requests
284            #[derive(Clone)]
285            pub struct HttpClient {
286                base_url: String,
287                api_key: Option<String>,
288                http_client: ClientWithMiddleware,
289                custom_headers: BTreeMap<String, String>,
290                max_response_body_bytes: usize,
291            }
292
293            // Buffer a response body while enforcing `limit`, using
294            // `bytes_stream()` rather than `Response::chunk()`: `chunk()` is
295            // native-only in reqwest and does not exist on the wasm32 backend,
296            // so it broke every generated client under `trunk serve`
297            // (openapi-generator-xhz, issue #74). `bytes_stream()` is available
298            // on both targets behind reqwest's `stream` feature.
299            async fn __read_bounded_response_body(
300                response: reqwest::Response,
301                limit: usize,
302            ) -> Result<Vec<u8>, HttpError> {
303                use futures_util::StreamExt;
304
305                let mut body = Vec::new();
306                let mut stream = std::pin::pin!(response.bytes_stream());
307                while let Some(chunk) = stream.next().await {
308                    let chunk = chunk.map_err(HttpError::Network)?;
309                    let next_len = body.len().checked_add(chunk.len());
310                    if next_len.is_none_or(|next_len| next_len > limit) {
311                        return Err(HttpError::ResponseTooLarge { limit });
312                    }
313                    body.extend_from_slice(&chunk);
314                }
315                Ok(body)
316            }
317        };
318
319        // Generate constructor
320        let constructor = self.generate_constructor(has_retry, has_tracing);
321
322        // Generate builder methods
323        let builder_methods = self.generate_builder_methods();
324
325        // Generate Default implementation
326        let default_impl = quote! {
327            impl Default for HttpClient {
328                fn default() -> Self {
329                    Self::new()
330                }
331            }
332        };
333
334        // Path-segment percent encoder, used by url construction (T5).
335        // Encodes per RFC3986 §3.3: only ALPHA, DIGIT, and `-._~` pass through;
336        // everything else becomes `%XX`.
337        let path_encoder = quote! {
338            fn __pct_encode_path_segment(s: &str) -> String {
339                let mut out = String::with_capacity(s.len());
340                for &b in s.as_bytes() {
341                    match b {
342                        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
343                            out.push(b as char);
344                        }
345                        _ => {
346                            out.push('%');
347                            out.push_str(&format!("{:02X}", b));
348                        }
349                    }
350                }
351                out
352            }
353        };
354
355        // Combine all parts
356        quote! {
357            #retry_config_struct
358            #client_struct
359
360            impl HttpClient {
361                #constructor
362                #builder_methods
363            }
364
365            #default_impl
366            #path_encoder
367        }
368    }
369
370    /// Generate the constructor method
371    fn generate_constructor(&self, has_retry: bool, has_tracing: bool) -> TokenStream {
372        // Seed `base_url` from configuration rather than always starting empty.
373        // A client built with an empty base URL sends every request to a
374        // relative path and fails, so a user who set `[http_client] base_url`
375        // in their TOML — or whose spec declares `servers[0].url`, which the
376        // config layer resolves into the same field — previously had to repeat
377        // it via `with_base_url` or watch every call 404 (openapi-generator-igg).
378        let configured_base_url = self
379            .config()
380            .http_client_config
381            .as_ref()
382            .and_then(|http| http.base_url.as_deref())
383            .unwrap_or_default();
384        let default_base_url = quote! { #configured_base_url.to_string() };
385        let max_response_body_bytes = self
386            .config()
387            .http_client_config
388            .as_ref()
389            .and_then(|http| http.max_response_body_bytes)
390            .unwrap_or(8 * 1024 * 1024);
391
392        let retry_param = if has_retry {
393            quote! { retry_config: Option<RetryConfig>, }
394        } else {
395            quote! {}
396        };
397
398        let tracing_param = if has_tracing {
399            quote! { enable_tracing: bool, }
400        } else {
401            quote! {}
402        };
403
404        let retry_middleware = if has_retry {
405            quote! {
406                if let Some(config) = retry_config {
407                    use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
408
409                    let retry_policy = ExponentialBackoff::builder()
410                        .retry_bounds(
411                            std::time::Duration::from_millis(config.initial_delay_ms),
412                            std::time::Duration::from_millis(config.max_delay_ms),
413                        )
414                        .build_with_max_retries(config.max_retries);
415
416                    let retry_middleware = RetryTransientMiddleware::new_with_policy(retry_policy);
417                    client_builder = client_builder.with(retry_middleware);
418                }
419            }
420        } else {
421            quote! {}
422        };
423
424        let tracing_middleware = if has_tracing {
425            quote! {
426                if enable_tracing {
427                    use reqwest_tracing::TracingMiddleware;
428                    client_builder = client_builder.with(TracingMiddleware::default());
429                }
430            }
431        } else {
432            quote! {}
433        };
434
435        let default_constructor = if has_retry && has_tracing {
436            quote! {
437                /// Create a new HTTP client with default configuration
438                pub fn new() -> Self {
439                    Self::with_config(None, true)
440                }
441            }
442        } else if has_retry {
443            quote! {
444                /// Create a new HTTP client with default configuration
445                pub fn new() -> Self {
446                    Self::with_config(None)
447                }
448            }
449        } else if has_tracing {
450            quote! {
451                /// Create a new HTTP client with default configuration
452                pub fn new() -> Self {
453                    Self::with_config(true)
454                }
455            }
456        } else {
457            quote! {
458                /// Create a new HTTP client with default configuration
459                pub fn new() -> Self {
460                    Self::with_client(reqwest::Client::new())
461                }
462            }
463        };
464
465        if has_retry || has_tracing {
466            let retry_arg = has_retry.then(|| quote! { retry_config, });
467            let tracing_arg = has_tracing.then(|| quote! { enable_tracing, });
468            let default_retry_arg = has_retry.then(|| quote! { None, });
469            let default_tracing_arg = has_tracing.then(|| quote! { true, });
470
471            quote! {
472                #default_constructor
473
474                /// Create a new HTTP client with custom configuration
475                pub fn with_config(#retry_param #tracing_param) -> Self {
476                    Self::with_client_and_config(reqwest::Client::new(), #retry_arg #tracing_arg)
477                }
478
479                /// Create an HTTP client using a preconfigured reqwest client.
480                ///
481                /// Preserves the supplied client's transport settings (for example,
482                /// TLS identity, trusted roots, proxy, timeout, and default headers).
483                /// Uses the same middleware defaults as `new()`.
484                pub fn with_client(client: reqwest::Client) -> Self {
485                    Self::with_client_and_config(client, #default_retry_arg #default_tracing_arg)
486                }
487
488                /// Create an HTTP client using a preconfigured reqwest client
489                /// and explicit retry/tracing configuration.
490                ///
491                /// Wraps the supplied client with the generated middleware stack.
492                pub fn with_client_and_config(client: reqwest::Client, #retry_param #tracing_param) -> Self {
493                    let mut client_builder = ClientBuilder::new(client);
494
495                    #tracing_middleware
496                    #retry_middleware
497
498                    let http_client = client_builder.build();
499
500                    Self {
501                        base_url: #default_base_url,
502                        api_key: None,
503                        http_client,
504                        custom_headers: BTreeMap::new(),
505                        max_response_body_bytes: #max_response_body_bytes,
506                    }
507                }
508            }
509        } else {
510            quote! {
511                #default_constructor
512
513                /// Create an HTTP client using a preconfigured reqwest client.
514                ///
515                /// Preserves the supplied client's transport settings (for example,
516                /// TLS identity, trusted roots, proxy, timeout, and default headers).
517                pub fn with_client(client: reqwest::Client) -> Self {
518                    let http_client = ClientBuilder::new(client).build();
519
520                    Self {
521                        base_url: #default_base_url,
522                        api_key: None,
523                        http_client,
524                        custom_headers: BTreeMap::new(),
525                        max_response_body_bytes: #max_response_body_bytes,
526                    }
527                }
528            }
529        }
530    }
531
532    /// Generate builder methods for configuration
533    fn generate_builder_methods(&self) -> TokenStream {
534        quote! {
535            /// Set the base URL for all requests
536            pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
537                self.base_url = base_url.into();
538                self
539            }
540
541            /// Set the API key for authentication
542            pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
543                self.api_key = Some(api_key.into());
544                self
545            }
546
547            /// Set the maximum number of response-body bytes buffered in memory.
548            pub fn with_max_response_body_bytes(mut self, limit: usize) -> Self {
549                self.max_response_body_bytes = limit;
550                self
551            }
552
553            /// Add a custom header to all requests
554            pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
555                self.custom_headers.insert(name.into(), value.into());
556                self
557            }
558
559            /// Add multiple custom headers
560            pub fn with_headers(mut self, headers: BTreeMap<String, String>) -> Self {
561                self.custom_headers.extend(headers);
562                self
563            }
564        }
565    }
566
567    /// Generate HTTP operation methods for the client.
568    ///
569    /// Emits per-operation typed error enums (one variant per declared non-2xx
570    /// response with a body schema) BEFORE the `impl HttpClient` block so the
571    /// generated method signatures can reference them. This low-level helper
572    /// intentionally emits every analyzed operation; use
573    /// [`Self::generate_http_client`] or [`Self::generate_all`] to honor the
574    /// configured `[client].operations` scope.
575    /// Generate standalone HTTP methods. Declared SSE alternatives return raw
576    /// byte streams; `generate_all` bundles the optional parsed SSE runtime.
577    pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream {
578        let operations: Vec<&OperationInfo> = analysis.operations.values().collect();
579        let mut config = self.config().clone();
580        config.enable_sse_client = false;
581        CodeGenerator::new(config).generate_operation_methods_for(analysis, &operations)
582    }
583
584    /// Generate every operation-owned client artifact from one resolved
585    /// operation slice. This keeps methods, parameter enums, and typed error
586    /// enums in lockstep for selective clients.
587    pub(crate) fn generate_operation_methods_for(
588        &self,
589        analysis: &SchemaAnalysis,
590        operations: &[&OperationInfo],
591    ) -> TokenStream {
592        let param_enums = self.generate_param_enum_types(operations);
593
594        let op_error_enums: Vec<TokenStream> = operations
595            .iter()
596            .copied()
597            .filter_map(|op| self.generate_op_error_enum(op))
598            .collect();
599
600        let plans = self.client_method_plans(analysis, operations);
601        let methods: Vec<TokenStream> = plans
602            .iter()
603            .map(|plan| self.generate_planned_operation_method(analysis, plan))
604            .collect();
605        let (operation_builders, builder_entries) =
606            self.generate_operation_builders(analysis, &plans);
607
608        quote! {
609            #param_enums
610
611            #(#op_error_enums)*
612
613            #(#operation_builders)*
614
615            impl HttpClient {
616                #(#methods)*
617                #(#builder_entries)*
618            }
619        }
620    }
621
622    pub(crate) fn client_method_plans<'a>(
623        &self,
624        analysis: &'a SchemaAnalysis,
625        operations: &[&'a OperationInfo],
626    ) -> Vec<ClientMethodPlan<'a>> {
627        // Reserve every real operation before allocating additive bindings.
628        let mut used: std::collections::HashSet<String> = [
629            "new",
630            "with_config",
631            "with_client",
632            "with_client_and_config",
633            "with_base_url",
634            "with_api_key",
635            "with_max_response_body_bytes",
636            "with_header",
637            "with_headers",
638        ]
639        .into_iter()
640        .map(str::to_owned)
641        .collect();
642        let inherent = used.clone();
643        for operation in operations {
644            let ident = self.get_method_name(operation).to_string();
645            used.insert(ident.strip_prefix("r#").unwrap_or(&ident).to_string());
646        }
647        let mut assigned = std::collections::HashSet::new();
648        let mut base_names = BTreeMap::new();
649        for operation in operations {
650            let ident = self.get_method_name(operation).to_string();
651            let plain = ident.strip_prefix("r#").unwrap_or(&ident);
652            let name = if inherent.contains(plain) || !assigned.insert(plain.to_string()) {
653                Self::allocate_name(&ident, &mut used)
654            } else {
655                ident
656            };
657            base_names.insert(operation.operation_id.as_str(), Self::to_field_ident(&name));
658        }
659        let mut plans = Vec::new();
660        for &operation in operations {
661            let method_ident = base_names[operation.operation_id.as_str()].clone();
662            let success = self.get_success_response(analysis, operation);
663            plans.push(self.make_client_method_plan(
664                operation,
665                method_ident.clone(),
666                success.clone(),
667                "buffered",
668                false,
669            ));
670            let Some(responses) = analysis.operation_responses.get(&operation.operation_id) else {
671                continue;
672            };
673            let mut representations: BTreeMap<&str, Vec<(&str, &OperationRepresentation)>> =
674                BTreeMap::new();
675            for (status, response) in responses
676                .iter()
677                .filter(|(status, _)| status.starts_with('2'))
678            {
679                for (media_type, representation) in &response.representations {
680                    representations
681                        .entry(media_type)
682                        .or_default()
683                        .push((status, representation));
684                }
685            }
686            let mut kind_counts = BTreeMap::new();
687            for alternatives in representations.values() {
688                let kind = Self::representation_kind(alternatives[0].1);
689                *kind_counts.entry(kind).or_insert(0usize) += 1;
690            }
691            for (media_type, alternatives) in representations {
692                let representation = alternatives[0].1;
693                let incompatible = alternatives
694                    .iter()
695                    .any(|(_, other)| *other != representation);
696                let groups: Vec<Vec<(&str, &OperationRepresentation)>> = if incompatible {
697                    alternatives
698                        .into_iter()
699                        .map(|alternative| vec![alternative])
700                        .collect()
701                } else {
702                    vec![alternatives]
703                };
704                for alternatives in groups {
705                    let representation = alternatives[0].1;
706                    let kind = Self::representation_kind(representation);
707                    let suffix = if kind_counts[kind] > 1 {
708                        format!("{kind}_{}", media_type.to_snake_case())
709                    } else {
710                        kind.to_string()
711                    };
712                    let suffix = if incompatible {
713                        format!("{suffix}_status_{}", alternatives[0].0.to_ascii_lowercase())
714                    } else {
715                        suffix
716                    };
717                    let body = match representation {
718                        OperationRepresentation::Json { schema_name } => {
719                            ClientSuccessBody::Json(schema_name)
720                        }
721                        OperationRepresentation::Text => ClientSuccessBody::Text,
722                        OperationRepresentation::Binary { .. } => ClientSuccessBody::Binary,
723                        OperationRepresentation::EventStream => ClientSuccessBody::EventStream,
724                    };
725                    let selection = ClientSuccessSelection {
726                        statuses: alternatives.iter().map(|(status, _)| *status).collect(),
727                        excluded_statuses: responses
728                            .keys()
729                            .filter(|status| {
730                                status.len() == 3
731                                    && status.bytes().all(|c| c.is_ascii_digit())
732                                    && !alternatives
733                                        .iter()
734                                        .any(|(selected, _)| selected == &status.as_str())
735                            })
736                            .map(String::as_str)
737                            .collect(),
738                        body,
739                        accept: Some(media_type),
740                    };
741                    let default = self.get_success_response(analysis, operation);
742                    let equivalent =
743                        Self::success_bodies_are_compatible(default.body, selection.body)
744                            && default.statuses == selection.statuses
745                            && default.accept == selection.accept;
746                    if !matches!(representation, OperationRepresentation::EventStream)
747                        && !equivalent
748                    {
749                        let name =
750                            Self::allocate_name(&format!("{method_ident}_{suffix}"), &mut used);
751                        plans.push(self.make_client_method_plan(
752                            operation,
753                            Self::to_field_ident(&name),
754                            selection.clone(),
755                            "buffered",
756                            true,
757                        ));
758                    }
759                    if matches!(representation, OperationRepresentation::Binary { .. }) {
760                        let name = Self::allocate_name(
761                            &format!("{method_ident}_{suffix}_stream"),
762                            &mut used,
763                        );
764                        let live_selection = ClientSuccessSelection {
765                            body: ClientSuccessBody::EventStream,
766                            ..selection.clone()
767                        };
768                        let mut plan = self.make_client_method_plan(
769                            operation,
770                            Self::to_field_ident(&name),
771                            live_selection,
772                            "binary_stream",
773                            true,
774                        );
775                        plan.response.kind = "binary".to_string();
776                        plans.push(plan);
777                    }
778                    if matches!(representation, OperationRepresentation::EventStream) {
779                        if self.config().enable_sse_client {
780                            let name =
781                                Self::allocate_name(&format!("{method_ident}_sse"), &mut used);
782                            let selection = ClientSuccessSelection {
783                                body: ClientSuccessBody::ParsedSse,
784                                ..selection
785                            };
786                            plans.push(self.make_client_method_plan(
787                                operation,
788                                Self::to_field_ident(&name),
789                                selection,
790                                "parsed_sse",
791                                true,
792                            ));
793                        } else if !equivalent {
794                            let name =
795                                Self::allocate_name(&format!("{method_ident}_{suffix}"), &mut used);
796                            plans.push(self.make_client_method_plan(
797                                operation,
798                                Self::to_field_ident(&name),
799                                selection,
800                                "raw_event_stream",
801                                true,
802                            ));
803                        }
804                    }
805                }
806            }
807        }
808        self.plan_operation_builders(analysis, &mut plans);
809        plans
810    }
811
812    fn representation_kind(representation: &OperationRepresentation) -> &'static str {
813        match representation {
814            OperationRepresentation::Json { .. } => "json",
815            OperationRepresentation::Text => "text",
816            OperationRepresentation::Binary { .. } => "binary",
817            OperationRepresentation::EventStream => "event_stream",
818        }
819    }
820
821    fn make_client_method_plan<'a>(
822        &self,
823        operation: &'a OperationInfo,
824        method_ident: syn::Ident,
825        success: ClientSuccessSelection<'a>,
826        consumption: &str,
827        validate_content_type: bool,
828    ) -> ClientMethodPlan<'a> {
829        let precise_stream = matches!(success.body, ClientSuccessBody::EventStream);
830        let mut captures = Vec::new();
831        let request = if precise_stream {
832            self.generate_request_param_with_capture(operation, Some(&mut captures))
833        } else {
834            self.generate_request_param(operation)
835        };
836        let generics = if captures.is_empty() {
837            TokenStream::new()
838        } else {
839            quote! { <#(#captures: AsRef<str>),*> }
840        };
841        let request_parameters = request;
842        let response_type = if precise_stream {
843            quote! { impl futures_util::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + 'static + use<#(#captures),*> }
844        } else if consumption == "parsed_sse" {
845            quote! { super::sse::BoxSseStream<Result<super::sse::SseEvent<String>, super::sse::StreamingError>> }
846        } else {
847            self.response_type_for_body(success.body)
848        };
849        let kind = match success.body {
850            ClientSuccessBody::Json(_) => "json",
851            ClientSuccessBody::Text => "text",
852            ClientSuccessBody::Binary => "binary",
853            ClientSuccessBody::EventStream | ClientSuccessBody::ParsedSse => "event_stream",
854            ClientSuccessBody::Empty => "empty",
855        };
856        let consumption = if matches!(success.body, ClientSuccessBody::EventStream)
857            && consumption == "buffered"
858        {
859            "raw_event_stream"
860        } else {
861            consumption
862        };
863        ClientMethodPlan {
864            operation,
865            method_ident,
866            request_parameters,
867            generics,
868            response_type,
869            error_type: self.op_error_type_token(operation),
870            response: ClientResponsePlan {
871                statuses: success.statuses.iter().map(|s| (*s).to_string()).collect(),
872                excluded_statuses: success
873                    .excluded_statuses
874                    .iter()
875                    .map(|s| (*s).to_string())
876                    .collect(),
877                media_type: success.accept.map(str::to_owned),
878                kind: kind.to_string(),
879                consumption: consumption.to_string(),
880            },
881            builder_method_ident: None,
882            builder_type_ident: None,
883            success,
884            validate_content_type,
885        }
886    }
887
888    fn plan_operation_builders(
889        &self,
890        analysis: &SchemaAnalysis,
891        plans: &mut [ClientMethodPlan<'_>],
892    ) {
893        if !self.config().builders.enabled {
894            return;
895        }
896
897        let mut used_entry_methods: std::collections::HashSet<String> = plans
898            .iter()
899            .map(|plan| plan.method_ident.to_string())
900            .collect();
901        let mut used_type_names = std::collections::HashSet::new();
902        for schema_name in analysis.schemas.keys() {
903            let rust_name = self.to_rust_type_name(schema_name);
904            used_type_names.insert(rust_name.clone());
905            // Request-model builders live in `types.rs` and are imported by
906            // glob into the client module. Reserve their conventional names
907            // so operation builders cannot create ambiguous re-exports.
908            used_type_names.insert(format!("{rust_name}Builder"));
909        }
910        used_type_names.insert("HttpClient".to_string());
911        used_type_names.insert("ApiOpError".to_string());
912        used_type_names.extend(
913            [
914                "ClientBuilder",
915                "ClientWithMiddleware",
916                "RetryConfig",
917                "HttpError",
918                "BTreeMap",
919            ]
920            .into_iter()
921            .map(str::to_string),
922        );
923        for plan in plans.iter() {
924            let operation = plan.operation;
925            used_type_names.insert(self.op_error_enum_ident(operation).to_string());
926            used_type_names.extend(
927                operation
928                    .parameters
929                    .iter()
930                    .filter(|parameter| parameter.enum_values.is_some())
931                    .map(|parameter| parameter.rust_type.clone()),
932            );
933        }
934
935        for plan in plans.iter_mut() {
936            let operation = plan.operation;
937            let allocated_params = self.allocated_operation_params(operation);
938            let body_plan = self.body_model_plan(operation, analysis);
939            let optional_param_count = allocated_params
940                .iter()
941                .filter(|allocated| !Self::builder_param_is_required(allocated.param))
942                .count();
943            let optional_body_count =
944                usize::from(operation.request_body.is_some() && !operation.request_body_required);
945            let body_field_count = body_plan
946                .as_ref()
947                .filter(|plan| {
948                    operation.request_body_required
949                        || matches!(
950                            &plan.required_construction,
951                            RequiredBodyConstruction::Default
952                        )
953                })
954                .map_or(0, |plan| plan.optional_fields.len());
955            let optional_count = optional_param_count + optional_body_count + body_field_count;
956            if optional_count <= self.config().builders.threshold {
957                continue;
958            }
959
960            let flat_method = plan.method_ident.clone();
961            let entry_base = format!("{flat_method}_builder");
962            let entry_name = Self::allocate_name(&entry_base, &mut used_entry_methods);
963            let entry_ident = Self::to_field_ident(&entry_name);
964
965            let builder_base = format!("{}Builder", flat_method.to_string().to_pascal_case());
966            let builder_name = Self::allocate_type_name(&builder_base, &mut used_type_names);
967            let builder_ident = format_ident!("{builder_name}");
968
969            plan.builder_method_ident = Some(entry_ident);
970            plan.builder_type_ident = Some(builder_ident);
971        }
972    }
973
974    fn generate_operation_builders(
975        &self,
976        analysis: &SchemaAnalysis,
977        plans: &[ClientMethodPlan<'_>],
978    ) -> (Vec<TokenStream>, Vec<TokenStream>) {
979        let mut definitions = Vec::new();
980        let mut entries = Vec::new();
981        for plan in plans {
982            let (Some(entry_ident), Some(builder_ident)) =
983                (&plan.builder_method_ident, &plan.builder_type_ident)
984            else {
985                continue;
986            };
987            let operation = plan.operation;
988            let allocated_params = self.allocated_operation_params(operation);
989            let (definition, entry) = self.generate_single_operation_builder(
990                analysis,
991                operation,
992                &allocated_params,
993                self.body_model_plan(operation, analysis),
994                &plan.method_ident,
995                entry_ident,
996                builder_ident,
997                plan,
998            );
999            definitions.push(definition);
1000            entries.push(entry);
1001        }
1002        (definitions, entries)
1003    }
1004
1005    #[allow(clippy::too_many_arguments)]
1006    fn generate_single_operation_builder(
1007        &self,
1008        _analysis: &SchemaAnalysis,
1009        operation: &OperationInfo,
1010        allocated_params: &[AllocatedOperationParam<'_>],
1011        body_plan: Option<BodyModelPlan>,
1012        flat_method: &syn::Ident,
1013        entry_ident: &syn::Ident,
1014        builder_ident: &syn::Ident,
1015        plan: &ClientMethodPlan<'_>,
1016    ) -> (TokenStream, TokenStream) {
1017        let mut fields = vec![quote! { client: &'a HttpClient }];
1018        let mut entry_parameters = Vec::new();
1019        let mut initializers = vec![quote! { client: self }];
1020        let mut call_arguments = Vec::new();
1021        let mut setters = Vec::new();
1022        let mut used_entry_params = std::collections::HashSet::new();
1023        let mut used_methods = std::collections::HashSet::from(["send".to_string()]);
1024
1025        for allocated in allocated_params {
1026            let field_ident = &allocated.ident;
1027            let storage_type = self.builder_param_storage_type(allocated.param);
1028            if Self::builder_param_is_required(allocated.param) {
1029                fields.push(quote! { #field_ident: #storage_type });
1030                let entry_name =
1031                    Self::allocate_name(&field_ident.to_string(), &mut used_entry_params);
1032                let entry_param = Self::to_field_ident(&entry_name);
1033                if Self::param_has_impl_as_ref_type(allocated.param) {
1034                    entry_parameters.push(quote! { #entry_param: impl Into<String> });
1035                    initializers.push(quote! { #field_ident: #entry_param.into() });
1036                } else {
1037                    entry_parameters.push(quote! { #entry_param: #storage_type });
1038                    initializers.push(quote! { #field_ident: #entry_param });
1039                }
1040            } else {
1041                fields.push(quote! { #field_ident: Option<#storage_type> });
1042                initializers.push(quote! { #field_ident: None });
1043                let setter_ident =
1044                    Self::allocate_builder_method(&field_ident.to_string(), &mut used_methods);
1045                let wire_name = &allocated.param.name;
1046                let assignment = if Self::param_has_impl_as_ref_type(allocated.param) {
1047                    quote! { self.#field_ident = Some(#field_ident.into()); }
1048                } else {
1049                    quote! { self.#field_ident = Some(#field_ident); }
1050                };
1051                let setter_type = if Self::param_has_impl_as_ref_type(allocated.param) {
1052                    quote! { impl Into<String> }
1053                } else {
1054                    storage_type.clone()
1055                };
1056                setters.push(quote! {
1057                    #[doc = concat!("Set the optional `", #wire_name, "` operation parameter.")]
1058                    #[must_use]
1059                    pub fn #setter_ident(mut self, #field_ident: #setter_type) -> Self {
1060                        #assignment
1061                        self
1062                    }
1063                });
1064            }
1065            call_arguments.push(quote! { self.#field_ident });
1066        }
1067
1068        if let Some(body_plan) = body_plan {
1069            let BodyModelPlan {
1070                body_ident,
1071                body_type,
1072                required_construction,
1073                optional_fields,
1074            } = body_plan;
1075            let can_initialize_optional_body =
1076                matches!(&required_construction, RequiredBodyConstruction::Default);
1077            if operation.request_body_required {
1078                fields.push(quote! { #body_ident: #body_type });
1079                match required_construction {
1080                    RequiredBodyConstruction::Default => {
1081                        initializers.push(quote! { #body_ident: Default::default() });
1082                    }
1083                    RequiredBodyConstruction::New(constructor_params) => {
1084                        let mut constructor_args = Vec::new();
1085                        for constructor in constructor_params {
1086                            let preferred = constructor.preferred_ident.to_string();
1087                            let entry_name =
1088                                Self::allocate_name(&preferred, &mut used_entry_params);
1089                            let entry_param = Self::to_field_ident(&entry_name);
1090                            let value_type = constructor.value_type;
1091                            entry_parameters.push(quote! { #entry_param: #value_type });
1092                            constructor_args.push(entry_param);
1093                        }
1094                        initializers.push(quote! {
1095                            #body_ident: #body_type::new(#(#constructor_args),*)
1096                        });
1097                    }
1098                    RequiredBodyConstruction::Whole => {
1099                        let entry_name =
1100                            Self::allocate_name(&body_ident.to_string(), &mut used_entry_params);
1101                        let entry_param = Self::to_field_ident(&entry_name);
1102                        entry_parameters.push(quote! { #entry_param: #body_type });
1103                        initializers.push(quote! { #body_ident: #entry_param });
1104                    }
1105                }
1106            } else {
1107                fields.push(quote! { #body_ident: Option<#body_type> });
1108                initializers.push(quote! { #body_ident: None });
1109            }
1110
1111            let body_setter =
1112                Self::allocate_builder_method(&body_ident.to_string(), &mut used_methods);
1113            let body_assignment = if operation.request_body_required {
1114                quote! { self.#body_ident = #body_ident; }
1115            } else {
1116                quote! { self.#body_ident = Some(#body_ident); }
1117            };
1118            setters.push(quote! {
1119                /// Replace the complete request body.
1120                #[must_use]
1121                pub fn #body_setter(mut self, #body_ident: #body_type) -> Self {
1122                    #body_assignment
1123                    self
1124                }
1125            });
1126
1127            if operation.request_body_required || can_initialize_optional_body {
1128                for field in optional_fields {
1129                    let setter_ident = Self::allocate_builder_method(
1130                        &field.preferred_method_name,
1131                        &mut used_methods,
1132                    );
1133                    let preferred_method_name = field.preferred_method_name.clone();
1134                    let value_ident = field.value_ident;
1135                    let value_type = field.value_type;
1136                    let wire_name = field.wire_name;
1137                    let access_path = field.access_path;
1138                    let assignment = if operation.request_body_required {
1139                        let mut target = quote! { self.#body_ident };
1140                        for access in &access_path {
1141                            target = quote! { #target.#access };
1142                        }
1143                        if field.tri_state {
1144                            quote! { #target = Some(Some(#value_ident)); }
1145                        } else {
1146                            quote! { #target = Some(#value_ident); }
1147                        }
1148                    } else {
1149                        let mut target = quote! { request };
1150                        for access in &access_path {
1151                            target = quote! { #target.#access };
1152                        }
1153                        if field.tri_state {
1154                            quote! {
1155                                let request = self.#body_ident.get_or_insert_with(Default::default);
1156                                #target = Some(Some(#value_ident));
1157                            }
1158                        } else {
1159                            quote! {
1160                                let request = self.#body_ident.get_or_insert_with(Default::default);
1161                                #target = Some(#value_ident);
1162                            }
1163                        }
1164                    };
1165                    setters.push(quote! {
1166                        #[doc = concat!("Set the optional request-body field `", #wire_name, "`.")]
1167                        #[must_use]
1168                        pub fn #setter_ident(mut self, #value_ident: #value_type) -> Self {
1169                            #assignment
1170                            self
1171                        }
1172                    });
1173                    if field.tri_state {
1174                        let null_ident = Self::allocate_builder_method(
1175                            &format!("{preferred_method_name}_null"),
1176                            &mut used_methods,
1177                        );
1178                        let absent_ident = Self::allocate_builder_method(
1179                            &format!("{preferred_method_name}_absent"),
1180                            &mut used_methods,
1181                        );
1182                        let null_assignment = if operation.request_body_required {
1183                            let mut target = quote! { self.#body_ident };
1184                            for access in &access_path {
1185                                target = quote! { #target.#access };
1186                            }
1187                            quote! { #target = Some(None); }
1188                        } else {
1189                            let mut target = quote! { request };
1190                            for access in &access_path {
1191                                target = quote! { #target.#access };
1192                            }
1193                            quote! {
1194                                let request = self.#body_ident.get_or_insert_with(Default::default);
1195                                #target = Some(None);
1196                            }
1197                        };
1198                        let absent_assignment = if operation.request_body_required {
1199                            let mut target = quote! { self.#body_ident };
1200                            for access in &access_path {
1201                                target = quote! { #target.#access };
1202                            }
1203                            quote! { #target = None; }
1204                        } else {
1205                            let mut target = quote! { request };
1206                            for access in &access_path {
1207                                target = quote! { #target.#access };
1208                            }
1209                            quote! {
1210                                if let Some(request) = self.#body_ident.as_mut() {
1211                                    #target = None;
1212                                }
1213                            }
1214                        };
1215                        setters.push(quote! {
1216                            #[doc = concat!("Set the optional nullable request-body field `", #wire_name, "` to JSON null.")]
1217                            #[must_use]
1218                            pub fn #null_ident(mut self) -> Self {
1219                                #null_assignment
1220                                self
1221                            }
1222
1223                            #[doc = concat!("Omit the optional nullable request-body field `", #wire_name, "`.")]
1224                            #[must_use]
1225                            pub fn #absent_ident(mut self) -> Self {
1226                                #absent_assignment
1227                                self
1228                            }
1229                        });
1230                    }
1231                }
1232            }
1233            call_arguments.push(quote! { self.#body_ident });
1234        }
1235
1236        let response_type = self.response_type_for_body(plan.success.body);
1237        let error_type = &plan.error_type;
1238        let operation_id = &operation.operation_id;
1239        let send = quote! { self.client.#flat_method(#(#call_arguments),*).await };
1240        // The entry point is deprecated with its operation. `send` calls the
1241        // deprecated method on the caller's behalf, who was warned at the entry.
1242        let deprecated = Self::deprecated_attribute(operation);
1243        let allow_deprecated = if operation.deprecated {
1244            quote! { #[allow(deprecated)] }
1245        } else {
1246            TokenStream::new()
1247        };
1248        let definition = quote! {
1249            #[doc = concat!("Additive request builder for `", #operation_id, "`.")]
1250            #[must_use]
1251            pub struct #builder_ident<'a> {
1252                #(#fields,)*
1253            }
1254
1255            impl<'a> #builder_ident<'a> {
1256                #(#setters)*
1257
1258                /// Send the request through the existing flat operation method.
1259                #allow_deprecated
1260                pub async fn send(self) -> Result<#response_type, ApiOpError<#error_type>> {
1261                    #send
1262                }
1263            }
1264        };
1265        let entry = quote! {
1266            #[doc = concat!("Start an additive builder for `", #operation_id, "`.")]
1267            #deprecated
1268            pub fn #entry_ident(
1269                &self,
1270                #(#entry_parameters),*
1271            ) -> #builder_ident<'_> {
1272                #builder_ident {
1273                    #(#initializers,)*
1274                }
1275            }
1276        };
1277        (definition, entry)
1278    }
1279
1280    fn allocate_name(base: &str, used: &mut std::collections::HashSet<String>) -> String {
1281        let raw = base.starts_with("r#");
1282        let base = base.strip_prefix("r#").unwrap_or(base);
1283        let mut candidate = base.to_string();
1284        let mut suffix = 2;
1285        while used.contains(&candidate) || used.contains(&format!("r#{candidate}")) {
1286            candidate = format!("{base}_{suffix}");
1287            suffix += 1;
1288        }
1289        used.insert(candidate.clone());
1290        if raw {
1291            format!("r#{candidate}")
1292        } else {
1293            candidate
1294        }
1295    }
1296
1297    fn allocate_type_name(base: &str, used: &mut std::collections::HashSet<String>) -> String {
1298        if used.insert(base.to_string()) {
1299            return base.to_string();
1300        }
1301
1302        let mut suffix = 2;
1303        loop {
1304            let candidate = format!("{base}{suffix}");
1305            if used.insert(candidate.clone()) {
1306                return candidate;
1307            }
1308            suffix += 1;
1309        }
1310    }
1311
1312    fn allocate_builder_method(
1313        preferred: &str,
1314        used: &mut std::collections::HashSet<String>,
1315    ) -> syn::Ident {
1316        let plain = preferred.strip_prefix("r#").unwrap_or(preferred);
1317        let base = if used.contains(preferred) {
1318            format!("with_{plain}")
1319        } else {
1320            preferred.to_string()
1321        };
1322        let allocated = Self::allocate_name(&base, used);
1323        Self::to_field_ident(&allocated)
1324    }
1325
1326    fn allocated_operation_params<'a>(
1327        &self,
1328        operation: &'a OperationInfo,
1329    ) -> Vec<AllocatedOperationParam<'a>> {
1330        // Analyzer resolves parameter collisions once. Every request path and
1331        // flat/builder signature uses those identifiers.
1332        let mut used: std::collections::HashSet<String> =
1333            ["request", "body", "req", "request_url", "client", "form"]
1334                .into_iter()
1335                .map(str::to_owned)
1336                .collect();
1337        let mut allocated = Vec::new();
1338        for location in ["path", "query", "header", "cookie"] {
1339            for parameter in &operation.parameters {
1340                if parameter.location != location {
1341                    continue;
1342                }
1343                let raw = self.param_ident_str(parameter);
1344                let chosen = Self::allocate_name(&raw, &mut used);
1345                allocated.push(AllocatedOperationParam {
1346                    param: parameter,
1347                    ident: Self::to_field_ident(&chosen),
1348                });
1349            }
1350        }
1351        allocated
1352    }
1353
1354    fn operation_param_ident(
1355        &self,
1356        operation: &OperationInfo,
1357        parameter: &ParameterInfo,
1358    ) -> String {
1359        self.allocated_operation_params(operation)
1360            .into_iter()
1361            .find(|allocated| std::ptr::eq(allocated.param, parameter))
1362            .map(|allocated| allocated.ident.to_string())
1363            .unwrap_or_else(|| self.param_ident_str(parameter))
1364    }
1365
1366    fn builder_param_is_required(parameter: &ParameterInfo) -> bool {
1367        // The existing flat signature always emits path parameters as bare
1368        // values. Invalid real-world specs sometimes omit `required: true`;
1369        // mirror the flat contract so builder delegation remains type-correct.
1370        parameter.location == "path" || parameter.required
1371    }
1372
1373    fn builder_param_storage_type(&self, parameter: &ParameterInfo) -> TokenStream {
1374        self.get_param_owned_rust_type(parameter)
1375    }
1376
1377    fn param_has_impl_as_ref_type(parameter: &ParameterInfo) -> bool {
1378        !matches!(
1379            &parameter.query_serialization,
1380            Some(
1381                crate::analysis::QuerySerialization::FormExplodedArray { .. }
1382                    | crate::analysis::QuerySerialization::FormArray { .. }
1383                    | crate::analysis::QuerySerialization::SimpleHeaderArray { .. },
1384            )
1385        ) && Self::param_uses_as_ref_str(parameter)
1386    }
1387
1388    fn body_model_plan(
1389        &self,
1390        operation: &OperationInfo,
1391        analysis: &SchemaAnalysis,
1392    ) -> Option<BodyModelPlan> {
1393        use crate::analysis::{RequestBodyContent, SchemaType};
1394
1395        let request_body = operation.request_body.as_ref()?;
1396        let (body_name, body_ident) = match request_body {
1397            RequestBodyContent::Json { schema_name, .. }
1398            | RequestBodyContent::FormUrlEncoded { schema_name, .. }
1399            | RequestBodyContent::Multipart { schema_name, .. } => {
1400                (schema_name.as_str(), format_ident!("request"))
1401            }
1402            RequestBodyContent::OctetStream { .. }
1403            | RequestBodyContent::Binary { .. }
1404            | RequestBodyContent::Unsupported { .. } => {
1405                return Some(BodyModelPlan {
1406                    body_ident: format_ident!("body"),
1407                    body_type: quote! { Vec<u8> },
1408                    required_construction: RequiredBodyConstruction::Whole,
1409                    optional_fields: Vec::new(),
1410                });
1411            }
1412            RequestBodyContent::TextPlain { .. } => {
1413                return Some(BodyModelPlan {
1414                    body_ident: format_ident!("body"),
1415                    body_type: quote! { String },
1416                    required_construction: RequiredBodyConstruction::Whole,
1417                    optional_fields: Vec::new(),
1418                });
1419            }
1420            RequestBodyContent::SchemaLess { .. } => return None,
1421        };
1422        let body_type_name = self.to_rust_type_name(body_name);
1423        let body_type = syn::Ident::new(&body_type_name, proc_macro2::Span::call_site());
1424        let Some((resolved_name, resolved_schema)) =
1425            self.resolve_reference_schema(body_name, analysis)
1426        else {
1427            return Some(BodyModelPlan {
1428                body_ident,
1429                body_type: quote! { #body_type },
1430                required_construction: RequiredBodyConstruction::Whole,
1431                optional_fields: Vec::new(),
1432            });
1433        };
1434
1435        let mut optional_fields = Vec::new();
1436        let mut stack = std::collections::HashSet::new();
1437        self.collect_optional_body_fields(
1438            resolved_name,
1439            Vec::new(),
1440            analysis,
1441            &mut stack,
1442            &mut optional_fields,
1443        );
1444
1445        let required_construction = match &resolved_schema.schema_type {
1446            // An object that declares variants beside its properties carries
1447            // the variant in a required field of its own, which neither
1448            // `Default` nor the required-properties constructor can supply.
1449            SchemaType::Object {
1450                variant: Some(_), ..
1451            } => RequiredBodyConstruction::Whole,
1452            SchemaType::Object {
1453                properties,
1454                required,
1455                additional_properties,
1456                ..
1457            } if !self.is_discriminated_variant(resolved_name, analysis) => {
1458                let emitted = self.emitted_object_properties(
1459                    resolved_name,
1460                    properties,
1461                    required,
1462                    additional_properties,
1463                    analysis,
1464                );
1465                let required_fields: Vec<_> = emitted
1466                    .iter()
1467                    .filter(|field| field.is_required)
1468                    .map(|field| BodyConstructorParam {
1469                        preferred_ident: field.ident.clone(),
1470                        value_type: field.field_type.clone(),
1471                    })
1472                    .collect();
1473                if required_fields.is_empty() {
1474                    RequiredBodyConstruction::Default
1475                } else if emitted.iter().any(|field| !field.is_required)
1476                    || additional_properties.is_open()
1477                {
1478                    RequiredBodyConstruction::New(required_fields)
1479                } else {
1480                    RequiredBodyConstruction::Whole
1481                }
1482            }
1483            _ => RequiredBodyConstruction::Whole,
1484        };
1485
1486        Some(BodyModelPlan {
1487            body_ident,
1488            body_type: quote! { #body_type },
1489            required_construction,
1490            optional_fields,
1491        })
1492    }
1493
1494    fn resolve_reference_schema<'a>(
1495        &self,
1496        schema_name: &'a str,
1497        analysis: &'a SchemaAnalysis,
1498    ) -> Option<(&'a str, &'a crate::analysis::AnalyzedSchema)> {
1499        let mut current = schema_name;
1500        let mut visited = std::collections::HashSet::new();
1501        loop {
1502            if !visited.insert(current) {
1503                return None;
1504            }
1505            let schema = analysis.schemas.get(current)?;
1506            if let crate::analysis::SchemaType::Reference { target } = &schema.schema_type {
1507                current = target;
1508            } else {
1509                return Some((current, schema));
1510            }
1511        }
1512    }
1513
1514    fn collect_optional_body_fields(
1515        &self,
1516        schema_name: &str,
1517        access_path: Vec<syn::Ident>,
1518        analysis: &SchemaAnalysis,
1519        stack: &mut std::collections::HashSet<String>,
1520        output: &mut Vec<BodyFieldPlan>,
1521    ) {
1522        use crate::analysis::SchemaType;
1523        if !stack.insert(schema_name.to_string()) {
1524            return;
1525        }
1526        let Some(schema) = analysis.schemas.get(schema_name) else {
1527            stack.remove(schema_name);
1528            return;
1529        };
1530        match &schema.schema_type {
1531            SchemaType::Reference { target } => {
1532                self.collect_optional_body_fields(target, access_path, analysis, stack, output);
1533            }
1534            SchemaType::Object {
1535                properties,
1536                required,
1537                additional_properties,
1538                ..
1539            } if !self.is_discriminated_variant(schema_name, analysis) => {
1540                for field in self.emitted_object_properties(
1541                    schema_name,
1542                    properties,
1543                    required,
1544                    additional_properties,
1545                    analysis,
1546                ) {
1547                    if field.is_required {
1548                        continue;
1549                    }
1550                    let mut field_path = access_path.clone();
1551                    field_path.push(field.ident.clone());
1552                    output.push(BodyFieldPlan {
1553                        wire_name: field.wire_name.to_string(),
1554                        preferred_method_name: field.ident.to_string(),
1555                        value_ident: field.ident.clone(),
1556                        value_type: self.generate_property_base_type(
1557                            schema_name,
1558                            field.wire_name,
1559                            field.property,
1560                            analysis,
1561                        ),
1562                        access_path: field_path,
1563                        tri_state: self.property_is_tri_state(
1564                            schema_name,
1565                            field.wire_name,
1566                            field.property,
1567                            field.is_required,
1568                        ),
1569                    });
1570                }
1571            }
1572            SchemaType::Composition { schemas } => {
1573                for (index, schema_ref) in schemas.iter().enumerate() {
1574                    let mut nested_path = access_path.clone();
1575                    nested_path.push(format_ident!("part_{index}"));
1576                    self.collect_optional_body_fields(
1577                        &schema_ref.target,
1578                        nested_path,
1579                        analysis,
1580                        stack,
1581                        output,
1582                    );
1583                }
1584            }
1585            _ => {}
1586        }
1587        stack.remove(schema_name);
1588    }
1589
1590    fn is_discriminated_variant(&self, schema_name: &str, analysis: &SchemaAnalysis) -> bool {
1591        analysis.schemas.values().any(|schema| {
1592            matches!(
1593                &schema.schema_type,
1594                crate::analysis::SchemaType::DiscriminatedUnion { variants, .. }
1595                    if variants.iter().any(|variant| variant.type_name == schema_name)
1596            )
1597        })
1598    }
1599
1600    /// Emit inline enum types for parameters whose schema is `type: string`
1601    /// with `enum` or `const`. The generated enum implements `Display` so it
1602    /// drops into the existing `format!`-based path/query templating without
1603    /// any special-casing at the call site. See issue #10 follow-up.
1604    fn generate_param_enum_types(&self, operations: &[&OperationInfo]) -> TokenStream {
1605        let mut by_name: BTreeMap<String, &ParameterInfo> = BTreeMap::new();
1606        for op in operations {
1607            for param in &op.parameters {
1608                if param.enum_values.is_some() {
1609                    by_name.entry(param.rust_type.clone()).or_insert(param);
1610                }
1611            }
1612        }
1613
1614        if by_name.is_empty() {
1615            return quote! {};
1616        }
1617
1618        let defs: Vec<TokenStream> = by_name
1619            .values()
1620            .map(|param| self.generate_single_param_enum(param))
1621            .collect();
1622
1623        quote! { #(#defs)* }
1624    }
1625
1626    fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream {
1627        let Some(values) = param.enum_values.as_deref() else {
1628            return quote! {};
1629        };
1630
1631        let enum_ident = format_ident!("{}", param.rust_type);
1632
1633        // Dedupe variant names. Real-world specs use sort enums like
1634        // `["created_at", "-created_at"]` (descending prefix), and both
1635        // PascalCase to `CreatedAt`. Number collisions `CreatedAt2`, … while
1636        // keeping each `serde(rename)` pointing at the original wire string.
1637        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1638        // `x-enum-varnames` wins over the naming heuristic when the spec
1639        // supplies it — the whole point of the extension is that the author
1640        // knows better than a transformation of the wire string. Schema-level
1641        // enums already honored it; parameter enums did not, so the same spec
1642        // produced different variant names depending on where its enum lived.
1643        // Suffix disambiguation still applies, since nothing stops a spec from
1644        // declaring two names that collide once converted to an identifier.
1645        let variant_names: Vec<String> = values
1646            .iter()
1647            .enumerate()
1648            .map(|(index, value)| {
1649                let base = param
1650                    .enum_varnames
1651                    .as_ref()
1652                    .and_then(|names| names.get(index))
1653                    .map(|name| self.to_rust_enum_variant(name))
1654                    .unwrap_or_else(|| self.to_rust_enum_variant(value));
1655                let mut chosen = base.clone();
1656                let mut suffix = 2;
1657                while !used.insert(chosen.clone()) {
1658                    chosen = crate::generator::numbered_variant_name(&base, suffix);
1659                    suffix += 1;
1660                }
1661                chosen
1662            })
1663            .collect();
1664
1665        let variants: Vec<TokenStream> = values
1666            .iter()
1667            .zip(&variant_names)
1668            .map(|(value, name)| {
1669                let variant_ident = format_ident!("{}", name);
1670                quote! {
1671                    #[serde(rename = #value)]
1672                    #variant_ident,
1673                }
1674            })
1675            .collect();
1676
1677        let display_arms: Vec<TokenStream> = values
1678            .iter()
1679            .zip(&variant_names)
1680            .map(|(value, name)| {
1681                let variant_ident = format_ident!("{}", name);
1682                quote! { Self::#variant_ident => #value, }
1683            })
1684            .collect();
1685
1686        let doc = format!(
1687            "Allowed values for the `{}` {} parameter.",
1688            param.name, param.location
1689        );
1690
1691        quote! {
1692            #[doc = #doc]
1693            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
1694            pub enum #enum_ident {
1695                #(#variants)*
1696            }
1697
1698            impl #enum_ident {
1699                pub fn as_str(&self) -> &'static str {
1700                    match self {
1701                        #(#display_arms)*
1702                    }
1703                }
1704            }
1705
1706            impl std::fmt::Display for #enum_ident {
1707                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1708                    f.write_str(self.as_str())
1709                }
1710            }
1711
1712            impl AsRef<str> for #enum_ident {
1713                fn as_ref(&self) -> &str {
1714                    self.as_str()
1715                }
1716            }
1717        }
1718    }
1719
1720    /// Generate the per-operation typed error enum, if the op has any non-2xx
1721    /// responses with a body schema. Returns None when the op has no declared
1722    /// error bodies — those operations use `ApiOpError<serde_json::Value>` so
1723    /// the raw response body is still inspectable.
1724    fn generate_op_error_enum(&self, op: &OperationInfo) -> Option<TokenStream> {
1725        let variants: Vec<(String, String)> = op
1726            .response_schemas
1727            .iter()
1728            .filter(|(code, _)| !code.starts_with('2'))
1729            .map(|(code, schema)| (code.clone(), schema.clone()))
1730            .collect();
1731
1732        if variants.is_empty() {
1733            return None;
1734        }
1735
1736        let enum_ident = self.op_error_enum_ident(op);
1737        let variant_decls: Vec<TokenStream> = variants
1738            .iter()
1739            .map(|(code, schema)| {
1740                let variant_ident = Self::op_error_variant_ident(code);
1741                let payload_ty_name = self.to_rust_type_name(schema);
1742                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
1743                quote! { #variant_ident(#payload_ty) }
1744            })
1745            .collect();
1746
1747        let doc = format!(
1748            "Typed error responses for `{}`. One variant per declared non-2xx response.",
1749            op.operation_id
1750        );
1751
1752        Some(quote! {
1753            #[doc = #doc]
1754            #[derive(Debug, Clone)]
1755            pub enum #enum_ident {
1756                #(#variant_decls,)*
1757            }
1758        })
1759    }
1760
1761    /// Type name (Ident) for the per-op error enum, e.g. `ListTodosApiError`.
1762    fn op_error_enum_ident(&self, op: &OperationInfo) -> syn::Ident {
1763        use heck::ToPascalCase;
1764        let name = format!(
1765            "{}ApiError",
1766            op.operation_id.replace('.', "_").to_pascal_case()
1767        );
1768        syn::Ident::new(&name, proc_macro2::Span::call_site())
1769    }
1770
1771    /// Variant name for a status code: "400" → Status400, "default" → Default,
1772    /// "4XX" → Status4xx.
1773    fn op_error_variant_ident(status_code: &str) -> syn::Ident {
1774        let raw = match status_code {
1775            "default" | "Default" => "Default".to_string(),
1776            other if other.chars().all(|c| c.is_ascii_digit()) => format!("Status{other}"),
1777            other => format!("Status{}", other.to_ascii_lowercase()),
1778        };
1779        syn::Ident::new(&raw, proc_macro2::Span::call_site())
1780    }
1781
1782    /// Token stream for the type plugged into `ApiOpError<T>` for an op:
1783    /// either the per-op enum, or `serde_json::Value` for ops with no
1784    /// declared error body schemas.
1785    fn op_error_type_token(&self, op: &OperationInfo) -> TokenStream {
1786        if op
1787            .response_schemas
1788            .iter()
1789            .any(|(code, _)| !code.starts_with('2'))
1790        {
1791            let ident = self.op_error_enum_ident(op);
1792            quote! { #ident }
1793        } else {
1794            quote! { serde_json::Value }
1795        }
1796    }
1797
1798    /// Generate a single operation method
1799    fn generate_planned_operation_method(
1800        &self,
1801        analysis: &SchemaAnalysis,
1802        plan: &ClientMethodPlan<'_>,
1803    ) -> TokenStream {
1804        let op = plan.operation;
1805        let method_name = &plan.method_ident;
1806        let http_method_call = self.http_method_call(op);
1807        let path = &op.path;
1808        let request_param = &plan.request_parameters;
1809        let generics = &plan.generics;
1810        let request_body = self.generate_request_body(op, analysis);
1811        let query_params = self.generate_query_params(op);
1812        let header_params = self.generate_header_params(op);
1813        let cookie_params = self.generate_cookie_params(op);
1814        let auth_application = self.generate_auth_application();
1815        let success = plan.success.clone();
1816        let response_type = &plan.response_type;
1817        let op_error_type = &plan.error_type;
1818        let accept = success.accept;
1819        let error_handling = self.generate_error_handling(op, success.clone());
1820        let content_validation = if plan.validate_content_type {
1821            self.generate_response_content_type_validation(op, &success)
1822        } else {
1823            TokenStream::new()
1824        };
1825        let (custom_headers, accept_header) = if let Some(media_type) = accept {
1826            (
1827                quote! {
1828                    for (name, value) in &self.custom_headers {
1829                        if !name.eq_ignore_ascii_case("accept") {
1830                            req = req.header(name, value);
1831                        }
1832                    }
1833                },
1834                quote! {
1835                    req = req.header(reqwest::header::ACCEPT, #media_type);
1836                },
1837            )
1838        } else {
1839            (
1840                quote! {
1841                    for (name, value) in &self.custom_headers {
1842                        req = req.header(name, value);
1843                    }
1844                },
1845                TokenStream::new(),
1846            )
1847        };
1848        let url_construction = self.generate_url_construction(path, op);
1849        let doc_comment = self.generate_operation_doc_comment(op);
1850        let variant_doc = if plan.validate_content_type {
1851            let description = format!(
1852                "Select `{}` as {}; accepts response statuses {}. Other successful statuses and mismatched Content-Type values are returned as inspectable API errors.",
1853                plan.response.media_type.as_deref().unwrap_or("no body"),
1854                plan.response.consumption,
1855                plan.response.statuses.join(", ")
1856            );
1857            quote! { #[doc = #description] }
1858        } else {
1859            TokenStream::new()
1860        };
1861        let stream_doc = if plan.response.consumption == "binary_stream" {
1862            quote! { #[doc = "Success chunks are returned live without a total body-size limit. Error responses use the configured buffered body-size limit."] }
1863        } else {
1864            TokenStream::new()
1865        };
1866
1867        let deprecated = Self::deprecated_attribute(op);
1868
1869        quote! {
1870            #doc_comment
1871            #variant_doc
1872            #stream_doc
1873            #deprecated
1874            pub async fn #method_name #generics(
1875                &self,
1876                #request_param
1877            ) -> Result<#response_type, ApiOpError<#op_error_type>> {
1878                #url_construction
1879
1880                let mut req = #http_method_call;
1881                #request_body
1882
1883                #query_params
1884                #header_params
1885                #cookie_params
1886
1887                // Apply configured authentication (T3). Was previously
1888                // hardcoded to bearer_auth regardless of GeneratorConfig.
1889                #auth_application
1890
1891                // Add custom headers
1892                #custom_headers
1893
1894                // Keep content negotiation aligned with the generated return type,
1895                // replacing any custom Accept value for this operation.
1896                #accept_header
1897
1898                let response = req.send().await?;
1899                #content_validation
1900                #error_handling
1901            }
1902        }
1903    }
1904
1905    /// T3: emit the auth-token application based on the configured AuthConfig.
1906    /// Default (no config) is Bearer on Authorization. ApiKey emits a custom
1907    /// header. Custom honors header_value_prefix.
1908    fn generate_auth_application(&self) -> TokenStream {
1909        use crate::http_config::AuthConfig;
1910        match &self.config().auth_config {
1911            Some(AuthConfig::Bearer { header_name }) if header_name == "Authorization" => quote! {
1912                if let Some(api_key) = &self.api_key {
1913                    req = req.bearer_auth(api_key);
1914                }
1915            },
1916            Some(AuthConfig::Bearer { header_name }) => {
1917                let h = header_name.clone();
1918                quote! {
1919                    if let Some(api_key) = &self.api_key {
1920                        req = req.header(#h, format!("Bearer {}", api_key));
1921                    }
1922                }
1923            }
1924            Some(AuthConfig::ApiKey { header_name }) => {
1925                let h = header_name.clone();
1926                quote! {
1927                    if let Some(api_key) = &self.api_key {
1928                        req = req.header(#h, api_key.as_str());
1929                    }
1930                }
1931            }
1932            Some(AuthConfig::Custom {
1933                header_name,
1934                header_value_prefix,
1935            }) => {
1936                let h = header_name.clone();
1937                let prefix = header_value_prefix.clone().unwrap_or_default();
1938                if prefix.is_empty() {
1939                    quote! {
1940                        if let Some(api_key) = &self.api_key {
1941                            req = req.header(#h, api_key.as_str());
1942                        }
1943                    }
1944                } else {
1945                    let format_str = format!("{}{{}}", prefix);
1946                    quote! {
1947                        if let Some(api_key) = &self.api_key {
1948                            req = req.header(#h, format!(#format_str, api_key));
1949                        }
1950                    }
1951                }
1952            }
1953            None => quote! {
1954                if let Some(api_key) = &self.api_key {
1955                    req = req.bearer_auth(api_key);
1956                }
1957            },
1958        }
1959    }
1960
1961    /// Generate header-parameter handling. Emits `req = req.header(name, ...)`
1962    /// for each `in: header` parameter — required headers unconditionally,
1963    /// optional ones gated on `Some(_)`.
1964    fn generate_header_params(&self, op: &OperationInfo) -> TokenStream {
1965        let header_params: Vec<_> = op
1966            .parameters
1967            .iter()
1968            .filter(|p| p.location == "header")
1969            .collect();
1970        if header_params.is_empty() {
1971            return quote! {};
1972        }
1973        let mut emit = Vec::new();
1974        for param in header_params {
1975            let param_name_snake = self.operation_param_ident(op, param);
1976            let param_ident = Self::to_field_ident(&param_name_snake);
1977            let header_name = &param.name;
1978            if matches!(
1979                param.query_serialization,
1980                Some(crate::analysis::QuerySerialization::SimpleHeaderArray { .. })
1981            ) {
1982                let encode = quote! {
1983                    v.iter().map(::std::string::ToString::to_string).collect::<Vec<_>>().join(",")
1984                };
1985                if param.required {
1986                    emit.push(quote! {
1987                        let v = #param_ident;
1988                        req = req.header(#header_name, #encode);
1989                    });
1990                } else {
1991                    emit.push(quote! {
1992                        if let Some(v) = #param_ident {
1993                            req = req.header(#header_name, #encode);
1994                        }
1995                    });
1996                }
1997                continue;
1998            }
1999            if param.required {
2000                if Self::param_uses_as_ref_str(param) {
2001                    emit.push(quote! {
2002                        req = req.header(#header_name, #param_ident.as_ref());
2003                    });
2004                } else {
2005                    emit.push(quote! {
2006                        req = req.header(#header_name, #param_ident.to_string());
2007                    });
2008                }
2009            } else if Self::param_uses_as_ref_str(param) {
2010                emit.push(quote! {
2011                    if let Some(v) = #param_ident {
2012                        req = req.header(#header_name, v.as_ref());
2013                    }
2014                });
2015            } else {
2016                emit.push(quote! {
2017                    if let Some(v) = #param_ident {
2018                        req = req.header(#header_name, v.to_string());
2019                    }
2020                });
2021            }
2022        }
2023        quote! {
2024            #(#emit)*
2025        }
2026    }
2027
2028    fn generate_cookie_params(&self, op: &OperationInfo) -> TokenStream {
2029        let cookie_params: Vec<_> = op
2030            .parameters
2031            .iter()
2032            .filter(|parameter| parameter.location == "cookie")
2033            .collect();
2034        if cookie_params.is_empty() {
2035            return quote! {};
2036        }
2037        let mut emit = Vec::new();
2038        for parameter in cookie_params {
2039            let ident = Self::to_field_ident(&self.operation_param_ident(op, parameter));
2040            let wire_name = parameter.name.as_str();
2041            if parameter.required {
2042                emit.push(quote! {
2043                    __cookie_fields.push(format!("{}={}", #wire_name, #ident));
2044                });
2045            } else {
2046                emit.push(quote! {
2047                    if let Some(value) = #ident {
2048                        __cookie_fields.push(format!("{}={}", #wire_name, value));
2049                    }
2050                });
2051            }
2052        }
2053        quote! {
2054            let mut __cookie_fields = Vec::new();
2055            #(#emit)*
2056            if !__cookie_fields.is_empty() {
2057                req = req.header(::reqwest::header::COOKIE, __cookie_fields.join("; "));
2058            }
2059        }
2060    }
2061
2062    /// Generate query parameter handling
2063    fn generate_query_params(&self, op: &OperationInfo) -> TokenStream {
2064        let query_params: Vec<_> = op
2065            .parameters
2066            .iter()
2067            .filter(|p| p.location == "query")
2068            .collect();
2069
2070        if query_params.is_empty() {
2071            return quote! {};
2072        }
2073
2074        let mut param_building = Vec::new();
2075        // Serialization applied on `req` directly, after the pair-vector
2076        // block: form-exploded objects and deepObject objects, whose keys
2077        // aren't the static parameter name.
2078        let mut req_appends = Vec::new();
2079
2080        for param in query_params {
2081            use crate::analysis::QuerySerialization;
2082
2083            // Use snake_case for Rust variable name with keyword escaping
2084            let param_name_snake = self.operation_param_ident(op, param);
2085            let param_name = Self::to_field_ident(&param_name_snake);
2086
2087            // Use the original parameter name from OpenAPI spec as the query string key
2088            let param_key = &param.name;
2089
2090            match &param.query_serialization {
2091                Some(QuerySerialization::FormExplodedNestedObject { properties }) => {
2092                    let emit_properties = properties.iter().map(|property| {
2093                        let wire_name = property.wire_name.as_str();
2094                        let field_ident = CodeGenerator::to_field_ident(
2095                            &self.to_rust_field_name(wire_name),
2096                        );
2097                        match &property.value_type {
2098                            crate::analysis::QueryStructPropertyType::Scalar(_) => quote! {
2099                                let value = serde_json::to_value(&v.#field_ident)
2100                                    .map_err(HttpError::serialization_error)?;
2101                                if !value.is_null() {
2102                                    let value = match value {
2103                                        serde_json::Value::String(value) => value,
2104                                        serde_json::Value::Bool(value) => value.to_string(),
2105                                        serde_json::Value::Number(value) => value.to_string(),
2106                                        _ => return Err(HttpError::serialization_error(
2107                                            format!("query field `{}` did not serialize as a scalar", #wire_name)
2108                                        ).into()),
2109                                    };
2110                                    nested_params.push((format!("{}.{}", #param_key, #wire_name), value));
2111                                }
2112                            },
2113                            crate::analysis::QueryStructPropertyType::Object { properties } => {
2114                                let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>();
2115                                quote! {
2116                                    let value = serde_json::to_value(&v.#field_ident)
2117                                        .map_err(HttpError::serialization_error)?;
2118                                    if !value.is_null() {
2119                                        let serde_json::Value::Object(object) = value else {
2120                                            return Err(HttpError::serialization_error(format!("query field `{}` did not serialize as an object", #wire_name)).into());
2121                                        };
2122                                        if object.is_empty() {
2123                                            nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
2124                                        } else {
2125                                            for leaf in [#(#leaves),*] {
2126                                                let Some(value) = object.get(leaf) else { continue };
2127                                                if value.is_null() { continue; }
2128                                                let value = match value {
2129                                                    serde_json::Value::String(value) => value.clone(),
2130                                                    serde_json::Value::Bool(value) => value.to_string(),
2131                                                    serde_json::Value::Number(value) => value.to_string(),
2132                                                    _ => return Err(HttpError::serialization_error(format!("query field `{}` contained a non-scalar leaf", #wire_name)).into()),
2133                                                };
2134                                                nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, leaf), value));
2135                                            }
2136                                        }
2137                                    }
2138                                }
2139                            }
2140                            crate::analysis::QueryStructPropertyType::Array { item_type } => {
2141                                let nested_properties = match item_type {
2142                                    crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => Some(
2143                                        properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>()
2144                                    ),
2145                                    _ => None,
2146                                };
2147                                if let Some(nested_properties) = nested_properties {
2148                                    quote! {
2149                                        let values = serde_json::to_value(&v.#field_ident)
2150                                            .map_err(HttpError::serialization_error)?;
2151                                        if !values.is_null() {
2152                                            let serde_json::Value::Array(values) = values else {
2153                                                return Err(HttpError::serialization_error(
2154                                                    format!("query field `{}` did not serialize as an array", #wire_name)
2155                                                ).into());
2156                                            };
2157                                            if values.is_empty() {
2158                                                nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
2159                                            }
2160                                            for (index, value) in values.into_iter().enumerate() {
2161                                                let serde_json::Value::Object(object) = value else {
2162                                                    return Err(HttpError::serialization_error(
2163                                                        format!("query field `{}` contained a non-object item", #wire_name)
2164                                                    ).into());
2165                                                };
2166                                                for leaf in [#(#nested_properties),*] {
2167                                                    let Some(value) = object.get(leaf) else { continue };
2168                                                    if value.is_null() { continue; }
2169                                                    let value = match value {
2170                                                        serde_json::Value::String(value) => value.clone(),
2171                                                        serde_json::Value::Bool(value) => value.to_string(),
2172                                                        serde_json::Value::Number(value) => value.to_string(),
2173                                                        _ => return Err(HttpError::serialization_error(
2174                                                            format!("query field `{}` contained a non-scalar leaf", #wire_name)
2175                                                        ).into()),
2176                                                    };
2177                                                    nested_params.push((format!("{}.{}.{}.{}", #param_key, #wire_name, index + 1, leaf), value));
2178                                                }
2179                                            }
2180                                        }
2181                                    }
2182                                } else {
2183                                    quote! {
2184                                        let values = serde_json::to_value(&v.#field_ident)
2185                                            .map_err(HttpError::serialization_error)?;
2186                                        if !values.is_null() {
2187                                            let serde_json::Value::Array(values) = values else {
2188                                                return Err(HttpError::serialization_error(
2189                                                    format!("query field `{}` did not serialize as an array", #wire_name)
2190                                                ).into());
2191                                            };
2192                                            if values.is_empty() {
2193                                                nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new()));
2194                                            }
2195                                            for (index, value) in values.into_iter().enumerate() {
2196                                                let value = match value {
2197                                                    serde_json::Value::String(value) => value,
2198                                                    serde_json::Value::Bool(value) => value.to_string(),
2199                                                    serde_json::Value::Number(value) => value.to_string(),
2200                                                    _ => return Err(HttpError::serialization_error(
2201                                                        format!("query field `{}` contained a non-scalar item", #wire_name)
2202                                                    ).into()),
2203                                                };
2204                                                nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, index + 1), value));
2205                                            }
2206                                        }
2207                                    }
2208                                }
2209                            }
2210                        }
2211                    }).collect::<Vec<_>>();
2212                    let apply = quote! {
2213                        let mut nested_params: Vec<(String, String)> = Vec::new();
2214                        #(#emit_properties)*
2215                        if nested_params.is_empty() {
2216                            nested_params.push((format!("{}[]", #param_key), String::new()));
2217                        }
2218                        req = req.query(&nested_params);
2219                    };
2220                    if param.required {
2221                        req_appends.push(quote! {{ let v = #param_name; #apply }});
2222                    } else {
2223                        req_appends.push(quote! { if let Some(v) = #param_name { #apply } });
2224                    }
2225                    continue;
2226                }
2227                Some(QuerySerialization::FormExplodedObject) => {
2228                    // Issue #27: reqwest serializes the struct through
2229                    // serde_urlencoded, so each property becomes its own
2230                    // `key=value` pair; the parameter's own name never
2231                    // appears in the query string (RFC 6570 form-explosion).
2232                    // `name[]=` is the shared zero-cardinality marker used to
2233                    // preserve Some(empty) and required-empty values.
2234                    let apply = quote! {
2235                        let __empty = match serde_json::to_value(&v)
2236                            .map_err(HttpError::serialization_error)?
2237                        {
2238                            serde_json::Value::Object(map) => map.is_empty(),
2239                            _ => false,
2240                        };
2241                        if __empty {
2242                            req = req.query(&[(format!("{}[]", #param_key), String::new())]);
2243                        } else {
2244                            req = req.query(&v);
2245                        }
2246                    };
2247                    if param.required {
2248                        req_appends.push(quote! {
2249                            {
2250                                let v = #param_name;
2251                                #apply
2252                            }
2253                        });
2254                    } else {
2255                        req_appends.push(quote! {
2256                            if let Some(v) = #param_name {
2257                                #apply
2258                            }
2259                        });
2260                    }
2261                    continue;
2262                }
2263                Some(QuerySerialization::DeepObject) => {
2264                    // `?filter[color]=red&filter[size]=5`. Property values
2265                    // stringify through their JSON form; Null (unset
2266                    // Option) properties are skipped.
2267                    let apply = quote! {
2268                        let map = match serde_json::to_value(&v)
2269                            .map_err(HttpError::serialization_error)?
2270                        {
2271                            serde_json::Value::Object(map) => map,
2272                            _ => return Err(HttpError::serialization_error(
2273                                format!("query parameter `{}` did not serialize as an object", #param_key)
2274                            ).into()),
2275                        };
2276                        let mut deep_params: Vec<(String, String)> = Vec::new();
2277                        for (k, val) in map {
2278                            let s = match val {
2279                                serde_json::Value::Null => continue,
2280                                serde_json::Value::String(s) => s,
2281                                other => other.to_string(),
2282                            };
2283                            deep_params.push((format!("{}[{}]", #param_key, k), s));
2284                        }
2285                        if deep_params.is_empty() {
2286                            deep_params.push((format!("{}[]", #param_key), String::new()));
2287                        }
2288                        req = req.query(&deep_params);
2289                    };
2290                    if param.required {
2291                        req_appends.push(quote! {
2292                            {
2293                                let v = #param_name;
2294                                #apply
2295                            }
2296                        });
2297                    } else {
2298                        req_appends.push(quote! {
2299                            if let Some(v) = #param_name {
2300                                #apply
2301                            }
2302                        });
2303                    }
2304                    continue;
2305                }
2306                Some(QuerySerialization::FormObject) => {
2307                    // `?filter=color,red,size,big` — one pair whose value is
2308                    // the comma-joined key,value list (RFC 6570 form,
2309                    // explode=false).
2310                    let apply = quote! {
2311                        let map = match serde_json::to_value(&v)
2312                            .map_err(HttpError::serialization_error)?
2313                        {
2314                            serde_json::Value::Object(map) => map,
2315                            _ => return Err(HttpError::serialization_error(
2316                                format!("query parameter `{}` did not serialize as an object", #param_key)
2317                            ).into()),
2318                        };
2319                        let mut parts: Vec<String> = Vec::new();
2320                        for (k, val) in map {
2321                            let s = match val {
2322                                serde_json::Value::Null => continue,
2323                                serde_json::Value::String(s) => s,
2324                                other => other.to_string(),
2325                            };
2326                            if k.contains(',') || s.contains(',') {
2327                                return Err(HttpError::serialization_error(
2328                                    format!(
2329                                        "query object `{}` contains a comma in key `{}`; use explode=true for lossless string values",
2330                                        #param_key,
2331                                        k,
2332                                    )
2333                                ).into());
2334                            }
2335                            parts.push(k);
2336                            parts.push(s);
2337                        }
2338                        if parts.is_empty() {
2339                            query_params.push((
2340                                format!("{}[]", #param_key),
2341                                String::new(),
2342                            ));
2343                        } else {
2344                            query_params.push((#param_key.to_string(), parts.join(",")));
2345                        }
2346                    };
2347                    if param.required {
2348                        param_building.push(quote! {
2349                            {
2350                                let v = #param_name;
2351                                #apply
2352                            }
2353                        });
2354                    } else {
2355                        param_building.push(quote! {
2356                            if let Some(v) = #param_name {
2357                                #apply
2358                            }
2359                        });
2360                    }
2361                    continue;
2362                }
2363                Some(QuerySerialization::FormExplodedArray { item_type }) => {
2364                    // `?tags=a&tags=b` — one pair per element; flat structures
2365                    // expand AWS query-protocol style as `?tags.1.Key=k&tags.1.Value=v`.
2366                    let struct_properties = match item_type {
2367                        crate::analysis::ArrayItemType::FlatStructRef { properties, .. }
2368                        | crate::analysis::ArrayItemType::NestedStructRef { properties, .. } => {
2369                            Some(properties.clone())
2370                        }
2371                        _ => None,
2372                    };
2373                    let emit_items = if let Some(properties) = struct_properties {
2374                        let pushes = properties
2375                            .iter()
2376                            .map(|property| {
2377                                let wire_name = &property.wire_name;
2378                                // Wire names such as `Type` land on struct
2379                                // fields via the same keyword-escaping the
2380                                // model generator uses (`r#type`).
2381                                let field_ident = CodeGenerator::to_field_ident(
2382                                    &self.to_rust_field_name(wire_name),
2383                                );
2384                                match &property.value_type {
2385                                    crate::analysis::QueryStructPropertyType::Scalar(_) => quote! {
2386                                        let value = serde_json::to_value(&item.#field_ident)
2387                                            .map_err(HttpError::serialization_error)?;
2388                                        if !value.is_null() {
2389                                            let value = match value {
2390                                                serde_json::Value::String(value) => value,
2391                                                serde_json::Value::Bool(value) => value.to_string(),
2392                                                serde_json::Value::Number(value) => value.to_string(),
2393                                                _ => return Err(HttpError::serialization_error(
2394                                                    format!("query field `{}.{}` did not serialize as a scalar", #param_key, #wire_name)
2395                                                ).into()),
2396                                            };
2397                                            query_params.push((
2398                                                format!("{}.{}.{}", #param_key, index, #wire_name),
2399                                                value,
2400                                            ));
2401                                        }
2402                                    },
2403                                    crate::analysis::QueryStructPropertyType::Object { properties } => {
2404                                        let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>();
2405                                        quote! {
2406                                            let value = serde_json::to_value(&item.#field_ident)
2407                                                .map_err(HttpError::serialization_error)?;
2408                                            if !value.is_null() {
2409                                                let serde_json::Value::Object(object) = value else {
2410                                                    return Err(HttpError::serialization_error(format!("query field `{}.{}` did not serialize as an object", #param_key, #wire_name)).into());
2411                                                };
2412                                                if object.is_empty() {
2413                                                    query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
2414                                                }
2415                                                for leaf in [#(#leaves),*] {
2416                                                    let Some(value) = object.get(leaf) else { continue };
2417                                                    if value.is_null() { continue; }
2418                                                    let value = match value {
2419                                                        serde_json::Value::String(value) => value.clone(),
2420                                                        serde_json::Value::Bool(value) => value.to_string(),
2421                                                        serde_json::Value::Number(value) => value.to_string(),
2422                                                        _ => return Err(HttpError::serialization_error(format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name)).into()),
2423                                                    };
2424                                                    query_params.push((format!("{}.{}.{}.{}", #param_key, index, #wire_name, leaf), value));
2425                                                }
2426                                            }
2427                                        }
2428                                    }
2429                                    crate::analysis::QueryStructPropertyType::Array { item_type } => {
2430                                        let nested_properties = match item_type {
2431                                            crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => {
2432                                                Some(properties.iter().map(|property| property.wire_name.clone()).collect::<Vec<_>>())
2433                                            }
2434                                            _ => None,
2435                                        };
2436                                        if let Some(nested_properties) = nested_properties {
2437                                            quote! {
2438                                                let values = serde_json::to_value(&item.#field_ident)
2439                                                    .map_err(HttpError::serialization_error)?;
2440                                                if !values.is_null() {
2441                                                    let serde_json::Value::Array(values) = values else {
2442                                                        return Err(HttpError::serialization_error(
2443                                                            format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name)
2444                                                        ).into());
2445                                                    };
2446                                                    if values.is_empty() {
2447                                                        query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
2448                                                    }
2449                                                    for (nested_index, value) in values.into_iter().enumerate() {
2450                                                        let serde_json::Value::Object(object) = value else {
2451                                                            return Err(HttpError::serialization_error(
2452                                                                format!("query field `{}.{}` contained a non-object item", #param_key, #wire_name)
2453                                                            ).into());
2454                                                        };
2455                                                        if object.is_empty() {
2456                                                            query_params.push((format!("{}.{}.{}.{}[]", #param_key, index, #wire_name, nested_index + 1), String::new()));
2457                                                        }
2458                                                        for nested_wire_name in [#(#nested_properties),*] {
2459                                                            let Some(value) = object.get(nested_wire_name) else { continue };
2460                                                            if value.is_null() { continue; }
2461                                                            let value = match value {
2462                                                                serde_json::Value::String(value) => value.clone(),
2463                                                                serde_json::Value::Bool(value) => value.to_string(),
2464                                                                serde_json::Value::Number(value) => value.to_string(),
2465                                                                _ => return Err(HttpError::serialization_error(
2466                                                                    format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name)
2467                                                                ).into()),
2468                                                            };
2469                                                            query_params.push((
2470                                                                format!("{}.{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1, nested_wire_name),
2471                                                                value,
2472                                                            ));
2473                                                        }
2474                                                    }
2475                                                }
2476                                            }
2477                                        } else {
2478                                            quote! {
2479                                                let values = serde_json::to_value(&item.#field_ident)
2480                                                    .map_err(HttpError::serialization_error)?;
2481                                                if !values.is_null() {
2482                                                    let serde_json::Value::Array(values) = values else {
2483                                                        return Err(HttpError::serialization_error(
2484                                                            format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name)
2485                                                        ).into());
2486                                                    };
2487                                                    if values.is_empty() {
2488                                                        query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new()));
2489                                                    }
2490                                                    for (nested_index, value) in values.into_iter().enumerate() {
2491                                                        let value = match value {
2492                                                            serde_json::Value::String(value) => value,
2493                                                            serde_json::Value::Bool(value) => value.to_string(),
2494                                                            serde_json::Value::Number(value) => value.to_string(),
2495                                                            _ => return Err(HttpError::serialization_error(
2496                                                                format!("query field `{}.{}` contained a non-scalar item", #param_key, #wire_name)
2497                                                            ).into()),
2498                                                        };
2499                                                        query_params.push((
2500                                                            format!("{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1),
2501                                                            value,
2502                                                        ));
2503                                                    }
2504                                                }
2505                                            }
2506                                        }
2507                                    }
2508                                }
2509                            })
2510                            .collect::<Vec<_>>();
2511                        quote! {
2512                            for (index, item) in v.iter().enumerate() {
2513                                let index = index + 1;
2514                                let item_start_len = query_params.len();
2515                                #(#pushes)*
2516                                if query_params.len() == item_start_len {
2517                                    query_params.push((format!("{}.{}[]", #param_key, index), String::new()));
2518                                }
2519                            }
2520                        }
2521                    } else {
2522                        quote! {
2523                            for item in v {
2524                                query_params.push((#param_key.to_string(), item.to_string()));
2525                            }
2526                        }
2527                    };
2528                    if param.required {
2529                        param_building.push(quote! {
2530                            let v = #param_name;
2531                            if v.is_empty() {
2532                                query_params.push((
2533                                    format!("{}[]", #param_key),
2534                                    String::new(),
2535                                ));
2536                            } else {
2537                                #emit_items
2538                            }
2539                        });
2540                    } else {
2541                        param_building.push(quote! {
2542                            if let Some(v) = #param_name {
2543                                if v.is_empty() {
2544                                    query_params.push((
2545                                        format!("{}[]", #param_key),
2546                                        String::new(),
2547                                    ));
2548                                } else {
2549                                    #emit_items
2550                                }
2551                            }
2552                        });
2553                    }
2554                    continue;
2555                }
2556                Some(QuerySerialization::FormArray { .. }) => {
2557                    // `?tags=a,b,c` — one comma-joined pair. Empty vectors
2558                    // use the shared `tags[]=` zero-cardinality marker.
2559                    let apply = quote! {
2560                        if v.is_empty() {
2561                            query_params.push((
2562                                format!("{}[]", #param_key),
2563                                String::new(),
2564                            ));
2565                        } else {
2566                            let mut parts = Vec::with_capacity(v.len());
2567                            for item in &v {
2568                                let item = item.to_string();
2569                                if item.contains(',') {
2570                                    return Err(HttpError::serialization_error(
2571                                        format!(
2572                                            "query array `{}` contains a comma; use explode=true for lossless string values",
2573                                            #param_key,
2574                                        )
2575                                    ).into());
2576                                }
2577                                parts.push(item);
2578                            }
2579                            query_params.push((
2580                                #param_key.to_string(),
2581                                parts.join(","),
2582                            ));
2583                        }
2584                    };
2585                    if param.required {
2586                        param_building.push(quote! {
2587                            {
2588                                let v = #param_name;
2589                                #apply
2590                            }
2591                        });
2592                    } else {
2593                        param_building.push(quote! {
2594                            if let Some(v) = #param_name {
2595                                #apply
2596                            }
2597                        });
2598                    }
2599                    continue;
2600                }
2601                Some(
2602                    QuerySerialization::Unsupported { .. }
2603                    | QuerySerialization::SimpleHeaderArray { .. },
2604                ) => {}
2605                None => {}
2606            }
2607
2608            if param.required {
2609                // Required parameters: always add
2610                if Self::param_uses_as_ref_str(param) {
2611                    param_building.push(quote! {
2612                        query_params.push((#param_key.to_string(), #param_name.as_ref().to_string()));
2613                    });
2614                } else {
2615                    param_building.push(quote! {
2616                        query_params.push((#param_key.to_string(), #param_name.to_string()));
2617                    });
2618                }
2619            } else {
2620                // Optional parameters: add only if Some
2621                if Self::param_uses_as_ref_str(param) {
2622                    param_building.push(quote! {
2623                        if let Some(v) = #param_name {
2624                            query_params.push((#param_key.to_string(), v.as_ref().to_string()));
2625                        }
2626                    });
2627                } else {
2628                    param_building.push(quote! {
2629                        if let Some(v) = #param_name {
2630                            query_params.push((#param_key.to_string(), v.to_string()));
2631                        }
2632                    });
2633                }
2634            }
2635        }
2636
2637        // Ops whose query params all serialize on `req` directly skip the
2638        // pair-vector block entirely.
2639        let pairs_block = if param_building.is_empty() {
2640            quote! {}
2641        } else {
2642            quote! {
2643                {
2644                    let mut query_params: Vec<(String, String)> = Vec::new();
2645                    #(#param_building)*
2646                    if !query_params.is_empty() {
2647                        req = req.query(&query_params);
2648                    }
2649                }
2650            }
2651        };
2652
2653        quote! {
2654            // Add query parameters
2655            #pairs_block
2656            #(#req_appends)*
2657        }
2658    }
2659
2660    /// `#[deprecated]` for an operation the document marks `deprecated: true`,
2661    /// so calling any of its client methods warns.
2662    fn deprecated_attribute(op: &OperationInfo) -> TokenStream {
2663        if op.deprecated {
2664            quote! { #[deprecated] }
2665        } else {
2666            TokenStream::new()
2667        }
2668    }
2669
2670    /// Generate the rustdoc block for an operation, surfacing summary,
2671    /// description, the HTTP method+path, and any tags from the OAS spec
2672    /// (T13). `deprecated_attribute` marks the method `#[deprecated]`.
2673    fn generate_operation_doc_comment(&self, op: &OperationInfo) -> TokenStream {
2674        let method = op.method.to_uppercase();
2675        let path = &op.path;
2676        let mut docs: Vec<String> = Vec::new();
2677        if let Some(s) = &op.summary {
2678            if !s.is_empty() {
2679                docs.push(s.clone());
2680                docs.push(String::new());
2681            }
2682        }
2683        if let Some(d) = &op.description {
2684            if !d.is_empty() {
2685                for line in d.lines() {
2686                    docs.push(line.to_string());
2687                }
2688                docs.push(String::new());
2689            }
2690        }
2691        docs.push(format!("`{} {}`", method, path));
2692        let doc_attrs: Vec<TokenStream> = docs
2693            .iter()
2694            .map(|line| {
2695                let prefixed = if line.is_empty() {
2696                    String::new()
2697                } else {
2698                    format!(" {line}")
2699                };
2700                quote! { #[doc = #prefixed] }
2701            })
2702            .collect();
2703        quote! { #(#doc_attrs)* }
2704    }
2705
2706    /// Get the method name from the operation
2707    fn get_method_name(&self, op: &OperationInfo) -> syn::Ident {
2708        let name = if !op.operation_id.is_empty() {
2709            op.operation_id.to_snake_case()
2710        } else {
2711            // Fallback: generate from HTTP method and path
2712            format!(
2713                "{}_{}",
2714                op.method,
2715                op.path.replace('/', "_").replace(['{', '}'], "")
2716            )
2717            .to_snake_case()
2718        };
2719
2720        Self::to_field_ident(&self.escape_keyword_ident(&name))
2721    }
2722
2723    /// Build the request-builder expression for the operation's HTTP method.
2724    /// Named reqwest methods (`.get`/`.post`/…) are used where available;
2725    /// OPTIONS and TRACE go through `Client::request(Method::OPTIONS, _)` since
2726    /// reqwest doesn't expose those as named methods.
2727    fn http_method_call(&self, op: &OperationInfo) -> TokenStream {
2728        match op.method.to_uppercase().as_str() {
2729            "GET" => quote! { self.http_client.get(request_url) },
2730            "POST" => quote! { self.http_client.post(request_url) },
2731            "PUT" => quote! { self.http_client.put(request_url) },
2732            "DELETE" => quote! { self.http_client.delete(request_url) },
2733            "PATCH" => quote! { self.http_client.patch(request_url) },
2734            "HEAD" => quote! { self.http_client.head(request_url) },
2735            "OPTIONS" => quote! {
2736                self.http_client.request(reqwest::Method::OPTIONS, request_url)
2737            },
2738            "TRACE" => quote! {
2739                self.http_client.request(reqwest::Method::TRACE, request_url)
2740            },
2741            // D1: 3.2 `QUERY` verb + any custom verb from
2742            // PathItem.additionalOperations. reqwest's Method::from_bytes
2743            // accepts arbitrary uppercase tokens that match the RFC7230
2744            // method grammar.
2745            other => {
2746                let upper = other.to_string();
2747                quote! {
2748                    self.http_client.request(
2749                        reqwest::Method::from_bytes(#upper.as_bytes())
2750                            .expect("invalid HTTP method"),
2751                        request_url,
2752                    )
2753                }
2754            }
2755        }
2756    }
2757
2758    /// Generate request parameters including path, query, header, and request body.
2759    fn generate_request_param(&self, op: &OperationInfo) -> TokenStream {
2760        self.generate_request_param_with_capture(op, None)
2761    }
2762
2763    fn generate_request_param_with_capture(
2764        &self,
2765        op: &OperationInfo,
2766        mut captures: Option<&mut Vec<syn::Ident>>,
2767    ) -> TokenStream {
2768        let mut params = Vec::new();
2769        for allocated in self.allocated_operation_params(op) {
2770            let param_name = allocated.ident;
2771            let param_type = if Self::param_has_impl_as_ref_type(allocated.param) {
2772                if let Some(captures) = captures.as_deref_mut() {
2773                    let ident = format_ident!("__Param{}", captures.len());
2774                    captures.push(ident.clone());
2775                    quote! { #ident }
2776                } else {
2777                    self.get_param_rust_type(allocated.param)
2778                }
2779            } else {
2780                self.get_param_rust_type(allocated.param)
2781            };
2782            if Self::builder_param_is_required(allocated.param) {
2783                params.push(quote! { #param_name: #param_type });
2784            } else {
2785                params.push(quote! { #param_name: Option<#param_type> });
2786            }
2787        }
2788
2789        // Add request body parameter based on content type. Optional bodies
2790        // (`requestBody.required` is false or absent) become `Option<T>` per T11.
2791        if let Some(ref rb) = op.request_body {
2792            use crate::analysis::RequestBodyContent;
2793            if matches!(rb, RequestBodyContent::SchemaLess { .. }) {
2794                return if params.is_empty() {
2795                    quote! {}
2796                } else {
2797                    quote! { #(#params),* }
2798                };
2799            }
2800            let required = op.request_body_required;
2801            let body_type = match rb {
2802                RequestBodyContent::Json { schema_name, .. }
2803                | RequestBodyContent::FormUrlEncoded { schema_name, .. }
2804                | RequestBodyContent::Multipart { schema_name, .. } => {
2805                    let rust_type_name = self.to_rust_type_name(schema_name);
2806                    let request_ident =
2807                        syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
2808                    quote! { #request_ident }
2809                }
2810
2811                RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. } => {
2812                    quote! { Vec<u8> }
2813                }
2814                RequestBodyContent::TextPlain { .. } => quote! { String },
2815                RequestBodyContent::Unsupported { .. } => quote! { Vec<u8> },
2816                RequestBodyContent::SchemaLess { .. } => unreachable!(
2817                    "schema-less request bodies preserve the historical client signature"
2818                ),
2819            };
2820            let body_ident = match rb {
2821                RequestBodyContent::OctetStream { .. }
2822                | RequestBodyContent::Binary { .. }
2823                | RequestBodyContent::TextPlain { .. }
2824                | RequestBodyContent::Unsupported { .. } => quote! { body },
2825                RequestBodyContent::SchemaLess { .. } => unreachable!(
2826                    "schema-less request bodies preserve the historical client signature"
2827                ),
2828                _ => quote! { request },
2829            };
2830            if required {
2831                params.push(quote! { #body_ident: #body_type });
2832            } else {
2833                params.push(quote! { #body_ident: Option<#body_type> });
2834            }
2835        }
2836
2837        if params.is_empty() {
2838            quote! {}
2839        } else {
2840            quote! { #(#params),* }
2841        }
2842    }
2843
2844    /// Get the Rust type for a parameter
2845    fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream {
2846        if Self::param_has_impl_as_ref_type(param) {
2847            quote! { impl AsRef<str> }
2848        } else {
2849            self.get_param_owned_rust_type(param)
2850        }
2851    }
2852
2853    /// Owned parameter type shared by client-builder storage and generated
2854    /// server extraction. [`ParameterInfo::query_serialization`] is the
2855    /// authoritative projection for typed query objects and arrays.
2856    pub(crate) fn get_param_owned_rust_type(
2857        &self,
2858        param: &crate::analysis::ParameterInfo,
2859    ) -> TokenStream {
2860        use crate::analysis::QuerySerialization;
2861        // Typed form-style arrays take Vec<item> (openapi-generator-anu).
2862        // Scalars parse as-is (they may be type paths from [type_mappings]);
2863        // schema refs are raw schema names and go through the same
2864        // to_rust_type_name sanitization as every other schema reference
2865        // (cloudflare has enum schemas like `resource-sharing_resource_type`).
2866        if let Some(
2867            QuerySerialization::FormExplodedArray { item_type }
2868            | QuerySerialization::FormArray { item_type }
2869            | QuerySerialization::SimpleHeaderArray { item_type },
2870        ) = &param.query_serialization
2871        {
2872            use crate::analysis::ArrayItemType;
2873            let item_ty: syn::Type = match item_type {
2874                ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type)
2875                    .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")),
2876                ArrayItemType::SchemaRef(schema_name) => {
2877                    let rust_name = self.to_rust_type_name(schema_name);
2878                    syn::parse_str(&rust_name)
2879                        .unwrap_or_else(|_| panic!("invalid schema item type `{rust_name}`"))
2880                }
2881                ArrayItemType::FlatStructRef { schema_name, .. } => {
2882                    let rust_name = self.to_rust_type_name(schema_name);
2883                    syn::parse_str(&rust_name)
2884                        .unwrap_or_else(|_| panic!("invalid struct item type `{rust_name}`"))
2885                }
2886                ArrayItemType::NestedStructRef { schema_name, .. } => {
2887                    let rust_name = self.to_rust_type_name(schema_name);
2888                    syn::parse_str(&rust_name)
2889                        .unwrap_or_else(|_| panic!("invalid nested struct item type `{rust_name}`"))
2890                }
2891            };
2892            return quote! { Vec<#item_ty> };
2893        }
2894        // T10: $ref-typed parameters used to lose their type because we only
2895        // consulted `rust_type` (which stays "String"). Now: prefer the
2896        // resolved schema reference if present.
2897        if let Some(ref schema_name) = param.schema_ref {
2898            let rust_name = self.to_rust_type_name(schema_name);
2899            let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site());
2900            return quote! { #ident };
2901        }
2902        syn::parse_str::<syn::Type>(&param.rust_type)
2903            .map(|ty| quote! { #ty })
2904            .unwrap_or_else(|_| {
2905                let type_ident = syn::Ident::new(&param.rust_type, proc_macro2::Span::call_site());
2906                quote! { #type_ident }
2907            })
2908    }
2909
2910    /// True when the parameter's compile-time type is `impl AsRef<str>` and
2911    /// we should call `.as_ref()` on it before stringifying. False for any
2912    /// $ref-resolved type (T10) or non-String primitive — those just call
2913    /// `.to_string()`.
2914    fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
2915        param.schema_ref.is_none() && param.rust_type == "String"
2916    }
2917
2918    fn multipart_client_field_kind(
2919        schema_type: &crate::analysis::SchemaType,
2920        analysis: &SchemaAnalysis,
2921        visited: &mut std::collections::HashSet<String>,
2922    ) -> Option<MultipartClientFieldKind> {
2923        match schema_type {
2924            crate::analysis::SchemaType::Primitive {
2925                rust_type,
2926                serde_with,
2927            } => {
2928                let rust_type = rust_type.replace(' ', "");
2929                if rust_type == "bytes::Bytes" {
2930                    Some(MultipartClientFieldKind::RawBytes)
2931                } else if serde_with
2932                    .as_deref()
2933                    .is_some_and(|codec| codec.contains("base64_url"))
2934                {
2935                    Some(MultipartClientFieldKind::Base64UrlUnpadded)
2936                } else if serde_with
2937                    .as_deref()
2938                    .is_some_and(|codec| codec.contains("base64"))
2939                    || rust_type == "Vec<u8>"
2940                {
2941                    Some(MultipartClientFieldKind::Base64)
2942                } else {
2943                    Some(MultipartClientFieldKind::Text)
2944                }
2945            }
2946            crate::analysis::SchemaType::StringEnum { .. }
2947            | crate::analysis::SchemaType::ExtensibleEnum { .. } => {
2948                Some(MultipartClientFieldKind::Text)
2949            }
2950            // OpenAPI's default `contentType` for an object is
2951            // `application/json`. Arrays are left out: their default depends
2952            // on the items, and each item is a part of its own.
2953            crate::analysis::SchemaType::Object { .. }
2954            | crate::analysis::SchemaType::DiscriminatedUnion { .. }
2955            | crate::analysis::SchemaType::Union { .. }
2956            | crate::analysis::SchemaType::Composition { .. }
2957            | crate::analysis::SchemaType::Untyped {
2958                shape:
2959                    crate::analysis::UntypedShape::Value | crate::analysis::UntypedShape::ValueMap,
2960                ..
2961            } => Some(MultipartClientFieldKind::Json),
2962            crate::analysis::SchemaType::Reference { target } => {
2963                if !visited.insert(target.clone()) {
2964                    return None;
2965                }
2966                analysis.schemas.get(target).and_then(|schema| {
2967                    Self::multipart_client_field_kind(&schema.schema_type, analysis, visited)
2968                })
2969            }
2970            _ => None,
2971        }
2972    }
2973
2974    fn generate_response_content_type_validation(
2975        &self,
2976        operation: &OperationInfo,
2977        success: &ClientSuccessSelection<'_>,
2978    ) -> TokenStream {
2979        let Some(expected) = success.accept else {
2980            return TokenStream::new();
2981        };
2982        let guard = self.success_selection_guard(operation, success);
2983        let errors = self.generate_error_match_arms(operation);
2984        let error_type = self.op_error_type_token(operation);
2985        let raw_body = if matches!(
2986            success.body,
2987            ClientSuccessBody::EventStream | ClientSuccessBody::ParsedSse
2988        ) {
2989            quote! { Vec::new() }
2990        } else {
2991            quote! { __read_bounded_response_body(response, self.max_response_body_bytes).await? }
2992        };
2993        quote! {
2994            let status = response.status();
2995            let status_code = status.as_u16();
2996            if #guard {
2997                let actual = response.headers().get(reqwest::header::CONTENT_TYPE).and_then(|value| value.to_str().ok()).unwrap_or("").to_string();
2998                let expected = #expected;
2999                let parse_media = |value: &str| {
3000                    let mut parts = Vec::new();
3001                    let mut start = 0;
3002                    let mut quoted = false;
3003                    let mut escaped = false;
3004                    for (index, character) in value.char_indices() {
3005                        if escaped { escaped = false; continue }
3006                        if character == '\\' && quoted { escaped = true; continue }
3007                        if character == '"' { quoted = !quoted; }
3008                        if character == ';' && !quoted { parts.push(&value[start..index]); start = index + 1; }
3009                    }
3010                    if quoted || escaped { return None }
3011                    parts.push(&value[start..]);
3012                    let essence = parts.first().copied().unwrap_or("").trim().to_ascii_lowercase();
3013                    let mut parameters = std::collections::BTreeMap::new();
3014                    for part in parts.into_iter().skip(1) {
3015                        let (name, value) = part.split_once('=')?;
3016                        let name = name.trim().to_ascii_lowercase();
3017                        if name.is_empty() { return None }
3018                        let value = value.trim();
3019                        let value = if let Some(quoted) = value.strip_prefix('"') {
3020                            let inner = quoted.strip_suffix('"')?;
3021                            let mut decoded = String::new();
3022                            let mut escaped = false;
3023                            for character in inner.chars() {
3024                                if escaped { decoded.push(character); escaped = false; }
3025                                else if character == '\\' { escaped = true; }
3026                                else if character == '"' { return None }
3027                                else { decoded.push(character); }
3028                            }
3029                            if escaped { return None }
3030                            decoded
3031                        } else {
3032                            if value.contains('"') { return None }
3033                            value.to_string()
3034                        };
3035                        if parameters.insert(name, value).is_some() { return None }
3036                    }
3037                    Some((essence, parameters))
3038                };
3039                let matches = match (parse_media(&actual), parse_media(expected)) {
3040                    (Some((actual_essence, actual_parameters)), Some((expected_essence, expected_parameters))) => {
3041                        let essence_matches = if expected_essence == "*/*" { actual_essence.contains('/') }
3042                            else if let Some(prefix) = expected_essence.strip_suffix("/*") { actual_essence.split('/').next() == Some(prefix) }
3043                            else { actual_essence == expected_essence };
3044                        essence_matches && expected_parameters.iter().all(|(name, value)| actual_parameters.get(name).is_some_and(|actual|
3045                            if name == "charset" { actual.eq_ignore_ascii_case(value) } else { actual == value }))
3046                    }
3047                    _ => false,
3048                };
3049                if !matches {
3050                    let headers = response.headers().clone();
3051                    let raw_body = #raw_body;
3052                    let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3053                    let typed: Option<#error_type>;
3054                    let parse_error: Option<String>;
3055                    #errors
3056                    let _ = parse_error;
3057                    return Err(ApiOpError::Api(ApiError {
3058                        status: status_code, headers, body: body_text, raw_body, typed,
3059                        parse_error: Some(format!("unexpected response Content-Type `{}`; expected `{}`", actual, expected)),
3060                    }));
3061                }
3062            }
3063        }
3064    }
3065
3066    /// The item type of a multipart field that is a list, directly or through
3067    /// named types such as `type Extensions = Vec<Extension>`.
3068    fn resolve_multipart_list<'a>(
3069        schema_type: &'a crate::analysis::SchemaType,
3070        analysis: &'a SchemaAnalysis,
3071    ) -> Option<&'a crate::analysis::SchemaType> {
3072        let mut current = schema_type;
3073        let mut visited = std::collections::HashSet::new();
3074        loop {
3075            match current {
3076                crate::analysis::SchemaType::Array { item_type } => return Some(item_type),
3077                crate::analysis::SchemaType::Reference { target } if visited.insert(target) => {
3078                    current = &analysis.schemas.get(target)?.schema_type;
3079                }
3080                _ => return None,
3081            }
3082        }
3083    }
3084
3085    /// Whether a multipart field holds a `MultipartFile`, the type the
3086    /// analysis gives file parts, under whatever name it gave it.
3087    fn is_multipart_file_type(
3088        schema_type: &crate::analysis::SchemaType,
3089        analysis: &SchemaAnalysis,
3090    ) -> bool {
3091        match schema_type {
3092            crate::analysis::SchemaType::Primitive { rust_type, .. } => {
3093                analysis.multipart_file_type.as_deref() == Some(rust_type.as_str())
3094            }
3095            crate::analysis::SchemaType::Nullable { inner_type } => {
3096                Self::is_multipart_file_type(inner_type, analysis)
3097            }
3098            _ => false,
3099        }
3100    }
3101
3102    /// The media type a multipart `encoding.<field>.contentType` gives its
3103    /// part, when it names exactly one. A comma-separated list or a range
3104    /// such as `image/*` only says what the server accepts.
3105    fn single_media_type(content_type: &str) -> Option<&str> {
3106        let content_type = content_type.trim();
3107        let essence = content_type.split(';').next().unwrap_or_default();
3108        (!content_type.contains(',') && !essence.contains('*') && essence.contains('/'))
3109            .then_some(content_type)
3110    }
3111
3112    fn generate_typed_multipart_form(
3113        &self,
3114        schema_name: &str,
3115        encoding: &BTreeMap<String, String>,
3116        analysis: &SchemaAnalysis,
3117    ) -> TokenStream {
3118        use crate::analysis::{ObjectAdditionalProperties, SchemaType};
3119
3120        let Some((resolved_name, resolved_schema)) =
3121            self.resolve_reference_schema(schema_name, analysis)
3122        else {
3123            let message = format!(
3124                "multipart request schema `{schema_name}` could not be resolved during generation"
3125            );
3126            return quote! {
3127                return Err(HttpError::Config(#message.to_string()).into());
3128            };
3129        };
3130        let SchemaType::Object {
3131            properties,
3132            required,
3133            additional_properties,
3134            ..
3135        } = &resolved_schema.schema_type
3136        else {
3137            let message =
3138                format!("multipart request schema `{schema_name}` must resolve to an object");
3139            return quote! {
3140                return Err(HttpError::Config(#message.to_string()).into());
3141            };
3142        };
3143        let fields = self.emitted_object_properties(
3144            resolved_name,
3145            properties,
3146            required,
3147            additional_properties,
3148            analysis,
3149        );
3150        if matches!(
3151            additional_properties,
3152            ObjectAdditionalProperties::Typed { .. }
3153        ) {
3154            let message = format!(
3155                "multipart request schema `{schema_name}` cannot contain typed additional properties"
3156            );
3157            return quote! {
3158                return Err(HttpError::Config(#message.to_string()).into());
3159            };
3160        }
3161
3162        let mut parts = Vec::new();
3163        for field in fields {
3164            let wire_name = field.wire_name;
3165            let is_nullable = self.property_is_nullable(resolved_name, wire_name, field.property);
3166            let is_tri_state = self.property_is_tri_state(
3167                resolved_name,
3168                wire_name,
3169                field.property,
3170                field.is_required,
3171            );
3172            let ident = field.ident;
3173            // A list is one part per item, under the field's name.
3174            let field_type = match &field.property.schema_type {
3175                crate::analysis::SchemaType::Nullable { inner_type } => &**inner_type,
3176                other => other,
3177            };
3178            let (is_array, item_type) = match Self::resolve_multipart_list(field_type, analysis) {
3179                Some(item_type) => (true, item_type),
3180                None => (false, field_type),
3181            };
3182            // A null item is no part, as a null field is none.
3183            let (nullable_items, item_type) = match item_type {
3184                crate::analysis::SchemaType::Nullable { inner_type } if is_array => {
3185                    (true, &**inner_type)
3186                }
3187                other => (false, other),
3188            };
3189            // A single media type in `encoding.<field>.contentType` is the
3190            // part's type, whatever the field holds. Several of them are the
3191            // set the server accepts, so none is picked.
3192            let declared_content_type = encoding
3193                .get(wire_name)
3194                .and_then(|content_type| Self::single_media_type(content_type));
3195            let add_item = if Self::is_multipart_file_type(item_type, analysis) {
3196                // A file's own content type wins over the declared one.
3197                let part = match self.config().types.binary {
3198                    crate::type_mapping::BinaryStrategy::Bytes => {
3199                        quote! { reqwest::multipart::Part::bytes(value.content.to_vec()) }
3200                    }
3201                    crate::type_mapping::BinaryStrategy::VecU8 => {
3202                        quote! { reqwest::multipart::Part::bytes(value.content.clone()) }
3203                    }
3204                    crate::type_mapping::BinaryStrategy::String => {
3205                        quote! { reqwest::multipart::Part::text(value.content.clone()) }
3206                    }
3207                };
3208                let invalid = format!("multipart field `{wire_name}` has an invalid content type");
3209                let set_content_type = quote! {
3210                    part = part.mime_str(content_type).map_err(|error| {
3211                        HttpError::Config(format!("{} `{}`: {}", #invalid, content_type, error))
3212                    })?;
3213                };
3214                let content_type = match declared_content_type {
3215                    Some(declared) => quote! {
3216                        let content_type = value.content_type.as_deref().unwrap_or(#declared);
3217                        #set_content_type
3218                    },
3219                    None => quote! {
3220                        if let Some(content_type) = value.content_type.as_deref() {
3221                            #set_content_type
3222                        }
3223                    },
3224                };
3225                quote! {
3226                    let mut part = #part;
3227                    if let Some(file_name) = &value.file_name {
3228                        part = part.file_name(file_name.clone());
3229                    }
3230                    #content_type
3231                    form = form.part(#wire_name, part);
3232                }
3233            } else {
3234                let Some(kind) = Self::multipart_client_field_kind(
3235                    item_type,
3236                    analysis,
3237                    &mut std::collections::HashSet::new(),
3238                ) else {
3239                    let message = format!(
3240                        "multipart field `{wire_name}` must be a file, an object, a scalar, or a list of them"
3241                    );
3242                    return quote! {
3243                        return Err(HttpError::Config(#message.to_string()).into());
3244                    };
3245                };
3246                let content_type =
3247                    declared_content_type.or(matches!(kind, MultipartClientFieldKind::Json)
3248                        .then_some("application/json"));
3249                // The shorthand for a part that needs no content type. An
3250                // object always has one.
3251                let shorthand = match kind {
3252                    MultipartClientFieldKind::RawBytes => Some(quote! {
3253                        form = form.part(#wire_name, reqwest::multipart::Part::bytes(value.to_vec()));
3254                    }),
3255                    MultipartClientFieldKind::Base64 => Some(quote! {
3256                        use base64::Engine as _;
3257                        form = form.text(#wire_name, base64::engine::general_purpose::STANDARD.encode(value));
3258                    }),
3259                    MultipartClientFieldKind::Base64UrlUnpadded => Some(quote! {
3260                        use base64::Engine as _;
3261                        form = form.text(#wire_name, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value));
3262                    }),
3263                    MultipartClientFieldKind::Text => {
3264                        Some(quote! { form = form.text(#wire_name, value.to_string()); })
3265                    }
3266                    MultipartClientFieldKind::Json => None,
3267                }
3268                .filter(|_| content_type.is_none());
3269                if let Some(shorthand) = shorthand {
3270                    shorthand
3271                } else {
3272                    let part = match kind {
3273                        MultipartClientFieldKind::RawBytes => {
3274                            quote! { reqwest::multipart::Part::bytes(value.to_vec()) }
3275                        }
3276                        MultipartClientFieldKind::Base64 => quote! { {
3277                            use base64::Engine as _;
3278                            reqwest::multipart::Part::text(base64::engine::general_purpose::STANDARD.encode(value))
3279                        } },
3280                        MultipartClientFieldKind::Base64UrlUnpadded => quote! { {
3281                            use base64::Engine as _;
3282                            reqwest::multipart::Part::text(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value))
3283                        } },
3284                        MultipartClientFieldKind::Text => {
3285                            quote! { reqwest::multipart::Part::text(value.to_string()) }
3286                        }
3287                        MultipartClientFieldKind::Json => quote! {
3288                            reqwest::multipart::Part::bytes(serde_json::to_vec(value).map_err(HttpError::serialization_error)?)
3289                        },
3290                    };
3291                    let content_type = content_type.map(|content_type| {
3292                        let message = format!(
3293                            "multipart field `{wire_name}` has an invalid content type `{content_type}`"
3294                        );
3295                        quote! {
3296                            let part = part
3297                                .mime_str(#content_type)
3298                                .map_err(|error| HttpError::Config(format!("{}: {}", #message, error)))?;
3299                        }
3300                    });
3301                    quote! {
3302                        let part = #part;
3303                        #content_type
3304                        form = form.part(#wire_name, part);
3305                    }
3306                }
3307            };
3308            let add_value = if nullable_items {
3309                quote! {
3310                    for value in value.iter().flatten() {
3311                        #add_item
3312                    }
3313                }
3314            } else if is_array {
3315                quote! {
3316                    for value in value {
3317                        #add_item
3318                    }
3319                }
3320            } else {
3321                add_item
3322            };
3323            parts.push(if is_tri_state {
3324                // Multipart has no representation for a JSON null part. A
3325                // present concrete value is sent; both outer absence and an
3326                // explicit-null model state omit the part.
3327                quote! {
3328                    if let Some(Some(value)) = &request.#ident {
3329                        #add_value
3330                    }
3331                }
3332            } else if field.is_required && is_nullable {
3333                quote! {
3334                    if let Some(value) = &request.#ident {
3335                        #add_value
3336                    }
3337                }
3338            } else if field.is_required {
3339                quote! {
3340                    let value = &request.#ident;
3341                    #add_value
3342                }
3343            } else {
3344                quote! {
3345                    if let Some(value) = &request.#ident {
3346                        #add_value
3347                    }
3348                }
3349            });
3350        }
3351
3352        quote! {
3353            let mut form = reqwest::multipart::Form::new();
3354            #(#parts)*
3355            req = req.multipart(form);
3356        }
3357    }
3358
3359    /// Generate request body serialization based on content type
3360    /// Emit statements that mutate `req` to apply the request body. Returns
3361    /// explicit zero-length framing for bodyless POST, PUT, and PATCH requests.
3362    /// Optional bodies (T11) gate the application on `Some(_)`; required bodies
3363    /// apply unconditionally.
3364    fn generate_request_body(&self, op: &OperationInfo, analysis: &SchemaAnalysis) -> TokenStream {
3365        let empty_request_framing = Self::generate_empty_request_framing(op);
3366        let Some(rb) = op.request_body.as_ref() else {
3367            return empty_request_framing;
3368        };
3369        use crate::analysis::RequestBodyContent;
3370        let required = op.request_body_required;
3371        let (ident, apply): (TokenStream, TokenStream) = match rb {
3372            RequestBodyContent::Json { media_type, .. } => (
3373                quote! { request },
3374                quote! {
3375                    req = req
3376                        .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
3377                        .header("content-type", #media_type);
3378                },
3379            ),
3380            RequestBodyContent::FormUrlEncoded { media_type, .. } => (
3381                quote! { request },
3382                quote! {
3383                    req = req
3384                        .body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
3385                        .header("content-type", #media_type);
3386                },
3387            ),
3388            RequestBodyContent::Multipart {
3389                schema_name,
3390                encoding,
3391                ..
3392            } => (
3393                quote! { request },
3394                self.generate_typed_multipart_form(schema_name, encoding, analysis),
3395            ),
3396            RequestBodyContent::OctetStream { media_type } => (
3397                quote! { body },
3398                quote! {
3399                    req = req
3400                        .body(body)
3401                        .header("content-type", #media_type);
3402                },
3403            ),
3404            RequestBodyContent::Binary { media_type } => (
3405                quote! { body },
3406                quote! {
3407                    req = req
3408                        .body(body)
3409                        .header("content-type", #media_type);
3410                },
3411            ),
3412            RequestBodyContent::TextPlain { media_type } => (
3413                quote! { body },
3414                quote! {
3415                    req = req
3416                        .body(body)
3417                        .header("content-type", #media_type);
3418                },
3419            ),
3420            RequestBodyContent::Unsupported { media_types } => {
3421                let media_type = media_types
3422                    .iter()
3423                    .find(|media_type| !crate::openapi::is_wildcard_media_type(media_type))
3424                    .map(String::as_str);
3425                let Some(media_type) = media_type else {
3426                    let ranges = media_types.join(", ");
3427                    let message = format!(
3428                        "request body for operation `{}` declares only wildcard media ranges ({ranges}); a concrete Content-Type is required",
3429                        op.operation_id,
3430                    );
3431                    return if required {
3432                        quote! {
3433                            let _ = body;
3434                            return Err(HttpError::Config(#message.to_string()).into());
3435                        }
3436                    } else {
3437                        quote! {
3438                            if body.is_some() {
3439                                return Err(HttpError::Config(#message.to_string()).into());
3440                            }
3441                            #empty_request_framing
3442                        }
3443                    };
3444                };
3445                (
3446                    quote! { body },
3447                    quote! {
3448                        req = req
3449                            .body(body)
3450                            .header("content-type", #media_type);
3451                    },
3452                )
3453            }
3454            RequestBodyContent::SchemaLess { .. } => return empty_request_framing,
3455        };
3456        if required {
3457            apply
3458        } else {
3459            quote! {
3460                if let Some(#ident) = #ident {
3461                    #apply
3462                } else {
3463                    #empty_request_framing
3464                }
3465            }
3466        }
3467    }
3468
3469    /// Emit explicit HTTP/1.1 framing when an operation sends no request body.
3470    /// RFC 9110 recommends `Content-Length: 0` for methods that define request
3471    /// content semantics. Methods without those semantics intentionally remain
3472    /// unchanged.
3473    fn generate_empty_request_framing(op: &OperationInfo) -> TokenStream {
3474        if ["POST", "PUT", "PATCH"]
3475            .iter()
3476            .any(|method| op.method.eq_ignore_ascii_case(method))
3477        {
3478            quote! {
3479                req = req.header(reqwest::header::CONTENT_LENGTH, "0");
3480            }
3481        } else {
3482            quote! {}
3483        }
3484    }
3485
3486    /// Find the success (2xx) response schema name, if any.
3487    ///
3488    /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored
3489    /// so that endpoints like 204 No Content correctly return `()` instead of
3490    /// accidentally picking up the error schema (e.g. `BadRequestError`).
3491    fn get_success_response_schema<'a>(
3492        &self,
3493        op: &'a OperationInfo,
3494    ) -> Option<(&'a str, &'a String)> {
3495        op.response_schemas
3496            .get_key_value("200")
3497            .or_else(|| op.response_schemas.get_key_value("201"))
3498            .or_else(|| {
3499                op.response_schemas
3500                    .iter()
3501                    .find(|(code, _)| code.starts_with('2'))
3502            })
3503            .map(|(status, schema)| (status.as_str(), schema))
3504    }
3505
3506    fn get_success_response<'a>(
3507        &self,
3508        analysis: &'a SchemaAnalysis,
3509        op: &'a OperationInfo,
3510    ) -> ClientSuccessSelection<'a> {
3511        if let Some(responses) = analysis.operation_responses.get(&op.operation_id) {
3512            let mut candidates = Vec::new();
3513            for preferred in ["200", "201"] {
3514                if let Some((status, response)) = responses.get_key_value(preferred) {
3515                    candidates.push((status.as_str(), response));
3516                }
3517            }
3518            candidates.extend(
3519                responses
3520                    .iter()
3521                    .filter(|(status, _)| {
3522                        status.starts_with('2')
3523                            && status.as_str() != "200"
3524                            && status.as_str() != "201"
3525                    })
3526                    .map(|(status, response)| (status.as_str(), response)),
3527            );
3528
3529            let selected = candidates
3530                .iter()
3531                .copied()
3532                .find(|(_, response)| {
3533                    matches!(
3534                        Self::response_body(response),
3535                        ClientSuccessBody::Json(_)
3536                            | ClientSuccessBody::Text
3537                            | ClientSuccessBody::Binary
3538                    )
3539                })
3540                .or_else(|| {
3541                    candidates.iter().copied().find(|(_, response)| {
3542                        matches!(
3543                            Self::response_body(response),
3544                            ClientSuccessBody::EventStream
3545                        )
3546                    })
3547                })
3548                .or_else(|| candidates.first().copied());
3549
3550            if let Some((_, response)) = selected {
3551                let body = Self::response_body(response);
3552                let statuses: Vec<&str> = candidates
3553                    .iter()
3554                    .filter_map(|(status, candidate)| {
3555                        Self::success_bodies_are_compatible(body, Self::response_body(candidate))
3556                            .then_some(*status)
3557                    })
3558                    .collect();
3559                let accept = match &response.body {
3560                    Some(OperationResponseBody::Json { media_type, .. })
3561                    | Some(OperationResponseBody::Text { media_type }) => Some(media_type.as_str()),
3562                    Some(OperationResponseBody::Binary {
3563                        media_type,
3564                        wildcard,
3565                    }) => (!wildcard).then_some(media_type.as_str()),
3566                    None if response.schema_name.is_some() => {
3567                        response.media_type.as_deref().or(Some("application/json"))
3568                    }
3569                    None if response.supports_streaming => Some("text/event-stream"),
3570                    None => None,
3571                };
3572                let excluded_statuses = responses
3573                    .keys()
3574                    .filter(|status| {
3575                        status.len() == 3
3576                            && status.bytes().all(|c| c.is_ascii_digit())
3577                            && !statuses.contains(&status.as_str())
3578                    })
3579                    .map(String::as_str)
3580                    .collect();
3581                return ClientSuccessSelection {
3582                    statuses,
3583                    excluded_statuses,
3584                    body,
3585                    accept,
3586                };
3587            }
3588        }
3589
3590        if let Some((_status, schema_name)) = self.get_success_response_schema(op) {
3591            let statuses = op
3592                .response_schemas
3593                .iter()
3594                .filter_map(|(candidate_status, candidate_schema)| {
3595                    (candidate_status.starts_with('2') && candidate_schema == schema_name)
3596                        .then_some(candidate_status.as_str())
3597                })
3598                .collect();
3599            ClientSuccessSelection {
3600                statuses,
3601                excluded_statuses: Vec::new(),
3602                body: ClientSuccessBody::Json(schema_name),
3603                accept: Some("application/json"),
3604            }
3605        } else if Self::returns_raw_event_stream(op) {
3606            ClientSuccessSelection {
3607                statuses: Vec::new(),
3608                excluded_statuses: Vec::new(),
3609                body: ClientSuccessBody::EventStream,
3610                accept: Some("text/event-stream"),
3611            }
3612        } else {
3613            ClientSuccessSelection {
3614                statuses: Vec::new(),
3615                excluded_statuses: Vec::new(),
3616                body: ClientSuccessBody::Empty,
3617                accept: None,
3618            }
3619        }
3620    }
3621
3622    fn response_body(response: &crate::analysis::OperationResponse) -> ClientSuccessBody<'_> {
3623        match &response.body {
3624            Some(OperationResponseBody::Json { schema_name, .. }) => {
3625                ClientSuccessBody::Json(schema_name)
3626            }
3627            Some(OperationResponseBody::Text { .. }) => ClientSuccessBody::Text,
3628            Some(OperationResponseBody::Binary { .. }) => ClientSuccessBody::Binary,
3629            None if response.schema_name.is_some() => {
3630                ClientSuccessBody::Json(response.schema_name.as_deref().unwrap_or_default())
3631            }
3632            None if response.supports_streaming => ClientSuccessBody::EventStream,
3633            None => ClientSuccessBody::Empty,
3634        }
3635    }
3636
3637    fn success_bodies_are_compatible(
3638        selected: ClientSuccessBody<'_>,
3639        candidate: ClientSuccessBody<'_>,
3640    ) -> bool {
3641        match (selected, candidate) {
3642            (ClientSuccessBody::Json(selected), ClientSuccessBody::Json(candidate)) => {
3643                selected == candidate
3644            }
3645            (ClientSuccessBody::Text, ClientSuccessBody::Text)
3646            | (ClientSuccessBody::Binary, ClientSuccessBody::Binary)
3647            | (ClientSuccessBody::EventStream, ClientSuccessBody::EventStream)
3648            | (ClientSuccessBody::Empty, ClientSuccessBody::Empty) => true,
3649            _ => false,
3650        }
3651    }
3652
3653    fn response_type_for_body(&self, body: ClientSuccessBody<'_>) -> TokenStream {
3654        match body {
3655            ClientSuccessBody::Json(response_type) => {
3656                // Convert schema name to Rust type name (handles underscores, etc.)
3657                let rust_type_name = self.to_rust_type_name(response_type);
3658                let response_ident =
3659                    syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
3660                quote! { #response_ident }
3661            }
3662            ClientSuccessBody::Text => quote! { String },
3663            ClientSuccessBody::Binary => quote! { bytes::Bytes },
3664            ClientSuccessBody::EventStream => {
3665                quote! { impl futures_util::Stream<Item = Result<bytes::Bytes, reqwest::Error>> }
3666            }
3667            ClientSuccessBody::ParsedSse => {
3668                quote! { super::sse::BoxSseStream<Result<super::sse::SseEvent<String>, super::sse::StreamingError>> }
3669            }
3670            ClientSuccessBody::Empty => quote! { () },
3671        }
3672    }
3673
3674    fn success_status_guard(statuses: &[&str]) -> TokenStream {
3675        if statuses.is_empty() {
3676            return quote! { status.is_success() };
3677        }
3678        let guards = statuses
3679            .iter()
3680            .map(|status| Self::single_status_guard(status));
3681        quote! { false #( || #guards )* }
3682    }
3683
3684    fn success_selection_guard(
3685        &self,
3686        _operation: &OperationInfo,
3687        success: &ClientSuccessSelection<'_>,
3688    ) -> TokenStream {
3689        let guard = Self::success_status_guard(&success.statuses);
3690        if !success
3691            .statuses
3692            .iter()
3693            .any(|status| status.eq_ignore_ascii_case("2XX"))
3694        {
3695            return guard;
3696        }
3697        let exact: Vec<_> = success
3698            .excluded_statuses
3699            .iter()
3700            .map(|status| Self::single_status_guard(status))
3701            .collect();
3702        quote! { (#guard) && !(false #( || #exact )*) }
3703    }
3704
3705    fn single_status_guard(status: &str) -> TokenStream {
3706        match status {
3707            status if status.chars().all(|character| character.is_ascii_digit()) => {
3708                let status: u16 = status.parse().unwrap_or_default();
3709                quote! { status_code == #status }
3710            }
3711            status if matches!(status.as_bytes(), [b'1'..=b'5', b'X' | b'x', b'X' | b'x']) => {
3712                let class = u16::from(status.as_bytes()[0] - b'0');
3713                quote! { status_code / 100 == #class }
3714            }
3715            _ => quote! { status.is_success() },
3716        }
3717    }
3718
3719    /// True when the operation's success response carries `text/event-stream`
3720    /// and nothing this generator can model as a JSON body.
3721    ///
3722    /// These previously generated `-> Result<(), _>` and then called
3723    /// `response.text().await`, which on a live SSE stream never returns: the
3724    /// caller's task deadlocks rather than erroring (openapi-generator-x9v).
3725    /// The streaming signal was already detected in analysis and honored by the
3726    /// server generator; only the client ignored it.
3727    ///
3728    /// Operations declaring *both* a JSON body and `text/event-stream` keep
3729    /// their JSON contract here — those are the `stream: true` style endpoints
3730    /// covered by the explicit `[streaming]` configuration, and silently
3731    /// changing their return type would break existing callers.
3732    fn returns_raw_event_stream(op: &OperationInfo) -> bool {
3733        op.supports_streaming
3734    }
3735
3736    /// Generate error handling.
3737    ///
3738    /// Buffers non-streaming responses once, retaining both exact bytes and a
3739    /// lossy UTF-8 view for compatibility. Only the selected declared success
3740    /// status is parsed into the generated return type; other 2xx statuses are
3741    /// inspectable `ApiError`s rather than being fed to an incompatible parser.
3742    fn generate_error_handling(
3743        &self,
3744        op: &OperationInfo,
3745        success: ClientSuccessSelection<'_>,
3746    ) -> TokenStream {
3747        let op_error_type = self.op_error_type_token(op);
3748        let success_body = success.body;
3749        let success_status_guard = self.success_selection_guard(op, &success);
3750        let selected_status = if success.statuses.is_empty() {
3751            "any declared 2xx response".to_string()
3752        } else {
3753            success.statuses.join(", ")
3754        };
3755        // With no 2xx status declared, the success guard is
3756        // `status.is_success()` itself, so no successful status can reach the
3757        // branch that rejects unexpected ones.
3758        let rejects_unexpected_success = !success.statuses.is_empty();
3759
3760        let success_branch = match success_body {
3761            ClientSuccessBody::Json(_) => quote! {
3762                match serde_json::from_str(&body_text) {
3763                    Ok(body) => Ok(body),
3764                    Err(e) => Err(ApiOpError::Api(ApiError {
3765                        status: status_code,
3766                        headers: headers,
3767                        body: body_text,
3768                        raw_body,
3769                        typed: None,
3770                        parse_error: Some(format!(
3771                            "failed to deserialize 2xx response body: {}",
3772                            e
3773                        )),
3774                    })),
3775                }
3776            },
3777            ClientSuccessBody::Text => quote! {
3778                let _ = raw_body;
3779                Ok(body_text)
3780            },
3781            ClientSuccessBody::Empty => quote! {
3782                let _ = body_text;
3783                let _ = raw_body;
3784                let _ = headers;
3785                Ok(())
3786            },
3787            ClientSuccessBody::Binary
3788            | ClientSuccessBody::EventStream
3789            | ClientSuccessBody::ParsedSse => quote! {},
3790        };
3791
3792        let error_match_arms = self.generate_error_match_arms(op);
3793
3794        // Streaming success path: hand back the live byte stream instead of
3795        // buffering it. Reading an SSE body to a string blocks until the server
3796        // closes the connection, which is precisely what it will not do.
3797        // The error path still buffers — an error response is finite.
3798        if matches!(
3799            success_body,
3800            ClientSuccessBody::EventStream | ClientSuccessBody::ParsedSse
3801        ) {
3802            let stream = if matches!(success_body, ClientSuccessBody::ParsedSse) {
3803                quote! { super::sse::parse_sse_response(response) }
3804            } else {
3805                quote! { response.bytes_stream() }
3806            };
3807            let reject_unexpected_success = if rejects_unexpected_success {
3808                quote! {
3809                    if status.is_success() {
3810                        return Err(ApiOpError::Api(ApiError {
3811                            status: status_code,
3812                            headers,
3813                            body: String::new(),
3814                            raw_body: Vec::new(),
3815                            typed: None,
3816                            parse_error: Some(format!(
3817                                "unexpected successful status {}; generated return type selects `{}`; live response body was not buffered",
3818                                status_code,
3819                                #selected_status,
3820                            )),
3821                        }));
3822                    }
3823                }
3824            } else {
3825                quote! {}
3826            };
3827            return quote! {
3828                let status = response.status();
3829                let status_code = status.as_u16();
3830                let headers = response.headers().clone();
3831
3832                if #success_status_guard {
3833                    Ok(#stream)
3834                } else {
3835                    #reject_unexpected_success
3836                    let body_bytes = __read_bounded_response_body(
3837                        response,
3838                        self.max_response_body_bytes,
3839                    ).await?;
3840                    let raw_body = body_bytes;
3841                    let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3842                    let typed: Option<#op_error_type>;
3843                    let parse_error: Option<String>;
3844                    #error_match_arms
3845                    Err(ApiOpError::Api(ApiError {
3846                        status: status_code,
3847                        headers,
3848                        body: body_text,
3849                        raw_body,
3850                        typed,
3851                        parse_error,
3852                    }))
3853                }
3854            };
3855        }
3856
3857        if matches!(success_body, ClientSuccessBody::Binary) {
3858            let reject_unexpected_success = if rejects_unexpected_success {
3859                quote! {
3860                    if status.is_success() {
3861                        return Err(ApiOpError::Api(ApiError {
3862                            status: status_code,
3863                            headers,
3864                            body: body_text,
3865                            raw_body,
3866                            typed: None,
3867                            parse_error: Some(format!(
3868                                "unexpected successful status {}; generated return type selects `{}`",
3869                                status_code,
3870                                #selected_status,
3871                            )),
3872                        }));
3873                    }
3874                }
3875            } else {
3876                quote! {}
3877            };
3878            return quote! {
3879                let status = response.status();
3880                let status_code = status.as_u16();
3881                let headers = response.headers().clone();
3882
3883                let body_bytes = __read_bounded_response_body(
3884                    response,
3885                    self.max_response_body_bytes,
3886                ).await?;
3887                if #success_status_guard {
3888                    Ok(bytes::Bytes::from(body_bytes))
3889                } else {
3890                    let raw_body = body_bytes;
3891                    let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3892                    #reject_unexpected_success
3893                    let typed: Option<#op_error_type>;
3894                    let parse_error: Option<String>;
3895                    #error_match_arms
3896                    Err(ApiOpError::Api(ApiError {
3897                        status: status_code,
3898                        headers,
3899                        body: body_text,
3900                        raw_body,
3901                        typed,
3902                        parse_error,
3903                    }))
3904                }
3905            };
3906        }
3907
3908        let reject_unexpected_success = if rejects_unexpected_success {
3909            quote! {
3910                else if status.is_success() {
3911                    Err(ApiOpError::Api(ApiError {
3912                        status: status_code,
3913                        headers,
3914                        body: body_text,
3915                        raw_body,
3916                        typed: None,
3917                        parse_error: Some(format!(
3918                            "unexpected successful status {}; generated return type selects `{}`",
3919                            status_code,
3920                            #selected_status,
3921                        )),
3922                    }))
3923                }
3924            }
3925        } else {
3926            quote! {}
3927        };
3928
3929        quote! {
3930            let status = response.status();
3931            let status_code = status.as_u16();
3932            let headers = response.headers().clone();
3933            let body_bytes = __read_bounded_response_body(
3934                response,
3935                self.max_response_body_bytes,
3936            ).await?;
3937            let raw_body = body_bytes;
3938            let body_text = String::from_utf8_lossy(&raw_body).into_owned();
3939
3940            if #success_status_guard {
3941                #success_branch
3942            } #reject_unexpected_success else {
3943                let typed: Option<#op_error_type>;
3944                let parse_error: Option<String>;
3945                #error_match_arms
3946                Err(ApiOpError::Api(ApiError {
3947                    status: status_code,
3948                    headers,
3949                    body: body_text,
3950                    raw_body,
3951                    typed,
3952                    parse_error,
3953                }))
3954            }
3955        }
3956    }
3957
3958    /// Generate the match arms that select which per-op error variant to
3959    /// deserialize the response body into based on the runtime status code.
3960    fn generate_error_match_arms(&self, op: &OperationInfo) -> TokenStream {
3961        let arms: Vec<TokenStream> = op
3962            .response_schemas
3963            .iter()
3964            .filter(|(code, _)| !code.starts_with('2'))
3965            .filter_map(|(code, schema)| {
3966                let variant_ident = Self::op_error_variant_ident(code);
3967                let payload_ty_name = self.to_rust_type_name(schema);
3968                let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
3969                let enum_ident = self.op_error_enum_ident(op);
3970
3971                // T8: range-keyed responses (1XX/2XX/3XX/4XX/5XX) per OAS
3972                // 3.x §"Responses Object". Specific codes still take priority
3973                // (handled by ordering — concrete codes deserialize first
3974                // because the generic dispatch is a generic `_ if (range)`).
3975                let pattern = match code.as_str() {
3976                    "default" | "Default" => return None, // handled in fallback
3977                    other if other.chars().all(|c| c.is_ascii_digit()) => {
3978                        let n: u16 = other.parse().ok()?;
3979                        quote! { #n }
3980                    }
3981                    "1XX" | "1xx" => quote! { code if (100..=199).contains(&code) },
3982                    "2XX" | "2xx" => quote! { code if (200..=299).contains(&code) },
3983                    "3XX" | "3xx" => quote! { code if (300..=399).contains(&code) },
3984                    "4XX" | "4xx" => quote! { code if (400..=499).contains(&code) },
3985                    "5XX" | "5xx" => quote! { code if (500..=599).contains(&code) },
3986                    _ => return None,
3987                };
3988
3989                Some(quote! {
3990                    #pattern => {
3991                        match serde_json::from_str::<#payload_ty>(&body_text) {
3992                            Ok(v) => {
3993                                typed = Some(#enum_ident::#variant_ident(v));
3994                                parse_error = None;
3995                            }
3996                            Err(e) => {
3997                                typed = None;
3998                                parse_error = Some(e.to_string());
3999                            }
4000                        }
4001                    }
4002                })
4003            })
4004            .collect();
4005
4006        // Fallback for "default" or undeclared status codes: try to parse
4007        // as `serde_json::Value` for inspectability when the op's error
4008        // type is generic, otherwise leave typed = None.
4009        // Must mirror op_error_type_token: if op_error_type is the typed
4010        // enum (any non-2xx response, including `default`), the fallback arm
4011        // can't deserialize into `serde_json::Value` because `typed` is the
4012        // enum. Default to `typed = None` in that case.
4013        let has_typed_enum = op
4014            .response_schemas
4015            .iter()
4016            .any(|(code, _)| !code.starts_with('2'));
4017
4018        // A spec-declared `default` response is the catch-all arm's payload
4019        // type. Without this the generated enum carries a `Default(..)` variant
4020        // that nothing ever constructs, so a response matched only by `default`
4021        // — a perfectly parseable typed body — still surfaces as
4022        // `typed: None` and callers fall back to raw strings
4023        // (openapi-generator-nu7).
4024        let default_payload = op
4025            .response_schemas
4026            .iter()
4027            .find(|(code, _)| matches!(code.as_str(), "default" | "Default"))
4028            .map(|(_, schema)| self.to_rust_type_name(schema));
4029
4030        let default_arm = if let Some(payload_ty_name) = default_payload {
4031            let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
4032            let enum_ident = self.op_error_enum_ident(op);
4033            quote! {
4034                _ => {
4035                    match serde_json::from_str::<#payload_ty>(&body_text) {
4036                        Ok(v) => {
4037                            typed = Some(#enum_ident::Default(v));
4038                            parse_error = None;
4039                        }
4040                        Err(e) => {
4041                            typed = None;
4042                            parse_error = Some(e.to_string());
4043                        }
4044                    }
4045                }
4046            }
4047        } else if has_typed_enum {
4048            quote! {
4049                _ => {
4050                    typed = None;
4051                    parse_error = None;
4052                }
4053            }
4054        } else {
4055            // No typed enum — op_error_type is serde_json::Value.
4056            quote! {
4057                _ => {
4058                    match serde_json::from_str::<serde_json::Value>(&body_text) {
4059                        Ok(v) => {
4060                            typed = Some(v);
4061                            parse_error = None;
4062                        }
4063                        Err(e) => {
4064                            typed = None;
4065                            parse_error = Some(e.to_string());
4066                        }
4067                    }
4068                }
4069            }
4070        };
4071
4072        if arms.is_empty() {
4073            // No declared status arms — just the fallback.
4074            quote! {
4075                match status_code {
4076                    #default_arm
4077                }
4078            }
4079        } else {
4080            quote! {
4081                match status_code {
4082                    #(#arms)*
4083                    #default_arm
4084                }
4085            }
4086        }
4087    }
4088
4089    /// Generate URL construction with path parameter substitution
4090    fn generate_url_construction(&self, path: &str, op: &OperationInfo) -> TokenStream {
4091        // Check if path has parameters (contains {...})
4092        if path.contains('{') {
4093            self.generate_url_with_params(path, op)
4094        } else {
4095            quote! {
4096                let request_url = format!("{}{}", self.base_url, #path);
4097            }
4098        }
4099    }
4100
4101    /// Generate URL with path parameters
4102    fn generate_url_with_params(&self, path: &str, op: &OperationInfo) -> TokenStream {
4103        // Find all path parameters in the operation.
4104        let path_params: Vec<_> = op
4105            .parameters
4106            .iter()
4107            .filter(|p| p.location == "path")
4108            .collect();
4109
4110        // T5: percent-encode each path-template variable per RFC3986 §3.3.
4111        // We build a positional-arg format string by walking the template
4112        // left-to-right and emitting one `{}` + one format arg per
4113        // placeholder occurrence. Cloudflare has paths like
4114        // `/accounts/{account_id}/.../accounts/{account_id}` — the same
4115        // variable appears twice. A naive `replace_all` produced two `{}`
4116        // placeholders but only one format arg (E0277). Per-occurrence
4117        // emission keeps them in sync.
4118        let mut format_string = String::with_capacity(path.len());
4119        let mut format_args: Vec<TokenStream> = Vec::new();
4120        let mut chars = path.chars().peekable();
4121        while let Some(c) = chars.next() {
4122            if c != '{' {
4123                format_string.push(c);
4124                continue;
4125            }
4126            // Read until the matching '}'.
4127            let mut name = String::new();
4128            while let Some(&n) = chars.peek() {
4129                chars.next();
4130                if n == '}' {
4131                    break;
4132                }
4133                name.push(n);
4134            }
4135            // Resolve to a path param. If no match, leave the placeholder
4136            // verbatim (real-world spec bug — this op shouldn't have made
4137            // it past analysis).
4138            let param = path_params.iter().find(|p| p.name == name);
4139            let Some(param) = param else {
4140                format_string.push('{');
4141                format_string.push_str(&name);
4142                format_string.push('}');
4143                continue;
4144            };
4145            format_string.push_str("{}");
4146            let param_name_snake = self.operation_param_ident(op, param);
4147            let param_ident = Self::to_field_ident(&param_name_snake);
4148            if Self::param_uses_as_ref_str(param) {
4149                format_args.push(quote! {
4150                    __pct_encode_path_segment(#param_ident.as_ref())
4151                });
4152            } else {
4153                format_args.push(quote! {
4154                    __pct_encode_path_segment(&#param_ident.to_string())
4155                });
4156            }
4157        }
4158
4159        if format_args.is_empty() {
4160            quote! {
4161                let request_url = format!("{}{}", self.base_url, #path);
4162            }
4163        } else {
4164            quote! {
4165                let request_url = format!("{}{}", self.base_url, format!(#format_string, #(#format_args),*));
4166            }
4167        }
4168    }
4169
4170    /// Resolve the Rust ident for a parameter. Prefers the disambiguated
4171    /// `rust_ident` set by the analyzer (which dedupes across the whole
4172    /// operation), falling back to a fresh sanitize of the wire name when
4173    /// no analyzer-side ident is present.
4174    pub(crate) fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String {
4175        if let Some(ident) = &param.rust_ident {
4176            // Apply the keyword-escape and self/super/crate dance the
4177            // sanitize fn does. The analyzer's base ident is already the
4178            // snake/kebab-aware shape; we only need post-processing.
4179            return self.escape_keyword_ident(ident);
4180        }
4181        self.sanitize_param_name(&param.name)
4182    }
4183
4184    fn escape_keyword_ident(&self, snake_case: &str) -> String {
4185        if matches!(snake_case, "self" | "super" | "crate" | "Self") {
4186            return format!("{snake_case}_param");
4187        }
4188        if Self::is_rust_keyword(snake_case) {
4189            format!("r#{snake_case}")
4190        } else {
4191            snake_case.to_string()
4192        }
4193    }
4194
4195    /// Sanitize a parameter name by escaping Rust reserved keywords with raw
4196    /// identifiers and disambiguating Twilio-style suffix operators
4197    /// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
4198    /// case to `start_time`).
4199    fn sanitize_param_name(&self, name: &str) -> String {
4200        // Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
4201        // filter-style query params; map them to `_lt` / `_gt` etc. so the
4202        // Rust ident is unique while the wire-level param name stays the
4203        // original string elsewhere in the codegen.
4204        let suffix = if name.ends_with("<=") {
4205            "_lte"
4206        } else if name.ends_with(">=") {
4207            "_gte"
4208        } else if name.ends_with('<') {
4209            "_lt"
4210        } else if name.ends_with('>') {
4211            "_gt"
4212        } else {
4213            ""
4214        };
4215        let stripped = name.trim_end_matches(['<', '>', '=']);
4216        let mut snake_case = stripped.to_snake_case();
4217        if snake_case.is_empty() {
4218            snake_case.push_str("parameter");
4219        } else if snake_case.starts_with(|character: char| character.is_ascii_digit()) {
4220            snake_case.insert(0, '_');
4221        }
4222        snake_case.push_str(suffix);
4223
4224        if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
4225            return format!("{snake_case}_param");
4226        }
4227        if Self::is_rust_keyword(&snake_case) {
4228            format!("r#{snake_case}")
4229        } else {
4230            snake_case
4231        }
4232    }
4233}