A pull request moves your OpenAPI document from one version to the next, and the spec diff lists dozens of lines. You generate an SDK, a CLI, and an MCP server from a model extracted from that document. The question you have is which generated methods, classes, or tool names move, and the spec diff answers a different question. The review covers the wire and misses the surfaces.
What you get
You will end up with a model file per spec version and a diff that names the entities, operations, and fields that moved. A reconciliation sets that diff beside two spec diff tools. This is for you if your team generates SDKs from a model.
Short answer
Run @voxgig/apidef over version N and version N+1 of the document, and write each model as
sorted JSON that carries the extractor version and the spec hash. Diff the two files entity by
entity. The model diff reports what your generated SDK, CLI, and MCP server will gain or lose.
A spec diff from oasdiff or openapi-diff reports what changes on the wire, which is a different
list. Stop at reporting; gating is a separate decision.
You will need
Node 22 or later, two versions of one OpenAPI 3 document, and @voxgig/apidef installed from a
lockfile. Verified 2026-10-21 against Node 22.22.0 and @voxgig/apidef 8.25.3. The two documents
here are specs/orders-v1.json and specs/orders-v2.json, a minor release that adds a field, a
response code, two endpoints, and two description edits. The spec-level reports were recorded
once from oasdiff and OpenAPITools openapi-diff and committed under recordings/. The checked
commands read those files rather than running Go or Java.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A model JSON diff from @voxgig/apidef | You generate SDKs, CLIs, or MCP servers from the extracted model and want the generated names that move | A model file per version, an extractor version to pin, and blindness to anything below the model, such as a status code | You generate nothing from a model, or the question is what callers see on the wire |
| oasdiff changelog | You want every wire-level change a consumer can feel, each with a stable id and a severity | A Go binary or Docker image in the pipeline, and one entry per endpoint, so a shared schema edit arrives several times | You need the SDK’s view, which arrives here as one property addition per endpoint |
| OpenAPITools openapi-diff | A Java shop that wants a compatible-or-incompatible verdict and a report people can read | A Java runtime or Docker, a report that names endpoints rather than properties, and a README that promises OpenAPI 3.0 | Your document is 3.1, or you need the changed property named rather than the schema flagged |
Voxgig maintains apidef. This page compares it with oasdiff and OpenAPITools openapi-diff.
The three tools answer three questions. oasdiff lists seven entries for this release, one per endpoint a change reaches, so a field added to a shared schema arrives four times. openapi-diff folds the release into one verdict, backward compatible, and names endpoints rather than properties. The model diff names the field once and the new entity once, and the status code is below its floor. Expect to keep two of the three.
Extract both versions into sorted model files
extract.mjs runs apidef with its file-writing step switched off, reads the in-memory model, and
keeps the parts a generator consumes. Those are each entity’s id field, its fields with their
type, required, format, and read-only flags, and its operations as lists of method and path with
their arguments. An argument added, or made required, changes a generated method’s signature.
apidef’s own model reference
documents the raw shape those come from. Descriptions stay out, because they reach generated
code as comments only.
/**
* The model file: entities with their id field, fields, operations, and the operations'
* arguments, the document it came from with its hash, and the extractor that produced it.
* Descriptions are left out on purpose. They reach the generated surfaces as comments, and a
* comment edit is not a surface change.
*/
export async function extractModel(specPath) {
const kit = await rawModel(specPath)
const entities = {}
for (const [name, entity] of Object.entries(kit.entity)) {
const operations = {}
const args = {}
for (const [op, value] of Object.entries(entity.op ?? {})) {
if (!value) continue
operations[op] = value.points.map(endpointOf).sort()
for (const point of value.points) Object.assign(args, argsOf(point))
}
entities[name] = { id: entity.id?.field ?? null, fields: fieldsOf(entity), operations, args }
}
const bytes = readFileSync(specPath)
return sorted({
extractor: EXTRACTOR,
spec: {
file: basename(specPath),
sha256: createHash('sha256').update(bytes).digest('hex'),
title: kit.info.title,
version: kit.info.version,
},
entities,
})
}
Run it once per version.
node extract.mjs specs/orders-v1.json models/orders-v1.model.json
node extract.mjs specs/orders-v2.json models/orders-v2.model.json
orders-v1.json -> models/orders-v1.model.json: 2 entities, 7 operations, 9 fields, extracted by @voxgig/apidef 8.25.3
orders-v2.json -> models/orders-v2.model.json: 3 entities, 9 operations, 15 fields, extracted by @voxgig/apidef 8.25.3
Two parts of the file exist for the diff rather than for a generator. extractor records the
package and version that produced the model, and spec.sha256 is the hash of the document bytes,
so a model can be traced to the exact file it came from. spec.version is the document’s own
info.version, which the specification keeps apart from the OpenAPI version and from the
implementation’s.1
"extractor": {
"name": "@voxgig/apidef",
"version": "8.25.3"
},
"spec": {
"file": "orders-v2.json",
"sha256": "b6eb084570de76a1376ce02a5093642068e6c91cfd0151d3631ad243d530463e",
"title": "Orders API",
"version": "1.1.0"
}
}
Every object’s keys are sorted before the file is written, so two extractions of one document are byte-identical.2 A text diff of two model files is then a diff of the API rather than of key order.
Diff the two models and read what moved
diff.mjs walks the union of entity names, then operations, then fields, and reports every
difference as one line. Before it compares anything it reads the two extractor stamps, and it
refuses when they differ.
export function diffModels(base, head, { allowExtractorDrift = false } = {}) {
const a = base.extractor
const b = head.extractor
if ((a.name !== b.name || a.version !== b.version) && !allowExtractorDrift) {
throw new ExtractorMismatch(
`the models were extracted by ${a.name} ${a.version} and ${b.name} ${b.version}. ` +
'Re-extract both with one version, or pass --allow-extractor-drift to see the churn.',
)
}
const changes = []
for (const entity of keysOf(base.entities, head.entities)) {
const x = base.entities[entity]
const y = head.entities[entity]
if (!x) { changes.push({ kind: 'entity added', entity, detail: describe(y) }); continue }
if (!y) { changes.push({ kind: 'entity removed', entity, detail: describe(x) }); continue }
node diff.mjs models/orders-v1.model.json models/orders-v2.model.json
base Orders API 1.0.0 (orders-v1.json, @voxgig/apidef 8.25.3)
head Orders API 1.1.0 (orders-v2.json, @voxgig/apidef 8.25.3)
+ field added order.shipped_at string date-time, optional, read-only
+ entity added shipment operations list, load; fields carrier, id, order_id, shipped_at, tracking_code
2 model changes
Two lines. The release added shipped_at to the order and introduced a shipment entity with a
list and a load operation. An SDK generated from the second model gains exactly that: one field
on one class, and one class with two methods. The 404 response and the description edits are
absent by construction, since none of them reaches the model. The command exits 0 whatever it
finds. Whether a change may ship is a release decision, and
detecting breaking changes between two OpenAPI versions in CI
is where that decision has a tool.
Set the spec diffs beside it
reconcile.mjs reads the two committed recordings and a live model diff, then lines the three up
by endpoint, using the model’s operation lists to find which entity owns each path.
node reconcile.mjs
GET /orders (order.list)
oasdiff changelog response-optional-property-added
openapi-diff 200 changed
model diff + field order.shipped_at
POST /orders (order.create)
oasdiff changelog response-optional-property-added
openapi-diff request changed, 201 changed
model diff + field order.shipped_at
GET /orders/{orderId} (order.load)
oasdiff changelog response-non-success-status-added, response-optional-property-added
openapi-diff 404 added, 200 changed
model diff + field order.shipped_at
PATCH /orders/{orderId} (order.update)
oasdiff changelog response-optional-property-added
openapi-diff request changed, 200 changed
model diff + field order.shipped_at
GET /orders/{orderId}/shipments (shipment.list)
oasdiff changelog endpoint-added
openapi-diff new
model diff + entity shipment
GET /shipments/{shipmentId} (shipment.load)
oasdiff changelog endpoint-added
openapi-diff new
model diff + entity shipment
oasdiff changelog: 7 entries on 6 endpoints
openapi-diff: 6 endpoints, 2 new
model diff: 2 changes on 2 entities
oasdiff diff: 14 description edits, 2 distinct; the changelog carries 0 of them and so does the model
Read it by column. oasdiff reports one entry per endpoint and change kind, so one new property on
a shared schema becomes four entries, and the 404 on GET /orders/{orderId} gets an entry of its
own. openapi-diff also reports per endpoint and adds a status code and a verdict.3 The model
diff has two things to say and repeats them under every endpoint the entity owns, because that is
its grain. The last line is the two description edits, one to info and one to the order’s
status. The full oasdiff diff output carries the second thirteen times, once per place the
schema appears. The changelog reports neither, and the model never carried them. Neither kind of
diff is wrong. They measure different things, and a release process that wants both answers runs
both.
Check it worked
Seven tests with the Node test runner pin the behavior. The one that matters most takes the first document, adds one read-only property to the order schema, extracts, and asserts that the diff is exactly one line.
test('one additive change to the document is exactly one change in the model', async () => {
const plusOne = await extractModel(
edited('specs/orders-v1.json', (doc) => {
doc.components.schemas.Order.properties.shipped_at = { type: 'string', format: 'date-time', readOnly: true }
}),
)
assert.deepEqual(diffModels(v1, plusOne).map(brief), ['field added order shipped_at'])
})
node --test diff.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# pass 7 is the line to look for. The other six pin determinism, the empty self-diff, the
two-line release, the refusal on mismatched stamps, the shape a rename takes, and an argument
added, then made required.
When it goes wrong
The diff is full of churn for an API that did not move. The two model files came from two
releases of the extractor, and the newer one records something the older one did not.
pitfall.mjs compares a model of the first document written by an earlier release of the same
major version, committed under recordings/, with a fresh extraction.
node pitfall.mjs
same document: yes, sha256 5d4ae413a862f561
extracted by: @voxgig/apidef 8.0.0, then @voxgig/apidef 8.25.3
with the stamp checked: refused. the models were extracted by @voxgig/apidef 8.0.0 and @voxgig/apidef 8.25.3. Re-extract both with one version, or pass --allow-extractor-drift to see the churn.
with --allow-extractor-drift: 2 changes on a document that did not change
~ field changed customer.email string, required -> string email, required
~ field changed order.created_at string, optional -> string date-time, optional, read-only
The older release recorded neither the format nor the readOnly flag of a property, so two
fields changed in the model while the document’s hash stayed the same. The stamp exists so the
diff refuses this comparison by default. Fix it by re-extracting both versions with the
extractor pinned in package.json, and treat --allow-extractor-drift as a way to inspect the
extractor, never the API.
A renamed collection appears as a removal and an addition. The diff keys on entity names and has
no signal that client is customer under a new path, so the test pins that pair rather than
pretending to detect a rename. Record the rename as a decision beside the model, which is what
reconciling an extracted entity model with a domain model
sets up, and read the two lines together.
The model diff is empty for a pull request that changed the document. The edit sat below the
model: a description, an example, or a non-success status code. That is the job of the spec diff, so
run oasdiff changelog beside the model diff rather than widening the model until it is the
document again.
When not to do this
Do not turn this diff into a release gate. It reports what a generated surface gains or loses and says nothing about whether a caller breaks. The oasdiff breaking changes command has a published rule set for that question and exits non-zero on a match, which is the shape a gate needs.
Do not use apidef’s model diff when nothing is generated from the model. If your SDKs are hand-written, or come from a generator that reads the document directly, the model file is an artifact only this check reads. The spec diff already answers your question.
Do not diff models extracted by two versions of the extractor and read the result as API change. The stamp is in the file so the diff can refuse.
Do not read a quiet model diff as a quiet release. A new 404, a changed description, or a tightened example reaches every client without moving the model.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0 and @voxgig/apidef 8.25.3. Every output block is what the
command preceding it printed, run in the page’s code directory after npm ci. The reports under
recordings/ were produced once, locally, by oasdiff v1.33.0 built with go install and by
openapi-diff-cli 2.1.7 run with java -jar, against the two committed documents, and the checked
commands read those files. The older model under recordings/ was written by the same
extract.mjs with @voxgig/apidef 8.0.0 installed in place of the pinned version.
Footnotes
-
An OpenAPI document carries two version numbers in its first lines, and the specification keeps them apart with a parenthesis. OpenAPI 3.0.3 defines
info.versionas the version of the OpenAPI document, which is distinct from the OpenAPI Specification version or the API implementation version. The model file here adds a fourth, the extractor’s, and the diff reads that one before it reads anything else. ↩︎ Back to text -
Sorting keys before writing JSON has a standard of its own. RFC 8785 defines a canonical form for JSON, named JCS, because hashing and signing need data expressed in an invariant format. It sorts object properties recursively, comparing names as arrays of UTF-16 code units treated as unsigned integers, independent of locale settings. JavaScript’s default string sort compares the same units in the same order, so
Object.keys(value).sort()follows the rule in the RFC by the design of the engine rather than by citation. ↩︎ Back to text -
The openapi-diff README opens by comparing two OpenAPI specifications in the 3.x line and lists support for OpenAPI 3.0 among its features. A 3.1 document sits inside the first sentence and outside the second. It asks for Java 8, and its thanks credit an earlier project, swagger-diff, as a source of inspiration for the tool. The jar on Maven Central is
openapi-diff-cli, a name the README does not use. ↩︎ Back to text