@guebbit/openapi-runnable-collections
v0.1.0
Published
Generate API client collections (Bruno, Insomnia, Mockoon, Postman) from an OpenAPI document, filled with values from your seed dataset so every request actually runs.
Maintainers
Readme
@guebbit/openapi-runnable-collections
Generate API client collections — Bruno, Insomnia, Mockoon and Postman — from one OpenAPI document, filled with values from your seed dataset so every request actually runs.
openapi.yaml + your seed data + your probes → contract.bruno.yml
contract.insomnia.yaml
contract.mockoon.json
contract.postman.jsonWhy this exists
There are good OpenAPI-to-collection converters already. Every one of them turns a schema into a request you cannot send:
{ "productId": "", "quantity": 0 }So the collection gets imported once, found useless, and abandoned — and the next person hand-edits their own copy, which then rots against the contract it was copied from.
This package fills the same requests from the dataset your API is actually seeded with:
{ "productId": "65dc8a99604c307b702b5ccc", "quantity": 2 }GET /products/{id} asks for a product that exists. POST /account/login sends credentials that
work — and returns an admin token, so the admin-only requests further down the collection do not
all 403. That is the difference between a collection you click and one you fix first.
It also carries the half of a collection a contract cannot describe. A spec declares valid calls and their declared answers; the requests that prove your API rejects things are declared as probes and emitted alongside the generated ones.
Three properties worth knowing
Deterministic. Ids are hashed from method and path; timestamps are a fixed instant. Regenerate and you get a byte-identical document unless the contract moved — so you can commit your collections and assert in CI that they match a fresh run. Every other converter mints random UUIDs, which means a diff on every run and a merge conflict on every branch.
Grouping is explicit. Sections are an input, not an inference. Tags look like the right answer
until you meet a path tagged with a group that does not own it, or a group spanning two tags —
sectionsFromTags is there for the cases where they do line up, and ignored otherwise.
Two output shapes, one content. bundles gives you the whole importable document. sections
and scaffolding give you the same content sliced per section, for repos that commit one fragment
per module. A bundle is exactly the concatenation of its slices — asserted in the test suite — so
which layout you adopt does not change a byte of output.
Install
npm install --save-dev @guebbit/openapi-runnable-collectionsUse
import { generateCollections, loadSpec } from '@guebbit/openapi-runnable-collections';
import { writeFileSync } from 'node:fs';
// Records your API is actually seeded with — imported from your seeder, not retyped here.
const seedProduct = { id: '65dc8a99604c307b702b5ccc', title: 'Espresso Machine', price: 249.99 };
const seedUser = { id: '65dc8a99604c307b702b5aaa', email: '[email protected]' };
const seedOrder = { id: '65dc8a99604c307b702b5bbb' };
const deletedOrder = { id: '65dc8a99604c307b702b5ddd' };
const [ADMIN_EMAIL, ADMIN_PASSWORD] = ['[email protected]', 'correct-horse-battery'];
const [USER_EMAIL, USER_PASSWORD] = [seedUser.email, 'correct-horse-battery'];
const { bundles } = generateCollections({
spec: loadSpec('openapi.yaml'),
// Which paths belong to which folder, in the order you want them listed.
sections: [
{ name: 'products', paths: ['/products', '/products/{id}'] },
{ name: 'cart', paths: ['/cart', '/cart/checkout'] },
{ name: 'account', label: 'Account & Auth', paths: ['/account/login'] }
],
// Where a runnable value comes from. Every field is optional.
values: {
byProperty: { productId: seedProduct.id, quantity: 2 },
byEntity: { Product: seedProduct, User: seedUser },
byOperation: {
'POST /account/login': { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }
},
byFormat: { email: USER_EMAIL, password: USER_PASSWORD },
pathParam: (name, template) =>
name === 'id' && template.startsWith('/orders') ? seedOrder.id : undefined,
tokens: { seedProductId: seedProduct.id, seedDeletedOrderId: deletedOrder.id }
},
// Requests the contract cannot describe, per section.
probes: {
cart: [
{
name: 'Reject a cart quantity of zero',
why: 'The contract cannot express a body it forbids.',
method: 'POST',
path: '/cart',
auth: 'bearer',
body: { productId: '{{seedProductId}}', quantity: 0 }
}
]
},
collection: { name: 'Ecommerce API', mockoon: { port: 3001 } }
});
writeFileSync('contract.bruno.yml', bundles.bruno!);
writeFileSync('contract.insomnia.yaml', bundles.insomnia!);
writeFileSync('contract.mockoon.json', bundles.mockoon!);
writeFileSync('contract.postman.json', bundles.postman!);The one line worth checking in the output is POST /cart's body: it carries a product id that
EXISTS, not {"productId": "", "quantity": 0}. Import any of the four files and run the login
request first to fill in the token — everything below it stops returning 401.
Committing per-section fragments instead
const { sections, trees, scaffolding } = generateCollections({ ...options });
writeFileSync('modules/cart/bruno.yml', sections.bruno!.cart!);
writeFileSync('modules/cart/mockoon.routes.json', sections.mockoon!.cart!);
writeFileSync('modules/cart/mockoon.tree.json', trees.cart!);Reassemble them yourself with scaffolding.<tool>.header and .footer — YAML slices concatenate
verbatim; JSON slices join with ,\n between and none after. Mockoon also gives you
scaffolding.mockoon.treeHeader, which sits between the routes and the footer. Or just use
bundles, which is that concatenation already — the test suite asserts the two are identical.
How values resolve
Most specific first. The first rule that matches wins:
| # | Source | Matches on |
| --- | ---------------------- | ----------------------------------- |
| 1 | the schema's example | the contract stated it |
| 2 | the schema's default | the contract stated it |
| 3 | values.byEntity | a $ref naming a component schema |
| 4 | values.byProperty | a leaf property's name |
| 5 | values.byFormat | the schema's format |
| 6 | the type itself | last resort — a visible placeholder |
values.byOperation sits outside the ladder: it replaces a whole request body for one operation,
for the cases a schema cannot get right at all (the login that has to return an admin token).
values.pathParam returning undefined falls through to byProperty, then to the literal
example, so you only write the parameters that need deciding.
Probes
# cart/probes.yml
- name: 'Probe: a quantity the schema forbids'
why: >-
Zero, where the contract requires at least one. Removing a line has its own endpoint, so a
zero quantity is a validation failure rather than a shortcut.
method: POST
path: /cart
auth: bearer
body: { productId: '{{seedProductId}}', quantity: 0 }Load them with loadProbes('cart/probes.yml').
One file for every section
probes is a plain object, so a repo that files probes per module builds it from one loadProbes
call each. For repos that keep them all in one place, loadProbeSections reads that file into the
same object:
# probes.yml
cart:
- name: 'Probe: a quantity the schema forbids'
why: 'Zero, where the contract requires at least one.'
method: POST
path: /cart
auth: bearer
body: { productId: '{{seedProductId}}', quantity: 0 }
account:
- name: 'Probe: login with a password that is not the password'
why: 'The 401 the contract declares but no valid call can reach.'
method: POST
path: /account/login
body: { email: '{{seedUserEmail}}', password: 'not-it' }generateCollections({
...options,
probes: loadProbeSections('probes.yml')
});Both loaders check what they read: a probe missing path, or carrying an auth that is not
bearer, throws naming the file and the position rather than surfacing later as a broken request.
Neither layout is preferred, and the probes are identical either way.
Three things are deliberate:
{{tokens}}, never pasted ids. A probe that hard-codes a seeded id is a copy of the dataset, and copies drift. An unknown token throws — a probe silently sending the literal{{typo}}would pass every check and test nothing.- No declared responses. A probe exists to show what the API does with an input nobody described. Inventing the answer here would defeat sending it.
- Not emitted to Mockoon. A mock server answers requests; it does not send them.
Response envelopes
If your API wraps every response — { success, status, message, data } — a schema-shaped example
puts the type's placeholder in status and message, giving you a 404 body claiming
"status": 1. The default envelope restates the real HTTP code and the response's own description.
A body that declares neither field is passed through untouched, so an API without an envelope pays
nothing. Override with envelope: (body, status, label) => ….
API
| Export | What it does |
| --------------------- | ----------------------------------------------------------------- |
| generateCollections | the entry point; returns bundles, sections, trees and scaffolding |
| loadSpec | read and parse an OpenAPI document (YAML or JSON) |
| loadProbes | read one section's probe file |
| loadProbeSections | read every section's probes out of one file |
| sectionsFromTags | build sections from tags, when tags are your grouping |
| dereference | follow a local #/components/… pointer |
| exampleFor | the value resolver, if you want it directly |
| defaultEnvelope | the default envelope fixer, to wrap rather than replace |
| stableId | the derived-id helper the emitters use |
| stableUuid | the same, in the v4-shaped form Mockoon and Postman validate |
Full types ship with the package — GenerateOptions, Section, ValueSources, Probe,
CollectionRequest and the rest are all exported.
Notes and limits
- Bundle your spec first.
$refresolution is local-only: this reads a single document, where a remote pointer cannot occur. Use@redocly/cli bundleor your own concatenation upstream. A pointer that resolves to nothing, or to itself, throws with the pointer named. - Bruno's single-document
opencollectionformat, not a folder of.brufiles. One file to commit, diff and assert. - Insomnia v5 YAML, which is a different shape from Postman v2.1 — hence four emitters rather than three plus a rename.
application/jsonrequest and response bodies. Other media types are not synthesised.
Requirements
Node 20 or newer. Ships as both ESM and CommonJS, with declarations for each, so import and
require both work.
Development
npm run complete:check runs the whole gate the way CI does, and is the pre-commit hook. The
layers it is made of, each catching something the others cannot:
| Command | What it catches |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| npm test | Behaviour, byte-identity against the golden fixtures, and invariants over generated contracts |
| npm run test:coverage | A branch no case reaches |
| npm run typecheck | Signatures widening to any — this is what enforces tests/types.test.ts, since ts-jest runs transpile-only |
| npm run test:pack | A broken exports map or a missing files entry, by installing a real tarball and importing it both ways |
| npm run test:mutation | Tests that run code without asserting on it. Gated per file by mutation-baseline.json; concurrency comes from .env, see .env-example |
Two things about the harness are worth knowing before trusting a green run, because both once
produced a false all-clear. Work done while a test file is being collected belongs to no test
under Stryker's per-test coverage, so anything generated at module or describe scope goes silent
— see the note in tests/integration/generate.test.ts. And enableFindRelatedTests matches
nothing in this repo, which hid the golden suite from every mutant — see the note in
stryker.config.json. A test nobody has watched fail is not evidence: break the source on purpose
and confirm it goes red.
License
MIT
