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 [`PatchConfig::with_project_config`](crate::PatchConfig::with_project_config).
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    /// Proto method short names for deprecated endpoints.
62    pub deprecated_methods: Vec<String>,
63
64    /// Endpoints that should use `text/plain` instead of `application/json`.
65    pub plain_text_endpoints: Vec<PlainTextEndpoint>,
66
67    /// Metrics endpoint path for response header enrichment (e.g., `/metrics`).
68    pub metrics_path: Option<String>,
69
70    /// Readiness probe path for adding 503 response (e.g., `/health/ready`).
71    pub readiness_path: Option<String>,
72
73    /// Server entries for the `servers` block.
74    pub servers: Vec<ServerEntry>,
75
76    /// `OpenAPI` `info` block overrides (contact, license, external docs).
77    pub info: InfoOverrides,
78
79    /// Additional field name patterns to mark as `writeOnly`.
80    pub write_only_fields: Vec<String>,
81
82    /// Additional field name patterns to mark as `readOnly`.
83    pub read_only_fields: Vec<String>,
84
85    /// Transform toggles.
86    pub transforms: TransformConfig,
87}
88
89/// An endpoint that returns plain text instead of JSON.
90#[derive(Debug, Clone, Deserialize)]
91pub struct PlainTextEndpoint {
92    /// HTTP path (e.g., `/health/live`).
93    pub path: String,
94    /// Optional example response body (e.g., `"OK"`).
95    pub example: Option<String>,
96}
97
98/// A server entry for the `OpenAPI` `servers` block.
99#[derive(Debug, Clone, Deserialize)]
100pub struct ServerEntry {
101    /// Server URL (e.g., `http://localhost:8080`).
102    pub url: String,
103    /// Optional human-readable server description.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub description: Option<String>,
106}
107
108/// Overrides for the `OpenAPI` `info` block.
109#[derive(Debug, Clone, Default, Deserialize)]
110#[serde(default)]
111pub struct InfoOverrides {
112    /// API contact information.
113    pub contact: Option<ContactInfo>,
114    /// API license information.
115    pub license: Option<LicenseInfo>,
116    /// Link to external documentation.
117    pub external_docs: Option<ExternalDocsInfo>,
118    /// URL to the Terms of Service.
119    pub terms_of_service: Option<String>,
120}
121
122/// Contact information for the `OpenAPI` `info.contact` block.
123#[derive(Debug, Clone, Deserialize)]
124pub struct ContactInfo {
125    /// Contact name.
126    pub name: Option<String>,
127    /// Contact email.
128    pub email: Option<String>,
129    /// Contact URL.
130    pub url: Option<String>,
131}
132
133/// License information for the `OpenAPI` `info.license` block.
134#[derive(Debug, Clone, Deserialize)]
135pub struct LicenseInfo {
136    /// License name (e.g., `"MIT"`).
137    pub name: String,
138    /// URL to the full license text.
139    pub url: Option<String>,
140}
141
142/// External documentation link for `externalDocs`.
143#[derive(Debug, Clone, Deserialize)]
144pub struct ExternalDocsInfo {
145    /// URL to the external documentation.
146    pub url: String,
147    /// Short description of the external docs.
148    pub description: Option<String>,
149}
150
151/// Individual transform on/off switches (all default to `true`).
152///
153/// Controls which phases of the 12-phase pipeline run. Each toggle maps to
154/// one or more pipeline phases. See [`patch()`](crate::patch) for phase ordering.
155#[derive(Debug, Clone, Copy, Deserialize)]
156#[serde(default)]
157#[allow(clippy::struct_excessive_bools)]
158pub struct TransformConfig {
159    /// Upgrade `OpenAPI` 3.0 → 3.1 (phase 1).
160    ///
161    /// Converts `openapi: "3.0.x"` to `"3.1.0"`, rewrites `nullable: true` to
162    /// `type: ["string", "null"]`, and applies other 3.1 structural changes.
163    pub upgrade_to_3_1: bool,
164
165    /// Annotate SSE streaming operations (phase 2).
166    ///
167    /// Adds `text/event-stream` response content type, `Last-Event-ID` header,
168    /// and streaming-specific descriptions to server-streaming RPCs.
169    pub annotate_sse: bool,
170
171    /// Inject proto validation constraints into JSON Schema (phase 9).
172    ///
173    /// Maps `validate.rules` from proto field options to `minLength`, `maxLength`,
174    /// `pattern`, `minimum`, `maximum`, `required`, and `enum` constraints.
175    pub inject_validation: bool,
176
177    /// Add bearer auth security schemes (phase 6).
178    ///
179    /// Injects a `bearerAuth` security scheme and applies it globally,
180    /// with overrides for public endpoints.
181    pub add_security: bool,
182
183    /// Inline request body schemas for better Swagger UI rendering (phase 11).
184    ///
185    /// Replaces `$ref` request bodies with inline schemas containing property
186    /// examples, improving the "Try it out" experience in Swagger UI.
187    pub inline_request_bodies: bool,
188
189    /// Flatten UUID wrapper `$ref` to inline `type: string, format: uuid` (phase 8).
190    ///
191    /// Simplifies single-field UUID wrapper messages by inlining the string type
192    /// with `format: uuid` and `pattern` validation.
193    pub flatten_uuid_refs: bool,
194
195    /// Normalize CRLF → LF in string values (phase 12).
196    ///
197    /// Ensures consistent line endings in the output spec, preventing
198    /// platform-dependent diffs.
199    pub normalize_line_endings: bool,
200
201    /// Inject `servers` and `info` overrides into the spec (phase 1).
202    ///
203    /// Merges configured server URLs and info block overrides (contact,
204    /// license, terms of service) into the spec.
205    pub inject_servers: bool,
206
207    /// Rewrite `200` → `201 Created` for create/signup endpoints (phase 3).
208    ///
209    /// Detects operations named `Create*`, `SignUp*`, or `Register*` and
210    /// changes their success response from 200 to 201.
211    pub rewrite_create_responses: bool,
212
213    /// Annotate fields with `writeOnly`/`readOnly` based on naming conventions (phase 9).
214    ///
215    /// Fields matching patterns like `password`, `secret`, `token` are marked
216    /// `writeOnly`. Fields like `created_at`, `updated_at` are marked `readOnly`.
217    /// Additional patterns can be configured via `write_only_fields` / `read_only_fields`.
218    pub annotate_field_access: bool,
219}
220
221impl Default for ProjectConfig {
222    fn default() -> Self {
223        Self {
224            error_schema_ref: crate::DEFAULT_ERROR_SCHEMA_REF.to_string(),
225            unimplemented_methods: Vec::new(),
226            public_methods: Vec::new(),
227            deprecated_methods: Vec::new(),
228            plain_text_endpoints: Vec::new(),
229            metrics_path: None,
230            readiness_path: None,
231            servers: Vec::new(),
232            info: InfoOverrides::default(),
233            write_only_fields: Vec::new(),
234            read_only_fields: Vec::new(),
235            transforms: TransformConfig::default(),
236        }
237    }
238}
239
240impl Default for TransformConfig {
241    fn default() -> Self {
242        Self {
243            upgrade_to_3_1: true,
244            annotate_sse: true,
245            inject_validation: true,
246            add_security: true,
247            inline_request_bodies: true,
248            flatten_uuid_refs: true,
249            normalize_line_endings: true,
250            inject_servers: true,
251            rewrite_create_responses: true,
252            annotate_field_access: true,
253        }
254    }
255}
256
257impl ProjectConfig {
258    /// Load config from a YAML file.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if the file cannot be read or parsed.
263    pub fn load(path: &Path) -> crate::error::Result<Self> {
264        let content = std::fs::read_to_string(path)?;
265        let config: Self = serde_yaml_ng::from_str(&content)?;
266        Ok(config)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn deserialize_defaults() {
276        let config: ProjectConfig = serde_yaml_ng::from_str("{}").unwrap();
277        assert!(config.unimplemented_methods.is_empty());
278        assert!(config.public_methods.is_empty());
279        assert!(config.deprecated_methods.is_empty());
280        assert!(config.plain_text_endpoints.is_empty());
281        assert!(config.metrics_path.is_none());
282        assert!(config.readiness_path.is_none());
283        assert!(config.servers.is_empty());
284        assert!(config.info.contact.is_none());
285        assert!(config.info.license.is_none());
286        assert!(config.write_only_fields.is_empty());
287        assert!(config.read_only_fields.is_empty());
288        assert!(config.transforms.upgrade_to_3_1);
289        assert!(config.transforms.annotate_sse);
290        assert!(config.transforms.inject_servers);
291        assert!(config.transforms.rewrite_create_responses);
292        assert!(config.transforms.annotate_field_access);
293    }
294
295    #[test]
296    fn deserialize_full() {
297        let yaml = r##"
298error_schema_ref: "#/components/schemas/MyError"
299unimplemented_methods:
300  - SetupMfa
301  - DisableMfa
302public_methods:
303  - Authenticate
304deprecated_methods:
305  - OldEndpoint
306plain_text_endpoints:
307  - path: /health/live
308    example: "OK"
309  - path: /metrics
310metrics_path: /metrics
311readiness_path: /health/ready
312servers:
313  - url: https://api.example.com
314    description: Production
315  - url: http://localhost:8080
316    description: Local dev
317info:
318  contact:
319    name: API Team
320    email: api@example.com
321  license:
322    name: MIT
323    url: https://opensource.org/licenses/MIT
324  external_docs:
325    url: https://docs.example.com
326    description: Full documentation
327  terms_of_service: https://example.com/tos
328write_only_fields:
329  - apiKey
330read_only_fields:
331  - lastSyncAt
332transforms:
333  add_security: false
334  inject_servers: false
335"##;
336        let config: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
337        assert_eq!(config.error_schema_ref, "#/components/schemas/MyError");
338        assert_eq!(config.unimplemented_methods, vec!["SetupMfa", "DisableMfa"]);
339        assert_eq!(config.public_methods, vec!["Authenticate"]);
340        assert_eq!(config.deprecated_methods, vec!["OldEndpoint"]);
341        assert_eq!(config.plain_text_endpoints.len(), 2);
342        assert_eq!(config.plain_text_endpoints[0].path, "/health/live");
343        assert_eq!(
344            config.plain_text_endpoints[0].example.as_deref(),
345            Some("OK")
346        );
347        assert!(config.plain_text_endpoints[1].example.is_none());
348        assert_eq!(config.metrics_path.as_deref(), Some("/metrics"));
349        assert_eq!(config.readiness_path.as_deref(), Some("/health/ready"));
350        assert_eq!(config.servers.len(), 2);
351        assert_eq!(config.servers[0].url, "https://api.example.com");
352        assert_eq!(config.servers[0].description.as_deref(), Some("Production"));
353        assert!(config.info.contact.is_some());
354        assert_eq!(
355            config.info.contact.as_ref().unwrap().name.as_deref(),
356            Some("API Team")
357        );
358        assert!(config.info.license.is_some());
359        assert_eq!(config.info.license.as_ref().unwrap().name, "MIT");
360        assert!(config.info.external_docs.is_some());
361        assert_eq!(
362            config.info.terms_of_service.as_deref(),
363            Some("https://example.com/tos")
364        );
365        assert_eq!(config.write_only_fields, vec!["apiKey"]);
366        assert_eq!(config.read_only_fields, vec!["lastSyncAt"]);
367        assert!(!config.transforms.add_security);
368        assert!(!config.transforms.inject_servers);
369        // Other transforms keep defaults
370        assert!(config.transforms.upgrade_to_3_1);
371        assert!(config.transforms.inline_request_bodies);
372        assert!(config.transforms.rewrite_create_responses);
373        assert!(config.transforms.annotate_field_access);
374    }
375
376    #[test]
377    fn load_from_file() {
378        let dir = std::env::temp_dir().join("tonic-rest-openapi-test");
379        std::fs::create_dir_all(&dir).unwrap();
380        let path = dir.join("test-config.yaml");
381        std::fs::write(
382            &path,
383            "public_methods:\n  - Login\nmetrics_path: /metrics\n",
384        )
385        .unwrap();
386
387        let config = ProjectConfig::load(&path).unwrap();
388        assert_eq!(config.public_methods, vec!["Login"]);
389        assert_eq!(config.metrics_path.as_deref(), Some("/metrics"));
390        // Defaults still apply
391        assert!(config.transforms.upgrade_to_3_1);
392
393        std::fs::remove_dir_all(&dir).ok();
394    }
395
396    #[test]
397    fn load_nonexistent_file_returns_error() {
398        let result = ProjectConfig::load(Path::new("/nonexistent/config.yaml"));
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn load_invalid_yaml_returns_error() {
404        let dir = std::env::temp_dir().join("tonic-rest-openapi-test-invalid");
405        std::fs::create_dir_all(&dir).unwrap();
406        let path = dir.join("bad.yaml");
407        std::fs::write(&path, "public_methods: [[[invalid").unwrap();
408
409        let result = ProjectConfig::load(&path);
410        assert!(result.is_err());
411
412        std::fs::remove_dir_all(&dir).ok();
413    }
414}