Aggregate and merge OpenAPI 3.x specifications from multiple sources into a single spec.
Available as both a Rust library and a CLI tool.
Try it in your browser: the playground runs the same merge engine compiled to WebAssembly. Add specs, edit the config, and share the result as a link.
- Multiple source types – local YAML files, local JSON files, and HTTP endpoints (with custom headers)
- Config-file driven – define sources and merge options in a single YAML config
- Conflict resolution – choose between
error,overwrite, orrenamestrategies for duplicate paths and component names $refrewriting – when using therenamestrategy,$refpointers are automatically updated- Path prefixing – optionally prefix every path with the source name to guarantee uniqueness
- Info override – set a custom
title,version, anddescriptionin the merged output - Per-source custom blocks – deep-merge arbitrary OpenAPI blocks/extensions from config (provider-agnostic)
curl -sSfL https://raw.githubusercontent.com/includeamin/openapi-aggregator/main/install.sh | shInstall a specific version or to a custom directory:
VERSION=v0.1.0 curl -sSfL https://raw.githubusercontent.com/includeamin/openapi-aggregator/main/install.sh | sh
INSTALL_DIR=/usr/local/bin curl -sSfL https://raw.githubusercontent.com/includeamin/openapi-aggregator/main/install.sh | shUninstall:
curl -sSfL https://raw.githubusercontent.com/includeamin/openapi-aggregator/main/install.sh | sh -s -- --uninstallcargo install --path crates/openapi-aggregatorDownload from GitHub Releases. Binaries are available for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64).
# Merge using a config file (defaults to openapi-aggregator.yaml)
openapi-aggregator
# Specify a config file and output location
openapi-aggregator -c my-config.yaml -o merged.yaml
# Output as JSON
openapi-aggregator -c my-config.yaml -f json
# Print help
openapi-aggregator --helpCreate an openapi-aggregator.yaml (see config.example.yaml):
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
- name: billing
url: https://billing.example.com/openapi.json
headers:
Authorization: "Bearer token"
output:
format: yaml # yaml | json
merge:
conflict_strategy: error # error | overwrite | rename
prefix_paths: false
info:
title: "My Aggregated API"
version: "1.0.0"Sources are detected automatically by their fields:
- If
urlis present → HTTP source - If
pathis present → file source (YAML or JSON auto-detected from content)
Exactly one of url / path must be set, and unknown keys anywhere in the config are rejected, so typos like conflict_stratgy fail loudly instead of being ignored.
An HTTP source without a name is named after its URL host (e.g. billing.example.com).
${VAR} placeholders in an HTTP source's url and headers values are replaced with environment variables at load time, so secrets don't have to live in the config file:
- name: billing
url: https://billing.example.com/openapi.json
headers:
Authorization: "Bearer ${BILLING_TOKEN}"A referenced variable that is not set is an error.
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.
use openapi_aggregator::{aggregate, Config, Source, MergeConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config {
sources: vec![
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(),
merge: MergeConfig::default(),
};
let merged = aggregate(&config).await?;
println!("{}", serde_json::to_string_pretty(&merged)?);
Ok(())
}Or load directly from a config file:
use openapi_aggregator::aggregate_from_file;
use std::path::Path;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let merged = aggregate_from_file(Path::new("openapi-aggregator.yaml")).await?;
println!("{}", serde_yaml::to_string(&merged)?);
Ok(())
}Only real conflicts trigger the conflict strategy:
- Paths are merged per operation.
GET /usersfrom one source andPOST /usersfrom another end up under the same path. A conflict is the same operation (or path-level field) defined differently. - Identical definitions are shared. Two sources defining the same
bearerAuthscheme orErrorschema produce one copy, not a conflict.
| Strategy | Conflicting paths / webhooks | Conflicting components |
|---|---|---|
error |
Fail immediately | Fail immediately |
overwrite |
Last source wins (per operation) | Last source wins |
rename |
Move the source's path to /{source_name}{path} |
Rename to {source_name}_{component} |
When prefix_paths: true, all paths are prefixed regardless of conflicts.
When using rename, any $ref pointing to a renamed component is rewritten automatically, and renamed names never overwrite an existing one (b_2_Pet is used if b_Pet is taken). Source names are sanitised (my api → my_api) wherever they become part of a path or component name.
Other top-level fields:
webhooks(OpenAPI 3.1) are merged like paths.- Top-level
securitystays top-level only when every source declares the same requirements. Otherwise each source's requirements are copied onto its own operations, so no operation gains or loses authentication. Renamed security schemes anddiscriminator.mappingentries are updated along with$refs. - The merged
openapiversion comes from the first source. The CLI prints a warning when sources use differentmajor.minorversions (also available viaaggregate_with_report). - Component types are always emitted in the same order, so the output is stable across runs.
# Run tests
cargo test
# Lint
cargo clippy --all-targets -- -D warnings
# Format
cargo fmtcd web
npm ci
npm run wasm # build the WASM engine (needs wasm-pack + wasm32-unknown-unknown target)
npm run dev # http://localhost:5173/openapi-aggregator/
npm test # unit tests
npm run e2e # Playwright smoke test