# How to generate an API client collection from an OpenAPI document

> Turn an OpenAPI document into a Bruno or Postman collection with one command, commit the output beside the document, and fail the build when the two stop agreeing.

Source: https://voxgig.com/howto/generate-api-collections-from-openapi

- Audience: devrel
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Run `bru import openapi` with `--collection-format=bru`, or `openapi2postmanv2` with `-O folderStrategy=Tags,parametersResolution=Example`, from a script committed beside the OpenAPI document, and commit what they write. The Postman converter writes a fresh `id` into every request on every run, so strip them before committing. Then have CI run the same script into a scratch directory and fail the build when any committed file differs, which a GUI importer cannot do.

---
## You will need

Node 22 or later, an OpenAPI 3 document, and a repository to commit the collection to. Verified
2026-10-21 against Node 22.22.0, `@usebruno/cli` 4.2.1, and `openapi-to-postmanv2` 6.4.0. The
[Bruno CLI](https://docs.usebruno.com/bru-cli/overview) is the command-line half of the Bruno
desktop client, and [`openapi-to-postmanv2`](https://github.com/postmanlabs/openapi-to-postman) is
the library behind Postman's own import, published with a command of its own. Neither needs the GUI
installed. The document on this page, `openapi.json`, describes a smart-meter API: seven operations
under two tags, a bearer token, and an `example` on every parameter. Both importers read those
examples instead of inventing values.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Bruno `bru import`](https://docs.usebruno.com/bru-cli/import/overview) | The collection lives in git beside the document and is reviewed in pull requests | A desktop client for the team to adopt, and a `.bru` layout its own CLI no longer writes by default | Everyone already has Postman open and will not switch |
| [Hoppscotch importer](https://docs.hoppscotch.io/documentation/features/importer) | A browser client with nothing to install, for a team that imports once and explores | An import that runs only in the app, so no script can refresh it, and request bodies are dropped on the way in | The collection has to be rebuilt from the document by a job |
| [Insomnia import](https://developer.konghq.com/how-to/import-an-api-spec/) | The team designs in Insomnia and wants the document and its requests in one API Collection | A GUI-only import, since Insomnia's `inso` command runs collections and exports specifications but never imports either, plus a YAML export format of Kong's own | A script has to rebuild the collection from the document |
| [`openapi-to-postmanv2`](https://github.com/postmanlabs/openapi-to-postman) | Postman is where the team lives and the collection must come from a script | One JSON file too long to review line by line, a fresh `id` on every run, and a default that resolves parameters from the schema instead of the examples | Reviewers need to read the diff, or Postman is not in use |

The split is between importers you can script and importers you click. Bruno's command and the
Postman converter both run in a CI job, which makes the drift check below possible. Each costs
something. Bruno asks the team to adopt a client, and the converter writes a thousand-line file that
is too long to review line by line. Hoppscotch and Insomnia import from the app only, so
their collections are a snapshot somebody refreshes by hand, and Insomnia's export format carries
two version numbers of its own.[^1]

## Import the document into a folder of `.bru` files

Bruno's importer writes one request file per operation, in a folder per tag, with the environment
and the collection-level auth beside them. Ask for the `.bru` layout explicitly: the
[CLI's default](https://docs.usebruno.com/bru-cli/import/options) is OpenCollection YAML, and a team
that reviews `.bru` files does not want the layout to change under it on the next machine.[^2] This
is the Bruno half of `generate.sh`.

```bash title="generate.sh"
set -euo pipefail
out="${1:-collections}"
export PATH="$PWD/node_modules/.bin:$PATH"

rm -rf "$out/bruno" "$out/meterco.postman_collection.json"
mkdir -p "$out"

bru import openapi --source openapi.json --output "$out/bruno" \
  --collection-name Meterco --collection-format=bru > /dev/null
```

With no argument it writes into `collections/` and lists what it wrote.

```bash
bash generate.sh
```

```text output
./bruno/bruno.json
./bruno/collection.bru
./bruno/environments/Environment 1.bru
./bruno/invoices/Get an invoice.bru
./bruno/invoices/List invoices.bru
./bruno/invoices/folder.bru
./bruno/meters/Get a meter.bru
./bruno/meters/List meters.bru
./bruno/meters/Record a reading.bru
./bruno/meters/Register a meter.bru
./bruno/meters/Update a meter.bru
./bruno/meters/folder.bru
./meterco.postman_collection.json
```

Seven request files, one per operation, each named from its `summary` and filed under its first
tag. `collection.bru` carries the bearer auth as `{{token}}`, and `environments/Environment 1.bru`
carries `baseUrl` from the document's `servers` entry. The last line is the Postman collection, which
the next section covers.

A request file is plain text a reviewer can read.

```text title="collections/bruno/meters/Get a meter.bru"
meta {
  name: Get a meter
  type: http
  seq: 3
  tags: [
    meters
  ]
}

get {
  url: {{baseUrl}}/meters/:meterId
  body: none
  auth: inherit
}

params:path {
  meterId: mtr_8f2
}
```

The path parameter is written as `:meterId` and given the document's example, `mtr_8f2`, so the
request is ready to send. Below this block the file holds the two example responses with their
bodies. Running the import twice gives byte-identical files, which the drift check depends on.

## Convert to a Postman collection and strip what changes per run

The Postman half runs the converter with two options, then a small script of this page's own.

```bash title="generate.sh"
openapi2postmanv2 -s openapi.json -o "$out/meterco.postman_collection.json" -p \
  -O folderStrategy=Tags,parametersResolution=Example > /dev/null
node strip-ids.mjs "$out/meterco.postman_collection.json"
```

`folderStrategy=Tags` mirrors Bruno's layout; the default builds folders from path segments.
`parametersResolution=Example` takes request values from the document's `example` fields. The
default, `Schema`, invents them, and for an enum it picks a member at random: two runs for this
page disagreed on whether `state` was `installed` or `retired`. Both are in the
[options reference](https://github.com/postmanlabs/openapi-to-postman/blob/develop/OPTIONS.md);
Postman's own app made the opposite choice and defaults to examples.[^3]

The converter also writes an identifier into the collection, every request, and every saved
response, minted afresh on each run. The
[collection schema](https://schema.postman.com/json/collection/v2.1.0/collection.json) makes every
one of them optional, so `strip-ids.mjs` removes them before the file is committed.[^4]

```js title="strip-ids.mjs"
export function stripIds(node) {
  if (Array.isArray(node)) return node.map(stripIds)
  if (node && typeof node === 'object') {
    const out = {}
    for (const [key, value] of Object.entries(node)) {
      if (key === 'id' || key === '_postman_id') continue
      out[key] = stripIds(value)
    }
    return out
  }
  return node
}
```

Without this step the file differs in eighteen places on every run and the drift check below can
never pass. The schema requires only `name` and `schema` in `info` and only `request` on an item,
so the stripped file is still a valid collection.

## Fail the build when the committed collection drifts

A committed generated file gets edited, because editing it is faster than finding the document, and
the edit is invisible until the next regeneration overwrites it. The check that stops this
regenerates into a scratch directory and compares, file by file.

```js title="check-drift.mjs"
export function drift(committedDir) {
  const fresh = mkdtempSync(join(tmpdir(), 'collections-'))
  try {
    execFileSync('bash', ['generate.sh', fresh], { stdio: ['ignore', 'ignore', 'inherit'] })
    const was = new Set(files(committedDir))
    const now = new Set(files(fresh))
    return [...new Set([...was, ...now])].sort().map((f) => {
      if (!now.has(f)) return ['removed', f]
      if (!was.has(f)) return ['added', f]
      const same = readFileSync(join(committedDir, f), 'utf8') === readFileSync(join(fresh, f), 'utf8')
      return [same ? 'same' : 'changed', f]
    })
  } finally {
    rmSync(fresh, { recursive: true, force: true })
  }
}
```

```bash
node check-drift.mjs
```

```text output
same     bruno/bruno.json
same     bruno/collection.bru
same     bruno/environments/Environment 1.bru
same     bruno/invoices/Get an invoice.bru
same     bruno/invoices/List invoices.bru
same     bruno/invoices/folder.bru
same     bruno/meters/Get a meter.bru
same     bruno/meters/List meters.bru
same     bruno/meters/Record a reading.bru
same     bruno/meters/Register a meter.bru
same     bruno/meters/Update a meter.bru
same     bruno/meters/folder.bru
same     meterco.postman_collection.json

all 13 files match a fresh generation
```

Four states cover it: `same`, `changed`, `added` for a file only the fresh output has, and `removed`
for one only the committed copy has. The exit status is 1 whenever a file is not `same`, so run it
after `npm ci` in the job that lints the document. A pull request that edits a collection by
hand then goes red, while one that edits the document and regenerates goes green. Bruno's
[import documentation](https://docs.usebruno.com/bru-cli/import/overview) shows the same regenerate
step inside a GitHub Actions job.

## Check it worked

Five tests with the [Node test runner](https://nodejs.org/api/test.html) pin the result. Three check
coverage: seven operations in the document, a `.bru` file for each in its tag's folder with the
right method and URL, and the same seven requests in the Postman folders. Two check
reproducibility: a Postman file with no `id` that names the v2.1.0 schema, and a fresh generation
matching all thirteen committed files byte for byte.

```js title="collections.test.mjs"
test('the Postman folders hold exactly the operations of the document', () => {
  const collection = JSON.parse(readFileSync('collections/meterco.postman_collection.json', 'utf8'))
  const found = collection.item.flatMap((folder) =>
    folder.item.map((r) => `${folder.name} ${r.request.method} ${r.name}`)).sort()
  assert.deepEqual(found, operations.map((o) => `${o.tag} ${o.method} ${o.name}`).sort())
})
```

```bash
node --test collections.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2747.751261
```

`# pass 5` is the line to look for; the last test runs both importers again.

## When it goes wrong

The check fails on a pull request that never touched the document. Somebody edited a collection
file by hand, a header added while debugging or a request renamed, and the edit is now a difference
from the document. Put the change in the document and regenerate, or keep hand-written requests in
a collection the script does not own. Two such edits, applied to a copy of the committed collection:

```bash
node pitfall.mjs
```

```text output
drift check against the hand-edited copy:
  changed  bruno/meters/Get a meter.bru
  changed  meterco.postman_collection.json
  check-drift.mjs exits 1 here: 2 of 13 files differ
```

You ran `openapi2postmanv2 --sync` to carry hand edits through a document change, but the edits are
gone. Sync matches each request to an operation and rewrites what it matched from the document,
keeping only the requests it could not match. This run starts from the committed
collection with three hand edits: a renamed request, a header on another, and a request the
document never had. It syncs them against a document with one added operation.

```bash
node sync.mjs
```

```text output
after --sync, folder "meters" holds:
  GET    List meters                    headers: Accept
  POST   Register a meter               headers: Content-Type,Accept
  GET    Get a meter                    headers: Accept
  PATCH  Update a meter                 headers: Content-Type,Accept
  POST   Record a reading               headers: Content-Type,Accept
  GET    Ping (hand-added)              headers: none
  DELETE Retire a meter                 headers: none
```

The added `DELETE` arrived, and the hand-added request survived because
[orphans are kept](https://github.com/postmanlabs/openapi-to-postman/blob/develop/SYNC_OPTIONS.md)
unless `deleteOrphanedRequests` says otherwise. The rename and the header were overwritten, because
both sat on requests the document describes. Sync keeps requests the document does not know about,
not edits to the ones it does.

Pass the same `-O` options as the first conversion. With the default folder strategy, sync merged a
second, path-shaped tree into the tag-shaped one, and the four requests whose paths carry a
parameter appeared twice.

The committed `.bru` files are all reported `removed` and a set of `.yml` files `added`. The
`--collection-format=bru` flag was dropped, so the importer wrote its default layout,
`opencollection.yml` with one `.yml` file per request. Keep the flag in the script, the one place
the command should live; a command retyped from memory is how a flag goes missing.

## When not to do this

Do not fix the API's description inside the generated collection. A wrong example, a missing
parameter, or a renamed field belongs in `openapi.json`; fix it there, regenerate, and commit both.

Do not commit a token. The import writes `{{token}}` into `collection.bru` and leaves the variable
undefined, and the environment file holds only `baseUrl`. Keep the value outside `collections/`,
because `generate.sh` deletes and rewrites `collections/bruno` on every run, along with any
environment file kept inside it. A Bruno
[global environment](https://docs.usebruno.com/variables/global-environment-variables) lives outside
the collection, as does whatever secret store the client offers.

Do not pick a GUI importer when the collection has to track the document. Hoppscotch and Insomnia
import from the app, and Insomnia's `inso` command exports a specification and runs a collection
but does not import one. A collection made that way is current on the day it was made.

Do not do this for an API whose only consumers are a generated SDK and its tests. A collection is
for people who click before they install; where everyone installs, it is one more artifact to keep
current, with no reader for the drift check to protect.

## Related how-tos

- [Turn an exploratory REPL session into a script](/howto/save-a-repl-session-as-a-script)

- [Detect a reference site drifting from its OpenAPI spec](/howto/check-a-reference-site-in-ci)

- [Publish an OpenAPI document at a stable URL for agents](/howto/publish-openapi-at-a-stable-url)

- [Preload a generated client into a Node REPL](/howto/preload-a-generated-client-into-a-node-repl)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@usebruno/cli` 4.2.1, and `openapi-to-postmanv2` 6.4.0.
Every output block is what the command preceding it printed. The Hoppscotch and Insomnia importers
were read from their documentation and not run, because both import from the app only. No
collection on this page was opened in a desktop or browser client.

[^1]: An Insomnia v5 file carries two version numbers, which
[Kong's import and export reference](https://developer.konghq.com/insomnia/import-export/) says are
independent. The `type` field ends in `5.0` and names the file format; `schema_version` and the
JSON Schema filename, `5.1` at the time of reading, track revisions to the schema that describes it.
A file can stay at one number while the document explaining it moves to another.

[^2]: Bruno's [import options](https://docs.usebruno.com/bru-cli/import/options) file the `.bru`
layout under the word classic and default to
[OpenCollection YAML](https://docs.usebruno.com/opencollection-yaml/overview), an open specification
Bruno wrote and published at a domain of its own. A format has reached a certain age when its own
vendor starts calling it classic.

[^3]: Postman's
[collection generator](https://learning.postman.com/docs/design-apis/specifications/generate-collections/)
in the app calls its parameter generation setting deprecated and says generated collections use
examples by default. The library underneath, published as `openapi-to-postmanv2`, still ships
`parametersResolution` defaulting to `Schema`, so one document yields different requests depending
on whether the app or the command ran the conversion.

[^4]: The [collection schema](https://schema.postman.com/json/collection/v2.1.0/collection.json)
describes `_postman_id` as the value every collection is identified by, and recommends keeping it
because changing it usually implies a different collection. It adds that the field exists for
compatibility with Collection Format V1. The converter mints a new one on every run, so by the
schema's own definition each regeneration is a different collection holding the same requests.