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
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ jobs:
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Core builds for wasm32
run: |
rustup target add wasm32-unknown-unknown
cargo check -p openapi-aggregator-core --target wasm32-unknown-unknown

test:
name: Test
Expand All @@ -42,6 +46,33 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --release
run: cargo build --release -p openapi-aggregator
- name: Smoke test
run: ./target/release/openapi-aggregator --help

web:
name: Web playground
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run wasm
- run: npm run typecheck
- run: npm test
- run: npx playwright install --with-deps chromium
- run: npm run e2e
52 changes: 52 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Deploy playground

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run wasm
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: web/dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
10 changes: 6 additions & 4 deletions .github/workflows/tag-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ jobs:
if: steps.skip_check.outputs.skip != 'true'
run: |
sed -i "0,/^version = .*/s//version = \"${{ steps.version.outputs.version }}\"/" Cargo.toml
cargo check --quiet 2>/dev/null || true # regenerate Cargo.lock if present
cargo update --workspace # sync the package version in Cargo.lock

- name: Install git-cliff
if: steps.skip_check.outputs.skip != 'true'
Expand All @@ -147,7 +147,7 @@ jobs:
- name: Commit version bump & changelog
if: steps.skip_check.outputs.skip != 'true'
run: |
git add Cargo.toml CHANGELOG.md
git add Cargo.toml Cargo.lock CHANGELOG.md
git commit -m "chore(release): ${{ steps.version.outputs.tag }}"
git push

Expand All @@ -159,7 +159,9 @@ jobs:

- name: Write release notes to file
if: steps.skip_check.outputs.skip != 'true'
run: echo '${{ steps.release_notes.outputs.content }}' > /tmp/release_notes.md
env:
RELEASE_NOTES: ${{ steps.release_notes.outputs.content }}
run: printf '%s\n' "$RELEASE_NOTES" > /tmp/release_notes.md

- name: Upload release notes
if: steps.skip_check.outputs.skip != 'true'
Expand Down Expand Up @@ -212,7 +214,7 @@ jobs:
key: ${{ matrix.target }}

- name: Build
run: cargo build --release --target ${{ matrix.target }}
run: cargo build --release -p openapi-aggregator --target ${{ matrix.target }}

- name: Package (Unix)
if: runner.os != 'Windows'
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,8 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml
web/node_modules/
web/dist/
web/src/wasm/
web/test-results/
web/playwright-report/
38 changes: 36 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 7 additions & 20 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
[package]
name = "openapi-aggregator"
[workspace]
members = ["crates/*"]
resolver = "2"

[workspace.package]
version = "0.6.1"
edition = "2021"
description = "Aggregate and merge OpenAPI specifications from multiple sources"
license = "MIT"
readme = "README.md"

[[bin]]
name = "openapi-aggregator"
path = "src/main.rs"

[lib]
name = "openapi_aggregator"
path = "src/lib.rs"

[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
[workspace.dependencies]
openapi-aggregator-core = { path = "crates/openapi-aggregator-core" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_yaml = "0.9"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

[dev-dependencies]
mockito = "1"
tempfile = "3"
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Aggregate and merge OpenAPI 3.x specifications from multiple sources into a sing

Available as both a **Rust library** and a **CLI tool**.

**Try it in your browser:** the [playground](https://includeamin.github.io/openapi-aggregator/) runs the same merge engine compiled to WebAssembly. Add specs, edit the config, and share the result as a link.

## Features

- **Multiple source types** – local YAML files, local JSON files, and HTTP endpoints (with custom headers)
Expand Down Expand Up @@ -38,7 +40,7 @@ curl -sSfL https://raw.githubusercontent.com/includeamin/openapi-aggregator/main
### From source

```sh
cargo install --path .
cargo install --path crates/openapi-aggregator
```

### Pre-built binaries
Expand Down Expand Up @@ -104,6 +106,23 @@ 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)

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`).

### Environment variables

`${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:

```yaml
- name: billing
url: https://billing.example.com/openapi.json
headers:
Authorization: "Bearer ${BILLING_TOKEN}"
```

A referenced variable that is not set is an error.

### 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.
Expand Down Expand Up @@ -159,15 +178,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

## Merge Behaviour

| Strategy | Duplicate paths | Duplicate components |
|-------------|----------------------------------------|------------------------------------------|
| `error` | Fail immediately | Fail immediately |
| `overwrite` | Last source wins | Last source wins |
| `rename` | Prefix path with `/{source_name}` | Rename to `{source_name}_{component}` |
Only real conflicts trigger the conflict strategy:

- **Paths are merged per operation.** `GET /users` from one source and `POST /users` from 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 `bearerAuth` scheme or `Error` schema 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.
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 `security` stays 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 and `discriminator.mapping` entries are updated along with `$ref`s.
- The merged `openapi` version comes from the first source. The CLI prints a warning when sources use different `major.minor` versions (also available via `aggregate_with_report`).
- Component types are always emitted in the same order, so the output is stable across runs.

## Development

Expand All @@ -182,6 +213,17 @@ cargo clippy --all-targets -- -D warnings
cargo fmt
```

### Web playground

```sh
cd 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
```

## License

[MIT](LICENSE)
3 changes: 2 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ sources:
- name: billing
url: https://billing.example.com/openapi.json
headers:
Authorization: "Bearer your-token-here"
Authorization: "Bearer ${BILLING_TOKEN}" # ${VAR} is read from the environment
Accept: "application/json"
# additional_blocks:
# x-another-extension:
Expand All @@ -40,6 +40,7 @@ merge:
# error – fail immediately (default)
# overwrite – last source wins
# rename – prefix conflicting names with the source name
# Different methods on the same path and identical definitions are never conflicts.
conflict_strategy: error

# Prefix every path with /{source_name} to guarantee uniqueness
Expand Down
12 changes: 12 additions & 0 deletions crates/openapi-aggregator-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[package]
name = "openapi-aggregator-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Pure merge logic for openapi-aggregator (no I/O, wasm-compatible)"

[dependencies]
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
thiserror.workspace = true
Loading
Loading