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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
Bruno bru import | 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 | 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 | 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 | 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 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.
Related how-tos
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
-
An Insomnia v5 file carries two version numbers, which Kong’s import and export reference says are independent. The
typefield ends in5.0and names the file format;schema_versionand the JSON Schema filename,5.1at 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 -
Bruno’s import options file the
.brulayout 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 -
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 shipsparametersResolutiondefaulting toSchema, so one document yields different requests depending on whether the app or the command ran the conversion. ↩︎ Back to text -
The collection schema describes
_postman_idas 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