From 99920747cec3ea36f81da0e98dde39839a67e549 Mon Sep 17 00:00:00 2001 From: arkptz Date: Thu, 28 May 2026 21:36:54 +0300 Subject: [PATCH 1/5] feat(cli): add --enrichments flag, expose get_operation_mut --- src/builder.rs | 7 +++++-- src/cli.rs | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index eba6c12..6ddf83f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -298,7 +298,10 @@ fn parse_body(body: &[u8], content_type: Option<&str>) -> Option<(String, serde_ None } -fn get_operation_ref<'a>(path_item: &'a PathItem, method: &str) -> Option<&'a Option> { +pub(crate) fn get_operation_ref<'a>( + path_item: &'a PathItem, + method: &str, +) -> Option<&'a Option> { match method.to_uppercase().as_str() { "GET" => Some(&path_item.get), "PUT" => Some(&path_item.put), @@ -314,7 +317,7 @@ fn get_operation_ref<'a>(path_item: &'a PathItem, method: &str) -> Option<&'a Op /// Get the method-specific operation slot from a PathItem (mutable). /// Returns `None` for HTTP methods not supported by the OpenAPI spec. -fn get_operation_mut<'a>( +pub(crate) fn get_operation_mut<'a>( path_item: &'a mut PathItem, method: &str, ) -> Option<&'a mut Option> { diff --git a/src/cli.rs b/src/cli.rs index 4a7ce0b..7219a10 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -253,4 +253,8 @@ pub struct GenerateArgs { /// E.g., operationId "getFairPrice" → "GetFairPriceSuccess" #[arg(long, default_value = "Success")] pub envelope_success_component_suffix: String, + + /// Apply overlay YAML (operationId-keyed) to enrich generated spec + #[arg(long, short = 'e')] + pub enrichments: Option, } From a3a09c4d5713677de43a3a5bfd12c0564ac9b487 Mon Sep 17 00:00:00 2001 From: arkptz Date: Thu, 28 May 2026 21:38:09 +0300 Subject: [PATCH 2/5] test(enrichments): add unit tests for overlay types and apply logic (RED) --- src/enrichments.rs | 361 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 362 insertions(+) create mode 100644 src/enrichments.rs diff --git a/src/enrichments.rs b/src/enrichments.rs new file mode 100644 index 0000000..1e3888b --- /dev/null +++ b/src/enrichments.rs @@ -0,0 +1,361 @@ +//! Overlay-based enrichments for generated OpenAPI specs. +//! +//! Users provide a YAML overlay file that adds summaries, descriptions, tags, +//! `x-` extensions, response descriptions, component schema descriptions, and +//! top-level info overrides. `apply_enrichments` merges the overlay into a +//! generated `OpenAPI` document. + +use anyhow::Result; +use indexmap::IndexMap; +use openapiv3::OpenAPI; +use serde::de::{IgnoredAny, Visitor}; +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; +use std::hash::Hash; +use std::marker::PhantomData; +use std::path::Path; + +#[derive(Debug, Deserialize)] +pub struct Overlay { + pub info: Option, + #[serde(default)] + pub operations: HashMap, + pub components: Option, +} + +#[derive(Debug, Deserialize)] +pub struct InfoOverlay { + pub title: Option, + pub description: Option, + pub version: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OperationOverlay { + pub summary: Option, + pub description: Option, + pub deprecated: Option, + pub tags: Option>, + pub responses: Option>, + #[serde(flatten, deserialize_with = "deserialize_extensions")] + pub extensions: IndexMap, +} + +#[derive(Debug, Deserialize)] +pub struct ResponseOverlay { + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ComponentsOverlay { + pub schemas: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct SchemaOverlay { + pub description: Option, +} + +#[derive(Debug, Clone, Copy)] +pub enum ApplyMode { + Lenient, + Strict, +} + +fn deserialize_extensions<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserializer.deserialize_map(PredicateVisitor( + |key: &String| key.starts_with("x-"), + PhantomData, + )) +} + +struct PredicateVisitor(F, PhantomData<(K, V)>); + +impl<'de, F, K, V> Visitor<'de> for PredicateVisitor +where + F: Fn(&K) -> bool, + K: serde::Deserialize<'de> + Eq + Hash, + V: serde::Deserialize<'de>, +{ + type Value = IndexMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map whose fields satisfy a predicate") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut ret = Self::Value::default(); + loop { + match map.next_key::() { + Err(_) => (), + Ok(None) => break, + Ok(Some(key)) if self.0(&key) => { + let _ = ret.insert(key, map.next_value()?); + } + Ok(Some(_)) => { + let _ = map.next_value::()?; + } + } + } + Ok(ret) + } +} + +const MAX_OVERLAY_SIZE: u64 = 10 * 1024 * 1024; + +pub fn load_overlay(path: &Path) -> Result { + let meta = std::fs::metadata(path)?; + if meta.len() > MAX_OVERLAY_SIZE { + anyhow::bail!("overlay file exceeds 10 MiB limit ({} bytes)", meta.len()); + } + let content = std::fs::read_to_string(path)?; + let overlay: Overlay = serde_yaml_ng::from_str(&content)?; + Ok(overlay) +} + +pub fn apply_enrichments(_spec: &mut OpenAPI, _overlay: &Overlay, _mode: ApplyMode) -> Result<()> { + // STUB — will be implemented in Task 3 + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn minimal_spec() -> OpenAPI { + serde_yaml_ng::from_str( + r#" +openapi: "3.0.3" +info: + title: Test + version: 1.0.0 +paths: + /fair_price/{symbol}: + get: + summary: GET /fair_price/{symbol} + operationId: getFairPrice + responses: + '200': + description: '' +"#, + ) + .unwrap() + } + + #[test] + fn overlay_summary_and_description_win_over_auto() { + let mut spec = minimal_spec(); + let overlay: Overlay = serde_yaml_ng::from_str( + r#" +operations: + getFairPrice: + summary: Fair price + description: Mark price for liquidation +"#, + ) + .unwrap(); + apply_enrichments(&mut spec, &overlay, ApplyMode::Lenient).unwrap(); + let paths = &spec.paths.paths; + let path_item = match paths.get("/fair_price/{symbol}").unwrap() { + openapiv3::ReferenceOr::Item(pi) => pi, + _ => panic!("expected Item"), + }; + let op = path_item.get.as_ref().unwrap(); + assert_eq!(op.summary.as_deref(), Some("Fair price")); + assert_eq!( + op.description.as_deref(), + Some("Mark price for liquidation") + ); + } + + #[test] + fn operation_not_in_overlay_is_untouched() { + let mut spec = minimal_spec(); + let overlay: Overlay = serde_yaml_ng::from_str("operations: {}").unwrap(); + apply_enrichments(&mut spec, &overlay, ApplyMode::Lenient).unwrap(); + let path_item = match spec.paths.paths.get("/fair_price/{symbol}").unwrap() { + openapiv3::ReferenceOr::Item(pi) => pi, + _ => panic!("expected Item"), + }; + let op = path_item.get.as_ref().unwrap(); + assert_eq!(op.summary.as_deref(), Some("GET /fair_price/{symbol}")); + } + + #[test] + fn x_extensions_are_passed_through_verbatim() { + let mut spec = minimal_spec(); + let overlay: Overlay = serde_yaml_ng::from_str( + r#" +operations: + getFairPrice: + x-requires-auth: false + x-rate-limit: "10/s" + x-error-codes: + - code: 401 + message: Not logged in +"#, + ) + .unwrap(); + apply_enrichments(&mut spec, &overlay, ApplyMode::Lenient).unwrap(); + let path_item = match spec.paths.paths.get("/fair_price/{symbol}").unwrap() { + openapiv3::ReferenceOr::Item(pi) => pi, + _ => panic!("expected Item"), + }; + let op = path_item.get.as_ref().unwrap(); + assert_eq!(op.extensions.get("x-requires-auth"), Some(&json!(false))); + assert_eq!(op.extensions.get("x-rate-limit"), Some(&json!("10/s"))); + assert_eq!( + op.extensions + .get("x-error-codes") + .unwrap() + .get(0) + .unwrap() + .get("code"), + Some(&json!(401)) + ); + } + + #[test] + fn unknown_operation_id_ok_in_lenient_mode() { + let mut spec = minimal_spec(); + let overlay: Overlay = serde_yaml_ng::from_str( + r#" +operations: + doesNotExist: + summary: Ghost +"#, + ) + .unwrap(); + let result = apply_enrichments(&mut spec, &overlay, ApplyMode::Lenient); + assert!(result.is_ok()); + } + + #[test] + fn unknown_operation_id_errors_in_strict_mode() { + let mut spec = minimal_spec(); + let overlay: Overlay = serde_yaml_ng::from_str( + r#" +operations: + doesNotExist: + summary: Ghost +"#, + ) + .unwrap(); + let result = apply_enrichments(&mut spec, &overlay, ApplyMode::Strict); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("doesNotExist"), + "error should mention the unknown opId: {err}" + ); + } + + #[test] + fn response_description_per_status_is_merged() { + let mut spec = minimal_spec(); + let overlay: Overlay = serde_yaml_ng::from_str( + r#" +operations: + getFairPrice: + responses: + "200": + description: Fair price payload +"#, + ) + .unwrap(); + apply_enrichments(&mut spec, &overlay, ApplyMode::Lenient).unwrap(); + let path_item = match spec.paths.paths.get("/fair_price/{symbol}").unwrap() { + openapiv3::ReferenceOr::Item(pi) => pi, + _ => panic!("expected Item"), + }; + let op = path_item.get.as_ref().unwrap(); + let resp = match op + .responses + .responses + .get(&openapiv3::StatusCode::Code(200)) + { + Some(openapiv3::ReferenceOr::Item(r)) => r, + other => panic!("expected Item response for 200, got: {other:?}"), + }; + assert_eq!(resp.description, "Fair price payload"); + } + + #[test] + fn component_schema_description_set_without_touching_properties() { + let mut spec: OpenAPI = serde_yaml_ng::from_str( + r#" +openapi: "3.0.3" +info: { title: T, version: "1" } +paths: {} +components: + schemas: + ApiError: + type: object + properties: + code: { type: integer } + success: { type: boolean } +"#, + ) + .unwrap(); + let overlay: Overlay = serde_yaml_ng::from_str( + r#" +components: + schemas: + ApiError: + description: MEXC envelope error +"#, + ) + .unwrap(); + apply_enrichments(&mut spec, &overlay, ApplyMode::Lenient).unwrap(); + let components = spec.components.as_ref().unwrap(); + let schema_ref = components.schemas.get("ApiError").unwrap(); + if let openapiv3::ReferenceOr::Item(schema) = schema_ref { + assert_eq!( + schema.schema_data.description.as_deref(), + Some("MEXC envelope error") + ); + // properties survived + if let openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj)) = &schema.schema_kind { + assert!( + obj.properties.contains_key("code"), + "code property must survive" + ); + assert!( + obj.properties.contains_key("success"), + "success property must survive" + ); + } else { + panic!("expected Object type"); + } + } else { + panic!("expected Item schema"); + } + } + + #[test] + fn info_overlay_merges_per_key() { + let mut spec = minimal_spec(); + let overlay: Overlay = serde_yaml_ng::from_str( + r#" +info: + description: Reverse-engineered API +"#, + ) + .unwrap(); + apply_enrichments(&mut spec, &overlay, ApplyMode::Lenient).unwrap(); + assert_eq!(spec.info.title, "Test"); // untouched + assert_eq!( + spec.info.description.as_deref(), + Some("Reverse-engineered API") + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index d6ae4d4..6cd0b57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod builder; pub mod cli; +pub mod enrichments; pub mod envelope; pub mod error; pub mod har_reader; From d94e54e81e6fb2a8e15d8899ea0d2c695449b44b Mon Sep 17 00:00:00 2001 From: arkptz Date: Thu, 28 May 2026 21:42:23 +0300 Subject: [PATCH 3/5] feat(enrichments): implement apply_enrichments (GREEN) --- src/enrichments.rs | 141 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 2 deletions(-) diff --git a/src/enrichments.rs b/src/enrichments.rs index 1e3888b..ac67220 100644 --- a/src/enrichments.rs +++ b/src/enrichments.rs @@ -121,8 +121,145 @@ pub fn load_overlay(path: &Path) -> Result { Ok(overlay) } -pub fn apply_enrichments(_spec: &mut OpenAPI, _overlay: &Overlay, _mode: ApplyMode) -> Result<()> { - // STUB — will be implemented in Task 3 +fn find_operation_mut<'a>( + spec: &'a mut OpenAPI, + target_id: &str, +) -> Option<&'a mut openapiv3::Operation> { + for (_path, path_ref) in &mut spec.paths.paths { + if let openapiv3::ReferenceOr::Item(path_item) = path_ref { + for op in [ + &mut path_item.get, + &mut path_item.put, + &mut path_item.post, + &mut path_item.delete, + &mut path_item.options, + &mut path_item.head, + &mut path_item.patch, + &mut path_item.trace, + ] + .into_iter() + .flatten() + { + if op.operation_id.as_deref() == Some(target_id) { + return Some(op); + } + } + } + } + None +} + +fn apply_operation_enrichment(op: &mut openapiv3::Operation, enrichment: &OperationOverlay) { + if let Some(s) = &enrichment.summary { + op.summary = Some(s.clone()); + } + if let Some(d) = &enrichment.description { + op.description = Some(d.clone()); + } + if let Some(d) = enrichment.deprecated { + op.deprecated = d; + } + if let Some(tags) = &enrichment.tags { + op.tags = tags.clone(); + } + + for (key, val) in &enrichment.extensions { + op.extensions.insert(key.clone(), val.clone()); + } + + if let Some(responses) = &enrichment.responses { + for (status_str, resp_overlay) in responses { + let status_code: u16 = match status_str.parse() { + Ok(n) => n, + Err(_) => { + tracing::warn!( + event = "non_numeric_response_status", + status = %status_str, + "non-numeric response status in enrichment overlay, skipping" + ); + continue; + } + }; + let key = openapiv3::StatusCode::Code(status_code); + if let Some(openapiv3::ReferenceOr::Item(resp)) = op.responses.responses.get_mut(&key) { + if let Some(d) = &resp_overlay.description { + resp.description = d.clone(); + } + } else { + tracing::warn!( + event = "enrichment_response_status_not_found", + status = %status_str, + "response status not found in operation, skipping" + ); + } + } + } +} + +pub fn apply_enrichments(spec: &mut OpenAPI, overlay: &Overlay, mode: ApplyMode) -> Result<()> { + // 1. Info merge + if let Some(info_overlay) = &overlay.info { + if let Some(title) = &info_overlay.title { + spec.info.title = title.clone(); + } + if let Some(desc) = &info_overlay.description { + spec.info.description = Some(desc.clone()); + } + if let Some(ver) = &info_overlay.version { + spec.info.version = ver.clone(); + } + } + + // 2. Operations merge + for (op_id, enrichment) in &overlay.operations { + let found = find_operation_mut(spec, op_id); + match found { + None => match mode { + ApplyMode::Lenient => { + tracing::warn!( + event = "unknown_enrichment_operation_id", + operation_id = %op_id, + "overlay references unknown operationId, skipping" + ); + } + ApplyMode::Strict => { + anyhow::bail!("unknown operationId '{op_id}' in enrichments overlay"); + } + }, + Some(op) => { + apply_operation_enrichment(op, enrichment); + } + } + } + + // 3. Components merge + if let Some(comp_overlay) = &overlay.components { + if let Some(schema_overlays) = &comp_overlay.schemas { + if let Some(ref mut components) = spec.components { + for (name, schema_overlay) in schema_overlays { + if let Some(schema_ref) = components.schemas.get_mut(name) { + if let openapiv3::ReferenceOr::Item(schema) = schema_ref { + if let Some(desc) = &schema_overlay.description { + schema.schema_data.description = Some(desc.clone()); + } + } else { + tracing::warn!( + event = "enrichment_schema_is_ref", + schema = %name, + "schema is a $ref, skipping enrichment" + ); + } + } + } + } else { + tracing::warn!( + event = "no_components_in_spec", + "spec has no components section, skipping component enrichments" + ); + } + } + } + Ok(()) } From 454e1b113ab6522be446b6cb1f5018dff3bb13aa Mon Sep 17 00:00:00 2001 From: arkptz Date: Thu, 28 May 2026 21:45:58 +0300 Subject: [PATCH 4/5] feat(generate): wire enrichments overlay into main pipeline --- src/main.rs | 33 ++++++- tests/enrichments_cli.rs | 198 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 tests/enrichments_cli.rs diff --git a/src/main.rs b/src/main.rs index 69207b1..27b9cc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -286,7 +286,38 @@ fn run(cli: Cli) -> Result { } } info!(count, path = %args.input.display(), "Processed requests"); - let spec = builder.build(); + let mut spec = builder.build(); + + if let Some(enrichments_path) = &args.enrichments { + if matches!(args.operation_id_strategy, OperationIdStrategyArg::None) { + bail!("--enrichments requires --operation-id-strategy to be set (not 'none')"); + } + let overlay = mitm2openapi::enrichments::load_overlay(enrichments_path) + .with_context(|| { + format!( + "failed to load enrichments from {}", + enrichments_path.display() + ) + })?; + let mode = if strict { + mitm2openapi::enrichments::ApplyMode::Strict + } else { + mitm2openapi::enrichments::ApplyMode::Lenient + }; + if let Err(e) = + mitm2openapi::enrichments::apply_enrichments(&mut spec, &overlay, mode) + { + if strict { + bail!("enrichments error: {e}"); + } + warn!(error = %e, "Enrichments warning"); + *report + .events + .parse_error + .entry(format!("enrichments: {e}")) + .or_insert(0) += 1; + } + } let path_count = spec.paths.paths.len(); report.result.paths_in_spec = path_count as u64; diff --git a/tests/enrichments_cli.rs b/tests/enrichments_cli.rs new file mode 100644 index 0000000..f8b9537 --- /dev/null +++ b/tests/enrichments_cli.rs @@ -0,0 +1,198 @@ +use assert_cmd::Command; +use tempfile::TempDir; + +fn fixture(name: &str) -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +const PREFIX: &str = "https://api.example.com"; + +#[test] +fn cli_enrichments_flag_applies_overlay() { + let dir = TempDir::new().unwrap(); + let overlay_path = dir.path().join("overlay.yaml"); + std::fs::write( + &overlay_path, + r#" +operations: + listTicker: + summary: Get ticker data + x-requires-auth: false +"#, + ) + .unwrap(); + let output = dir.path().join("spec.yaml"); + + Command::cargo_bin("mitm2openapi") + .unwrap() + .args([ + "generate", + "-i", + fixture("envelope_test.har").to_str().unwrap(), + "-t", + fixture("envelope_templates.yaml").to_str().unwrap(), + "-o", + output.to_str().unwrap(), + "-p", + PREFIX, + "--operation-id-strategy", + "path", + "--enrichments", + overlay_path.to_str().unwrap(), + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output).unwrap(); + assert!( + content.contains("Get ticker data"), + "enrichment summary should be applied:\n{content}" + ); + assert!( + content.contains("x-requires-auth"), + "enrichment extension should be present:\n{content}" + ); +} + +#[test] +fn cli_enrichments_requires_operation_id_strategy() { + let dir = TempDir::new().unwrap(); + let overlay_path = dir.path().join("overlay.yaml"); + std::fs::write(&overlay_path, "operations: {}").unwrap(); + let output = dir.path().join("spec.yaml"); + + let cmd = Command::cargo_bin("mitm2openapi") + .unwrap() + .args([ + "generate", + "-i", + fixture("envelope_test.har").to_str().unwrap(), + "-t", + fixture("envelope_templates.yaml").to_str().unwrap(), + "-o", + output.to_str().unwrap(), + "-p", + PREFIX, + "--enrichments", + overlay_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!( + !cmd.status.success(), + "should fail without --operation-id-strategy" + ); + let stderr = String::from_utf8_lossy(&cmd.stderr); + assert!( + stderr.contains("--enrichments requires --operation-id-strategy") + || stderr.contains("operation-id-strategy"), + "error should mention --operation-id-strategy:\n{stderr}" + ); +} + +#[test] +fn cli_enrichments_missing_file_errors() { + let dir = TempDir::new().unwrap(); + let output = dir.path().join("spec.yaml"); + + let cmd = Command::cargo_bin("mitm2openapi") + .unwrap() + .args([ + "generate", + "-i", + fixture("envelope_test.har").to_str().unwrap(), + "-t", + fixture("envelope_templates.yaml").to_str().unwrap(), + "-o", + output.to_str().unwrap(), + "-p", + PREFIX, + "--operation-id-strategy", + "path", + "--enrichments", + "/nonexistent/overlay.yaml", + ]) + .output() + .unwrap(); + + assert!( + !cmd.status.success(), + "should fail for missing enrichments file" + ); +} + +#[test] +fn cli_enrichments_invalid_yaml_errors() { + let dir = TempDir::new().unwrap(); + let overlay_path = dir.path().join("overlay.yaml"); + std::fs::write( + &overlay_path, + "operations:\n : invalid\n broken yaml {{{{", + ) + .unwrap(); + let output = dir.path().join("spec.yaml"); + + let cmd = Command::cargo_bin("mitm2openapi") + .unwrap() + .args([ + "generate", + "-i", + fixture("envelope_test.har").to_str().unwrap(), + "-t", + fixture("envelope_templates.yaml").to_str().unwrap(), + "-o", + output.to_str().unwrap(), + "-p", + PREFIX, + "--operation-id-strategy", + "path", + "--enrichments", + overlay_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!(!cmd.status.success(), "should fail for invalid YAML"); +} + +#[test] +fn cli_enrichments_strict_mode_rejects_unknown_opid() { + let dir = TempDir::new().unwrap(); + let overlay_path = dir.path().join("overlay.yaml"); + std::fs::write( + &overlay_path, + "operations:\n doesNotExist:\n summary: Ghost", + ) + .unwrap(); + let output = dir.path().join("spec.yaml"); + + let cmd = Command::cargo_bin("mitm2openapi") + .unwrap() + .args([ + "generate", + "-i", + fixture("envelope_test.har").to_str().unwrap(), + "-t", + fixture("envelope_templates.yaml").to_str().unwrap(), + "-o", + output.to_str().unwrap(), + "-p", + PREFIX, + "--operation-id-strategy", + "path", + "--enrichments", + overlay_path.to_str().unwrap(), + "--strict", + ]) + .output() + .unwrap(); + + assert!( + !cmd.status.success(), + "strict mode should fail for unknown operationId" + ); +} From 28077b274f963d19471f87112a59e4f92daa58b0 Mon Sep 17 00:00:00 2001 From: arkptz Date: Thu, 28 May 2026 21:48:48 +0300 Subject: [PATCH 5/5] docs: document --enrichments overlay feature --- CHANGELOG.md | 1 + book/src/usage/cli-reference.md | 1 + book/src/usage/pipeline.md | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fdd35..df501b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - *(generate)* add `--envelope-error-shape ` for hand-supplied ApiError schema - *(generate)* add `--envelope-success-component-suffix ` (default `Success`) - *(output)* sort paths and component schemas alphabetically for deterministic YAML output +- *(generate)* add `--enrichments ` (`-e`) flag for applying operationId-keyed YAML overlays with human-written metadata (summaries, descriptions, tags, `x-` extensions, response descriptions, component schema descriptions) ### Fixed diff --git a/book/src/usage/cli-reference.md b/book/src/usage/cli-reference.md index f39b9cd..819ef74 100644 --- a/book/src/usage/cli-reference.md +++ b/book/src/usage/cli-reference.md @@ -88,6 +88,7 @@ mitm2openapi generate [OPTIONS] -i -t -o -p ` | | JSON field name for discriminating success vs error | | `--envelope-error-shape ` | | YAML file with hand-supplied ApiError schema | | `--envelope-success-component-suffix ` | `Success` | Suffix for success component names | +| `--enrichments ` / `-e` | — | path | Apply operationId-keyed YAML overlay to enrich the generated spec (requires `--operation-id-strategy`) | ## Common flag details diff --git a/book/src/usage/pipeline.md b/book/src/usage/pipeline.md index 2eea556..e557716 100644 --- a/book/src/usage/pipeline.md +++ b/book/src/usage/pipeline.md @@ -334,3 +334,62 @@ mitm2openapi generate ... \ --envelope-discriminator success \ --envelope-error-shape api-error.yaml ``` + +## Enriching generated specs + +Auto-generated summaries like `GET /api/v1/contract/fair_price/{symbol}` aren't ideal for documentation or SDK generation. Use `--enrichments` to apply a YAML overlay with human-written metadata: + +```yaml +# enrichments.yaml +info: + description: | + Reverse-engineered MEXC web API. + Source: captured browser traffic. + +operations: + getFairPrice: + summary: Get fair price for a futures contract + description: | + Returns the mark price used for liquidation calculations. + x-requires-auth: false + x-rate-limit: "10/s" + responses: + "200": + description: Fair price payload + + getAssets: + summary: List futures account balances + x-requires-auth: true + +components: + schemas: + ApiError: + description: | + MEXC envelope error response. + HTTP status is always 200; failure is signalled by success=false. +``` + +```sh +mitm2openapi generate \ + -i capture.har -t templates.yaml -o openapi.yaml \ + -p https://api.example.com \ + --operation-id-strategy path \ + --enrichments enrichments.yaml +``` + +### Merge semantics + +| Scope | Rule | +|-------|------| +| `info.*` | Overlay wins per-key (title, description, version) | +| `operations..summary`, `description`, `deprecated` | Overlay wins | +| `operations..tags` | Overlay replaces entire list | +| `operations..x-*` | Passed through verbatim | +| `operations..responses..description` | Overlay wins | +| `components.schemas..description` | Overlay wins (properties/type untouched) | +| Operation in overlay but not in spec | Warning (error under `--strict`) | +| Operation in spec but not in overlay | Left untouched | + +> **Note**: operationIds in the overlay must match the final IDs after collision resolution. If two operations produce the same base ID, one gets a `_2` suffix. Run `generate` once without `--enrichments` to see the resolved IDs. + +The `--enrichments` flag requires `--operation-id-strategy` to be set (not `none`), since the overlay keys operations by operationId.