openapi-merge-cli
v2.0.2
Published
A cli tool for the openapi-merge library.
Readme
openapi-merge-cli
📖 Read the full CLI reference
This tool is based on the library. Please read
that README for more details on how the merging algorithm works.
This tool is intended to be used for merging multiple OpenAPI 3.0, 3.1 or 3.2 files together (all inputs must agree on the same major.minor version). The most common reason that developers want to do this is because they have multiple services that they wish to expose underneath a single API Gateway. Therefore, even though this merging logic is sufficiently generic to be used for most use cases, some of the feature decisions are tailored for that specific use case.
Contents
Getting started
In order to use this merging cli tool you need to have one or more OpenAPI 3.0, 3.1 or 3.2 files that you wish to merge. Then you need to create a configuration file,
called openapi-merge.yaml by default (openapi-merge.json is also read, for configurations written before this tool wrote YAML), in your current directory.
The init command below writes a starting point for you, with every setting documented (most commented out, a couple turned on by
default -- see below). Written by hand, it should look something like this:
{
"inputs": [
{
"inputFile": "./gateway.swagger.json"
},
{
"inputFile": "./jira.swagger.json",
"pathModification": {
"stripStart": "/rest",
"prepend": "/jira"
},
"operationSelection": {
"includeTags": ["included"]
},
"description": {
"append": true,
"title": {
"value": "Jira",
"headingLevel" : 2
}
}
},
{
"inputFile": "./confluence.swagger.yaml",
"dispute": {
"prefix": "Confluence"
},
"pathModification": {
"prepend": "/confluence"
},
"operationSelection": {
"excludeTags": ["excluded"]
}
}
],
"output": "./output.swagger.json"
}In this configuration you specify your inputs and your output file. For each input you have the following parameters:
inputFileorinputURL: the relative or absolute path (or URL), from theopenapi-merge.json, to the OpenAPI schema file for that input (in JSON or Yaml format). Absolute paths (e.g./tmp/spec.yaml) are honoured as-is.dispute: if two inputs both define a component with the same name then, in order to prevent incorrect overlaps, we will attempt to use the dispute prefix or suffix to come up with a unique name for that component. Seedisputefor the full format.pathModification.stripStart: When copying over thepathsfrom your OpenAPI specification for this input, it will strip this string from the start of the path if it is found.pathModification.prepend: When copying over thepathsfrom your OpenAPI specification for this input, it will prepend this string to the start of the path if it is found.prependwill always run afterstripStartso that it is deterministic.operationSelection.includeTags: Only operations that are tagged with the tags configured here will be extracted from the OpenAPI file and merged with the others. This instruction will not remove other tags from the top level tags definition for this input. This filter works per operation, not per path: ifGET /thingcarries the tag andPOST /thingdoes not, the merged document contains/thingwith only itsGET. A path whose operations are all filtered out is dropped entirely.operationSelection.excludeTags: Only operations that are NOT tagged with the tags configured here will be extracted from the OpenAPI file and merged with the others. Also, these tags will also be removed from the top leveltagselement for this file before being merged. If a single REST API operation has anincludeTagsreference and anexcludeTagsreference then the exclusion rule will take precidence.operationSelection.includePaths/operationSelection.excludePaths: Select operations by path (and, optionally, method) instead of by tag -- see Selecting operations by path below.description.append: All of the inputs withappend: truewill have theirinfo.descriptions merged together, in order, and placed in the output OpenAPI file in theinfo.descriptionsection.description.title.value: An optional string that lets you specify a custom section title for this input's description when it is merged together in the output OpenAPI file'sinfo.descriptionsectiondescription.title.headingLevel: The integer heading level for the title,1to6. The default is1.
Selecting only the operations with a particular tag
A common case (see issue #100) is merging several services but taking only the operations each one owns, identified by a tag:
{
"inputs": [
{
"inputFile": "service1/swagger.json",
"operationSelection": { "includeTags": ["Service1"] }
},
{
"inputFile": "service2/swagger.json",
"operationSelection": { "includeTags": ["Service2"] }
},
{
"inputFile": "service3/swagger.json",
"operationSelection": { "includeTags": ["Service3"] }
}
],
"output": "./dist/service.output.swagger.json"
}Three things are worth knowing about how this behaves:
- Untagged operations are excluded.
includeTagsis an allow-list, so an operation with no tags at all does not survive it. If a service has operations you want that are not tagged, either tag them upstream or useexcludeTagsto remove what you do not want instead. - A partially-filtered path keeps its remaining operations. Filtering is per operation; the path itself survives as long as one of its operations does.
- The top-level
tagsarray is only pruned byexcludeTags.includeTagsdeliberately leaves it alone, so a tag you filtered in keeps its description.
Selecting operations by path
Not every input's tags are under your control -- some generators do not let you customise them at all -- and two services can legitimately share a tag while only some of the operations under it should survive. includePaths/excludePaths select by where an operation lives in the document instead:
{
"inputs": [
{
"inputFile": "admin-service/swagger.json",
"operationSelection": {
"excludePaths": [{ "path": "/admin/users", "method": "get" }]
}
},
{
"inputFile": "internal-service/swagger.json",
"operationSelection": {
"excludePaths": [{ "path": "/internal/*" }]
}
}
],
"output": "./dist/service.output.swagger.json"
}pathsupports a*wildcard, matched the same wayincludeTags/excludeTagsare:*matches any run of characters (including none), nothing else is special, and the match is anchored at both ends --/admin/*matches/admin/usersbut not/other/admin/users. A path containing a literal.or other regex-looking character (/v1.2/status) is matched literally, not interpreted.methodis optional. Omit it to match every method on that path. Give a single method ("get") or a list (["get", "post"]) to narrow it -- including a 3.2additionalOperationscustom verb like"PURGE", matched case-sensitively. Standard methods are lowercase in a parsed OpenAPI document (get, notGET) -- a selector must match that spelling.- Selectors are matched against this input's own original path, before
pathModificationruns. Write the selector against the path as it appears in that input's own file, not the path it will have in the merged output. - If an operation matches both an
includePathsand anexcludePathsselector, exclusion wins -- the same precedenceincludeTags/excludeTagsalready have. If it's matched by both a path rule and a tag rule of the same kind (both include, or both exclude), it needs to clear an include rule of every kind configured to survive, and is dropped by an exclude rule of either kind. includePathson a document with 3.1webhookswill drop every webhook operation, unless one of your selectors happens to match the webhook's event name -- the same allow-list behaviourincludeTagsalready has for untagged webhooks. If you need to keep webhooks while filtering paths, tag the webhook operations and useincludeTagsinstead (or omitincludePathsand useexcludePaths, which does not have this effect).
Combining custom x- extensions
By default, a document-root x- extension -- x-tagGroups, x-logo, a vendor's own metadata -- is first-wins: whichever input declares it first supplies the value, and every other input's value for that same key is discarded (issue #60). extensionMergeStrategies lets you combine one instead, keyed by extension name and shaped as a small tree that mirrors the extension's own JSON structure:
{
"inputs": [
{ "inputFile": "service1/swagger.json" },
{ "inputFile": "service2/swagger.json" }
],
"output": "./dist/service.output.swagger.json",
"extensionMergeStrategies": {
"x-tagGroups": {
"kind": "array",
"strategy": "union-by-key",
"key": "name",
"item": {
"kind": "object",
"strategy": "merge",
"fields": {
"tags": { "kind": "array", "strategy": "concat-unique" }
}
}
}
}
}This example combines ReDoc's x-tagGroups across inputs: groups sharing a name merge into one entry, and each merged group's tags are concatenated and deduplicated -- so a group two services both contribute to ends up with every tag from both, instead of only the first input's.
An extension not mentioned in extensionMergeStrategies keeps first-wins, unchanged. Only the document root is covered; x- fields elsewhere in the document (inside info, a Tag object, a path item, a component) are not reached by this option.
Each node in the tree has a kind (what shape is expected there: scalar, array or object) and a strategy (how to combine it):
{ "kind": "scalar", "strategy": "first" | "last" | "error" }-- take the first input's value, the last input's value, or fail the merge if the inputs disagree.first/last/errorwork the same way at every kind, not justscalar: they operate on a value of any shape, soerrorstill reports a disagreement even where the actual value turns out to be an array or object.{ "kind": "array", "strategy": "first" | "last" | "error" }-- the same three choices, taking (or comparing) one input's whole array rather than combining elements.{ "kind": "array", "strategy": "concat" | "concat-unique", "sortBy"?: string }-- concatenate every input's array, in input order.concat-uniqueadditionally deduplicates by deep equality.sortBy(optional) sorts the result afterwards by a named field, for an array of objects; omitted, the result keeps concatenation order.{ "kind": "array", "strategy": "union-by-key", "key": string, "item": ExtensionMergeNode }-- elements sharing the same value atkey, across (and within) inputs, are the same logical entry and are combined usingitem. Elements whose key value appears only once pass through unchanged. Output order is first-seen order across all inputs.itemis required -- there is no sensible default that still does what this strategy is for.{ "kind": "object", "strategy": "first" | "last" | "error" }-- take (or compare) one input's whole object.{ "kind": "object", "strategy": "merge", "fields"?: { [fieldName]: ExtensionMergeNode } }-- combine field by field. A field not listed infieldsdefaults tofirst, applied wholesale regardless of that field's own shape.
Two things worth knowing about how this behaves:
- A type mismatch degrades to
first, not an error. If a node'skindsaysarraybut one input's actual value is an object (or vice versa), that value -- and only that value, not the whole document -- falls back to first-wins rather than the merge failing or guessing at a shape it was not told to expect. The same applies to aunion-by-keyarray whose elements do not all carry the configuredkey. erroralways fails the whole merge on disagreement, at whatever depth it is configured -- there is no partial-failure mode where a nestederrormerely drops that one field.
Getting started: init
To write that configuration file for you, run:
npx openapi-merge-cli initIt creates openapi-merge.yaml in the current directory, pre-filled with any
OpenAPI 3.x files it finds alongside it:
## Wrote openapi-merge.yaml with 2 inputs:
## ./service-a.yaml
## ./service-b.yaml
## resolveExternalReferences and inputRoot are turned on by default -- see the
## comments above them. Every other setting is included, commented out -- uncomment what you need.
## Edit openapi-merge.yaml, then run openapi-merge-cli to produce './openapi.yaml'.The generated file is not just inputs and output. Two settings,
resolveExternalReferences and inputRoot, are turned on by default --
see Cross-document $refs below for what the first
one does; the second bounds it to the directory init just scanned, which
costs nothing since everything init found already lives there. Every other
optional setting this tool supports, both per-input (dispute,
pathModification, operationSelection, description,
duplicatePathHandling, tag) and top-level (outputRoot, formatting,
serversStrategy, securitySchemesStrategy, pruneUnusedComponents,
info, extensionMergeStrategies), is written out commented, with a one-line explanation and a working
example. Uncomment a block and it is immediately valid -- nothing else to
fill in. A field with more than one possible value -- an enum like
serversStrategy, or a choice like dispute's prefix-vs-suffix -- shows
every value as its own commented example line, so picking one is
"uncomment this line instead of that one," not "look up the other spellings
and edit a value by hand":
inputs:
- inputFile: ./service-a.yaml
# Per-input options (all optional, all commented out below).
# Rewrite this input's paths before merging: strip a prefix, then prepend one.
# pathModification:
# stripStart: /v1
# prepend: /service-a
# What to do when this input declares a path another input already contributed.
# duplicatePathHandling: error # (default) fail the merge
# duplicatePathHandling: skip-later # keep the definition already present, drop this one
# duplicatePathHandling: prefer-later # replace the definition already present with this one
# duplicatePathHandling: merge-operations # combine when methods don't overlap and path-level fields agree
# ... operationSelection, description, tag, dispute ...
- inputFile: ./service-b.yaml
# Per-input options: see the commented block under the first input above -- the same fields apply here.
output: ./openapi.yaml
# Follows $refs into files these inputs don't declare, and files those pull in,
# however many deep -- so a $ref into an undeclared file just works. Paired
# with inputRoot, below, which bounds every local file this can reach to '.'.
# Set to false to turn this off.
resolveExternalReferences: true
# Defence in depth for the setting above: refuses to read any local file --
# declared or discovered -- from outside this directory.
inputRoot: .
# Defence in depth: refuse to write the merged output anywhere outside this directory.
# outputRoot: .
# How to combine the top-level 'servers' array across inputs.
# serversStrategy: first # (default) keep only the first input's servers, discard the rest
# serversStrategy: concat # keep every input's servers, deduplicated by URL
# ... formatting, securitySchemesStrategy, pruneUnusedComponents, info, extensionMergeStrategies ...If you would rather start from the historical permissive defaults (both
settings unset, matching every version of init before this), delete or
comment out the two active lines -- deleting an active line is exactly as
valid as leaving a commented one uncommented.
Details worth knowing:
- It identifies inputs by content, not by extension. Every
.json,.yamland.ymlfile is opened and kept only if it has a top-levelopenapi: 3.x. That is what keepspackage.jsonand your CI configuration out of the result without a list of names to exclude. - It scans the current directory only. Not recursive: descending would mean guessing which directories to skip, and picking up a vendored copy of somebody else's API is a worse outcome than finding nothing.
- It will not overwrite an existing configuration --
openapi-merge.yamloropenapi-merge.json-- unless you pass--force.--forceonly ever writesopenapi-merge.yaml; a pre-existingopenapi-merge.jsonis left in place (and the command tells you it is no longer used, since.yamlis preferred on load). - Swagger 2.0 files are named, not silently skipped, so you know the scan saw
them and why they were left out. Convert them with
swagger2openapifirst. - It warns if the files it found declare different OpenAPI minor versions, because the merge requires them all to agree and would otherwise fail on your next command.
- If nothing is found, you still get a valid file with one placeholder input to replace.
And then, once you have your Inputs in place and your configuration file you merely run the following in the directory that has your configuration file:
npx openapi-merge-cliFor more fine grained details on what Configuration options are available to you, see the
full configuration reference.
If you wish, you may write your configuration file in YAML format and then run:
npx openapi-merge-cli --config path/to/openapi-merge.yamlAnd the merge should be run and complete! Congratulations and enjoy!
Formatting
Control the indentation of the merged output via an optional formatting
block. Indentation is expressed as a discriminated union so contradictory
combinations (e.g. "tabs of width 4") are unrepresentable:
{
"inputs": [...],
"output": "./merged.json",
// 4-space indentation (default is 2 spaces; same as today's behaviour).
"formatting": { "indent": { "style": "spaces", "width": 4 } }
}{
"inputs": [...],
"output": "./merged.json",
// Tab indentation. JSON only — see note below.
"formatting": { "indent": { "style": "tabs" } }
}If formatting is omitted the output keeps the historical default of
two-space indentation.
Note: YAML 1.1 disallows tab characters as indentation. Combining
{ "style": "tabs" } with a .yaml or .yml output is rejected at
configuration-load time with a clear error message.
Paths
Both inputFile and output accept either relative or absolute paths.
Relative paths are resolved against the directory that contains the
configuration file. Absolute paths (e.g. /tmp/merged.yaml,
C:\build\out.json) are used as-is. This means you can safely write the
merged spec into directories like /tmp or /var/build/... from CI.
Any directory in output's path that doesn't exist yet is created
automatically (including multiple missing levels at once), so
"output": "./dist/service.output.swagger.json" works even on a project
where dist/ hasn't been created yet. If a directory can't be created --
a permissions error, or a path component that's already a regular file --
the CLI exits with ErrorCreatingOutputDirectory (see Exit
codes) rather than a raw stack trace.
Cross-document $refs
If one input's $ref points at another file rather than somewhere inside
itself -- $ref: "../common/Errors.yml#/components/schemas/ServerError" --
two different things can happen, depending on whether that file is one of
your declared inputs:
- It's one of your
inputsalready. The$refis rewritten to point at wherever that component ended up in the merged document, automatically and unconditionally -- no configuration needed. This is always correct to do: the alternative is a$refthat is already broken in the merged output, since the original relative path means nothing once the inputs are combined into one document (issue #104). - It isn't one of your
inputs. By default the$refis left as-is (now resolved to an absolute path or URL, so at least it's unambiguous, but still not something the merged document can resolve). Set"resolveExternalReferences": truein your configuration to have the CLI follow it: load that file (or URL), pull in just the component the$refasked for, and rewrite the$refto point at it locally -- following further$refs the same way, however many files deep, with cycles detected and reported rather than hanging (issue #10).
{
"inputs": [{ "inputFile": "./api.yaml" }],
"output": "./bundle.yaml",
"resolveExternalReferences": true
}A $ref this discovers but cannot load -- a missing file, a failed fetch, a
document that doesn't parse -- is left exactly as written and reported as a
warning, not a hard failure, the same way an unresolvable $ref into a
declared input is also left alone rather than erroring.
Security
openapi-merge-cli reads, merges, and writes files using the paths specified
in your openapi-merge.json (or via --config). The tool assumes that this
configuration file is trusted, the same way you trust a Makefile,
package.json, or webpack.config.js in your repository. Do not run the CLI
against a configuration file from an untrusted source without restricting the
input and output locations.
resolveExternalReferences widens what gets read, not just written: with
it on, the files and URLs the CLI loads are no longer limited to what
inputs names -- it follows wherever a $ref in any loaded document
points, transitively. Leave it off (the default) unless your inputs are
trusted to the same degree the configuration file itself is.
For defence-in-depth in less-trusted contexts (for example a server that accepts user-supplied configs), you can restrict where the CLI will write the merged output:
- Add
"outputRoot": "/path/to/safe/dir"to youropenapi-merge.json, or - Pass
--restrict-output-to /path/to/safe/diron the command line (the flag takes precedence over the config field).
When set, any resolved output path that does not lie under the configured
root is rejected at config-load time with a clear error message, and the CLI
exits with code 5 (ExitCode.ErrorUnsafePath). Symlink-out-of-jail tricks
are defeated by realpath-ing the closest existing ancestor of the output.
When unset, the CLI keeps its historical permissive default and writes wherever you tell it to.
You can restrict where the CLI will read local files from the same way --
the read-side counterpart, and the one that matters most once
resolveExternalReferences is on, since that setting is what makes the
reachable file set transitive rather than confined to what inputs lists:
- Add
"inputRoot": "/path/to/safe/dir"to youropenapi-merge.json, or - Pass
--restrict-input-to /path/to/safe/diron the command line (the flag takes precedence over the config field).
When set, any local file the CLI would read -- a declared inputFile or a
file resolveExternalReferences discovers -- that does not lie under the
configured root is refused, and the CLI exits with code 10
(ExitCode.ErrorUnsafeInputPath). The offending file is never opened: the
check runs before the read is attempted, using the same realpath-based
containment check as outputRoot, extended to also realpath the file itself
(not just its parent directory) before comparing -- an input, unlike an
output, normally already exists, so a symlink planted as the file itself,
not just an ancestor directory, has to be defeated too. A declared
inputFile outside the root is reported before the merge starts at all; a
discovered file outside the root
aborts the merge the same way, rather than being left as an unresolved $ref
the way an ordinary missing or unparseable discovered file is. inputURL and
URLs discovered via resolveExternalReferences are unaffected -- inputRoot
bounds the filesystem, not the network.
When unset, the CLI keeps its historical permissive default and reads whatever the inputs point to.
Exit codes
The CLI's exit codes are part of its contract; scripts and CI pipelines can branch on them.
| Code | Meaning |
| ---- | ------- |
| 0 | Success — the merge completed and the output was written |
| 1 | Failed to load or parse the configuration file |
| 2 | Failed to load one or more inputs (missing file, unreachable URL, unparseable content) |
| 3 | The merge itself failed (duplicate paths, unresolvable operationId conflicts, a cyclic cross-document $ref chain, …) |
| 4 | An uncaught exception escaped the CLI |
| 5 | The resolved output path escaped outputRoot / --restrict-output-to |
| 6 | An inputURL responded with a 4xx status |
| 7 | An inputURL responded with a 5xx status |
| 8 | An inputURL responded with some other non-2xx status |
| 9 | An input declared an unsupported OpenAPI version, or the inputs disagreed |
| 10 | A local file read escaped inputRoot / --restrict-input-to |
| 11 | The output directory could not be created (permissions, read-only filesystem, or a path component that's an existing file) |
Codes 6–8 are separate from 2 on purpose. 2 means an input could not be
obtained at all — a missing file, an unreachable host, content that parses as
neither JSON nor YAML. 6–8 mean the server answered and refused.
They are separate from each other so that CI can branch on retryability:
6(4xx) is the request's fault — a stale URL, missing credentials, a retired endpoint. Retrying changes nothing; the config needs to change.7(5xx) is the server's fault and is plausibly transient. If you merge specs published by other teams, you will see this during their deploys, and a retry is usually the right response.8is anything else outside the 2xx range. Ordinary redirects are followed automatically and never surface; in practice this is a304 Not Modifiedfrom a caching proxy. Read the printed status.
OpenAPI version support
This tool merges OpenAPI 3.0.x, 3.1.x and 3.2.x documents. Every input must
declare a full openapi version (for example "3.2.0"), and all inputs must
agree on the major.minor version — patch differences such as 3.1.0 and
3.1.1 are fine, since they are the same feature set, but 3.0, 3.1 and 3.2
inputs cannot be mixed with each other.
3.1 support covers webhooks (which merge exactly like paths: same
duplicate rule, same operationId uniqueness, same $ref rewriting),
components.pathItems, jsonSchemaDialect, and documents with no paths at
all.
3.2 support covers the query HTTP method and additionalOperations
(custom verbs such as PURGE), which participate fully in operation counting,
operationId uniqueness, $ref rewriting and tag-based selection. The new tag
fields summary, parent and kind are carried through, as are
itemSchema, discriminator defaultMapping, OAuth2 device-authorization
flows and in: querystring parameters.
$self is a special case: it declares a document's own identity, and a
merged document is not any of its inputs. It is kept when there is exactly one
input and dropped otherwise, rather than arbitrarily inheriting one input's
identity — which would also affect how relative $refs resolve.
An input declaring a version this tool does not know, no version at all, or a
version that disagrees with the other inputs exits with code 9 and a message
naming the offending input.
The output now declares the version the inputs used, rather than always
3.0.3. Merging documents that declare 3.0.0 now produces 3.0.0. Relabelling
within a minor is safe in both directions, so no document becomes invalid, but
the emitted value has changed.
For example, retrying only on a server-side failure:
openapi-merge-cli
case $? in
0) echo "merged" ;;
7) echo "upstream is down, retrying later"; exit 75 ;; # EX_TEMPFAIL
*) echo "merge failed permanently"; exit 1 ;;
esacIf you experience any issues then please raise them in the bug tracker.
