Skip to main content

openapi_to_rust/
client_generator.rs

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