Skip to main content

openapi_to_rust/
config.rs

1//! TOML configuration file support for OpenAPI code generation.
2//!
3//! This module provides TOML-based configuration as an alternative to the Rust API.
4//! It enables CLI-based code generation without requiring the generator as a build dependency.
5//!
6//! # Overview
7//!
8//! The TOML configuration system provides:
9//! - Declarative configuration in `openapi-to-rust.toml` files
10//! - Comprehensive validation with helpful error messages
11//! - Support for all generator features (HTTP client, retry, tracing, Specta)
12//! - Conversion to internal [`GeneratorConfig`] for code generation
13//!
14//! # Quick Start
15//!
16//! Create an `openapi-to-rust.toml` file:
17//!
18//! ```toml
19//! [generator]
20//! spec_path = "openapi.json"
21//! output_dir = "src/generated"
22//! module_name = "api"
23//!
24//! [features]
25//! enable_async_client = true
26//!
27//! [http_client]
28//! base_url = "https://api.example.com"
29//! timeout_seconds = 30
30//!
31//! [http_client.retry]
32//! max_retries = 3
33//! initial_delay_ms = 500
34//! max_delay_ms = 16000
35//! ```
36//!
37//! Load and use the configuration:
38//!
39//! ```no_run
40//! use openapi_to_rust::config::ConfigFile;
41//! use std::path::Path;
42//!
43//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
44//! // Load configuration from TOML file
45//! let config_file = ConfigFile::load(Path::new("openapi-to-rust.toml"))?;
46//!
47//! // Convert to internal GeneratorConfig
48//! let generator_config = config_file.into_generator_config();
49//!
50//! // Use with CodeGenerator...
51//! # Ok(())
52//! # }
53//! ```
54//!
55//! # Configuration Sections
56//!
57//! ## Generator Section (Required)
58//!
59//! ```toml
60//! [generator]
61//! spec_path = "openapi.json"       # Path to OpenAPI spec
62//! output_dir = "src/generated"     # Output directory
63//! module_name = "api"              # Module name
64//! ```
65//!
66//! ## Features Section (Optional)
67//!
68//! ```toml
69//! [features]
70//! enable_sse_client = true         # Generate SSE streaming client
71//! enable_async_client = true       # Generate HTTP REST client
72//! enable_specta = false            # Add specta::Type derives
73//! ```
74//!
75//! ## HTTP Client Section (Optional)
76//!
77//! ```toml
78//! [http_client]
79//! base_url = "https://api.example.com"
80//! timeout_seconds = 30
81//!
82//! [http_client.retry]
83//! max_retries = 3                  # 0-10 retries
84//! initial_delay_ms = 500           # 100-10000ms
85//! max_delay_ms = 16000             # 1000-300000ms
86//!
87//! [http_client.tracing]
88//! enabled = true                   # Enable request tracing (default: true)
89//!
90//! [http_client.auth]
91//! type = "Bearer"                  # Bearer, ApiKey, or Custom
92//! header_name = "Authorization"
93//!
94//! [[http_client.headers]]
95//! name = "content-type"
96//! value = "application/json"
97//! ```
98//!
99//! ## Client Selection Section (Optional)
100//!
101//! ```toml
102//! [client]
103//! # Shared selector grammar: operationId | "METHOD /path" | "tag:<name>"
104//! operations = ["createResponse", "GET /models", "tag:Files"]
105//! prune_models = true
106//! ```
107//!
108//! Omitting `[client]`, or leaving `operations` empty, generates every HTTP
109//! client operation. When pruning is enabled, model reachability is the union
110//! of selected client and server operations.
111//!
112//! # Validation
113//!
114//! The configuration is validated on load:
115//! - The input specification path is checked for existence
116//! - Numeric ranges are enforced (timeout, retry counts, delays)
117//! - Enum values are validated (auth types, event flow types)
118//! - Required fields are checked
119//! - Relative generator paths are resolved from the configuration file's directory
120//!
121//! Invalid configurations produce helpful error messages:
122//!
123//! ```text
124//! Configuration validation failed:
125//!   - generator.spec_path: OpenAPI spec file not found: missing.json
126//!   - http_client.retry.max_retries: max_retries must be between 0 and 10
127//! ```
128//!
129//! # Examples
130//!
131//! See the [examples](https://github.com/gpu-cli/openapi-to-rust/tree/main/examples) directory for complete examples:
132//! - `toml_config_example.rs` - Various configuration patterns
133//! - `complete_workflow.rs` - Full generation workflow with TOML
134//!
135//! # Backward Compatibility
136//!
137//! The TOML configuration is fully optional. The existing Rust API continues to work:
138//!
139//! ```no_run
140//! use openapi_to_rust::{GeneratorConfig, CodeGenerator};
141//! use std::path::PathBuf;
142//!
143//! let config = GeneratorConfig {
144//!     spec_path: PathBuf::from("openapi.json"),
145//!     enable_async_client: true,
146//!     // ... other fields
147//!     ..Default::default()
148//! };
149//!
150//! let generator = CodeGenerator::new(config);
151//! // ... generate code
152//! ```
153
154use crate::{GeneratorError, generator::GeneratorConfig};
155use serde::{Deserialize, Serialize};
156use std::collections::BTreeMap;
157use std::path::{Path, PathBuf};
158
159/// Root configuration loaded from TOML file
160#[derive(Debug, Clone)]
161pub struct ConfigFile {
162    pub generator: GeneratorSection,
163    pub features: FeaturesSection,
164    pub http_client: Option<HttpClientSection>,
165    pub streaming: Option<StreamingSection>,
166    /// Server codegen opt-in. Absent or empty operations list ⇒ no
167    /// server code emitted. See `docs/planning/server-codegen.md`.
168    pub server: Option<ServerSection>,
169    /// Optional HTTP-client operation scope. Absent means all operations.
170    pub client: Option<ClientSection>,
171    pub nullable_overrides: BTreeMap<String, bool>,
172    /// Force a closed string-enum schema to be rendered as an extensible enum
173    /// (with a `Custom(String)` fallback variant). Use when the spec under-
174    /// declares the enum and the API returns values outside the declared set.
175    /// Format: `"SchemaName" = true`. Mirror of `nullable_overrides`.
176    pub extensible_enums: BTreeMap<String, bool>,
177    pub type_mappings: BTreeMap<String, String>,
178    /// Normalized type-mapping configuration.
179    ///
180    /// TOML deserialization accepts canonical `[generator.types]` and the
181    /// temporary top-level `[types]` compatibility alias. Serialization always
182    /// writes this value back in the canonical nested location.
183    pub types: crate::type_mapping::TypeMappingConfig,
184}
185
186#[derive(Debug, Clone, Deserialize, Serialize)]
187#[serde(deny_unknown_fields)]
188pub struct GeneratorSection {
189    /// OpenAPI input path or HTTPS URL. Relative filesystem paths are resolved
190    /// from the directory containing the configuration file.
191    pub spec_path: PathBuf,
192    /// Generated-code destination. Relative paths are resolved from the
193    /// directory containing the configuration file; it need not exist yet.
194    pub output_dir: PathBuf,
195    /// Informational label, not a directory or module path. The
196    /// generator writes the same files (mod.rs, types.rs, server/*)
197    /// regardless of this value. It shows up only in the generated
198    /// mod.rs header doc comment as a hint and is used by the
199    /// streaming codegen for naming the SSE client module. You
200    /// mount the tree at whatever Rust module path you prefer.
201    pub module_name: String,
202    /// Schema extension files to merge into the main spec before codegen.
203    /// Relative paths are resolved from the configuration file's directory.
204    #[serde(default)]
205    pub schema_extensions: Vec<PathBuf>,
206    /// Additive operation-builder generation policy.
207    #[serde(default)]
208    pub builders: BuildersSection,
209}
210
211/// Configuration for additive `*_builder()` operation entry points.
212#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
213#[serde(default, deny_unknown_fields)]
214pub struct BuildersSection {
215    /// Generate builders for operations above [`Self::threshold`].
216    pub enabled: bool,
217    /// Minimum optional-value count. Builders are emitted only when an
218    /// operation has more optional values than this threshold.
219    pub threshold: usize,
220}
221
222impl Default for BuildersSection {
223    fn default() -> Self {
224        Self {
225            enabled: false,
226            threshold: 3,
227        }
228    }
229}
230
231#[derive(Debug, Clone, Deserialize, Serialize)]
232#[serde(deny_unknown_fields)]
233pub struct FeaturesSection {
234    #[serde(default)]
235    pub enable_sse_client: bool,
236    #[serde(default)]
237    pub enable_async_client: bool,
238    #[serde(default)]
239    pub enable_specta: bool,
240    /// Generate a static operation registry with metadata for CLI/proxy routing
241    #[serde(default)]
242    pub enable_registry: bool,
243    /// Generate only the operation registry (skip types, client, streaming)
244    #[serde(default)]
245    pub registry_only: bool,
246}
247
248/// Opt-in server codegen scope.
249///
250/// `operations` accepts three selector forms, parsed by
251/// [`crate::server::Selector::parse`]:
252///   - `operationId` (recommended)
253///   - `METHOD /path`
254///   - `tag:<name>`
255#[derive(Debug, Clone, Deserialize, Serialize)]
256#[serde(deny_unknown_fields)]
257pub struct ServerSection {
258    /// Target framework. Only `"axum"` is currently supported.
259    pub framework: String,
260    /// Selectors picking which operations get server scaffolding.
261    /// Empty ⇒ section is a no-op.
262    #[serde(default)]
263    pub operations: Vec<String>,
264    /// Emit only the model types reachable (transitively) from all selected
265    /// output scopes. When client and server generation coexist, pruning keeps
266    /// the union of both operation sets plus configured streaming event roots.
267    #[serde(default)]
268    pub prune_models: bool,
269}
270
271/// Optional scope for generated HTTP-client operations.
272///
273/// Selectors use the same grammar as [`ServerSection::operations`]. An absent
274/// section, or an empty `operations` list, preserves the historical behavior
275/// of generating every operation. The section is ignored when the async HTTP
276/// client is disabled and never filters the operation registry. Set
277/// `prune_models = true` to also remove models outside the combined selected
278/// client/server operation closure.
279#[derive(Debug, Clone, Deserialize, Serialize)]
280#[serde(deny_unknown_fields)]
281pub struct ClientSection {
282    /// Selectors picking client methods: `operationId`, `METHOD /path`, or
283    /// `tag:<name>`. Empty means all operations.
284    #[serde(default)]
285    pub operations: Vec<String>,
286    /// Restrict `types.rs` to models reachable from the selected client
287    /// operations plus any selected server operations.
288    #[serde(default)]
289    pub prune_models: bool,
290}
291
292impl ClientSection {
293    /// Parse configured selectors using the shared client/server grammar.
294    pub fn parsed_selectors(
295        &self,
296    ) -> Result<Vec<crate::server::Selector>, crate::server::SelectorParseError> {
297        self.operations
298            .iter()
299            .map(|s| crate::server::Selector::parse(s))
300            .collect()
301    }
302}
303
304impl ServerSection {
305    /// Parse each `operations` entry into a [`crate::server::Selector`].
306    /// Returns the first parse error encountered.
307    pub fn parsed_selectors(
308        &self,
309    ) -> Result<Vec<crate::server::Selector>, crate::server::SelectorParseError> {
310        self.operations
311            .iter()
312            .map(|s| crate::server::Selector::parse(s))
313            .collect()
314    }
315}
316
317#[derive(Debug, Clone, Deserialize, Serialize)]
318#[serde(deny_unknown_fields)]
319pub struct HttpClientSection {
320    pub base_url: Option<String>,
321    pub timeout_seconds: Option<u64>,
322    pub auth: Option<AuthConfigSection>,
323    #[serde(default)]
324    pub headers: Vec<HeaderEntry>,
325    pub retry: Option<RetryConfigSection>,
326    pub tracing: Option<TracingConfigSection>,
327}
328
329#[derive(Debug, Clone, Deserialize, Serialize)]
330#[serde(deny_unknown_fields)]
331pub struct TracingConfigSection {
332    #[serde(default = "default_tracing_enabled")]
333    pub enabled: bool,
334}
335
336fn default_tracing_enabled() -> bool {
337    true
338}
339
340#[derive(Debug, Clone, Deserialize, Serialize)]
341#[serde(deny_unknown_fields)]
342pub struct RetryConfigSection {
343    #[serde(default = "default_max_retries")]
344    pub max_retries: u32,
345    #[serde(default = "default_initial_delay_ms")]
346    pub initial_delay_ms: u64,
347    #[serde(default = "default_max_delay_ms")]
348    pub max_delay_ms: u64,
349}
350
351fn default_max_retries() -> u32 {
352    3
353}
354fn default_initial_delay_ms() -> u64 {
355    500
356}
357fn default_max_delay_ms() -> u64 {
358    16000
359}
360
361#[derive(Debug, Clone, Deserialize, Serialize)]
362#[serde(deny_unknown_fields)]
363pub struct AuthConfigSection {
364    #[serde(rename = "type")]
365    pub auth_type: String,
366    pub header_name: String,
367}
368
369#[derive(Debug, Clone, Deserialize, Serialize)]
370#[serde(deny_unknown_fields)]
371pub struct HeaderEntry {
372    pub name: String,
373    pub value: String,
374}
375
376#[derive(Debug, Clone, Deserialize, Serialize)]
377#[serde(deny_unknown_fields)]
378pub struct StreamingSection {
379    pub endpoints: Vec<StreamingEndpointSection>,
380}
381
382#[derive(Debug, Clone, Deserialize, Serialize)]
383#[serde(deny_unknown_fields)]
384pub struct StreamingEndpointSection {
385    pub operation_id: String,
386    pub path: String,
387    /// HTTP method: "GET" or "POST" (default: POST)
388    #[serde(default)]
389    pub http_method: Option<String>,
390    /// Parameter name that controls streaming (only for POST requests)
391    #[serde(default)]
392    pub stream_parameter: String,
393    /// Query parameters for GET requests
394    #[serde(default)]
395    pub query_parameters: Vec<QueryParameterSection>,
396    pub event_union_type: String,
397    pub content_type: Option<String>,
398    pub event_flow: Option<EventFlowSection>,
399}
400
401#[derive(Debug, Clone, Deserialize, Serialize)]
402#[serde(deny_unknown_fields)]
403pub struct QueryParameterSection {
404    pub name: String,
405    #[serde(default)]
406    pub required: bool,
407}
408
409#[derive(Debug, Clone, Deserialize, Serialize)]
410#[serde(deny_unknown_fields)]
411pub struct EventFlowSection {
412    #[serde(rename = "type")]
413    pub flow_type: String,
414    pub start_events: Option<Vec<String>>,
415    pub delta_events: Option<Vec<String>>,
416    pub stop_events: Option<Vec<String>>,
417}
418
419/// Serde-only representation of the TOML contract. Keeping this separate from
420/// the public Rust API lets TOML use canonical `[generator.types]` while
421/// preserving the long-standing normalized [`ConfigFile::types`] field.
422#[derive(Deserialize)]
423#[serde(deny_unknown_fields)]
424struct ConfigFileWire {
425    generator: GeneratorSectionWire,
426    features: FeaturesSection,
427    #[serde(default)]
428    http_client: Option<HttpClientSection>,
429    #[serde(default)]
430    streaming: Option<StreamingSection>,
431    #[serde(default)]
432    server: Option<ServerSection>,
433    #[serde(default)]
434    client: Option<ClientSection>,
435    #[serde(default)]
436    nullable_overrides: BTreeMap<String, bool>,
437    #[serde(default)]
438    extensible_enums: BTreeMap<String, bool>,
439    #[serde(default)]
440    type_mappings: BTreeMap<String, String>,
441    #[serde(default)]
442    types: Option<crate::type_mapping::TypeMappingConfig>,
443}
444
445#[derive(Deserialize)]
446#[serde(deny_unknown_fields)]
447struct GeneratorSectionWire {
448    spec_path: PathBuf,
449    output_dir: PathBuf,
450    module_name: String,
451    #[serde(default)]
452    schema_extensions: Vec<PathBuf>,
453    #[serde(default)]
454    builders: BuildersSection,
455    #[serde(default)]
456    types: Option<crate::type_mapping::TypeMappingConfig>,
457}
458
459impl TryFrom<ConfigFileWire> for ConfigFile {
460    type Error = String;
461
462    fn try_from(wire: ConfigFileWire) -> Result<Self, Self::Error> {
463        let types = match (wire.generator.types, wire.types) {
464            (Some(_), Some(_)) => {
465                return Err(
466                    "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]."
467                        .to_string(),
468                );
469            }
470            (Some(types), None) | (None, Some(types)) => types,
471            (None, None) => crate::type_mapping::TypeMappingConfig::default(),
472        };
473
474        Ok(Self {
475            generator: GeneratorSection {
476                spec_path: wire.generator.spec_path,
477                output_dir: wire.generator.output_dir,
478                module_name: wire.generator.module_name,
479                schema_extensions: wire.generator.schema_extensions,
480                builders: wire.generator.builders,
481            },
482            features: wire.features,
483            http_client: wire.http_client,
484            streaming: wire.streaming,
485            server: wire.server,
486            client: wire.client,
487            nullable_overrides: wire.nullable_overrides,
488            extensible_enums: wire.extensible_enums,
489            type_mappings: wire.type_mappings,
490            types,
491        })
492    }
493}
494
495impl<'de> Deserialize<'de> for ConfigFile {
496    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
497    where
498        D: serde::Deserializer<'de>,
499    {
500        ConfigFileWire::deserialize(deserializer)?
501            .try_into()
502            .map_err(serde::de::Error::custom)
503    }
504}
505
506#[derive(Serialize)]
507struct ConfigFileRef<'a> {
508    generator: GeneratorSectionRef<'a>,
509    features: &'a FeaturesSection,
510    #[serde(skip_serializing_if = "Option::is_none")]
511    http_client: Option<&'a HttpClientSection>,
512    #[serde(skip_serializing_if = "Option::is_none")]
513    streaming: Option<&'a StreamingSection>,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    server: Option<&'a ServerSection>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    client: Option<&'a ClientSection>,
518    nullable_overrides: &'a BTreeMap<String, bool>,
519    extensible_enums: &'a BTreeMap<String, bool>,
520    type_mappings: &'a BTreeMap<String, String>,
521}
522
523#[derive(Serialize)]
524struct GeneratorSectionRef<'a> {
525    spec_path: &'a Path,
526    output_dir: &'a Path,
527    module_name: &'a str,
528    schema_extensions: &'a [PathBuf],
529    builders: &'a BuildersSection,
530    types: &'a crate::type_mapping::TypeMappingConfig,
531}
532
533impl Serialize for ConfigFile {
534    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
535    where
536        S: serde::Serializer,
537    {
538        ConfigFileRef {
539            generator: GeneratorSectionRef {
540                spec_path: &self.generator.spec_path,
541                output_dir: &self.generator.output_dir,
542                module_name: &self.generator.module_name,
543                schema_extensions: &self.generator.schema_extensions,
544                builders: &self.generator.builders,
545                types: &self.types,
546            },
547            features: &self.features,
548            http_client: self.http_client.as_ref(),
549            streaming: self.streaming.as_ref(),
550            server: self.server.as_ref(),
551            client: self.client.as_ref(),
552            nullable_overrides: &self.nullable_overrides,
553            extensible_enums: &self.extensible_enums,
554            type_mappings: &self.type_mappings,
555        }
556        .serialize(serializer)
557    }
558}
559
560fn resolve_relative_path(config_dir: &Path, path: &mut PathBuf) {
561    if path.is_relative() && !path.to_string_lossy().contains("://") {
562        *path = config_dir.join(&*path);
563    }
564}
565
566fn inspect_type_config_layout(value: &toml::Value) -> Result<(), GeneratorError> {
567    let legacy_types = value.get("types").is_some();
568    let canonical_types = value
569        .get("generator")
570        .and_then(|generator| generator.get("types"));
571
572    if legacy_types && canonical_types.is_some() {
573        return Err(GeneratorError::ValidationError(
574            "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]."
575                .to_string(),
576        ));
577    }
578
579    if canonical_types
580        .and_then(|types| types.get("strategies"))
581        .is_some()
582    {
583        return Err(GeneratorError::ValidationError(
584            "[generator.types.strategies] is obsolete. Move its fields directly under [generator.types]. Use snake_case keys such as date_time (not date-time); valid byte values are string, base64, base64_url_unpadded, and vec_u8 (for example: byte = \"base64\")."
585                .to_string(),
586        ));
587    }
588
589    Ok(())
590}
591
592impl ConfigFile {
593    /// Load and validate configuration from a TOML file.
594    ///
595    /// Relative `spec_path`, `output_dir`, and `schema_extensions` values are
596    /// resolved against the directory containing `path`, independent of the
597    /// process's current working directory.
598    pub fn load(path: &Path) -> Result<Self, GeneratorError> {
599        let config_path = path.canonicalize().map_err(|e| GeneratorError::FileError {
600            message: format!("Failed to resolve config file '{}': {}", path.display(), e),
601        })?;
602        let config_dir = config_path
603            .parent()
604            .ok_or_else(|| GeneratorError::FileError {
605                message: format!(
606                    "Config file '{}' has no parent directory",
607                    config_path.display()
608                ),
609            })?;
610        let content =
611            std::fs::read_to_string(&config_path).map_err(|e| GeneratorError::FileError {
612                message: format!(
613                    "Failed to read config file '{}': {}",
614                    config_path.display(),
615                    e
616                ),
617            })?;
618
619        let value: toml::Value =
620            toml::from_str(&content).map_err(|e| GeneratorError::FileError {
621                message: format!(
622                    "Failed to parse TOML config: {}\n\nExample config:\n{}",
623                    e, EXAMPLE_CONFIG
624                ),
625            })?;
626        inspect_type_config_layout(&value)?;
627
628        let mut config: ConfigFile =
629            toml::from_str(&content).map_err(|e| GeneratorError::FileError {
630                message: format!(
631                    "Failed to parse TOML config: {}\n\nExample config:\n{}",
632                    e, EXAMPLE_CONFIG
633                ),
634            })?;
635
636        resolve_relative_path(config_dir, &mut config.generator.spec_path);
637        resolve_relative_path(config_dir, &mut config.generator.output_dir);
638        for extension in &mut config.generator.schema_extensions {
639            resolve_relative_path(config_dir, extension);
640        }
641
642        config.validate()?;
643
644        Ok(config)
645    }
646
647    fn validate(&self) -> Result<(), GeneratorError> {
648        let mut errors = Vec::new();
649
650        let spec_source = self.generator.spec_path.to_string_lossy();
651        if crate::spec_source::is_remote_spec(&spec_source) {
652            if let Err(error) = crate::spec_source::validate_remote_spec_url(&spec_source) {
653                errors.push(format!("generator.spec_path: {error}"));
654            }
655        } else if spec_source.contains("://") {
656            let error = crate::spec_source::validate_remote_spec_url(&spec_source)
657                .err()
658                .unwrap_or_else(|| "unsupported remote OpenAPI URL".to_string());
659            errors.push(format!("generator.spec_path: {error}"));
660        } else if !self.generator.spec_path.exists() {
661            errors.push(format!(
662                "generator.spec_path: OpenAPI spec file not found: {}. Ensure spec_path points to a valid OpenAPI JSON or YAML file.",
663                self.generator.spec_path.display()
664            ));
665        }
666        if self.generator.module_name.is_empty() {
667            errors.push("generator.module_name: module_name cannot be empty".to_string());
668        }
669
670        if let Some(server) = &self.server
671            && server.framework != "axum"
672        {
673            errors.push(format!(
674                "server.framework: framework must be \"axum\" (got \"{}\"); other frameworks are not supported yet",
675                server.framework
676            ));
677        }
678
679        if let Some(client) = &self.client {
680            for (index, selector) in client.operations.iter().enumerate() {
681                if let Err(error) = crate::server::Selector::parse(selector) {
682                    errors.push(format!("client.operations[{index}]: {error}"));
683                }
684            }
685        }
686        if let Some(server) = &self.server {
687            for (index, selector) in server.operations.iter().enumerate() {
688                if let Err(error) = crate::server::Selector::parse(selector) {
689                    errors.push(format!("server.operations[{index}]: {error}"));
690                }
691            }
692        }
693
694        if let Some(http) = &self.http_client {
695            if let Some(base_url) = &http.base_url
696                && url::Url::parse(base_url).is_err()
697            {
698                errors.push("http_client.base_url: base_url must be a valid URL".to_string());
699            }
700            if let Some(timeout) = http.timeout_seconds
701                && !(1..=3600).contains(&timeout)
702            {
703                errors.push(
704                    "http_client.timeout_seconds: timeout_seconds must be between 1 and 3600"
705                        .to_string(),
706                );
707            }
708            if let Some(auth) = &http.auth {
709                if !matches!(auth.auth_type.as_str(), "Bearer" | "ApiKey" | "Custom") {
710                    errors.push(format!(
711                        "http_client.auth.type: Invalid auth type '{}'. Must be one of: Bearer, ApiKey, Custom",
712                        auth.auth_type
713                    ));
714                }
715                if auth.header_name.is_empty() {
716                    errors.push(
717                        "http_client.auth.header_name: header_name cannot be empty".to_string(),
718                    );
719                }
720            }
721            for (index, header) in http.headers.iter().enumerate() {
722                if header.name.is_empty() {
723                    errors.push(format!(
724                        "http_client.headers[{index}].name: header name cannot be empty"
725                    ));
726                }
727            }
728            if let Some(retry) = &http.retry {
729                if retry.max_retries > 10 {
730                    errors.push(
731                        "http_client.retry.max_retries: max_retries must be between 0 and 10"
732                            .to_string(),
733                    );
734                }
735                if !(100..=10000).contains(&retry.initial_delay_ms) {
736                    errors.push(
737                        "http_client.retry.initial_delay_ms: initial_delay_ms must be between 100 and 10000"
738                            .to_string(),
739                    );
740                }
741                if !(1000..=300000).contains(&retry.max_delay_ms) {
742                    errors.push(
743                        "http_client.retry.max_delay_ms: max_delay_ms must be between 1000 and 300000"
744                            .to_string(),
745                    );
746                }
747            }
748        }
749
750        if let Some(streaming) = &self.streaming {
751            for (index, endpoint) in streaming.endpoints.iter().enumerate() {
752                let prefix = format!("streaming.endpoints[{index}]");
753                if endpoint.operation_id.is_empty() {
754                    errors.push(format!("{prefix}.operation_id: must not be empty"));
755                }
756                if endpoint.path.is_empty() {
757                    errors.push(format!("{prefix}.path: must not be empty"));
758                }
759                if endpoint.event_union_type.is_empty() {
760                    errors.push(format!("{prefix}.event_union_type: must not be empty"));
761                }
762                for (query_index, query) in endpoint.query_parameters.iter().enumerate() {
763                    if query.name.is_empty() {
764                        errors.push(format!(
765                            "{prefix}.query_parameters[{query_index}].name: must not be empty"
766                        ));
767                    }
768                }
769                if let Some(flow) = &endpoint.event_flow
770                    && !matches!(
771                        flow.flow_type.as_str(),
772                        "StartDeltaStop" | "start_delta_stop" | "Continuous"
773                    )
774                {
775                    errors.push(format!(
776                        "{prefix}.event_flow.type: Invalid event flow type '{}'. Must be one of: StartDeltaStop, Continuous",
777                        flow.flow_type
778                    ));
779                }
780            }
781        }
782
783        if errors.is_empty() {
784            Ok(())
785        } else {
786            Err(GeneratorError::ValidationError(format!(
787                "Configuration validation failed:\n  - {}",
788                errors.join("\n  - ")
789            )))
790        }
791    }
792
793    /// Convert to internal GeneratorConfig
794    pub fn into_generator_config(self) -> GeneratorConfig {
795        use crate::http_config::{AuthConfig, HttpClientConfig, RetryConfig};
796
797        let types = self.types;
798
799        // Convert HTTP client config
800        let http_client_config = self.http_client.as_ref().map(|http| HttpClientConfig {
801            base_url: http.base_url.clone(),
802            timeout_seconds: http.timeout_seconds,
803            default_headers: http
804                .headers
805                .iter()
806                .map(|h| (h.name.clone(), h.value.clone()))
807                .collect(),
808        });
809
810        // Convert retry config
811        let retry_config = self
812            .http_client
813            .as_ref()
814            .and_then(|http| http.retry.as_ref())
815            .map(|retry| RetryConfig {
816                max_retries: retry.max_retries,
817                initial_delay_ms: retry.initial_delay_ms,
818                max_delay_ms: retry.max_delay_ms,
819            });
820
821        // Convert tracing config
822        let tracing_enabled = self
823            .http_client
824            .as_ref()
825            .and_then(|http| http.tracing.as_ref())
826            .map(|tracing| tracing.enabled)
827            .unwrap_or(true);
828
829        // Convert auth config
830        let auth_config = self
831            .http_client
832            .as_ref()
833            .and_then(|http| http.auth.as_ref())
834            .map(|auth| match auth.auth_type.as_str() {
835                "Bearer" => AuthConfig::Bearer {
836                    header_name: auth.header_name.clone(),
837                },
838                "ApiKey" => AuthConfig::ApiKey {
839                    header_name: auth.header_name.clone(),
840                },
841                "Custom" => AuthConfig::Custom {
842                    header_name: auth.header_name.clone(),
843                    header_value_prefix: None,
844                },
845                _ => AuthConfig::Bearer {
846                    header_name: "Authorization".to_string(),
847                },
848            });
849
850        // Convert streaming section to StreamingConfig
851        let streaming_config = self.streaming.map(|section| {
852            use crate::streaming::{
853                EventFlow, HttpMethod, QueryParameter, StreamingConfig, StreamingEndpoint,
854            };
855
856            let endpoints = section
857                .endpoints
858                .into_iter()
859                .map(|e| {
860                    let event_flow = e
861                        .event_flow
862                        .map(|ef| match ef.flow_type.as_str() {
863                            "StartDeltaStop" | "start_delta_stop" => EventFlow::StartDeltaStop {
864                                start_events: ef.start_events.unwrap_or_default(),
865                                delta_events: ef.delta_events.unwrap_or_default(),
866                                stop_events: ef.stop_events.unwrap_or_default(),
867                            },
868                            _ => EventFlow::Simple,
869                        })
870                        .unwrap_or(EventFlow::Simple);
871
872                    let http_method = e
873                        .http_method
874                        .map(|m| match m.to_uppercase().as_str() {
875                            "GET" => HttpMethod::Get,
876                            _ => HttpMethod::Post,
877                        })
878                        .unwrap_or(HttpMethod::Post);
879
880                    let query_parameters = e
881                        .query_parameters
882                        .into_iter()
883                        .map(|qp| QueryParameter {
884                            name: qp.name,
885                            required: qp.required,
886                        })
887                        .collect();
888
889                    StreamingEndpoint {
890                        operation_id: e.operation_id,
891                        path: e.path,
892                        http_method,
893                        stream_parameter: e.stream_parameter,
894                        query_parameters,
895                        event_union_type: e.event_union_type,
896                        content_type: e.content_type,
897                        event_flow,
898                        ..Default::default()
899                    }
900                })
901                .collect();
902
903            StreamingConfig {
904                endpoints,
905                ..Default::default()
906            }
907        });
908
909        GeneratorConfig {
910            spec_path: self.generator.spec_path,
911            output_dir: self.generator.output_dir,
912            module_name: self.generator.module_name,
913            enable_sse_client: self.features.enable_sse_client,
914            enable_async_client: self.features.enable_async_client,
915            enable_specta: self.features.enable_specta,
916            type_mappings: if self.type_mappings.is_empty() {
917                super::generator::default_type_mappings()
918            } else {
919                self.type_mappings
920            },
921            streaming_config,
922            nullable_field_overrides: self.nullable_overrides,
923            extensible_enum_overrides: self.extensible_enums,
924            schema_extensions: self.generator.schema_extensions,
925            http_client_config,
926            retry_config,
927            tracing_enabled,
928            auth_config,
929            enable_registry: self.features.enable_registry,
930            registry_only: self.features.registry_only,
931            types,
932            builders: self.generator.builders,
933            server: self.server,
934            client: self.client,
935        }
936    }
937}
938
939const EXAMPLE_CONFIG: &str = r#"[generator]
940spec_path = "openapi.json"
941output_dir = "src/generated"
942module_name = "types"
943
944[generator.builders]
945enabled = true
946threshold = 3
947
948[features]
949enable_async_client = true
950
951[http_client]
952base_url = "https://api.example.com"
953timeout_seconds = 30
954
955[http_client.retry]
956max_retries = 3
957
958[http_client.auth]
959type = "Bearer"
960header_name = "Authorization""#;