npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 npm 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:

  • inputFile or inputURL: the relative or absolute path (or URL), from the openapi-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. See dispute for the full format.
  • pathModification.stripStart: When copying over the paths from 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 the paths from your OpenAPI specification for this input, it will prepend this string to the start of the path if it is found. prepend will always run after stripStart so 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: if GET /thing carries the tag and POST /thing does not, the merged document contains /thing with only its GET. 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 level tags element for this file before being merged. If a single REST API operation has an includeTags reference and an excludeTags reference 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 with append: true will have their info.descriptions merged together, in order, and placed in the output OpenAPI file in the info.description section.
  • 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's info.description section
  • description.title.headingLevel: The integer heading level for the title, 1 to 6. The default is 1.

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. includeTags is 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 use excludeTags to 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 tags array is only pruned by excludeTags. includeTags deliberately 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"
}
  • path supports a * wildcard, matched the same way includeTags/excludeTags are: * matches any run of characters (including none), nothing else is special, and the match is anchored at both ends -- /admin/* matches /admin/users but not /other/admin/users. A path containing a literal . or other regex-looking character (/v1.2/status) is matched literally, not interpreted.
  • method is 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.2 additionalOperations custom verb like "PURGE", matched case-sensitively. Standard methods are lowercase in a parsed OpenAPI document (get, not GET) -- a selector must match that spelling.
  • Selectors are matched against this input's own original path, before pathModification runs. 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 includePaths and an excludePaths selector, exclusion wins -- the same precedence includeTags/excludeTags already 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.
  • includePaths on a document with 3.1 webhooks will drop every webhook operation, unless one of your selectors happens to match the webhook's event name -- the same allow-list behaviour includeTags already has for untagged webhooks. If you need to keep webhooks while filtering paths, tag the webhook operations and use includeTags instead (or omit includePaths and use excludePaths, 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/error work the same way at every kind, not just scalar: they operate on a value of any shape, so error still 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-unique additionally 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 at key, across (and within) inputs, are the same logical entry and are combined using item. Elements whose key value appears only once pass through unchanged. Output order is first-seen order across all inputs. item is 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 in fields defaults to first, 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's kind says array but 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 a union-by-key array whose elements do not all carry the configured key.
  • error always fails the whole merge on disagreement, at whatever depth it is configured -- there is no partial-failure mode where a nested error merely drops that one field.

Getting started: init

To write that configuration file for you, run:

npx openapi-merge-cli init

It 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, .yaml and .yml file is opened and kept only if it has a top-level openapi: 3.x. That is what keeps package.json and 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.yaml or openapi-merge.json -- unless you pass --force. --force only ever writes openapi-merge.yaml; a pre-existing openapi-merge.json is left in place (and the command tells you it is no longer used, since .yaml is 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 swagger2openapi first.
  • 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-cli

For 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.yaml

And 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 inputs already. The $ref is 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 $ref that 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 $ref is 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": true in your configuration to have the CLI follow it: load that file (or URL), pull in just the component the $ref asked for, and rewrite the $ref to 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 your openapi-merge.json, or
  • Pass --restrict-output-to /path/to/safe/dir on 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 your openapi-merge.json, or
  • Pass --restrict-input-to /path/to/safe/dir on 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.
  • 8 is anything else outside the 2xx range. Ordinary redirects are followed automatically and never surface; in practice this is a 304 Not Modified from 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 ;;
esac

If you experience any issues then please raise them in the bug tracker.