Skip to main content

tonic_rest_openapi/
config.rs

1//! Project-level `OpenAPI` configuration loaded from YAML.
2//!
3//! Externalizes project-specific knobs (method lists, error schema, transform
4//! toggles, endpoint paths) so they live next to the proto/OpenAPI files
5//! instead of being hardcoded in Rust source.
6//!
7//! # File format
8//!
9//! ```yaml
10//! # api/openapi/config.yaml
11//! error_schema_ref: "#/components/schemas/ErrorResponse"
12//!
13//! # Proto method names that return UNIMPLEMENTED at runtime.
14//! unimplemented_methods:
15//!   - SetupMfa
16//!   - DisableMfa
17//!
18//! # Proto method names that require no authentication.
19//! public_methods:
20//!   - Login
21//!   - SignUp
22//!
23//! # Endpoints that should use text/plain instead of application/json.
24//! plain_text_endpoints:
25//!   - path: /health/live
26//!     example: "OK"
27//!   - path: /metrics
28//!
29//! # Metrics endpoint for response header enrichment.
30//! metrics_path: /metrics
31//!
32//! # Readiness probe path for 503 response addition.
33//! readiness_path: /health/ready
34//!
35//! # Transform toggles (all default to true).
36//! transforms:
37//!   upgrade_to_3_1: true
38//!   annotate_sse: true
39//! ```
40
41use std::path::Path;
42
43use serde::Deserialize;
44
45/// Project-level `OpenAPI` generation config.
46///
47/// Loaded from a YAML file via [`ProjectConfig::load`], then applied to a
48/// [`PatchConfig`](crate::PatchConfig) via [`ProjectConfig::apply`].
49#[derive(Debug, Deserialize)]
50#[serde(default)]
51pub struct ProjectConfig {
52    /// `$ref` path for the REST error response schema.
53    pub error_schema_ref: String,
54
55    /// Proto method short names for endpoints returning `UNIMPLEMENTED`.
56    pub unimplemented_methods: Vec<String>,
57
58    /// Proto method short names for public (no-auth) endpoints.
59    pub public_methods: Vec<String>,
60
61    /// Endpoints that should use `text/plain` instead of `application/json`.
62    pub plain_text_endpoints: Vec<PlainTextEndpoint>,
63
64    /// Metrics endpoint path for response header enrichment (e.g., `/metrics`).
65    pub metrics_path: Option<String>,
66
67    /// Readiness probe path for adding 503 response (e.g., `/health/ready`).
68    pub readiness_path: Option<String>,
69
70    /// Transform toggles.
71    pub transforms: TransformConfig,
72}
73
74/// An endpoint that returns plain text instead of JSON.
75#[derive(Debug, Clone, Deserialize)]
76pub struct PlainTextEndpoint {
77    /// HTTP path (e.g., `/health/live`).
78    pub path: String,
79    /// Optional example response body (e.g., `"OK"`).
80    pub example: Option<String>,
81}
82
83/// Individual transform on/off switches (all default to `true`).
84#[derive(Debug, Deserialize)]
85#[serde(default)]
86#[allow(clippy::struct_excessive_bools)]
87pub struct TransformConfig {
88    /// Upgrade `OpenAPI` 3.0 → 3.1.
89    pub upgrade_to_3_1: bool,
90
91    /// Annotate SSE streaming operations.
92    pub annotate_sse: bool,
93
94    /// Inject proto validation constraints into JSON Schema.
95    pub inject_validation: bool,
96
97    /// Add bearer auth security schemes.
98    pub add_security: bool,
99
100    /// Inline request body schemas for better Swagger UI rendering.
101    pub inline_request_bodies: bool,
102
103    /// Flatten UUID wrapper `$ref` to inline `type: string, format: uuid`.
104    pub flatten_uuid_refs: bool,
105
106    /// Normalize CRLF → LF in string values.
107    pub normalize_line_endings: bool,
108}
109
110impl Default for ProjectConfig {
111    fn default() -> Self {
112        Self {
113            error_schema_ref: crate::DEFAULT_ERROR_SCHEMA_REF.to_string(),
114            unimplemented_methods: Vec::new(),
115            public_methods: Vec::new(),
116            plain_text_endpoints: Vec::new(),
117            metrics_path: None,
118            readiness_path: None,
119            transforms: TransformConfig::default(),
120        }
121    }
122}
123
124impl Default for TransformConfig {
125    fn default() -> Self {
126        Self {
127            upgrade_to_3_1: true,
128            annotate_sse: true,
129            inject_validation: true,
130            add_security: true,
131            inline_request_bodies: true,
132            flatten_uuid_refs: true,
133            normalize_line_endings: true,
134        }
135    }
136}
137
138impl ProjectConfig {
139    /// Load config from a YAML file.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if the file cannot be read or parsed.
144    pub fn load(path: &Path) -> crate::error::Result<Self> {
145        let content = std::fs::read_to_string(path)?;
146        let config: Self = serde_yaml_ng::from_str(&content)?;
147        Ok(config)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn deserialize_defaults() {
157        let config: ProjectConfig = serde_yaml_ng::from_str("{}").unwrap();
158        assert!(config.unimplemented_methods.is_empty());
159        assert!(config.public_methods.is_empty());
160        assert!(config.plain_text_endpoints.is_empty());
161        assert!(config.metrics_path.is_none());
162        assert!(config.readiness_path.is_none());
163        assert!(config.transforms.upgrade_to_3_1);
164        assert!(config.transforms.annotate_sse);
165    }
166
167    #[test]
168    fn deserialize_full() {
169        let yaml = r##"
170error_schema_ref: "#/components/schemas/MyError"
171unimplemented_methods:
172  - SetupMfa
173  - DisableMfa
174public_methods:
175  - Authenticate
176plain_text_endpoints:
177  - path: /health/live
178    example: "OK"
179  - path: /metrics
180metrics_path: /metrics
181readiness_path: /health/ready
182transforms:
183  add_security: false
184"##;
185        let config: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
186        assert_eq!(config.error_schema_ref, "#/components/schemas/MyError");
187        assert_eq!(config.unimplemented_methods, vec!["SetupMfa", "DisableMfa"]);
188        assert_eq!(config.public_methods, vec!["Authenticate"]);
189        assert_eq!(config.plain_text_endpoints.len(), 2);
190        assert_eq!(config.plain_text_endpoints[0].path, "/health/live");
191        assert_eq!(
192            config.plain_text_endpoints[0].example.as_deref(),
193            Some("OK")
194        );
195        assert!(config.plain_text_endpoints[1].example.is_none());
196        assert_eq!(config.metrics_path.as_deref(), Some("/metrics"));
197        assert_eq!(config.readiness_path.as_deref(), Some("/health/ready"));
198        assert!(!config.transforms.add_security);
199        // Other transforms keep defaults
200        assert!(config.transforms.upgrade_to_3_1);
201        assert!(config.transforms.inline_request_bodies);
202    }
203
204    #[test]
205    fn load_from_file() {
206        let dir = std::env::temp_dir().join("tonic-rest-openapi-test");
207        std::fs::create_dir_all(&dir).unwrap();
208        let path = dir.join("test-config.yaml");
209        std::fs::write(
210            &path,
211            "public_methods:\n  - Login\nmetrics_path: /metrics\n",
212        )
213        .unwrap();
214
215        let config = ProjectConfig::load(&path).unwrap();
216        assert_eq!(config.public_methods, vec!["Login"]);
217        assert_eq!(config.metrics_path.as_deref(), Some("/metrics"));
218        // Defaults still apply
219        assert!(config.transforms.upgrade_to_3_1);
220
221        std::fs::remove_dir_all(&dir).ok();
222    }
223
224    #[test]
225    fn load_nonexistent_file_returns_error() {
226        let result = ProjectConfig::load(Path::new("/nonexistent/config.yaml"));
227        assert!(result.is_err());
228    }
229
230    #[test]
231    fn load_invalid_yaml_returns_error() {
232        let dir = std::env::temp_dir().join("tonic-rest-openapi-test-invalid");
233        std::fs::create_dir_all(&dir).unwrap();
234        let path = dir.join("bad.yaml");
235        std::fs::write(&path, "public_methods: [[[invalid").unwrap();
236
237        let result = ProjectConfig::load(&path);
238        assert!(result.is_err());
239
240        std::fs::remove_dir_all(&dir).ok();
241    }
242}