# 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

- Loads YAML/JSON specs from files, URLs or git refs (`git:main:openapi.yaml`, read with `git show`). Resolves internal and relative-file `$ref`s, including cyclic schemas.
- Matches operations by method and path template; `/pets/{id}` -> `/pets/{petId}` is the same endpoint.
- Compares schemas structurally rather than by name: `allOf` is flattened, `oneOf`/`anyOf` members are matched by similarity, 3.0 `nullable` equals a 3.1 `type: [x, "null"]`, discriminators, `additionalProperties`, enums, formats and constraints are all understood.
- Direction-aware classification with 210 catalogued change kinds (see [docs/change-catalog.md](docs/change-catalog.md)).
- Reports: grouped console report with JSON pointers, Markdown changelog (Added / Changed / Deprecated / Removed), JSON, GitHub workflow annotations.
- `--fail-on breaking|potentially-breaking` exit codes, `.apidiff.toml` policy with expiring allow rules, `x-apidiff-ignore` vendor extension, semver bump advice.
- `apidiff summary` for quick spec statistics and `apidiff catalog` to list change kinds.

## Install

```bash
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

```bash
# 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 `$ref`s 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`:

````markdown
## [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:

```yaml
- 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`

```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:

```yaml
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](docs/design.md) for the algorithms and [docs/change-catalog.md](docs/change-catalog.md) for the catalogue.

## Tests

```bash
.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

- OpenAPI 3.0/3.1 only (Swagger 2.0 is rejected). Webhooks, callbacks, links and `patternProperties`/`prefixItems` are not compared.
- Changes are located by JSON pointer, not by source line, so GitHub annotations point at the file rather than a line.
- A shared schema that changes is reported once per endpoint that uses it in the text/JSON reports.
- Severity is conservative by design; use policy rules for deliberate exceptions.
- Request/response direction is inferred from where a schema is used; schemas reached through both directions are checked in each.
