diff --git a/README.md b/README.md index 65a2177..16f100d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Available as both a **Rust library** and a **CLI tool**. - **`$ref` rewriting** – when using the `rename` strategy, `$ref` pointers are automatically updated - **Path prefixing** – optionally prefix every path with the source name to guarantee uniqueness - **Info override** – set a custom `title`, `version`, and `description` in the merged output +- **Per-source custom blocks** – deep-merge arbitrary OpenAPI blocks/extensions from config (provider-agnostic) ## Installation @@ -68,6 +69,14 @@ Create an `openapi-aggregator.yaml` (see [config.example.yaml](config.example.ya sources: - name: petstore path: ./specs/petstore.yaml + additional_blocks: + x-custom-root: + enabled: true + paths: + /pets: + get: + x-custom-operation: + rate_limit: 100 - name: users path: ./specs/users.json @@ -95,6 +104,10 @@ Sources are detected automatically by their fields: - If `url` is present → HTTP source - If `path` is present → file source (YAML or JSON auto-detected from content) +### Additional source blocks + +Each source can define `additional_blocks` as any YAML/JSON object. It is deep-merged into that source document before merge, so you can inject vendor extensions (for example API gateway related `x-...` blocks) or other custom OpenAPI fragments without adding provider-specific fields. + ## Library Usage ```rust @@ -107,6 +120,8 @@ async fn main() -> Result<(), Box> { Source::File { name: Some("petstore".into()), path: "./specs/petstore.yaml".into(), + additional_blocks: None, + tag_prefix: None, }, Source::Http { name: Some("billing".into()), @@ -114,6 +129,8 @@ async fn main() -> Result<(), Box> { headers: [("Authorization".into(), "Bearer token".into())] .into_iter() .collect(), + additional_blocks: None, + tag_prefix: None, }, ], output: Default::default(), diff --git a/config.example.yaml b/config.example.yaml index 86c0990..ddcce1f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,6 +8,14 @@ sources: - name: petstore path: ./specs/petstore.yaml # tag_prefix: "MyPets" # optional: override the prefix for this source's tags + # additional_blocks: # optional: deep-merged into this source spec + # x-custom-root: + # enabled: true + # paths: + # /pets: + # get: + # x-custom-operation: + # rate_limit: 100 - name: users path: ./specs/users.json @@ -18,6 +26,9 @@ sources: headers: Authorization: "Bearer your-token-here" Accept: "application/json" + # additional_blocks: + # x-another-extension: + # key: value # Output settings (used by the CLI) output: diff --git a/src/config.rs b/src/config.rs index dc128e6..56f0616 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; @@ -27,6 +28,9 @@ pub enum Source { /// Custom tag prefix for this source (used when `tag_prefix` is `source_name`). /// If set, overrides the source name as the prefix. tag_prefix: Option, + /// Additional blocks deep-merged into this source spec before merge. + /// Can be used for vendor extensions or any custom OpenAPI blocks. + additional_blocks: Option, }, File { name: Option, @@ -34,6 +38,9 @@ pub enum Source { /// Custom tag prefix for this source (used when `tag_prefix` is `source_name`). /// If set, overrides the source name as the prefix. tag_prefix: Option, + /// Additional blocks deep-merged into this source spec before merge. + /// Can be used for vendor extensions or any custom OpenAPI blocks. + additional_blocks: Option, }, } diff --git a/src/merge.rs b/src/merge.rs index 6027b2f..ae47f4c 100644 --- a/src/merge.rs +++ b/src/merge.rs @@ -44,6 +44,7 @@ pub fn merge_specs( let mut merged_components: HashMap> = HashMap::new(); let mut merged_tags: Vec = Vec::new(); let mut merged_servers: Vec = Vec::new(); + let mut merged_custom_top_level = Map::new(); for (source_name, tag_prefix, mut spec) in specs { // Phase 1: detect component conflicts and build a $ref rename map @@ -117,6 +118,24 @@ pub fn merge_specs( } } } + + // Phase 3e: merge non-standard top-level blocks (e.g. vendor extensions) + if let Some(root) = spec.as_object() { + for (key, value) in root { + if is_reserved_top_level_key(key) { + continue; + } + if let Some(existing) = merged_custom_top_level.get_mut(key) { + deep_merge_value(existing, value); + } else { + merged_custom_top_level.insert(key.clone(), value.clone()); + } + } + } + } + + for (key, value) in merged_custom_top_level { + merged.insert(key, value); } merged.insert("paths".into(), Value::Object(merged_paths)); @@ -195,6 +214,30 @@ fn build_info(specs: &[(String, String, Value)], info_override: Option<&InfoOver info } +fn is_reserved_top_level_key(key: &str) -> bool { + matches!( + key, + "openapi" | "info" | "paths" | "components" | "tags" | "servers" + ) +} + +fn deep_merge_value(target: &mut Value, patch: &Value) { + match (target, patch) { + (Value::Object(target_map), Value::Object(patch_map)) => { + for (key, patch_value) in patch_map { + if let Some(existing) = target_map.get_mut(key) { + deep_merge_value(existing, patch_value); + } else { + target_map.insert(key.clone(), patch_value.clone()); + } + } + } + (target_slot, patch_value) => { + *target_slot = patch_value.clone(); + } + } +} + /// Rewrite tag arrays inside all operations of a source spec, prefixing each tag name. fn rewrite_spec_operation_tags(spec: &mut Value, tag_prefix: &str, config: &MergeConfig) { let http_methods = [ diff --git a/src/source.rs b/src/source.rs index 4c965f2..4df8b91 100644 --- a/src/source.rs +++ b/src/source.rs @@ -11,7 +11,7 @@ pub async fn load_source(source: &Source) -> Result<(String, Value), Error> { path: path.display().to_string(), source: e, })?; - let value = parse_content(&content)?; + let value = parse_and_prepare(&content, source)?; validate_openapi(&value, &source.display_name())?; Ok((source.display_name(), value)) } @@ -29,13 +29,55 @@ pub async fn load_source(source: &Source) -> Result<(String, Value), Error> { url: url.clone(), source: e, })?; - let value = parse_content(&content)?; + let value = parse_and_prepare(&content, source)?; validate_openapi(&value, &source.display_name())?; Ok((source.display_name(), value)) } } } +fn parse_and_prepare(content: &str, source: &Source) -> Result { + let mut value = parse_content(content)?; + if let Some(blocks) = source_additional_blocks(source) { + if !blocks.is_object() { + return Err(Error::InvalidSpec { + name: source.display_name(), + reason: "'additional_blocks' must be a mapping/object".into(), + }); + } + deep_merge(&mut value, blocks); + } + Ok(value) +} + +fn source_additional_blocks(source: &Source) -> Option<&Value> { + match source { + Source::File { + additional_blocks, .. + } + | Source::Http { + additional_blocks, .. + } => additional_blocks.as_ref(), + } +} + +fn deep_merge(target: &mut Value, patch: &Value) { + match (target, patch) { + (Value::Object(target_map), Value::Object(patch_map)) => { + for (key, patch_value) in patch_map { + if let Some(existing) = target_map.get_mut(key) { + deep_merge(existing, patch_value); + } else { + target_map.insert(key.clone(), patch_value.clone()); + } + } + } + (target_slot, patch_value) => { + *target_slot = patch_value.clone(); + } + } +} + /// Try JSON first, fall back to YAML. fn parse_content(content: &str) -> Result { serde_json::from_str(content).or_else(|_| { @@ -62,6 +104,8 @@ fn validate_openapi(value: &Value, source_name: &str) -> Result<(), Error> { #[cfg(test)] mod tests { use super::*; + use serde_json::json; + use std::path::PathBuf; #[test] fn parse_json_content() { @@ -88,4 +132,55 @@ mod tests { let v = serde_json::json!({"openapi": "2.0"}); assert!(validate_openapi(&v, "test").is_err()); } + + #[test] + fn additional_blocks_are_deep_merged() { + let source = Source::File { + name: Some("test".into()), + path: PathBuf::from("ignored.yaml"), + tag_prefix: None, + additional_blocks: Some(json!({ + "x-vendor-root": { "enabled": true }, + "paths": { + "/pets": { + "get": { + "x-vendor-extension": { "timeout": 3000 } + } + } + } + })), + }; + + let base = r#"{ + "openapi": "3.0.3", + "info": {"title": "T", "version": "1"}, + "paths": { + "/pets": { + "get": {"summary": "list pets"} + } + } + }"#; + + let merged = parse_and_prepare(base, &source).unwrap(); + assert_eq!(merged["x-vendor-root"]["enabled"], true); + assert_eq!(merged["paths"]["/pets"]["get"]["summary"], "list pets"); + assert_eq!( + merged["paths"]["/pets"]["get"]["x-vendor-extension"]["timeout"], + 3000 + ); + } + + #[test] + fn additional_blocks_must_be_object() { + let source = Source::File { + name: Some("test".into()), + path: PathBuf::from("ignored.yaml"), + tag_prefix: None, + additional_blocks: Some(json!([1, 2, 3])), + }; + + let base = r#"{"openapi":"3.0.3","info":{"title":"T","version":"1"},"paths":{}}"#; + let err = parse_and_prepare(base, &source).unwrap_err().to_string(); + assert!(err.contains("additional_blocks")); + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 59d9191..9969aac 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -15,6 +15,7 @@ async fn load_yaml_file_source() { name: Some("petstore".into()), path: fixtures().join("petstore.yaml"), tag_prefix: None, + additional_blocks: None, }; let (name, spec) = load_source(&source).await.unwrap(); assert_eq!(name, "petstore"); @@ -28,6 +29,7 @@ async fn load_json_file_source() { name: Some("conflict".into()), path: fixtures().join("conflict.json"), tag_prefix: None, + additional_blocks: None, }; let (name, spec) = load_source(&source).await.unwrap(); assert_eq!(name, "conflict"); @@ -42,11 +44,13 @@ async fn aggregate_two_specs_from_config() { name: Some("petstore".into()), path: fixtures().join("petstore.yaml"), tag_prefix: None, + additional_blocks: None, }, Source::File { name: Some("users".into()), path: fixtures().join("users.yaml"), tag_prefix: None, + additional_blocks: None, }, ], output: Default::default(), @@ -70,6 +74,48 @@ async fn aggregate_two_specs_from_config() { assert_eq!(tags.len(), 2); } +#[tokio::test] +async fn aggregate_applies_additional_blocks_per_source() { + let config = Config { + sources: vec![ + Source::File { + name: Some("petstore".into()), + path: fixtures().join("petstore.yaml"), + tag_prefix: None, + additional_blocks: Some(serde_json::json!({ + "x-custom-root": { + "owner": "platform" + }, + "paths": { + "/pets": { + "get": { + "x-custom-operation": { + "rate_limit": 100 + } + } + } + } + })), + }, + Source::File { + name: Some("users".into()), + path: fixtures().join("users.yaml"), + tag_prefix: None, + additional_blocks: None, + }, + ], + output: Default::default(), + merge: MergeConfig::default(), + }; + + let merged = aggregate(&config).await.unwrap(); + assert_eq!(merged["x-custom-root"]["owner"], "platform"); + assert_eq!( + merged["paths"]["/pets"]["get"]["x-custom-operation"]["rate_limit"], + 100 + ); +} + #[tokio::test] async fn aggregate_conflict_errors_by_default() { let config = Config { @@ -78,11 +124,13 @@ async fn aggregate_conflict_errors_by_default() { name: Some("petstore".into()), path: fixtures().join("petstore.yaml"), tag_prefix: None, + additional_blocks: None, }, Source::File { name: Some("conflict".into()), path: fixtures().join("conflict.json"), tag_prefix: None, + additional_blocks: None, }, ], output: Default::default(), @@ -103,11 +151,13 @@ async fn aggregate_conflict_rename_rewrites_refs() { name: Some("petstore".into()), path: fixtures().join("petstore.yaml"), tag_prefix: None, + additional_blocks: None, }, Source::File { name: Some("alt".into()), path: fixtures().join("conflict.json"), tag_prefix: None, + additional_blocks: None, }, ], output: Default::default(), @@ -213,6 +263,7 @@ async fn http_source_with_headers() { .into_iter() .collect(), tag_prefix: None, + additional_blocks: None, }; let (name, spec) = load_source(&source).await.unwrap();