How-to › Describe your API

How to diff two semantic models from successive spec versions#

Extract a model from each spec version, diff the two model files, and read the result beside what oasdiff and openapi-diff report about the same release.

Audience
Platform team
Level
intermediate
Topic
Extract a semantic model from a spec
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A model JSON diff from @voxgig/apidefYou generate SDKs, CLIs, or MCP servers from the extracted model and want the generated names that moveA model file per version, an extractor version to pin, and blindness to anything below the model, such as a status codeYou generate nothing from a model, or the question is what callers see on the wire
oasdiff changelogYou want every wire-level change a consumer can feel, each with a stable id and a severityA Go binary or Docker image in the pipeline, and one entry per endpoint, so a shared schema edit arrives several timesYou need the SDK’s view, which arrives here as one property addition per endpoint
OpenAPITools openapi-diffA Java shop that wants a compatible-or-incompatible verdict and a report people can readA Java runtime or Docker, a report that names endpoints rather than properties, and a README that promises OpenAPI 3.0Your 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.

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

  1. 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.version as 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

  2. 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

  3. 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

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.