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//! # Validation
100//!
101//! The configuration is validated on load using the `validator` crate:
102//! - File paths are checked for existence
103//! - Numeric ranges are enforced (timeout, retry counts, delays)
104//! - Enum values are validated (auth types, event flow types)
105//! - Required fields are checked
106//!
107//! Invalid configurations produce helpful error messages:
108//!
109//! ```text
110//! Configuration validation failed:
111//!   - generator.spec_path: OpenAPI spec file not found: missing.json
112//!   - http_client.retry.max_retries: max_retries must be between 0 and 10
113//! ```
114//!
115//! # Examples
116//!
117//! See the [examples](https://github.com/your-repo/examples) directory for complete examples:
118//! - `toml_config_example.rs` - Various configuration patterns
119//! - `complete_workflow.rs` - Full generation workflow with TOML
120//!
121//! # Backward Compatibility
122//!
123//! The TOML configuration is fully optional. The existing Rust API continues to work:
124//!
125//! ```no_run
126//! use openapi_to_rust::{GeneratorConfig, CodeGenerator};
127//! use std::path::PathBuf;
128//!
129//! let config = GeneratorConfig {
130//!     spec_path: PathBuf::from("openapi.json"),
131//!     enable_async_client: true,
132//!     // ... other fields
133//!     ..Default::default()
134//! };
135//!
136//! let generator = CodeGenerator::new(config);
137//! // ... generate code
138//! ```
139
140use crate::{GeneratorError, generator::GeneratorConfig};
141use serde::{Deserialize, Serialize};
142use std::collections::BTreeMap;
143use std::path::{Path, PathBuf};
144use validator::Validate;
145
146/// Root configuration loaded from TOML file
147#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
148pub struct ConfigFile {
149    #[validate(nested)]
150    pub generator: GeneratorSection,
151    #[validate(nested)]
152    pub features: FeaturesSection,
153    #[serde(default)]
154    #[validate(nested)]
155    pub http_client: Option<HttpClientSection>,
156    #[serde(default)]
157    #[validate(nested)]
158    pub streaming: Option<StreamingSection>,
159    /// Server codegen opt-in. Absent or empty operations list ⇒ no
160    /// server code emitted. See `docs/planning/server-codegen.md`.
161    #[serde(default)]
162    #[validate(nested)]
163    pub server: Option<ServerSection>,
164    #[serde(default)]
165    pub nullable_overrides: BTreeMap<String, bool>,
166    /// Force a closed string-enum schema to be rendered as an extensible enum
167    /// (with a `Custom(String)` fallback variant). Use when the spec under-
168    /// declares the enum and the API returns values outside the declared set.
169    /// Format: `"SchemaName" = true`. Mirror of `nullable_overrides`.
170    #[serde(default)]
171    pub extensible_enums: BTreeMap<String, bool>,
172    #[serde(default)]
173    pub type_mappings: BTreeMap<String, String>,
174    /// `[generator.types]` block — per-format type-mapping strategies.
175    /// Wired in by Q2.0; populated in subsequent Q2.* issues.
176    #[serde(default)]
177    pub types: crate::type_mapping::TypeMappingConfig,
178}
179
180#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
181pub struct GeneratorSection {
182    #[validate(custom(function = "validate_spec_path_exists"))]
183    pub spec_path: PathBuf,
184    pub output_dir: PathBuf,
185    /// Informational label, not a directory or module path. The
186    /// generator writes the same files (mod.rs, types.rs, server/*)
187    /// regardless of this value. It shows up only in the generated
188    /// mod.rs header doc comment as a hint and is used by the
189    /// streaming codegen for naming the SSE client module. You
190    /// mount the tree at whatever Rust module path you prefer.
191    #[validate(length(min = 1, message = "module_name cannot be empty"))]
192    pub module_name: String,
193    /// Schema extension files to merge into the main spec before codegen.
194    /// Paths are relative to the working directory (same as spec_path).
195    #[serde(default)]
196    pub schema_extensions: Vec<PathBuf>,
197}
198
199#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
200pub struct FeaturesSection {
201    #[serde(default)]
202    pub enable_sse_client: bool,
203    #[serde(default)]
204    pub enable_async_client: bool,
205    #[serde(default)]
206    pub enable_specta: bool,
207    /// Generate a static operation registry with metadata for CLI/proxy routing
208    #[serde(default)]
209    pub enable_registry: bool,
210    /// Generate only the operation registry (skip types, client, streaming)
211    #[serde(default)]
212    pub registry_only: bool,
213}
214
215/// Opt-in server codegen scope.
216///
217/// `operations` accepts three selector forms, parsed by
218/// [`crate::server::Selector::parse`]:
219///   - `operationId` (recommended)
220///   - `METHOD /path`
221///   - `tag:<name>`
222#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
223pub struct ServerSection {
224    /// Target framework. Only `"axum"` is currently supported.
225    #[validate(custom(function = "validate_server_framework"))]
226    pub framework: String,
227    /// Selectors picking which operations get server scaffolding.
228    /// Empty ⇒ section is a no-op.
229    #[serde(default)]
230    pub operations: Vec<String>,
231    /// Emit only the model types reachable (transitively) from the
232    /// picked operations. Off by default because the bundled
233    /// `types.rs` is shared with the HTTP client generator and
234    /// pruning would silently drop types client code still needs.
235    /// Enable when generating a server-only crate to cut the
236    /// emitted surface dramatically (often 100× for spec-heavy APIs).
237    #[serde(default)]
238    pub prune_models: bool,
239}
240
241impl ServerSection {
242    /// Parse each `operations` entry into a [`crate::server::Selector`].
243    /// Returns the first parse error encountered.
244    pub fn parsed_selectors(
245        &self,
246    ) -> Result<Vec<crate::server::Selector>, crate::server::SelectorParseError> {
247        self.operations
248            .iter()
249            .map(|s| crate::server::Selector::parse(s))
250            .collect()
251    }
252}
253
254#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
255pub struct HttpClientSection {
256    #[validate(url(message = "base_url must be a valid URL"))]
257    pub base_url: Option<String>,
258    #[validate(custom(function = "validate_timeout_seconds"))]
259    pub timeout_seconds: Option<u64>,
260    #[validate(nested)]
261    pub auth: Option<AuthConfigSection>,
262    #[serde(default)]
263    #[validate(nested)]
264    pub headers: Vec<HeaderEntry>,
265    #[validate(nested)]
266    pub retry: Option<RetryConfigSection>,
267    #[validate(nested)]
268    pub tracing: Option<TracingConfigSection>,
269}
270
271#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
272pub struct TracingConfigSection {
273    #[serde(default = "default_tracing_enabled")]
274    pub enabled: bool,
275}
276
277fn default_tracing_enabled() -> bool {
278    true
279}
280
281#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
282pub struct RetryConfigSection {
283    #[serde(default = "default_max_retries")]
284    #[validate(custom(function = "validate_max_retries"))]
285    pub max_retries: u32,
286    #[serde(default = "default_initial_delay_ms")]
287    #[validate(custom(function = "validate_initial_delay_ms"))]
288    pub initial_delay_ms: u64,
289    #[serde(default = "default_max_delay_ms")]
290    #[validate(custom(function = "validate_max_delay_ms"))]
291    pub max_delay_ms: u64,
292}
293
294fn default_max_retries() -> u32 {
295    3
296}
297fn default_initial_delay_ms() -> u64 {
298    500
299}
300fn default_max_delay_ms() -> u64 {
301    16000
302}
303
304#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
305pub struct AuthConfigSection {
306    #[serde(rename = "type")]
307    #[validate(custom(function = "validate_auth_type"))]
308    pub auth_type: String,
309    #[validate(length(min = 1, message = "header_name cannot be empty"))]
310    pub header_name: String,
311}
312
313#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
314pub struct HeaderEntry {
315    #[validate(length(min = 1, message = "header name cannot be empty"))]
316    pub name: String,
317    pub value: String,
318}
319
320#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
321pub struct StreamingSection {
322    #[validate(nested)]
323    pub endpoints: Vec<StreamingEndpointSection>,
324}
325
326#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
327pub struct StreamingEndpointSection {
328    #[validate(length(min = 1))]
329    pub operation_id: String,
330    #[validate(length(min = 1))]
331    pub path: String,
332    /// HTTP method: "GET" or "POST" (default: POST)
333    #[serde(default)]
334    pub http_method: Option<String>,
335    /// Parameter name that controls streaming (only for POST requests)
336    #[serde(default)]
337    pub stream_parameter: String,
338    /// Query parameters for GET requests
339    #[serde(default)]
340    pub query_parameters: Vec<QueryParameterSection>,
341    #[validate(length(min = 1))]
342    pub event_union_type: String,
343    pub content_type: Option<String>,
344    #[validate(nested)]
345    pub event_flow: Option<EventFlowSection>,
346}
347
348#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
349pub struct QueryParameterSection {
350    #[validate(length(min = 1))]
351    pub name: String,
352    #[serde(default)]
353    pub required: bool,
354}
355
356#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
357pub struct EventFlowSection {
358    #[serde(rename = "type")]
359    #[validate(custom(function = "validate_event_flow_type"))]
360    pub flow_type: String,
361    pub start_events: Option<Vec<String>>,
362    pub delta_events: Option<Vec<String>>,
363    pub stop_events: Option<Vec<String>>,
364}
365
366// Custom validators
367fn validate_spec_path_exists(path: &Path) -> Result<(), validator::ValidationError> {
368    if !path.exists() {
369        let mut error = validator::ValidationError::new("file_not_found");
370        error.message = Some(
371            format!(
372                "OpenAPI spec file not found: {}. Ensure spec_path points to a valid OpenAPI JSON or YAML file.",
373                path.display()
374            )
375            .into(),
376        );
377        return Err(error);
378    }
379    Ok(())
380}
381
382fn validate_server_framework(framework: &str) -> Result<(), validator::ValidationError> {
383    if framework == "axum" {
384        Ok(())
385    } else {
386        let mut err = validator::ValidationError::new("invalid_framework");
387        err.message = Some(
388            format!("framework must be \"axum\" (got \"{framework}\"); other frameworks are not supported yet").into(),
389        );
390        Err(err)
391    }
392}
393
394fn validate_auth_type(auth_type: &str) -> Result<(), validator::ValidationError> {
395    match auth_type {
396        "Bearer" | "ApiKey" | "Custom" => Ok(()),
397        _ => {
398            let mut error = validator::ValidationError::new("invalid_auth_type");
399            error.message = Some(
400                format!(
401                    "Invalid auth type '{}'. Must be one of: Bearer, ApiKey, Custom",
402                    auth_type
403                )
404                .into(),
405            );
406            Err(error)
407        }
408    }
409}
410
411fn validate_event_flow_type(flow_type: &str) -> Result<(), validator::ValidationError> {
412    match flow_type {
413        "StartDeltaStop" | "Continuous" => Ok(()),
414        _ => {
415            let mut error = validator::ValidationError::new("invalid_event_flow_type");
416            error.message = Some(
417                format!(
418                    "Invalid event flow type '{}'. Must be one of: StartDeltaStop, Continuous",
419                    flow_type
420                )
421                .into(),
422            );
423            Err(error)
424        }
425    }
426}
427
428fn validate_timeout_seconds(timeout: u64) -> Result<(), validator::ValidationError> {
429    if !(1..=3600).contains(&timeout) {
430        let mut error = validator::ValidationError::new("out_of_range");
431        error.message = Some("timeout_seconds must be between 1 and 3600".into());
432        return Err(error);
433    }
434    Ok(())
435}
436
437fn validate_max_retries(retries: u32) -> Result<(), validator::ValidationError> {
438    if retries > 10 {
439        let mut error = validator::ValidationError::new("out_of_range");
440        error.message = Some("max_retries must be between 0 and 10".into());
441        return Err(error);
442    }
443    Ok(())
444}
445
446fn validate_initial_delay_ms(delay: u64) -> Result<(), validator::ValidationError> {
447    if !(100..=10000).contains(&delay) {
448        let mut error = validator::ValidationError::new("out_of_range");
449        error.message = Some("initial_delay_ms must be between 100 and 10000".into());
450        return Err(error);
451    }
452    Ok(())
453}
454
455fn validate_max_delay_ms(delay: u64) -> Result<(), validator::ValidationError> {
456    if !(1000..=300000).contains(&delay) {
457        let mut error = validator::ValidationError::new("out_of_range");
458        error.message = Some("max_delay_ms must be between 1000 and 300000".into());
459        return Err(error);
460    }
461    Ok(())
462}
463
464impl ConfigFile {
465    /// Load and validate configuration from TOML file
466    pub fn load(path: &Path) -> Result<Self, GeneratorError> {
467        let content = std::fs::read_to_string(path).map_err(|e| GeneratorError::FileError {
468            message: format!("Failed to read config file '{}': {}", path.display(), e),
469        })?;
470
471        let config: ConfigFile =
472            toml::from_str(&content).map_err(|e| GeneratorError::FileError {
473                message: format!(
474                    "Failed to parse TOML config: {}\n\nExample config:\n{}",
475                    e, EXAMPLE_CONFIG
476                ),
477            })?;
478
479        // Validate the configuration
480        config.validate().map_err(|e| {
481            GeneratorError::ValidationError(format!(
482                "Configuration validation failed:\n{}",
483                format_validation_errors(&e)
484            ))
485        })?;
486
487        Ok(config)
488    }
489
490    /// Convert to internal GeneratorConfig
491    pub fn into_generator_config(self) -> GeneratorConfig {
492        use crate::http_config::{AuthConfig, HttpClientConfig, RetryConfig};
493
494        // Convert HTTP client config
495        let http_client_config = self.http_client.as_ref().map(|http| HttpClientConfig {
496            base_url: http.base_url.clone(),
497            timeout_seconds: http.timeout_seconds,
498            default_headers: http
499                .headers
500                .iter()
501                .map(|h| (h.name.clone(), h.value.clone()))
502                .collect(),
503        });
504
505        // Convert retry config
506        let retry_config = self
507            .http_client
508            .as_ref()
509            .and_then(|http| http.retry.as_ref())
510            .map(|retry| RetryConfig {
511                max_retries: retry.max_retries,
512                initial_delay_ms: retry.initial_delay_ms,
513                max_delay_ms: retry.max_delay_ms,
514            });
515
516        // Convert tracing config
517        let tracing_enabled = self
518            .http_client
519            .as_ref()
520            .and_then(|http| http.tracing.as_ref())
521            .map(|tracing| tracing.enabled)
522            .unwrap_or(true);
523
524        // Convert auth config
525        let auth_config = self
526            .http_client
527            .as_ref()
528            .and_then(|http| http.auth.as_ref())
529            .map(|auth| match auth.auth_type.as_str() {
530                "Bearer" => AuthConfig::Bearer {
531                    header_name: auth.header_name.clone(),
532                },
533                "ApiKey" => AuthConfig::ApiKey {
534                    header_name: auth.header_name.clone(),
535                },
536                "Custom" => AuthConfig::Custom {
537                    header_name: auth.header_name.clone(),
538                    header_value_prefix: None,
539                },
540                _ => AuthConfig::Bearer {
541                    header_name: "Authorization".to_string(),
542                },
543            });
544
545        // Convert streaming section to StreamingConfig
546        let streaming_config = self.streaming.map(|section| {
547            use crate::streaming::{
548                EventFlow, HttpMethod, QueryParameter, StreamingConfig, StreamingEndpoint,
549            };
550
551            let endpoints = section
552                .endpoints
553                .into_iter()
554                .map(|e| {
555                    let event_flow = e
556                        .event_flow
557                        .map(|ef| match ef.flow_type.as_str() {
558                            "start_delta_stop" => EventFlow::StartDeltaStop {
559                                start_events: ef.start_events.unwrap_or_default(),
560                                delta_events: ef.delta_events.unwrap_or_default(),
561                                stop_events: ef.stop_events.unwrap_or_default(),
562                            },
563                            _ => EventFlow::Simple,
564                        })
565                        .unwrap_or(EventFlow::Simple);
566
567                    let http_method = e
568                        .http_method
569                        .map(|m| match m.to_uppercase().as_str() {
570                            "GET" => HttpMethod::Get,
571                            _ => HttpMethod::Post,
572                        })
573                        .unwrap_or(HttpMethod::Post);
574
575                    let query_parameters = e
576                        .query_parameters
577                        .into_iter()
578                        .map(|qp| QueryParameter {
579                            name: qp.name,
580                            required: qp.required,
581                        })
582                        .collect();
583
584                    StreamingEndpoint {
585                        operation_id: e.operation_id,
586                        path: e.path,
587                        http_method,
588                        stream_parameter: e.stream_parameter,
589                        query_parameters,
590                        event_union_type: e.event_union_type,
591                        content_type: e.content_type,
592                        event_flow,
593                        ..Default::default()
594                    }
595                })
596                .collect();
597
598            StreamingConfig {
599                endpoints,
600                ..Default::default()
601            }
602        });
603
604        GeneratorConfig {
605            spec_path: self.generator.spec_path,
606            output_dir: self.generator.output_dir,
607            module_name: self.generator.module_name,
608            enable_sse_client: self.features.enable_sse_client,
609            enable_async_client: self.features.enable_async_client,
610            enable_specta: self.features.enable_specta,
611            type_mappings: if self.type_mappings.is_empty() {
612                super::generator::default_type_mappings()
613            } else {
614                self.type_mappings
615            },
616            streaming_config,
617            nullable_field_overrides: self.nullable_overrides,
618            extensible_enum_overrides: self.extensible_enums,
619            schema_extensions: self.generator.schema_extensions,
620            http_client_config,
621            retry_config,
622            tracing_enabled,
623            auth_config,
624            enable_registry: self.features.enable_registry,
625            registry_only: self.features.registry_only,
626            types: self.types,
627            server: self.server,
628        }
629    }
630}
631
632/// Format validator errors into a readable message
633fn format_validation_errors(errors: &validator::ValidationErrors) -> String {
634    let mut messages = Vec::new();
635
636    // Handle direct field errors
637    for (field, field_errors) in errors.field_errors() {
638        for error in field_errors {
639            let msg = if let Some(message) = &error.message {
640                format!("  - {}: {}", field, message)
641            } else {
642                format!("  - {}: validation failed (code: {})", field, error.code)
643            };
644            messages.push(msg);
645        }
646    }
647
648    // Handle nested errors
649    for (field, nested_errors) in errors.errors() {
650        if let validator::ValidationErrorsKind::Struct(struct_errors) = nested_errors {
651            let nested_msgs = format_validation_errors(struct_errors);
652            if !nested_msgs.is_empty() {
653                messages.push(format!("  - {} (nested):\n{}", field, nested_msgs));
654            }
655        }
656    }
657
658    messages.join("\n")
659}
660
661const EXAMPLE_CONFIG: &str = r#"[generator]
662spec_path = "openapi.json"
663output_dir = "src/generated"
664module_name = "types"
665
666[features]
667enable_async_client = true
668
669[http_client]
670base_url = "https://api.example.com"
671timeout_seconds = 30
672
673[http_client.retry]
674max_retries = 3
675
676[http_client.auth]
677type = "Bearer"
678header_name = "Authorization""#;