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::config::{InfoOverrides, ServerEntry};
27use crate::discover::ProtoMetadata;
28use crate::error;
29
30/// Configuration for the `OpenAPI` patch pipeline.
31///
32/// Controls which transforms run and their parameters. Construct with
33/// [`PatchConfig::new`] and configure via [`with_project_config`](Self::with_project_config)
34/// (file-based) or individual builder methods (programmatic).
35///
36/// # Example
37///
38/// ```ignore
39/// let config = PatchConfig::new(&metadata)
40///     .unimplemented_methods(&["SetupMfa", "DisableMfa"])
41///     .public_methods(&["Login", "SignUp"])
42///     .error_schema_ref("#/components/schemas/ErrorResponse");
43/// ```
44#[derive(Debug)]
45pub struct PatchConfig<'a> {
46    /// Proto metadata extracted via [`crate::discover()`].
47    metadata: &'a ProtoMetadata,
48
49    /// Raw proto method names — resolved to operation IDs at [`patch()`] time.
50    unimplemented_method_names: Vec<String>,
51
52    /// Raw proto method names — resolved to operation IDs at [`patch()`] time.
53    public_method_names: Vec<String>,
54
55    /// Raw proto method names — resolved to operation IDs at [`patch()`] time.
56    deprecated_method_names: Vec<String>,
57
58    /// `$ref` path for the REST error response schema.
59    error_schema_ref: String,
60
61    /// Endpoints that should use `text/plain` instead of `application/json`.
62    plain_text_endpoints: Vec<PlainTextEndpoint>,
63
64    /// Metrics endpoint path for response header enrichment (e.g., `/metrics`).
65    metrics_path: Option<String>,
66
67    /// Readiness probe path for adding 503 response (e.g., `/health/ready`).
68    readiness_path: Option<String>,
69
70    /// Transform toggles (all default to `true`).
71    transforms: crate::config::TransformConfig,
72
73    /// Custom description for the Bearer auth scheme in `OpenAPI`.
74    ///
75    /// Defaults to `"Bearer authentication token"` when `None`.
76    bearer_description: Option<String>,
77
78    /// Server entries for the `servers` block.
79    servers: Vec<ServerEntry>,
80
81    /// `OpenAPI` `info` block overrides.
82    info: InfoOverrides,
83
84    /// Additional field name patterns to mark as `writeOnly`.
85    write_only_fields: Vec<String>,
86
87    /// Additional field name patterns to mark as `readOnly`.
88    read_only_fields: Vec<String>,
89}
90
91impl<'a> PatchConfig<'a> {
92    /// Create a new config with all transforms enabled and default settings.
93    #[must_use]
94    pub fn new(metadata: &'a ProtoMetadata) -> Self {
95        Self {
96            metadata,
97            unimplemented_method_names: Vec::new(),
98            public_method_names: Vec::new(),
99            deprecated_method_names: Vec::new(),
100            error_schema_ref: crate::DEFAULT_ERROR_SCHEMA_REF.to_string(),
101            plain_text_endpoints: Vec::new(),
102            metrics_path: None,
103            readiness_path: None,
104            transforms: crate::config::TransformConfig::default(),
105            bearer_description: None,
106            servers: Vec::new(),
107            info: InfoOverrides::default(),
108            write_only_fields: Vec::new(),
109            read_only_fields: Vec::new(),
110        }
111    }
112
113    /// Apply settings from a [`ProjectConfig`](crate::ProjectConfig).
114    ///
115    /// Copies method lists, error schema ref, transform toggles, and endpoint
116    /// settings from the config into this builder. Builder methods called after
117    /// this will override config values.
118    ///
119    /// # Example
120    ///
121    /// ```ignore
122    /// let project = ProjectConfig::load(Path::new("config.yaml"))?;
123    /// let config = PatchConfig::new(&metadata).with_project_config(&project);
124    /// ```
125    #[must_use]
126    pub fn with_project_config(mut self, project: &crate::ProjectConfig) -> Self {
127        self.error_schema_ref.clone_from(&project.error_schema_ref);
128        self.plain_text_endpoints
129            .clone_from(&project.plain_text_endpoints);
130        self.metrics_path.clone_from(&project.metrics_path);
131        self.readiness_path.clone_from(&project.readiness_path);
132        self.servers.clone_from(&project.servers);
133        self.info = project.info.clone();
134        self.write_only_fields
135            .clone_from(&project.write_only_fields);
136        self.read_only_fields.clone_from(&project.read_only_fields);
137        self.transforms = project.transforms;
138
139        if !project.unimplemented_methods.is_empty() {
140            self.unimplemented_method_names
141                .clone_from(&project.unimplemented_methods);
142        }
143        if !project.public_methods.is_empty() {
144            self.public_method_names.clone_from(&project.public_methods);
145        }
146        if !project.deprecated_methods.is_empty() {
147            self.deprecated_method_names
148                .clone_from(&project.deprecated_methods);
149        }
150
151        self
152    }
153
154    /// Set proto method names of endpoints that return `UNIMPLEMENTED`.
155    ///
156    /// Method names are resolved to gnostic operation IDs at [`patch()`] time.
157    /// Invalid names will produce an error when `patch()` is called.
158    #[must_use]
159    pub fn unimplemented_methods(mut self, methods: &[&str]) -> Self {
160        self.unimplemented_method_names = methods.iter().map(ToString::to_string).collect();
161        self
162    }
163
164    /// Set proto method names of endpoints that do not require authentication.
165    ///
166    /// Method names are resolved to gnostic operation IDs at [`patch()`] time.
167    /// Invalid names will produce an error when `patch()` is called.
168    #[must_use]
169    pub fn public_methods(mut self, methods: &[&str]) -> Self {
170        self.public_method_names = methods.iter().map(ToString::to_string).collect();
171        self
172    }
173
174    /// Set proto method names of deprecated endpoints.
175    ///
176    /// Method names are resolved to gnostic operation IDs at [`patch()`] time.
177    /// These operations will receive `deprecated: true` in the output spec.
178    #[must_use]
179    pub fn deprecated_methods(mut self, methods: &[&str]) -> Self {
180        self.deprecated_method_names = methods.iter().map(ToString::to_string).collect();
181        self
182    }
183
184    /// Set the `$ref` path for the REST error response schema.
185    #[must_use]
186    pub fn error_schema_ref(mut self, ref_path: &str) -> Self {
187        self.error_schema_ref = ref_path.to_string();
188        self
189    }
190
191    /// Enable or disable the 3.0 → 3.1 upgrade transform.
192    #[must_use]
193    pub fn upgrade_to_3_1(mut self, enabled: bool) -> Self {
194        self.transforms.upgrade_to_3_1 = enabled;
195        self
196    }
197
198    /// Enable or disable SSE streaming annotation.
199    #[must_use]
200    pub fn annotate_sse(mut self, enabled: bool) -> Self {
201        self.transforms.annotate_sse = enabled;
202        self
203    }
204
205    /// Enable or disable validation constraint injection.
206    #[must_use]
207    pub fn inject_validation(mut self, enabled: bool) -> Self {
208        self.transforms.inject_validation = enabled;
209        self
210    }
211
212    /// Enable or disable security scheme addition.
213    #[must_use]
214    pub fn add_security(mut self, enabled: bool) -> Self {
215        self.transforms.add_security = enabled;
216        self
217    }
218
219    /// Enable or disable request body inlining.
220    #[must_use]
221    pub fn inline_request_bodies(mut self, enabled: bool) -> Self {
222        self.transforms.inline_request_bodies = enabled;
223        self
224    }
225
226    /// Enable or disable UUID wrapper flattening.
227    #[must_use]
228    pub fn flatten_uuid_refs(mut self, enabled: bool) -> Self {
229        self.transforms.flatten_uuid_refs = enabled;
230        self
231    }
232
233    /// Enable or disable CRLF → LF normalization.
234    #[must_use]
235    pub fn normalize_line_endings(mut self, enabled: bool) -> Self {
236        self.transforms.normalize_line_endings = enabled;
237        self
238    }
239
240    /// Enable or disable server/info injection.
241    #[must_use]
242    pub fn inject_servers(mut self, enabled: bool) -> Self {
243        self.transforms.inject_servers = enabled;
244        self
245    }
246
247    /// Enable or disable `200` → `201 Created` rewrite.
248    #[must_use]
249    pub fn rewrite_create_responses(mut self, enabled: bool) -> Self {
250        self.transforms.rewrite_create_responses = enabled;
251        self
252    }
253
254    /// Enable or disable `writeOnly`/`readOnly` field annotation.
255    #[must_use]
256    pub fn annotate_field_access(mut self, enabled: bool) -> Self {
257        self.transforms.annotate_field_access = enabled;
258        self
259    }
260
261    /// Skip the 3.0 → 3.1 upgrade transform.
262    #[must_use]
263    pub fn skip_upgrade(self) -> Self {
264        self.upgrade_to_3_1(false)
265    }
266
267    /// Skip SSE streaming annotation.
268    #[must_use]
269    pub fn skip_sse(self) -> Self {
270        self.annotate_sse(false)
271    }
272
273    /// Skip validation constraint injection.
274    #[must_use]
275    pub fn skip_validation(self) -> Self {
276        self.inject_validation(false)
277    }
278
279    /// Skip security scheme addition.
280    #[must_use]
281    pub fn skip_security(self) -> Self {
282        self.add_security(false)
283    }
284
285    /// Skip request body inlining.
286    #[must_use]
287    pub fn skip_inline_request_bodies(self) -> Self {
288        self.inline_request_bodies(false)
289    }
290
291    /// Skip UUID wrapper flattening.
292    #[must_use]
293    pub fn skip_uuid_flattening(self) -> Self {
294        self.flatten_uuid_refs(false)
295    }
296
297    /// Skip CRLF → LF normalization.
298    #[must_use]
299    pub fn skip_line_ending_normalization(self) -> Self {
300        self.normalize_line_endings(false)
301    }
302
303    /// Skip server/info injection.
304    #[must_use]
305    pub fn skip_servers(self) -> Self {
306        self.inject_servers(false)
307    }
308
309    /// Skip `200` → `201 Created` rewrite.
310    #[must_use]
311    pub fn skip_create_response_rewrite(self) -> Self {
312        self.rewrite_create_responses(false)
313    }
314
315    /// Skip `writeOnly`/`readOnly` field annotation.
316    #[must_use]
317    pub fn skip_field_access_annotation(self) -> Self {
318        self.annotate_field_access(false)
319    }
320
321    /// Set a custom description for the Bearer auth scheme.
322    ///
323    /// When `None`, defaults to `"Bearer authentication token"`.
324    #[must_use]
325    pub fn bearer_description(mut self, description: &str) -> Self {
326        self.bearer_description = Some(description.to_string());
327        self
328    }
329
330    /// Set server entries for the `servers` block.
331    #[must_use]
332    pub fn servers(mut self, servers: &[ServerEntry]) -> Self {
333        self.servers = servers.to_vec();
334        self
335    }
336
337    /// Set `OpenAPI` `info` block overrides.
338    #[must_use]
339    pub fn info(mut self, info: InfoOverrides) -> Self {
340        self.info = info;
341        self
342    }
343
344    /// Set additional field name patterns to mark as `writeOnly`.
345    #[must_use]
346    pub fn write_only_fields(mut self, fields: &[&str]) -> Self {
347        self.write_only_fields = fields.iter().map(ToString::to_string).collect();
348        self
349    }
350
351    /// Set additional field name patterns to mark as `readOnly`.
352    #[must_use]
353    pub fn read_only_fields(mut self, fields: &[&str]) -> Self {
354        self.read_only_fields = fields.iter().map(ToString::to_string).collect();
355        self
356    }
357
358    /// Set endpoints that should use `text/plain` content type.
359    #[must_use]
360    pub fn plain_text_endpoints(mut self, endpoints: &[PlainTextEndpoint]) -> Self {
361        self.plain_text_endpoints = endpoints.to_vec();
362        self
363    }
364
365    /// Set the metrics endpoint path for response header enrichment.
366    #[must_use]
367    pub fn metrics_path(mut self, path: &str) -> Self {
368        self.metrics_path = Some(path.to_string());
369        self
370    }
371
372    /// Set the readiness probe path for 503 response addition.
373    #[must_use]
374    pub fn readiness_path(mut self, path: &str) -> Self {
375        self.readiness_path = Some(path.to_string());
376        self
377    }
378
379    /// Resolve deferred method names to operation IDs.
380    fn resolved_ops(&self) -> error::Result<(Vec<String>, Vec<String>, Vec<String>)> {
381        let unimplemented = self.resolve_method_list(&self.unimplemented_method_names)?;
382        let public = self.resolve_method_list(&self.public_method_names)?;
383        let deprecated = self.resolve_method_list(&self.deprecated_method_names)?;
384        Ok((unimplemented, public, deprecated))
385    }
386
387    /// Resolve a list of method names to gnostic operation IDs.
388    fn resolve_method_list(&self, names: &[String]) -> error::Result<Vec<String>> {
389        if names.is_empty() {
390            return Ok(Vec::new());
391        }
392        let refs: Vec<&str> = names.iter().map(String::as_str).collect();
393        crate::discover::resolve_operation_ids(self.metadata, &refs)
394    }
395}
396
397/// Apply the configured transform pipeline to an `OpenAPI` YAML spec.
398///
399/// Parses the input YAML, applies all enabled transforms in the correct order,
400/// and returns the patched YAML string.
401///
402/// # Phase Ordering
403///
404/// The pipeline has ordering dependencies:
405/// - **Phase 1** (structural): 3.0 → 3.1 upgrade, server/info injection.
406/// - **Phase 2** (streaming): SSE annotations, `Last-Event-ID` header.
407/// - **Phase 3** (responses): status codes, plain text, redirects, error
408///   schemas, `201 Created` rewrite.
409/// - **Phase 4** (enum rewrites): must run before inlining (phase 11) so that
410///   inlined schemas contain the rewritten enum values.
411/// - **Phase 5** (markers): unimplemented (`501`) and deprecated flags; must
412///   run after response fixes (phase 3).
413/// - **Phase 6** (security): bearer auth schemes; independent of validation.
414/// - **Phase 7** (cleanup): removes empty bodies before constraint injection.
415/// - **Phase 8** (UUID flattening): path template `.value` stripping, `$ref`
416///   flattening, query param simplification; must run before validation.
417/// - **Phase 9** (validation): constraint injection, `writeOnly`/`readOnly`
418///   annotation, `Duration` field rewriting.
419/// - **Phase 10** (path field stripping): must run after constraint injection
420///   (phase 9) since it clones schemas before removing path fields.
421/// - **Phase 11** (inlining): must run after path stripping (phase 10) to
422///   correctly detect emptied bodies; runs last among content transforms.
423/// - **Phase 12** (normalization): always runs last as a final cleanup pass.
424///
425/// # Errors
426///
427/// Returns an error if the input YAML cannot be parsed, processing fails,
428/// or any deferred method name (from [`PatchConfig::unimplemented_methods`]
429/// or [`PatchConfig::public_methods`]) cannot be resolved against proto metadata.
430pub fn patch(input_yaml: &str, config: &PatchConfig<'_>) -> error::Result<String> {
431    let mut doc: Value = serde_yaml_ng::from_str(input_yaml)?;
432
433    // Resolve deferred method names to operation IDs
434    let (unimplemented_ops, public_ops, deprecated_ops) = config.resolved_ops()?;
435
436    // Phase 1: Structural transforms (3.0 → 3.1)
437    if config.transforms.upgrade_to_3_1 {
438        oas31::upgrade_version(&mut doc);
439        oas31::convert_nullable(&mut doc);
440    }
441    if config.transforms.inject_servers {
442        oas31::inject_servers_and_info(&mut doc, &config.servers, &config.info);
443    }
444
445    // Phase 2: Streaming annotations
446    if config.transforms.annotate_sse {
447        streaming::annotate_sse(&mut doc, &config.metadata.streaming_ops);
448    }
449
450    // Phase 3: Response fixes
451    responses::patch_empty_responses(&mut doc);
452    responses::remove_redundant_query_params(&mut doc);
453    responses::patch_plain_text_endpoints(&mut doc, &config.plain_text_endpoints);
454    responses::patch_metrics_response_headers(&mut doc, config.metrics_path.as_deref());
455    responses::patch_readiness_probe_responses(&mut doc, config.readiness_path.as_deref());
456    responses::patch_redirect_endpoints(&mut doc, &config.metadata.redirect_paths);
457    responses::ensure_rest_error_schema(&mut doc, &config.error_schema_ref);
458    responses::rewrite_default_error_responses(&mut doc, &config.error_schema_ref);
459    if config.transforms.rewrite_create_responses {
460        responses::rewrite_create_responses(&mut doc);
461    }
462
463    // Phase 4: Enum value rewrites
464    // Rewrite first (prefix-stripping), then strip unspecified sentinels.
465    // Order matters: rewrite_enum_values replaces enum arrays wholesale on
466    // component schemas (including the lowercased "unspecified" value), so
467    // stripping must run after to remove them from all locations.
468    cleanup::rewrite_enum_values(&mut doc, config.metadata);
469    cleanup::strip_unspecified_from_query_enums(&mut doc);
470
471    // Phase 5: Unimplemented operation markers
472    if !unimplemented_ops.is_empty() {
473        cleanup::mark_unimplemented_operations(
474            &mut doc,
475            &unimplemented_ops,
476            &config.error_schema_ref,
477        );
478    }
479
480    if !deprecated_ops.is_empty() {
481        cleanup::mark_deprecated_operations(&mut doc, &deprecated_ops);
482    }
483
484    // Phase 6: Security
485    if config.transforms.add_security {
486        security::add_security_schemes(&mut doc, &public_ops, config.bearer_description.as_deref());
487    }
488
489    // Phase 7: Cleanup (tags, summaries, empty bodies, format noise)
490    cleanup::clean_tag_descriptions(&mut doc);
491    cleanup::populate_operation_summaries(&mut doc);
492    cleanup::remove_empty_request_bodies(&mut doc);
493    cleanup::remove_unused_empty_schemas(&mut doc);
494    cleanup::remove_format_enum(&mut doc);
495
496    // Phase 8: UUID flattening
497    validation::flatten_uuid_path_templates(&mut doc);
498    if config.transforms.flatten_uuid_refs {
499        validation::flatten_uuid_refs(&mut doc, config.metadata.uuid_schema.as_deref());
500    }
501    validation::simplify_uuid_query_params(&mut doc);
502
503    // Phase 9: Validation constraint injection
504    if config.transforms.inject_validation {
505        validation::inject_validation_constraints(&mut doc, &config.metadata.field_constraints);
506    }
507    if config.transforms.annotate_field_access {
508        validation::annotate_field_access(
509            &mut doc,
510            &config.write_only_fields,
511            &config.read_only_fields,
512        );
513    }
514    validation::annotate_duration_fields(&mut doc);
515
516    // Phase 10: Path field stripping (must run after constraint injection)
517    validation::strip_path_fields_from_body(&mut doc);
518    validation::enrich_path_params(&mut doc, &config.metadata.path_param_constraints);
519
520    // Phase 11: Request body handling
521    //
522    // When inlining is enabled, request body schemas are inlined into
523    // operations with per-property examples and the originals are removed
524    // as orphans. When disabled, component schemas are enriched with
525    // per-property examples in-place so they remain visible in the
526    // Schemas section of Swagger UI.
527    //
528    // Empty body removal and orphan cleanup always run regardless of the
529    // inlining mode — path-field stripping (phase 10) can leave empty
530    // bodies, and self-referential schema clusters (e.g., google.rpc.Status)
531    // should always be pruned.
532    if config.transforms.inline_request_bodies {
533        cleanup::inline_request_bodies(&mut doc);
534    } else {
535        cleanup::enrich_schema_examples(&mut doc);
536    }
537    cleanup::enrich_inline_request_body_examples(&mut doc);
538    cleanup::remove_empty_inlined_request_bodies(&mut doc);
539    cleanup::remove_orphaned_schemas(&mut doc);
540
541    // Phase 12: Final normalization
542    if config.transforms.normalize_line_endings {
543        oas31::normalize_line_endings(&mut doc);
544    }
545
546    serde_yaml_ng::to_string(&doc).map_err(error::Error::from)
547}