CLI ยท commit 924a927

apidiff

OpenAPI breaking-change detector & changelog generator

Download apidiff.zip
Quickstart
unzip apidiff.zip && cd apidiff
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

apidiff

apidiff compares two versions of an OpenAPI 3.0/3.1 spec, classifies every difference as breaking, potentially breaking, safe or informational (taking request vs response direction into account), and renders the result as a console report, a Keep a Changelog entry, JSON or GitHub annotations. It is built for CI: fail a pull request that accidentally breaks API consumers, and publish a human-readable API changelog from the same run.

Features

Install

uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/apidiff --help

Requires Python 3.12+. Dependencies: click, rich, pyyaml.

Quickstart

# two files
apidiff diff examples/v1.yaml examples/v2.yaml

# a PR against main, straight from git (run inside the repository)
apidiff diff --base git:main:openapi.yaml --head openapi.yaml --fail-on breaking

# changelog entry for the docs
apidiff diff examples/v1.yaml examples/v2.yaml -f markdown -o CHANGELOG-api.md --fail-on none

# spec statistics
apidiff summary examples/v2.yaml

Exit codes: 0 nothing at or above the --fail-on threshold (after policy), 1 threshold reached, 2 bad input (unreadable spec, bad policy file, usage error).

Usage

apidiff diff [BASE HEAD] [--base SPEC] [--head SPEC] [options]
  -f, --format [text|markdown|json|github]   default: text
  -o, --output FILE                          write the report to a file
  --fail-on [breaking|potentially-breaking|none]
                                             default: policy `fail_on`, else breaking
  --min-severity [breaking|potentially-breaking|safe|info]
                                             hide lower severities in the text report
  --config FILE                              policy file (default: ./.apidiff.toml if present)
  --repo DIR                                 repository used for git:REF:PATH (default: cwd)
  --today YYYY-MM-DD                         override "today" for allow-rule expiry
  --release-date YYYY-MM-DD                  date in the Markdown heading
apidiff summary SPEC [-f text|json] [--repo DIR]
apidiff catalog [-f text|json|markdown]

A spec is a file path, an http(s):// URL or git:REF:PATH, where PATH is relative to the repository root. Relative $refs inside a git or URL spec are resolved against the same ref or URL.

Console report

apidiff diff examples/v1.yaml examples/v2.yaml --config examples/.apidiff.toml --min-severity breaking (exit code 1):

apidiff examples/v1.yaml (1.4.0) -> examples/v2.yaml (2.0.0)

BREAKING (18)
  GET /customers/{customerId}
    response-became-nullable  Response value became nullable (property `description`)
      /paths/~1customers~1{customerId}/get/responses/200/content/application~1json/schema/properties/default_payment/properties/description
    response-property-removed  Property removed from response: `legacy_ref`
      /paths/~1customers~1{customerId}/get/responses/200/content/application~1json/schema/properties/default_payment/properties/legacy_ref
    operation-security-requirement-removed  Security requirement removed: ApiKeyAuth
      /paths/~1customers~1{customerId}/get/security
  GET /payments
    request-maximum-tightened  Request maximum tightened: maximum: 100 -> 50
      /paths/~1payments/get/parameters/1/schema/maximum
    response-became-nullable  Response value became nullable (property `description`)
      /paths/~1payments/get/responses/200/content/application~1json/schema/properties/data/items/properties/description
    response-property-removed  Property removed from response: `legacy_ref`
      /paths/~1payments/get/responses/200/content/application~1json/schema/properties/data/items/properties/legacy_ref
    response-media-type-removed  Response media type removed: `text/csv`
      /paths/~1payments/get/responses/200/content/text~1csv
  POST /payments
    parameter-added-required  Required parameter added: `Idempotency-Key` (header)
      /paths/~1payments/post/parameters/0
    request-enum-value-removed  Enum value removed from request: `JPY` (property `currency`)
      /paths/~1payments/post/requestBody/content/application~1json/schema/properties/currency/enum
    request-property-became-required  Request property became required: `customer_id`
      /paths/~1payments/post/requestBody/content/application~1json/schema/properties/customer_id
    request-max-length-tightened  Request max length tightened: maxLength: 255 -> 140 (property `description`)
      /paths/~1payments/post/requestBody/content/application~1json/schema/properties/description/maxLength
    request-media-type-removed  Request media type removed: `application/x-www-form-urlencoded`
      /paths/~1payments/post/requestBody/content/application~1x-www-form-urlencoded
    response-success-status-removed  Success response removed: `201`
      /paths/~1payments/post/responses/201
  POST /payments/{id}/cancel
    operation-removed  Operation removed [allowed: Cancel was replaced by refunds; agreed with partners in PAY-212]
      /paths/~1payments~1{id}~1cancel/post
  GET /payments/{paymentId}
    response-became-nullable  Response value became nullable (property `description`)
      /paths/~1payments~1{paymentId}/get/responses/200/content/application~1json/schema/properties/description
    response-property-removed  Property removed from response: `legacy_ref`
      /paths/~1payments~1{paymentId}/get/responses/200/content/application~1json/schema/properties/legacy_ref
  POST /payments/{paymentId}/capture
    response-became-nullable  Response value became nullable (property `description`)
      /paths/~1payments~1{paymentId}~1capture/post/responses/200/content/application~1json/schema/properties/description
    response-property-removed  Property removed from response: `legacy_ref`
      /paths/~1payments~1{paymentId}~1capture/post/responses/200/content/application~1json/schema/properties/legacy_ref

warning: allow rule 'response-property-removed' for '*/legacy_ref' expired on 2026-01-01; matching changes are enforced again

Summary: 18 breaking, 15 potentially-breaking, 27 safe, 7 info (1 allowed by policy)
Semver: MAJOR bump (1.4.0 -> 2.0.0), 18 breaking changes

Each line is change-id message, followed by the JSON pointer into the head spec (the base spec for removals). Without --min-severity the potentially-breaking, safe and info groups follow. The same comparison on all severities finds 18 breaking, 15 potentially breaking, 27 safe and 7 informational changes.

Markdown changelog

apidiff diff examples/v1.yaml examples/v2.yaml --config examples/.apidiff.toml -f markdown --release-date 2026-10-16:

## [2.0.0] - 2026-10-16

> Suggested version bump: **major** (1.4.0 -> 2.0.0), 18 breaking changes.

### Added

- **Potentially breaking** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Discriminator mapping added to response: `wallet` (property `method`)
- **Potentially breaking** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: oneof alternative added to response: `Wallet` (property `method`)
- **Potentially breaking** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Enum value added to response: `refunded` (property `status`)
- `GET /balance`, `GET /customers/{customerId}`, `GET /payments` and 3 more: Alternative security requirement added: OAuth2
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Property added to response: `receipt_url`
- `GET /payments`: Enum value added to request: `refunded`
- `POST /payments`: Success response added: `200`
- OAuth scope added: `customers:read`
- Server added: `https://api.example.com/v2`
- Server added: `https://sandbox.example.com/v2`
- `POST /refunds`: Operation added

### Changed

- **BREAKING** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Response value became nullable (property `description`)
- **BREAKING** `GET /payments`: Request maximum tightened: maximum: 100 -> 50
- **BREAKING** `POST /payments`: Required parameter added: `Idempotency-Key` (header)
- **BREAKING** `POST /payments`: Request property became required: `customer_id`
- **BREAKING** `POST /payments`: Request max length tightened: maxLength: 255 -> 140 (property `description`)
- **Potentially breaking** `GET /payments`: Default value changed: 20 -> 10
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Response max length tightened: maxLength: 255 -> 140 (property `description`)
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Type narrowed in response: number -> integer (property `fee`)
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Enum restriction added to response value: `visa`, `mastercard`, `amex` (property `brand`)
- `GET /customers/{customerId}`: Format added to response value: none -> e164 (property `phone`)
- `POST /payments`: Type widened in request: integer -> number (property `amount`)
- `/payments/{paymentId}`: Path parameter renamed: `/payments/{id}` -> `/payments/{paymentId}`
- `/payments/{paymentId}/capture`: Path parameter renamed: `/payments/{id}/capture` -> `/payments/{paymentId}/capture`

### Deprecated

- `GET /balance`: Operation deprecated

### Removed

- **BREAKING** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Property removed from response: `legacy_ref`
- **BREAKING** `GET /customers/{customerId}`: Security requirement removed: ApiKeyAuth
- **BREAKING** `GET /payments`: Response media type removed: `text/csv`
- **BREAKING** `POST /payments`: Enum value removed from request: `JPY` (property `currency`)
- **BREAKING** `POST /payments`: Request media type removed: `application/x-www-form-urlencoded`
- **BREAKING** `POST /payments`: Success response removed: `201`
- **BREAKING** `POST /payments/{id}/cancel`: Operation removed (allowed: Cancel was replaced by refunds; agreed with partners in PAY-212)
- **Potentially breaking** Server removed: `https://api.example.com/v1`
- **Potentially breaking** Server removed: `https://sandbox.example.com/v1`
- `GET /customers/{customerId}`: Error response removed: `404`

A shared schema that changes (here Payment, used by four endpoints) is listed once with the affected endpoints.

JSON

-f json emits {base, head, summary, semver, failed, warnings, changes[]}; each change has id, severity, category, message, endpoint, location, old, new and allowed.

GitHub annotations

-f github prints workflow commands, ::error for breaking, ::warning for potentially breaking and ::notice for safe changes (policy-allowed changes become notices):

::warning file=api/v1.yaml,title=server-removed::Server removed: `https://api.example.com/v1` at /servers
::error file=api/v1.yaml,title=response-property-removed::GET /customers/{customerId}: Property removed from response: `legacy_ref` at /paths/~1customers~1{customerId}/get/...

A GitHub Actions job:

- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- run: pip install ./apidiff
- run: apidiff diff --base git:origin/main:openapi.yaml --head openapi.yaml -f github

Spec summary

apidiff summary examples/v2.yaml:

Payments API 2.0.0 (OpenAPI 3.1.0)
Paths                   6                
Operations              7 (GET 4, POST 3)
Tags                    2                
Schemas                 8                
Security schemes        2                
Servers                 2                
Responses               10               
Deprecated operations   1                
Missing operationId     0                
No error responses      4                
Undocumented responses  0                

Deprecated
  GET /balance

Without error responses
  POST /payments/{paymentId}/capture
  GET /customers/{customerId}
  GET /balance
  POST /refunds

"Undocumented responses" counts responses with no description, or no content schema where a body is expected (everything except 1xx, 3xx, 204, 205).

Configuration

.apidiff.toml

[settings]
fail_on = "breaking"          # default for --fail-on

[[allow]]
id = "operation-removed"      # change id, glob allowed ("response-*")
path = "POST /payments/*/cancel"   # glob matched against the path template, "METHOD path" or the JSON pointer
reason = "Cancel was replaced by refunds; agreed with partners in PAY-212"
expires = 2027-03-31          # inclusive; afterwards the change is enforced again and a warning is printed

Allowed changes stay in every report (marked [allowed: reason]) but do not count toward the exit code. Semver advice still reflects the true change.

x-apidiff-ignore

Place on an operation, path item, parameter, response, media type or any schema:

x-apidiff-ignore: true                       # skip this whole subtree
x-apidiff-ignore: [response-enum-value-added] # ignore these change ids (globs allowed) in the subtree

Architecture

module role
loader.py file/git/URL loading, YAML/JSON parsing, $ref resolution into a shared (cyclic) graph
catalog.py change kinds: id, severity, changelog category, rationale
differ.py document level: paths, operations, parameters, bodies, responses, ignore handling
schema_diff.py, schema_util.py direction-aware structural schema comparison, allOf flattening, type normalisation, oneOf member matching
security_diff.py security schemes and per-operation requirements
policy.py, advice.py .apidiff.toml rules with expiry; semver advice
report.py, summary.py, cli.py renderers, summary statistics, click CLI

See docs/design.md for the algorithms and docs/change-catalog.md for the catalogue.

Tests

.venv/bin/pytest

187 tests cover $ref and cycles, path matching, every schema change kind (table driven, both directions), parameters, bodies, responses, security, policy allow/expiry, git-ref loading from a temporary repository, URL loading, report formats and CLI exit codes.

Limitations