API Diff (v1 vs v2)
The docs site ships an interactive API Diff modal that compares the v1 and v2 OpenAPI specs side-by-side, grouping changes by tag, path, and request/response object. It lives in the Navbar and is visible to all non-merchant audiences.
Architecture
Navbar ("v1 vs v2" button)
└─ ApiDiffModal.js [src/components/ApiDiff/]
└─ GET /api-diff.json (static asset — pre-generated at build time)
└─ scripts/generate-api-diff.js
├─ parse openapi/apispec.yaml (v1)
└─ parse openapi/v2/apispec.yaml (v2)
The diff is pre-generated into static/api-diff.json and served as a plain static file. There is no live /diff/compare endpoint involved at runtime. Run yarn generate-api-diff to regenerate after spec changes.
Files
| File | Purpose |
|---|---|
src/components/ApiDiff/ApiDiffModal.js | React modal — layout, grouping, MOVED detection |
src/components/ApiDiff/ApiDiff.css | All adm-* scoped styles |
src/theme/Navbar/index.js | Renders the v1 vs v2 trigger button |
scripts/compare.js | Diffs two OpenAPI specs; returns raw JSON |
scripts/generate-api-diff.js | Slims the raw diff and writes static/api-diff.json |
static/api-diff.json | Pre-generated static asset loaded by the modal |
scripts/test-moves.js | Unit tests for collapseDescendants and detectMoves |
UI hierarchy
The modal renders a three-level collapsible tree:
TagGroup (e.g. "Merchant Wallet Manegement")
└─ PathGroup (e.g. "payments" — last static segment before first {param})
└─ EndpointRow (e.g. "POST /v2/payments/{id}/capture")
├─ SummaryBar (counts: +2 -1 ~3)
├─ Status Codes (collapsible per code)
├─ Request Body (ObjectGroupPanel per root segment)
└─ Response Body (ObjectGroupPanel per root segment)
getPathGroup(path) extracts the path group label: it returns the last static segment before the first {param}, or the first segment if there are no params.
// /v2/payments/{id}/capture → "payments"
// /v2/customers → "customers"
Endpoints that are fully added or removed show a single-line bulk notice and suppress all field details.
Object grouping — groupIntoObjects()
scripts/compare.js → extractSchemaProps() produces a flat list of fields with dot-path name values (e.g. paymentAllocations[].paymentMethod.id). The modal groups these by the first dot-path segment into object groups (e.g. paymentMethod, paymentAllocations, amount).
Each group carries counts: { added, removed, changed, unchanged } and isComplex (more than 3 fields).
Bulk object notices — ObjectGroupPanel
When every field in a group shares the same status:
- All added → single
ADDEDpill, field count - All removed → single
REMOVEDpill, field count - Group-level MOVED → purple
MOVEDpill +→ destinationhint (see below)
Otherwise the group renders a CollapsibleSection (closed by default) with a field table.
MOVED detection — detectMoves()
detectMoves(objects) returns movedMap: { [fieldOrGroupName]: { from, to } }.
Level 1 — group-level
If an entire object group is fully removed, scan all added fields (including descendants) across every group. If any added field's last segment (after stripping []) equals the removed group's objectName, the group is marked MOVED.
Example: paymentMethod group fully removed (all fields removed). An added field paymentAllocations[].paymentMethod has last segment paymentMethod → movedMap['paymentMethod'] = { from: '(root)', to: 'paymentAllocations' }.
Level 2 — field-level
Uses collapseDescendants() to suppress child rows when an ancestor is already removed/added, then matches collapsed removed fields against collapsed added fields at a different parent:
- Complex/object fields: match when
lastSegmentis the same and parents differ. - Scalar fields with depth ≥ 2: match when the last-two-segments are the same and parents differ.
Fields already covered by a Level 1 group match are excluded from Level 2.
collapseDescendants()
Strips child rows from a field list when their ancestor is already listed as added or removed. This prevents redundant rows (e.g. paymentMethod.id when paymentMethod itself is removed) and keeps Level 2 matching clean.
paymentMethod removed ← kept
paymentMethod.id removed ← suppressed (ancestor is removed)
paymentMethod.card removed ← suppressed
amount unchanged ← kept (no removed/added ancestor)
Running the tests
node scripts/test-moves.js
scripts/test-moves.js contains 9 assertions covering:
collapseDescendants— children of removed parent, descendants of added parent, children of changed parent (kept)detectMoves— group-level move, scalar field-level move, false-positive guard (same-name fields in unrelated groups must not match)
Regenerating the static JSON
yarn generate-api-diff
Run this whenever an OpenAPI spec changes. The script is also wired into prebuild so it runs automatically with yarn build.
The generator (scripts/generate-api-diff.js) strips everything the UI doesn't use:
| Stripped | Reason |
|---|---|
description on fields | Not rendered |
enum, format, nullable, default, isArray on fields | Not rendered |
Full v1/v2 endpoint copies | Only operationId (for search) is kept |
Flat added/removed/changed arrays | Replaced by pre-computed objects groups |
| Unchanged field rows inside changed groups | Not visible in the UI |
| Entirely unchanged object groups | Nothing to show |
fieldDiff for unchanged status codes | No changes to render |
Resulting file: ~650 KB (gzips to ~120 KB over the wire).
Status pill colours are defined in the STATUS_STYLES constant at the top of ApiDiffModal.js. The moved entry uses purple (#f3e5f5 / #6a1b9a). Add new entries there and use the adm-delta--{status} CSS class pattern in ApiDiff.css.
CSS namespace
All styles use the adm- prefix (API Diff Modal) to avoid collisions with Docusaurus or other component styles.
| Class | Description |
|---|---|
.adm-tag-group / .adm-tag-header | Tag-level collapsible group |
.adm-path-group / .adm-path-header | Path sub-group |
.adm-object-bulk-row | Single-line notice for fully added/removed/moved objects |
.adm-delta--moved | Purple MOVED badge |
.adm-field-row--moved | Purple left-border on a moved field row |
.navbar-api-diff-btn | Navbar trigger button |