Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -107,13 +120,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Source::File {
name: Some("petstore".into()),
path: "./specs/petstore.yaml".into(),
additional_blocks: None,
tag_prefix: None,
},
Source::Http {
name: Some("billing".into()),
url: "https://billing.example.com/openapi.json".into(),
headers: [("Authorization".into(), "Bearer token".into())]
.into_iter()
.collect(),
additional_blocks: None,
tag_prefix: None,
},
],
output: Default::default(),
Expand Down
11 changes: 11 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;

Expand Down Expand Up @@ -27,13 +28,19 @@ 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<String>,
/// Additional blocks deep-merged into this source spec before merge.
/// Can be used for vendor extensions or any custom OpenAPI blocks.
additional_blocks: Option<Value>,
},
File {
name: Option<String>,
path: PathBuf,
/// 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<String>,
/// Additional blocks deep-merged into this source spec before merge.
/// Can be used for vendor extensions or any custom OpenAPI blocks.
additional_blocks: Option<Value>,
},
}

Expand Down
43 changes: 43 additions & 0 deletions src/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub fn merge_specs(
let mut merged_components: HashMap<String, Map<String, Value>> = HashMap::new();
let mut merged_tags: Vec<Value> = Vec::new();
let mut merged_servers: Vec<Value> = 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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 = [
Expand Down
99 changes: 97 additions & 2 deletions src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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<Value, Error> {
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<Value, Error> {
serde_json::from_str(content).or_else(|_| {
Expand All @@ -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() {
Expand All @@ -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"));
}
}
Loading
Loading