How-to › Ship a CLI or REPL

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.

Audience
DevRel
Level
beginner
Topic
Explore an API interactively
Verified

A developer who has never called your API asks for the Postman collection. The one in the shared workspace was exported eight months ago, and two endpoints have changed shape since. The OpenAPI document in the repository is current, but the collection people click through lags it by two releases, so the first request a newcomer sends answers 404.

What you get

You will end up with Bruno and Postman collections generated from one OpenAPI document by one script, committed beside it, and a check that fails the build when either drifts. This is for you if newcomers click through your API before installing anything.

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 is the command-line half of the Bruno desktop client, and openapi-to-postmanv2 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Bruno bru importThe collection lives in git beside the document and is reviewed in pull requestsA desktop client for the team to adopt, and a .bru layout its own CLI no longer writes by defaultEveryone already has Postman open and will not switch
Hoppscotch importerA browser client with nothing to install, for a team that imports once and exploresAn import that runs only in the app, so no script can refresh it, and request bodies are dropped on the way inThe collection has to be rebuilt from the document by a job
Insomnia importThe team designs in Insomnia and wants the document and its requests in one API CollectionA 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 ownA script has to rebuild the collection from the document
openapi-to-postmanv2Postman is where the team lives and the collection must come from a scriptOne 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 examplesReviewers 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 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.

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 generate.sh
./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.

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.

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; 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 makes every one of them optional, so strip-ids.mjs removes them before the file is committed.4

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.

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 })
  }
}
node check-drift.mjs
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 shows the same regenerate step inside a GitHub Actions job.

Check it worked

Five tests with the Node test runner 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.

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())
})
node --test collections.test.mjs
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:

node pitfall.mjs
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.

node sync.mjs
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 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 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.

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.

Footnotes

  1. An Insomnia v5 file carries two version numbers, which Kong’s import and export reference 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. ↩︎ Back to text

  2. Bruno’s import options file the .bru layout under the word classic and default to OpenCollection YAML, 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. ↩︎ Back to text

  3. Postman’s collection generator 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. ↩︎ Back to text

  4. The collection schema 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.