assert-json-body

Framework-agnostic toolkit to:
- Extract OpenAPI response schemas into a compact
responses.json artifact
- Validate real JSON response bodies against the extracted required/optional field model
- Assert inside any test runner (Vitest, Jest, Playwright, etc.)
Installation
npm install assert-json-body
Quick Start
-
(Optional) Initialize a config file:
npx assert-json-body config:init
Produces assert-json-body.config.json (edit repo, spec path, output dir, etc.).
-
Extract responses from your OpenAPI spec:
npx assert-json-body extract
This writes (by default):
./json-body-assertions/responses.json (schema bundle)
./json-body-assertions/index.ts (auto-generated typed wrapper)
or the configured responsesFile for the JSON schema artifact.
The init step also adds an npm script for convenience:
// package.json
{
"scripts": {
"responses:regenerate": "assert-json-body extract"
}
}
So you can run:
npm run responses:regenerate
-
Validate in a test (untyped import):
import { validateResponseShape, validateResponse } from 'assert-json-body';
validateResponseShape({ path: '/process-instance/create', method: 'POST', status: '200' }, jsonBody);
await validateResponse({ path: '/process-instance/create', method: 'POST', status: '200' }, playwrightResponse);
-
Prefer typed validation (after extract):
import { validateResponseShape, validateResponse } from './json-body-assertions/index';
validateResponseShape({ path: '/process-instance/create', method: 'POST', status: '200' }, jsonBody);
await validateResponse({ path: '/process-instance/create', method: 'POST', status: '200' }, playwrightResponse);
Regenerate typed file whenever the spec changes by re-running extract (commit both responses.json and index.ts if you track API contract changes in version control).
You can control default throw/record behavior globally via config or env (see below) and override per call.
CI Integration
Keep the generated artifacts (responses.json, index.ts) in sync with the upstream spec during continuous integration:
Example GitHub Actions step (add after install):
- name: Regenerate response schemas
run: npm run responses:regenerate
If you commit the generated files:
- Run the regenerate step early (before tests).
- Add a check that the working tree is clean to ensure developers didn’t forget to re-run extraction locally:
- name: Verify no uncommitted changes
run: |
git diff --exit-code || (echo 'Generated response artifacts out of date. Run: npm run responses:regenerate' && exit 1)
If you prefer not to commit generated artifacts:
- Add the output directory (default
json-body-assertions/) to .gitignore.
- Always run
npm run responses:regenerate before building / testing.
Caching tip: if your spec repo is large, you can cache the sparse checkout directory by keying on the spec ref (commit SHA) to speed up subsequent runs.
Configuration
Config file: assert-json-body.config.json (created with npx assert-json-body config:init).
The configuration is now split into two blocks:
extract: Controls OpenAPI repo checkout and artifact generation.
validate: Controls global validation behaviour (recording & throw semantics).
repo | string | https://github.com/camunda/camunda-orchestration-cluster-api | Git repository containing the OpenAPI spec | AJB_REPO, REPO |
specPath | string | specification/rest-api.yaml | Path to OpenAPI spec inside the repo | AJB_SPEC_PATH, SPEC_PATH |
ref | string | main | Git ref (branch/tag/sha) to checkout | AJB_REF, SPEC_REF, REF |
outputDir | string | json-body-assertions | Directory to write responses.json + generated index.ts | AJB_OUTPUT_DIR, OUTPUT_DIR |
preserveCheckout | boolean | false | Keep sparse checkout working copy (debug) | AJB_PRESERVE_CHECKOUT, PRESERVE_SPEC_CHECKOUT |
dryRun | boolean | false | Parse spec but do not write files | AJB_DRY_RUN |
logLevel | enum | info | silent error warn info debug | AJB_LOG_LEVEL |
failIfExists | boolean | false | Abort if target responses file already exists | AJB_FAIL_IF_EXISTS |
responsesFile | string | — | Optional explicit path for responses JSON (advanced) | AJB_RESPONSES_FILE, ROUTE_TEST_RESPONSES_FILE |
validate block
recordResponses | boolean | false | Globally enable body recording | AJB_RECORD, TEST_RESPONSE_BODY_RECORD |
throwOnValidationFail | boolean | true | Throw vs structured { ok:false } result | AJB_THROW_ON_FAIL |
Additional env variables:
TEST_RESPONSE_BODY_RECORD_DIR | Override directory for JSONL body recordings (default <outputDir>/recording) |
Example full config:
{
"extract": {
"repo": "https://github.com/camunda/camunda-orchestration-cluster-api",
"specPath": "specification/rest-api.yaml",
"ref": "main",
"outputDir": "json-body-assertions",
"preserveCheckout": false,
"dryRun": false,
"logLevel": "info",
"failIfExists": false
},
"validate": {
"recordResponses": false,
"throwOnValidationFail": true
}
}
Notes:
- The responses schema file defaults to
<outputDir>/responses.json unless overridden.
- Boolean env overrides accept
1|true|yes (case-insensitive).
- Precedence per value: CLI flag > environment variable > config file > built-in default.
Schema Resolution Precedence
When validateResponseShape looks for the schema artifact, precedence is:
- Explicit option:
responsesFilePath passed to the function
- Environment variable:
ROUTE_TEST_RESPONSES_FILE or AJB_RESPONSES_FILE
- Config file:
extract.responsesFile or <outputDir>/responses.json
- Default fallback:
./json-body-assertions/responses.json
All file issues (missing, unreadable, parse errors, malformed structure) throw clear errors.
API Reference
validateResponseShape(spec, body, options?)
Single unified API: validates body against the schema entry. Supports optional structured result mode and recording.
validateResponseShape(
{ path: '/foo', method: 'GET', status: '200' },
jsonBody,
{ responsesFilePath: './custom/responses.json', configPath: './custom-config.json' }
);
Default behavior: throws on mismatch (configurable). If you pass throw:false (or set global flag) it returns a structured object:
interface ValidateResultBase {
ok: boolean;
errors?: string[];
response: unknown;
routeContext: RouteContext;
}
Examples:
const r1 = validateResponseShape({ path: '/foo', method: 'GET', status: '200' }, body, { throw: false });
if (!r1.ok) throw new Error('unexpected');
console.log(r1.routeContext.requiredFields.map(f => f.name));
const r2 = validateResponseShape({ path: '/foo', method: 'GET', status: '200' }, otherBody, { throw: false });
if (!r2.ok) {
console.warn(r2.errors);
console.log(r2.routeContext.status);
}
Options
responsesFilePath / configPath – override resolution
throw?: boolean – override global throw setting
record?: boolean | { label?: string } – enable recording for this call
validateResponse(spec, playwrightResponse, options?)
Playwright-friendly wrapper: reads await response.json(), optionally enforces the expected status, then routes through validateResponseShape.
const apiResponse = await request.post('/process-instance/create', { data: payload });
await validateResponse({ path: '/process-instance/create', method: 'POST', status: '200' }, apiResponse);
spec.status is required when you need HTTP status enforcement; the helper throws if it does not match response.status().
options shares the same shape as validateResponseShape (file resolution, throw, record, config overrides).
- Returns the same
ValidateResultBase promise (use { throw:false } for structured result mode).
Types
FieldSpec, RouteContext, PlaywrightAPIResponse, and other structural types are exported from @/types.
After running the extractor you can import strongly-typed versions of validateResponseShape and validateResponse that constrain path, method and status to only the extracted endpoints:
import { validateResponseShape, validateResponse } from './json-body-assertions/index';
validateResponseShape({ path: '/process-instance/create', method: 'POST', status: '200' }, body);
await validateResponse({ path: '/process-instance/create', method: 'POST', status: '200' }, playwrightResponse);
You can also use the exported helper types:
import type { RoutePath, MethodFor, StatusFor } from './json-body-assertions/index';
type AnyRoute = RoutePath;
type GetStatus<P extends RoutePath> = StatusFor<P, MethodFor<P>>;
FAQ
Why do I get two files (responses.json and index.ts)?
responses.json is the runtime artifact used for validation. index.ts adds compile-time safety and autocomplete for route specifications.
Do I need to commit index.ts?
Recommended: yes. Changes in that file make API evolution explicit in diffs. If you prefer to ignore it, ensure your build pipeline runs assert-json-body extract first.
Can I disable type emission?
Not yet—emission is always on. Open an issue if you need a toggle.
Large spec performance concerns?
The generated index.ts only stores a nested map of route/method/status flags, not full schema trees, keeping file size modest. Extremely large specs are still typically < a few hundred KB.
How do I update types after the spec changes?
Re-run npx assert-json-body extract. The index.ts file is regenerated deterministically.
Do I still need to import from the package root?
Use the package root for generic validation utilities (validateResponseShape), and the generated ./json-body-assertions/index for strongly typed validation.
Releasing & Versioning
This project uses semantic-release with Conventional Commits to automate:
- Version determination (based on commit messages)
- CHANGELOG generation (
CHANGELOG.md)
- GitHub release notes
- npm publication
Every push to main triggers the release workflow. Ensure your commits follow Conventional Commit prefixes so changes are categorized correctly:
Common types:
feat: – new feature (minor release)
fix: – bug fix (patch release)
docs: – documentation only
refactor: – code change that neither fixes a bug nor adds a feature
perf: – performance improvement
test: – adding or correcting tests
chore: – build / tooling / infra
Breaking changes: add a footer line BREAKING CHANGE: <description> (or use ! after the type, e.g. feat!: drop Node 16).
Example commit message:
feat: add structured validation result for non-throw mode
BREAKING CHANGE: removed deprecated assertResponseShape in favor of unified validateResponseShape
Manual version bumps in package.json are not needed; semantic-release will handle it.
Local commit messages are validated by commitlint + husky (commit-msg hook). If a commit is rejected, adjust the prefix / format to match Conventional Commits.
CLI Commands
assert-json-body extract | Performs sparse checkout + OpenAPI parse + response schema flattening into responses.json and emits typed index.ts. |
assert-json-body config:init | Creates a starter assert-json-body.config.json. |
Environment variables (selected):
ROUTE_TEST_RESPONSES_FILE / AJB_RESPONSES_FILE – override schema file
TEST_RESPONSE_BODY_RECORD_DIR – override recording directory (default <outputDir>/recording)
AJB_RECORD / TEST_RESPONSE_BODY_RECORD – set default recording on (true/1/yes)
AJB_THROW_ON_FAIL – set default throw behavior (true/false)
Recording (Optional)
By default, recordings are written to <outputDir>/recording (e.g. json-body-assertions/recording). Set TEST_RESPONSE_BODY_RECORD_DIR only if you want a custom location. To record responses, either:
validateResponseShape({ path: '/foo', method: 'GET', status: '200' }, body, { record: { label: 'GET /foo success' } });
Produces JSONL rows with required field list, top-level present, deep presence and body snapshot.
Integration Tests
An optional end-to-end integration test suite lives under integration/ and is excluded from the default unit test run.
Run unit tests (fast, pure):
npm test
Run integration tests (performs real OpenAPI extraction and live HTTP calls):
npm run test:integration
Local requirements:
- Start the target service (expected at
http://localhost:8080 by default), or
- Set
TEST_BASE_URL to point to a running instance
CI (Docker) example:
- name: Start API container
run: |
docker run -d --name api -p 8080:8080 your/api:image
for i in {1..30}; do curl -sf http://localhost:8080/license && break; sleep 1; done
- name: Run integration tests
run: npm run test:integration
If the service is unreachable, the integration test logs a warning and exits early (treated as a soft skip).
Error Messages
Errors show a capped (first 15) list of issues (missing, type, enum, extra) with JSON Pointer paths. Additional errors are summarized with a count.
Precedence Test Illustration
See src/tests/precedence.spec.ts for an executable example verifying explicit > env > config > default ordering.
License
ISC (see LICENSE).