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    /// Runtime request validation policy for generated server scaffolding.
270    #[serde(default)]
271    pub validation: ServerValidationSection,
272}
273
274/// Limits and policy for generated request validation.
275#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
276#[serde(default, deny_unknown_fields)]
277pub struct ServerValidationSection {
278    /// Validate request inputs against their OpenAPI schemas.
279    pub enabled: bool,
280    /// Maximum request-body bytes accepted before buffering is rejected.
281    pub max_body_bytes: usize,
282    /// Maximum number of normalized violations returned to a client.
283    pub max_errors: usize,
284}
285
286impl Default for ServerValidationSection {
287    fn default() -> Self {
288        Self {
289            enabled: true,
290            max_body_bytes: 2_097_152,
291            max_errors: 16,
292        }
293    }
294}
295
296/// Optional scope for generated HTTP-client operations.
297///
298/// Selectors use the same grammar as [`ServerSection::operations`]. An absent
299/// section, or an empty `operations` list, preserves the historical behavior
300/// of generating every operation. The section is ignored when the async HTTP
301/// client is disabled and never filters the operation registry. Set
302/// `prune_models = true` to also remove models outside the combined selected
303/// client/server operation closure.
304#[derive(Debug, Clone, Deserialize, Serialize)]
305#[serde(deny_unknown_fields)]
306pub struct ClientSection {
307    /// Selectors picking client methods: `operationId`, `METHOD /path`, or
308    /// `tag:<name>`. Empty means all operations.
309    #[serde(default)]
310    pub operations: Vec<String>,
311    /// Restrict `types.rs` to models reachable from the selected client
312    /// operations plus any selected server operations.
313    #[serde(default)]
314    pub prune_models: bool,
315}
316
317impl ClientSection {
318    /// Parse configured selectors using the shared client/server grammar.
319    pub fn parsed_selectors(
320        &self,
321    ) -> Result<Vec<crate::server::Selector>, crate::server::SelectorParseError> {
322        self.operations
323            .iter()
324            .map(|s| crate::server::Selector::parse(s))
325            .collect()
326    }
327}
328
329impl ServerSection {
330    /// Parse each `operations` entry into a [`crate::server::Selector`].
331    /// Returns the first parse error encountered.
332    pub fn parsed_selectors(
333        &self,
334    ) -> Result<Vec<crate::server::Selector>, crate::server::SelectorParseError> {
335        self.operations
336            .iter()
337            .map(|s| crate::server::Selector::parse(s))
338            .collect()
339    }
340}
341
342#[derive(Debug, Clone, Deserialize, Serialize)]
343#[serde(deny_unknown_fields)]
344pub struct HttpClientSection {
345    pub base_url: Option<String>,
346    pub timeout_seconds: Option<u64>,
347    pub auth: Option<AuthConfigSection>,
348    #[serde(default)]
349    pub headers: Vec<HeaderEntry>,
350    pub retry: Option<RetryConfigSection>,
351    pub tracing: Option<TracingConfigSection>,
352}
353
354#[derive(Debug, Clone, Deserialize, Serialize)]
355#[serde(deny_unknown_fields)]
356pub struct TracingConfigSection {
357    #[serde(default = "default_tracing_enabled")]
358    pub enabled: bool,
359}
360
361fn default_tracing_enabled() -> bool {
362    true
363}
364
365#[derive(Debug, Clone, Deserialize, Serialize)]
366#[serde(deny_unknown_fields)]
367pub struct RetryConfigSection {
368    #[serde(default = "default_max_retries")]
369    pub max_retries: u32,
370    #[serde(default = "default_initial_delay_ms")]
371    pub initial_delay_ms: u64,
372    #[serde(default = "default_max_delay_ms")]
373    pub max_delay_ms: u64,
374}
375
376fn default_max_retries() -> u32 {
377    3
378}
379fn default_initial_delay_ms() -> u64 {
380    500
381}
382fn default_max_delay_ms() -> u64 {
383    16000
384}
385
386#[derive(Debug, Clone, Deserialize, Serialize)]
387#[serde(deny_unknown_fields)]
388pub struct AuthConfigSection {
389    #[serde(rename = "type")]
390    pub auth_type: String,
391    pub header_name: String,
392}
393
394#[derive(Debug, Clone, Deserialize, Serialize)]
395#[serde(deny_unknown_fields)]
396pub struct HeaderEntry {
397    pub name: String,
398    pub value: String,
399}
400
401#[derive(Debug, Clone, Deserialize, Serialize)]
402#[serde(deny_unknown_fields)]
403pub struct StreamingSection {
404    pub endpoints: Vec<StreamingEndpointSection>,
405}
406
407#[derive(Debug, Clone, Deserialize, Serialize)]
408#[serde(deny_unknown_fields)]
409pub struct StreamingEndpointSection {
410    pub operation_id: String,
411    pub path: String,
412    /// HTTP method: "GET" or "POST" (default: POST)
413    #[serde(default)]
414    pub http_method: Option<String>,
415    /// Parameter name that controls streaming (only for POST requests)
416    #[serde(default)]
417    pub stream_parameter: String,
418    /// Query parameters for GET requests
419    #[serde(default)]
420    pub query_parameters: Vec<QueryParameterSection>,
421    pub event_union_type: String,
422    pub content_type: Option<String>,
423    pub event_flow: Option<EventFlowSection>,
424}
425
426#[derive(Debug, Clone, Deserialize, Serialize)]
427#[serde(deny_unknown_fields)]
428pub struct QueryParameterSection {
429    pub name: String,
430    #[serde(default)]
431    pub required: bool,
432}
433
434#[derive(Debug, Clone, Deserialize, Serialize)]
435#[serde(deny_unknown_fields)]
436pub struct EventFlowSection {
437    #[serde(rename = "type")]
438    pub flow_type: String,
439    pub start_events: Option<Vec<String>>,
440    pub delta_events: Option<Vec<String>>,
441    pub stop_events: Option<Vec<String>>,
442}
443
444/// Serde-only representation of the TOML contract. Keeping this separate from
445/// the public Rust API lets TOML use canonical `[generator.types]` while
446/// preserving the long-standing normalized [`ConfigFile::types`] field.
447#[derive(Deserialize)]
448#[serde(deny_unknown_fields)]
449struct ConfigFileWire {
450    generator: GeneratorSectionWire,
451    features: FeaturesSection,
452    #[serde(default)]
453    http_client: Option<HttpClientSection>,
454    #[serde(default)]
455    streaming: Option<StreamingSection>,
456    #[serde(default)]
457    server: Option<ServerSection>,
458    #[serde(default)]
459    client: Option<ClientSection>,
460    #[serde(default)]
461    nullable_overrides: BTreeMap<String, bool>,
462    #[serde(default)]
463    extensible_enums: BTreeMap<String, bool>,
464    #[serde(default)]
465    type_mappings: BTreeMap<String, String>,
466    #[serde(default)]
467    types: Option<crate::type_mapping::TypeMappingConfig>,
468}
469
470#[derive(Deserialize)]
471#[serde(deny_unknown_fields)]
472struct GeneratorSectionWire {
473    spec_path: PathBuf,
474    output_dir: PathBuf,
475    module_name: String,
476    #[serde(default)]
477    schema_extensions: Vec<PathBuf>,
478    #[serde(default)]
479    builders: BuildersSection,
480    #[serde(default)]
481    types: Option<crate::type_mapping::TypeMappingConfig>,
482}
483
484impl TryFrom<ConfigFileWire> for ConfigFile {
485    type Error = String;
486
487    fn try_from(wire: ConfigFileWire) -> Result<Self, Self::Error> {
488        let types = match (wire.generator.types, wire.types) {
489            (Some(_), Some(_)) => {
490                return Err(
491                    "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]."
492                        .to_string(),
493                );
494            }
495            (Some(types), None) | (None, Some(types)) => types,
496            (None, None) => crate::type_mapping::TypeMappingConfig::default(),
497        };
498
499        Ok(Self {
500            generator: GeneratorSection {
501                spec_path: wire.generator.spec_path,
502                output_dir: wire.generator.output_dir,
503                module_name: wire.generator.module_name,
504                schema_extensions: wire.generator.schema_extensions,
505                builders: wire.generator.builders,
506            },
507            features: wire.features,
508            http_client: wire.http_client,
509            streaming: wire.streaming,
510            server: wire.server,
511            client: wire.client,
512            nullable_overrides: wire.nullable_overrides,
513            extensible_enums: wire.extensible_enums,
514            type_mappings: wire.type_mappings,
515            types,
516        })
517    }
518}
519
520impl<'de> Deserialize<'de> for ConfigFile {
521    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
522    where
523        D: serde::Deserializer<'de>,
524    {
525        ConfigFileWire::deserialize(deserializer)?
526            .try_into()
527            .map_err(serde::de::Error::custom)
528    }
529}
530
531#[derive(Serialize)]
532struct ConfigFileRef<'a> {
533    generator: GeneratorSectionRef<'a>,
534    features: &'a FeaturesSection,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    http_client: Option<&'a HttpClientSection>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    streaming: Option<&'a StreamingSection>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    server: Option<&'a ServerSection>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    client: Option<&'a ClientSection>,
543    nullable_overrides: &'a BTreeMap<String, bool>,
544    extensible_enums: &'a BTreeMap<String, bool>,
545    type_mappings: &'a BTreeMap<String, String>,
546}
547
548#[derive(Serialize)]
549struct GeneratorSectionRef<'a> {
550    spec_path: &'a Path,
551    output_dir: &'a Path,
552    module_name: &'a str,
553    schema_extensions: &'a [PathBuf],
554    builders: &'a BuildersSection,
555    types: &'a crate::type_mapping::TypeMappingConfig,
556}
557
558impl Serialize for ConfigFile {
559    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
560    where
561        S: serde::Serializer,
562    {
563        ConfigFileRef {
564            generator: GeneratorSectionRef {
565                spec_path: &self.generator.spec_path,
566                output_dir: &self.generator.output_dir,
567                module_name: &self.generator.module_name,
568                schema_extensions: &self.generator.schema_extensions,
569                builders: &self.generator.builders,
570                types: &self.types,
571            },
572            features: &self.features,
573            http_client: self.http_client.as_ref(),
574            streaming: self.streaming.as_ref(),
575            server: self.server.as_ref(),
576            client: self.client.as_ref(),
577            nullable_overrides: &self.nullable_overrides,
578            extensible_enums: &self.extensible_enums,
579            type_mappings: &self.type_mappings,
580        }
581        .serialize(serializer)
582    }
583}
584
585fn resolve_relative_path(config_dir: &Path, path: &mut PathBuf) {
586    if path.is_relative() && !path.to_string_lossy().contains("://") {
587        *path = config_dir.join(&*path);
588    }
589}
590
591fn inspect_type_config_layout(value: &toml::Value) -> Result<(), GeneratorError> {
592    let legacy_types = value.get("types").is_some();
593    let canonical_types = value
594        .get("generator")
595        .and_then(|generator| generator.get("types"));
596
597    if legacy_types && canonical_types.is_some() {
598        return Err(GeneratorError::ValidationError(
599            "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]."
600                .to_string(),
601        ));
602    }
603
604    if canonical_types
605        .and_then(|types| types.get("strategies"))
606        .is_some()
607    {
608        return Err(GeneratorError::ValidationError(
609            "[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\")."
610                .to_string(),
611        ));
612    }
613
614    Ok(())
615}
616
617impl ConfigFile {
618    /// Load and validate configuration from a TOML file.
619    ///
620    /// Relative `spec_path`, `output_dir`, and `schema_extensions` values are
621    /// resolved against the directory containing `path`, independent of the
622    /// process's current working directory.
623    pub fn load(path: &Path) -> Result<Self, GeneratorError> {
624        let config_path = path.canonicalize().map_err(|e| GeneratorError::FileError {
625            message: format!("Failed to resolve config file '{}': {}", path.display(), e),
626        })?;
627        let config_dir = config_path
628            .parent()
629            .ok_or_else(|| GeneratorError::FileError {
630                message: format!(
631                    "Config file '{}' has no parent directory",
632                    config_path.display()
633                ),
634            })?;
635        let content =
636            std::fs::read_to_string(&config_path).map_err(|e| GeneratorError::FileError {
637                message: format!(
638                    "Failed to read config file '{}': {}",
639                    config_path.display(),
640                    e
641                ),
642            })?;
643
644        let value: toml::Value =
645            toml::from_str(&content).map_err(|e| GeneratorError::FileError {
646                message: format!(
647                    "Failed to parse TOML config: {}\n\nExample config:\n{}",
648                    e, EXAMPLE_CONFIG
649                ),
650            })?;
651        inspect_type_config_layout(&value)?;
652
653        let mut config: ConfigFile =
654            toml::from_str(&content).map_err(|e| GeneratorError::FileError {
655                message: format!(
656                    "Failed to parse TOML config: {}\n\nExample config:\n{}",
657                    e, EXAMPLE_CONFIG
658                ),
659            })?;
660
661        resolve_relative_path(config_dir, &mut config.generator.spec_path);
662        resolve_relative_path(config_dir, &mut config.generator.output_dir);
663        for extension in &mut config.generator.schema_extensions {
664            resolve_relative_path(config_dir, extension);
665        }
666
667        config.validate()?;
668
669        Ok(config)
670    }
671
672    fn validate(&self) -> Result<(), GeneratorError> {
673        let mut errors = Vec::new();
674
675        let spec_source = self.generator.spec_path.to_string_lossy();
676        if crate::spec_source::is_remote_spec(&spec_source) {
677            if let Err(error) = crate::spec_source::validate_remote_spec_url(&spec_source) {
678                errors.push(format!("generator.spec_path: {error}"));
679            }
680        } else if spec_source.contains("://") {
681            let error = crate::spec_source::validate_remote_spec_url(&spec_source)
682                .err()
683                .unwrap_or_else(|| "unsupported remote OpenAPI URL".to_string());
684            errors.push(format!("generator.spec_path: {error}"));
685        } else if !self.generator.spec_path.exists() {
686            errors.push(format!(
687                "generator.spec_path: OpenAPI spec file not found: {}. Ensure spec_path points to a valid OpenAPI JSON or YAML file.",
688                self.generator.spec_path.display()
689            ));
690        }
691        if self.generator.module_name.is_empty() {
692            errors.push("generator.module_name: module_name cannot be empty".to_string());
693        }
694
695        if let Some(server) = &self.server
696            && server.framework != "axum"
697        {
698            errors.push(format!(
699                "server.framework: framework must be \"axum\" (got \"{}\"); other frameworks are not supported yet",
700                server.framework
701            ));
702        }
703
704        if let Some(client) = &self.client {
705            for (index, selector) in client.operations.iter().enumerate() {
706                if let Err(error) = crate::server::Selector::parse(selector) {
707                    errors.push(format!("client.operations[{index}]: {error}"));
708                }
709            }
710        }
711        if let Some(server) = &self.server {
712            if !(1..=67_108_864).contains(&server.validation.max_body_bytes) {
713                errors.push(
714                    "server.validation.max_body_bytes: max_body_bytes must be between 1 and 67108864"
715                        .to_string(),
716                );
717            }
718            if !(1..=100).contains(&server.validation.max_errors) {
719                errors.push(
720                    "server.validation.max_errors: max_errors must be between 1 and 100"
721                        .to_string(),
722                );
723            }
724            for (index, selector) in server.operations.iter().enumerate() {
725                if let Err(error) = crate::server::Selector::parse(selector) {
726                    errors.push(format!("server.operations[{index}]: {error}"));
727                }
728            }
729        }
730
731        if let Some(http) = &self.http_client {
732            if let Some(base_url) = &http.base_url
733                && url::Url::parse(base_url).is_err()
734            {
735                errors.push("http_client.base_url: base_url must be a valid URL".to_string());
736            }
737            if let Some(timeout) = http.timeout_seconds
738                && !(1..=3600).contains(&timeout)
739            {
740                errors.push(
741                    "http_client.timeout_seconds: timeout_seconds must be between 1 and 3600"
742                        .to_string(),
743                );
744            }
745            if let Some(auth) = &http.auth {
746                if !matches!(auth.auth_type.as_str(), "Bearer" | "ApiKey" | "Custom") {
747                    errors.push(format!(
748                        "http_client.auth.type: Invalid auth type '{}'. Must be one of: Bearer, ApiKey, Custom",
749                        auth.auth_type
750                    ));
751                }
752                if auth.header_name.is_empty() {
753                    errors.push(
754                        "http_client.auth.header_name: header_name cannot be empty".to_string(),
755                    );
756                }
757            }
758            for (index, header) in http.headers.iter().enumerate() {
759                if header.name.is_empty() {
760                    errors.push(format!(
761                        "http_client.headers[{index}].name: header name cannot be empty"
762                    ));
763                }
764            }
765            if let Some(retry) = &http.retry {
766                if retry.max_retries > 10 {
767                    errors.push(
768                        "http_client.retry.max_retries: max_retries must be between 0 and 10"
769                            .to_string(),
770                    );
771                }
772                if !(100..=10000).contains(&retry.initial_delay_ms) {
773                    errors.push(
774                        "http_client.retry.initial_delay_ms: initial_delay_ms must be between 100 and 10000"
775                            .to_string(),
776                    );
777                }
778                if !(1000..=300000).contains(&retry.max_delay_ms) {
779                    errors.push(
780                        "http_client.retry.max_delay_ms: max_delay_ms must be between 1000 and 300000"
781                            .to_string(),
782                    );
783                }
784            }
785        }
786
787        if let Some(streaming) = &self.streaming {
788            for (index, endpoint) in streaming.endpoints.iter().enumerate() {
789                let prefix = format!("streaming.endpoints[{index}]");
790                if endpoint.operation_id.is_empty() {
791                    errors.push(format!("{prefix}.operation_id: must not be empty"));
792                }
793                if endpoint.path.is_empty() {
794                    errors.push(format!("{prefix}.path: must not be empty"));
795                }
796                if endpoint.event_union_type.is_empty() {
797                    errors.push(format!("{prefix}.event_union_type: must not be empty"));
798                }
799                for (query_index, query) in endpoint.query_parameters.iter().enumerate() {
800                    if query.name.is_empty() {
801                        errors.push(format!(
802                            "{prefix}.query_parameters[{query_index}].name: must not be empty"
803                        ));
804                    }
805                }
806                if let Some(flow) = &endpoint.event_flow
807                    && !matches!(
808                        flow.flow_type.as_str(),
809                        "StartDeltaStop" | "start_delta_stop" | "Continuous"
810                    )
811                {
812                    errors.push(format!(
813                        "{prefix}.event_flow.type: Invalid event flow type '{}'. Must be one of: StartDeltaStop, Continuous",
814                        flow.flow_type
815                    ));
816                }
817            }
818        }
819
820        if errors.is_empty() {
821            Ok(())
822        } else {
823            Err(GeneratorError::ValidationError(format!(
824                "Configuration validation failed:\n  - {}",
825                errors.join("\n  - ")
826            )))
827        }
828    }
829
830    /// Convert to internal GeneratorConfig
831    pub fn into_generator_config(self) -> GeneratorConfig {
832        use crate::http_config::{AuthConfig, HttpClientConfig, RetryConfig};
833
834        let types = self.types;
835
836        // Convert HTTP client config
837        let http_client_config = self.http_client.as_ref().map(|http| HttpClientConfig {
838            base_url: http.base_url.clone(),
839            timeout_seconds: http.timeout_seconds,
840            default_headers: http
841                .headers
842                .iter()
843                .map(|h| (h.name.clone(), h.value.clone()))
844                .collect(),
845        });
846
847        // Convert retry config
848        let retry_config = self
849            .http_client
850            .as_ref()
851            .and_then(|http| http.retry.as_ref())
852            .map(|retry| RetryConfig {
853                max_retries: retry.max_retries,
854                initial_delay_ms: retry.initial_delay_ms,
855                max_delay_ms: retry.max_delay_ms,
856            });
857
858        // Convert tracing config
859        let tracing_enabled = self
860            .http_client
861            .as_ref()
862            .and_then(|http| http.tracing.as_ref())
863            .map(|tracing| tracing.enabled)
864            .unwrap_or(true);
865
866        // Convert auth config
867        let auth_config = self
868            .http_client
869            .as_ref()
870            .and_then(|http| http.auth.as_ref())
871            .map(|auth| match auth.auth_type.as_str() {
872                "Bearer" => AuthConfig::Bearer {
873                    header_name: auth.header_name.clone(),
874                },
875                "ApiKey" => AuthConfig::ApiKey {
876                    header_name: auth.header_name.clone(),
877                },
878                "Custom" => AuthConfig::Custom {
879                    header_name: auth.header_name.clone(),
880                    header_value_prefix: None,
881                },
882                _ => AuthConfig::Bearer {
883                    header_name: "Authorization".to_string(),
884                },
885            });
886
887        // Convert streaming section to StreamingConfig
888        let streaming_config = self.streaming.map(|section| {
889            use crate::streaming::{
890                EventFlow, HttpMethod, QueryParameter, StreamingConfig, StreamingEndpoint,
891            };
892
893            let endpoints = section
894                .endpoints
895                .into_iter()
896                .map(|e| {
897                    let event_flow = e
898                        .event_flow
899                        .map(|ef| match ef.flow_type.as_str() {
900                            "StartDeltaStop" | "start_delta_stop" => EventFlow::StartDeltaStop {
901                                start_events: ef.start_events.unwrap_or_default(),
902                                delta_events: ef.delta_events.unwrap_or_default(),
903                                stop_events: ef.stop_events.unwrap_or_default(),
904                            },
905                            _ => EventFlow::Simple,
906                        })
907                        .unwrap_or(EventFlow::Simple);
908
909                    let http_method = e
910                        .http_method
911                        .map(|m| match m.to_uppercase().as_str() {
912                            "GET" => HttpMethod::Get,
913                            _ => HttpMethod::Post,
914                        })
915                        .unwrap_or(HttpMethod::Post);
916
917                    let query_parameters = e
918                        .query_parameters
919                        .into_iter()
920                        .map(|qp| QueryParameter {
921                            name: qp.name,
922                            required: qp.required,
923                        })
924                        .collect();
925
926                    StreamingEndpoint {
927                        operation_id: e.operation_id,
928                        path: e.path,
929                        http_method,
930                        stream_parameter: e.stream_parameter,
931                        query_parameters,
932                        event_union_type: e.event_union_type,
933                        content_type: e.content_type,
934                        event_flow,
935                        ..Default::default()
936                    }
937                })
938                .collect();
939
940            StreamingConfig {
941                endpoints,
942                ..Default::default()
943            }
944        });
945
946        GeneratorConfig {
947            spec_path: self.generator.spec_path,
948            output_dir: self.generator.output_dir,
949            module_name: self.generator.module_name,
950            enable_sse_client: self.features.enable_sse_client,
951            enable_async_client: self.features.enable_async_client,
952            enable_specta: self.features.enable_specta,
953            type_mappings: if self.type_mappings.is_empty() {
954                super::generator::default_type_mappings()
955            } else {
956                self.type_mappings
957            },
958            streaming_config,
959            nullable_field_overrides: self.nullable_overrides,
960            extensible_enum_overrides: self.extensible_enums,
961            schema_extensions: self.generator.schema_extensions,
962            http_client_config,
963            retry_config,
964            tracing_enabled,
965            auth_config,
966            enable_registry: self.features.enable_registry,
967            registry_only: self.features.registry_only,
968            types,
969            builders: self.generator.builders,
970            server: self.server,
971            client: self.client,
972        }
973    }
974}
975
976const EXAMPLE_CONFIG: &str = r#"[generator]
977spec_path = "openapi.json"
978output_dir = "src/generated"
979module_name = "types"
980
981[generator.builders]
982enabled = true
983threshold = 3
984
985[features]
986enable_async_client = true
987
988[http_client]
989base_url = "https://api.example.com"
990timeout_seconds = 30
991
992[http_client.retry]
993max_retries = 3
994
995[http_client.auth]
996type = "Bearer"
997header_name = "Authorization""#;