Skip to main content

tonic_rest_openapi/patch/
mod.rs

1//! `OpenAPI` spec patching pipeline.
2//!
3//! Applies a configurable sequence of transforms to a gnostic-generated
4//! `OpenAPI` YAML spec, producing a clean `OpenAPI` 3.1 spec that matches
5//! the runtime REST behavior.
6//!
7//! Transforms are grouped into logical modules:
8//! - [`oas31`] — `OpenAPI` 3.0 → 3.1 structural changes
9//! - [`streaming`] — SSE streaming annotations
10//! - [`responses`] — Response status codes, redirects, plain text, error schemas
11//! - [`security`] — Bearer auth schemes and per-operation overrides
12//! - [`validation`] — Proto validation constraints → JSON Schema
13//! - [`cleanup`] — Tag cleanup, orphan removal, formatting normalization
14
15mod cleanup;
16mod helpers;
17mod oas31;
18mod responses;
19mod security;
20mod streaming;
21mod validation;
22
23use serde_yaml_ng::Value;
24
25use crate::config::PlainTextEndpoint;
26use crate::discover::ProtoMetadata;
27use crate::error;
28
29/// Configuration for the `OpenAPI` patch pipeline.
30///
31/// Controls which transforms run and their parameters. Construct with
32/// [`PatchConfig::new`] and configure via [`with_project_config`](Self::with_project_config)
33/// (file-based) or individual builder methods (programmatic).
34///
35/// # Example
36///
37/// ```ignore
38/// let config = PatchConfig::new(&metadata)
39///     .unimplemented_methods(&["SetupMfa", "DisableMfa"])
40///     .public_methods(&["Login", "SignUp"])
41///     .error_schema_ref("#/components/schemas/ErrorResponse");
42/// ```
43#[derive(Debug)]
44pub struct PatchConfig<'a> {
45    /// Proto metadata extracted via [`crate::discover`].
46    metadata: &'a ProtoMetadata,
47
48    /// Raw proto method names — resolved to operation IDs at [`patch()`] time.
49    unimplemented_method_names: Vec<String>,
50
51    /// Raw proto method names — resolved to operation IDs at [`patch()`] time.
52    public_method_names: Vec<String>,
53
54    /// `$ref` path for the REST error response schema.
55    error_schema_ref: String,
56
57    /// Endpoints that should use `text/plain` instead of `application/json`.
58    plain_text_endpoints: Vec<PlainTextEndpoint>,
59
60    /// Metrics endpoint path for response header enrichment (e.g., `/metrics`).
61    metrics_path: Option<String>,
62
63    /// Readiness probe path for adding 503 response (e.g., `/health/ready`).
64    readiness_path: Option<String>,
65
66    /// Transform toggles (all default to `true`).
67    transforms: crate::config::TransformConfig,
68
69    /// Custom description for the Bearer auth scheme in `OpenAPI`.
70    ///
71    /// Defaults to `"Bearer authentication token"` when `None`.
72    bearer_description: Option<String>,
73}
74
75impl<'a> PatchConfig<'a> {
76    /// Create a new config with all transforms enabled and default settings.
77    #[must_use]
78    pub fn new(metadata: &'a ProtoMetadata) -> Self {
79        Self {
80            metadata,
81            unimplemented_method_names: Vec::new(),
82            public_method_names: Vec::new(),
83            error_schema_ref: crate::DEFAULT_ERROR_SCHEMA_REF.to_string(),
84            plain_text_endpoints: Vec::new(),
85            metrics_path: None,
86            readiness_path: None,
87            transforms: crate::config::TransformConfig::default(),
88            bearer_description: None,
89        }
90    }
91
92    /// Apply settings from a [`ProjectConfig`](crate::ProjectConfig).
93    ///
94    /// Copies method lists, error schema ref, transform toggles, and endpoint
95    /// settings from the config into this builder. Builder methods called after
96    /// this will override config values.
97    ///
98    /// # Example
99    ///
100    /// ```ignore
101    /// let project = ProjectConfig::load(Path::new("config.yaml"))?;
102    /// let config = PatchConfig::new(&metadata).with_project_config(&project);
103    /// ```
104    #[must_use]
105    pub fn with_project_config(mut self, project: &crate::ProjectConfig) -> Self {
106        self.error_schema_ref.clone_from(&project.error_schema_ref);
107        self.plain_text_endpoints
108            .clone_from(&project.plain_text_endpoints);
109        self.metrics_path.clone_from(&project.metrics_path);
110        self.readiness_path.clone_from(&project.readiness_path);
111        self.transforms = crate::config::TransformConfig {
112            upgrade_to_3_1: project.transforms.upgrade_to_3_1,
113            annotate_sse: project.transforms.annotate_sse,
114            inject_validation: project.transforms.inject_validation,
115            add_security: project.transforms.add_security,
116            inline_request_bodies: project.transforms.inline_request_bodies,
117            flatten_uuid_refs: project.transforms.flatten_uuid_refs,
118            normalize_line_endings: project.transforms.normalize_line_endings,
119        };
120
121        if !project.unimplemented_methods.is_empty() {
122            self.unimplemented_method_names
123                .clone_from(&project.unimplemented_methods);
124        }
125        if !project.public_methods.is_empty() {
126            self.public_method_names.clone_from(&project.public_methods);
127        }
128
129        self
130    }
131
132    /// Set proto method names of endpoints that return `UNIMPLEMENTED`.
133    ///
134    /// Method names are resolved to gnostic operation IDs at [`patch()`] time.
135    /// Invalid names will produce an error when `patch()` is called.
136    #[must_use]
137    pub fn unimplemented_methods(mut self, methods: &[&str]) -> Self {
138        self.unimplemented_method_names = methods.iter().map(ToString::to_string).collect();
139        self
140    }
141
142    /// Set proto method names of endpoints that do not require authentication.
143    ///
144    /// Method names are resolved to gnostic operation IDs at [`patch()`] time.
145    /// Invalid names will produce an error when `patch()` is called.
146    #[must_use]
147    pub fn public_methods(mut self, methods: &[&str]) -> Self {
148        self.public_method_names = methods.iter().map(ToString::to_string).collect();
149        self
150    }
151
152    /// Set the `$ref` path for the REST error response schema.
153    #[must_use]
154    pub fn error_schema_ref(mut self, ref_path: &str) -> Self {
155        self.error_schema_ref = ref_path.to_string();
156        self
157    }
158
159    /// Enable or disable the 3.0 → 3.1 upgrade transform.
160    #[must_use]
161    pub fn upgrade_to_3_1(mut self, enabled: bool) -> Self {
162        self.transforms.upgrade_to_3_1 = enabled;
163        self
164    }
165
166    /// Enable or disable SSE streaming annotation.
167    #[must_use]
168    pub fn annotate_sse(mut self, enabled: bool) -> Self {
169        self.transforms.annotate_sse = enabled;
170        self
171    }
172
173    /// Enable or disable validation constraint injection.
174    #[must_use]
175    pub fn inject_validation(mut self, enabled: bool) -> Self {
176        self.transforms.inject_validation = enabled;
177        self
178    }
179
180    /// Enable or disable security scheme addition.
181    #[must_use]
182    pub fn add_security(mut self, enabled: bool) -> Self {
183        self.transforms.add_security = enabled;
184        self
185    }
186
187    /// Enable or disable request body inlining.
188    #[must_use]
189    pub fn inline_request_bodies(mut self, enabled: bool) -> Self {
190        self.transforms.inline_request_bodies = enabled;
191        self
192    }
193
194    /// Enable or disable UUID wrapper flattening.
195    #[must_use]
196    pub fn flatten_uuid_refs(mut self, enabled: bool) -> Self {
197        self.transforms.flatten_uuid_refs = enabled;
198        self
199    }
200
201    /// Enable or disable CRLF → LF normalization.
202    #[must_use]
203    pub fn normalize_line_endings(mut self, enabled: bool) -> Self {
204        self.transforms.normalize_line_endings = enabled;
205        self
206    }
207
208    /// Skip the 3.0 → 3.1 upgrade transform.
209    #[must_use]
210    pub fn skip_upgrade(self) -> Self {
211        self.upgrade_to_3_1(false)
212    }
213
214    /// Skip SSE streaming annotation.
215    #[must_use]
216    pub fn skip_sse(self) -> Self {
217        self.annotate_sse(false)
218    }
219
220    /// Skip validation constraint injection.
221    #[must_use]
222    pub fn skip_validation(self) -> Self {
223        self.inject_validation(false)
224    }
225
226    /// Skip security scheme addition.
227    #[must_use]
228    pub fn skip_security(self) -> Self {
229        self.add_security(false)
230    }
231
232    /// Skip request body inlining.
233    #[must_use]
234    pub fn skip_inline_request_bodies(self) -> Self {
235        self.inline_request_bodies(false)
236    }
237
238    /// Skip UUID wrapper flattening.
239    #[must_use]
240    pub fn skip_uuid_flattening(self) -> Self {
241        self.flatten_uuid_refs(false)
242    }
243
244    /// Skip CRLF → LF normalization.
245    #[must_use]
246    pub fn skip_line_ending_normalization(self) -> Self {
247        self.normalize_line_endings(false)
248    }
249
250    /// Set a custom description for the Bearer auth scheme.
251    ///
252    /// When `None`, defaults to `"Bearer authentication token"`.
253    #[must_use]
254    pub fn bearer_description(mut self, description: &str) -> Self {
255        self.bearer_description = Some(description.to_string());
256        self
257    }
258
259    /// Set endpoints that should use `text/plain` content type.
260    #[must_use]
261    pub fn plain_text_endpoints(mut self, endpoints: &[PlainTextEndpoint]) -> Self {
262        self.plain_text_endpoints = endpoints.to_vec();
263        self
264    }
265
266    /// Set the metrics endpoint path for response header enrichment.
267    #[must_use]
268    pub fn metrics_path(mut self, path: &str) -> Self {
269        self.metrics_path = Some(path.to_string());
270        self
271    }
272
273    /// Set the readiness probe path for 503 response addition.
274    #[must_use]
275    pub fn readiness_path(mut self, path: &str) -> Self {
276        self.readiness_path = Some(path.to_string());
277        self
278    }
279
280    /// Resolve deferred method names to operation IDs.
281    fn resolved_ops(&self) -> error::Result<(Vec<String>, Vec<String>)> {
282        let unimplemented = self.resolve_method_list(&self.unimplemented_method_names)?;
283        let public = self.resolve_method_list(&self.public_method_names)?;
284        Ok((unimplemented, public))
285    }
286
287    /// Resolve a list of method names to gnostic operation IDs.
288    fn resolve_method_list(&self, names: &[String]) -> error::Result<Vec<String>> {
289        if names.is_empty() {
290            return Ok(Vec::new());
291        }
292        let refs: Vec<&str> = names.iter().map(String::as_str).collect();
293        crate::discover::resolve_operation_ids(self.metadata, &refs)
294    }
295}
296
297/// Apply the configured transform pipeline to an `OpenAPI` YAML spec.
298///
299/// Parses the input YAML, applies all enabled transforms in the correct order,
300/// and returns the patched YAML string.
301///
302/// # Phase Ordering
303///
304/// The 12-phase pipeline has ordering dependencies:
305/// - **Phases 1–3** (structural, streaming, responses): run first to establish
306///   the base spec structure; later phases depend on correct response entries.
307/// - **Phase 4** (enum rewrites): must run before inlining (phase 11) so that
308///   inlined schemas contain the rewritten enum values.
309/// - **Phase 5** (unimplemented markers): must run after response fixes (phase 3)
310///   so that `501` responses are added to specs with correct error schema refs.
311/// - **Phase 6** (security): must run after operation ID resolution; independent
312///   of validation.
313/// - **Phase 7** (cleanup): removes empty bodies before constraint injection
314///   to avoid injecting constraints into schemas about to be removed.
315/// - **Phase 8** (UUID flattening): must run before validation (phase 9) so
316///   that flattened UUID fields get correct format/pattern constraints.
317/// - **Phase 9** (validation): injects constraints into component schemas.
318/// - **Phase 10** (path field stripping): must run after constraint injection
319///   (phase 9) since it clones schemas before removing path fields.
320/// - **Phase 11** (inlining): must run after path stripping (phase 10) to
321///   correctly detect emptied bodies; runs last among content transforms.
322/// - **Phase 12** (normalization): always runs last as a final cleanup pass.
323///
324/// # Errors
325///
326/// Returns an error if the input YAML cannot be parsed, processing fails,
327/// or any deferred method name (from [`PatchConfig::unimplemented_methods`]
328/// or [`PatchConfig::public_methods`]) cannot be resolved against proto metadata.
329pub fn patch(input_yaml: &str, config: &PatchConfig<'_>) -> error::Result<String> {
330    let mut doc: Value = serde_yaml_ng::from_str(input_yaml)?;
331
332    // Resolve deferred method names to operation IDs
333    let (unimplemented_ops, public_ops) = config.resolved_ops()?;
334
335    // Phase 1: Structural transforms (3.0 → 3.1)
336    if config.transforms.upgrade_to_3_1 {
337        oas31::upgrade_version(&mut doc);
338        oas31::convert_nullable(&mut doc);
339    }
340
341    // Phase 2: Streaming annotations
342    if config.transforms.annotate_sse {
343        streaming::annotate_sse(&mut doc, &config.metadata.streaming_ops);
344    }
345
346    // Phase 3: Response fixes
347    responses::patch_empty_responses(&mut doc);
348    responses::remove_redundant_query_params(&mut doc);
349    responses::patch_plain_text_endpoints(&mut doc, &config.plain_text_endpoints);
350    responses::patch_metrics_response_headers(&mut doc, config.metrics_path.as_deref());
351    responses::patch_readiness_probe_responses(&mut doc, config.readiness_path.as_deref());
352    responses::patch_redirect_endpoints(&mut doc, &config.metadata.redirect_paths);
353    responses::ensure_rest_error_schema(&mut doc, &config.error_schema_ref);
354    responses::rewrite_default_error_responses(&mut doc, &config.error_schema_ref);
355
356    // Phase 4: Enum value rewrites
357    cleanup::strip_unspecified_from_query_enums(&mut doc);
358    cleanup::rewrite_enum_values(&mut doc, config.metadata);
359
360    // Phase 5: Unimplemented operation markers
361    if !unimplemented_ops.is_empty() {
362        cleanup::mark_unimplemented_operations(
363            &mut doc,
364            &unimplemented_ops,
365            &config.error_schema_ref,
366        );
367    }
368
369    // Phase 6: Security
370    if config.transforms.add_security {
371        security::add_security_schemes(&mut doc, &public_ops, config.bearer_description.as_deref());
372    }
373
374    // Phase 7: Cleanup (tags, empty bodies, format noise)
375    cleanup::clean_tag_descriptions(&mut doc);
376    cleanup::remove_empty_request_bodies(&mut doc);
377    cleanup::remove_unused_empty_schemas(&mut doc);
378    cleanup::remove_format_enum(&mut doc);
379
380    // Phase 8: UUID flattening
381    if config.transforms.flatten_uuid_refs {
382        validation::flatten_uuid_refs(&mut doc, config.metadata.uuid_schema.as_deref());
383    }
384    validation::simplify_uuid_query_params(&mut doc);
385
386    // Phase 9: Validation constraint injection
387    if config.transforms.inject_validation {
388        validation::inject_validation_constraints(&mut doc, &config.metadata.field_constraints);
389    }
390
391    // Phase 10: Path field stripping (must run after constraint injection)
392    validation::strip_path_fields_from_body(&mut doc);
393    validation::enrich_path_params(&mut doc, &config.metadata.path_param_constraints);
394
395    // Phase 11: Request body inlining
396    if config.transforms.inline_request_bodies {
397        cleanup::inline_request_bodies(&mut doc);
398        cleanup::remove_empty_inlined_request_bodies(&mut doc);
399    }
400
401    // Phase 12: Final normalization
402    if config.transforms.normalize_line_endings {
403        oas31::normalize_line_endings(&mut doc);
404    }
405
406    serde_yaml_ng::to_string(&doc).map_err(error::Error::from)
407}