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