Design notes
Pipeline
load_spec(base) --\
>-- diff_specs(old, new) --> [Change] --> apply_policy --> advise --> report
load_spec(head) --/
Each Change carries a catalogue id (which fixes its severity and changelog category), a message, a JSON pointer, and the operation it belongs to.
Loading and $ref resolution
loader.py parses the root document (JSON if it starts with {, else YAML with timestamps kept as strings), then walks it replacing every $ref with the resolved target:
- Targets are memoised by
(document, pointer), so every$refto#/components/schemas/Petyields the same dict object. - A target is registered in the memo before its children are resolved. A recursive schema (
Node.children.items -> Node) therefore ends up pointing at itself and resolution terminates. $refchains (A -> B -> C) are followed iteratively; a chain that loops without ever reaching content is aLoadError.- A
$refwith sibling keys (3.1 allowsdescriptionnext to$ref) becomes a shallow merge, unless the target is still being built (recursion), in which case siblings are dropped. - Relative file refs are joined against the location of the referring document. Locations are file paths, URLs or
git:REF:PATH, andjoin_locationhandles each, so a git spec's$ref: schemas.yaml#/Petreadsschemas.yamlfrom the same commit.
The differ guards against cycles with the set of (id(old), id(new), direction) pairs currently being compared; meeting a pair again means we are inside a recursive schema and the branch has already been compared.
Matching operations
A path template is normalised by replacing every {name} with {}. Old and new operations are paired by (method, normalised template):
| old | new | result |
|---|---|---|
/pets/{id} |
/pets/{petId} |
same endpoint, one path-param-renamed (info) |
/pets/{id} |
/animals/{id} |
operation-removed + operation-added |
Path parameters are matched by position in the template, so renaming id to petId does not show up as a removed and an added required parameter. Other parameters match on (in, name) (header names case-insensitively). A leftover removed/added pair sharing a name is reported as one parameter-location-changed. Path-level parameters are merged under operation-level ones first.
Directions
SchemaDiff.diff(a, b, pointer, direction) is called with "request" for request bodies and parameters, and "response" for response bodies and headers. The direction becomes a prefix of the change id, and the catalogue assigns it the right severity. Worked example, Payment.status enum [pending, succeeded, failed] becoming [pending, succeeded, failed, refunded]:
- on a response (
response-enum-value-added): potentially breaking, because a client with an exhaustiveswitchhas no case forrefunded; - as a query parameter (
request-enum-value-added): safe, the server merely accepts more.
readOnly properties are invisible in requests and writeOnly ones in responses, so adding a required id that is readOnly is not a breaking request change.
Normalising schemas
Before comparing, each schema pair goes through:
allOfflattening. Members are merged:propertiesare unioned (a property defined by two members becomesallOfof both and is flattened when recursed into),requiredis unioned, other keywords take the first value.allOf: [A, B]and the inlined equivalent compare equal.- Type normalisation.
types_ofreturns(set of non-null types, nullable). OpenAPI 3.0nullable: true, 3.1type: [string, "null"]andnullinside anenumall producenullable = True. A schema with notypeinfers one from its keywords (propertiesmeans object,itemsmeans array, enum values give their Python types) and is otherwise unconstrained.integeris a subtype ofnumber. - Constraint folding. 3.0 boolean
exclusiveMaximum: true+maximum: 9becomes the 3.1 numericexclusiveMaximum: 9.constis treated as a one-valueenum.
Type changes are classified by containment: new type set contains the old = widened, contained by the old = narrowed, neither = changed (and the comparison stops there, since nested differences would be noise).
oneOf / anyOf
Members have no names, so they are paired by similarity (schema_util.match_members):
- structurally equal members score 1000;
- two object members that both define a property with a constant/single-value enum and disagree on it (the usual discriminator
type: cardvstype: wallet) never match; - otherwise points are added for equal titles, equal types and the Jaccard overlap of property names; objects with no common property do not match; the threshold is 10.
Pairs are taken greedily best-first. Matched pairs are compared recursively; unmatched old members are *-member-removed, unmatched new members *-member-added. If only one side uses a combinator, the other side is treated as a one-member list, so Pet turning into oneOf: [Pet, Wallet] yields just a member-added change and not a pile of spurious property removals.
Worked example: v1 Payment.method is oneOf: [Card, BankTransfer], v2 adds Wallet. Card and BankTransfer pair with their v2 versions (identical type constants), Wallet is left over: response-oneof-member-added (potentially breaking), plus response-discriminator-mapping-added for the new wallet tag. Card.brand also gained an enum, matched through the paired member and reported at .../method/oneOf/0/properties/brand/enum.
Security
Effective requirements per operation are operation.security if present, else the document's security. An empty list, or a requirement {}, means anonymous access is allowed.
- anonymous -> authenticated:
operation-security-added(breaking); the reverse is safe; - requirements are keyed by the set of schemes they name; a vanished key is breaking (clients using that credential), a new key is safe;
- for a surviving key, extra scopes are breaking, fewer are safe.
Scheme definitions are compared separately (type, HTTP scheme, API key location, OAuth flows, URLs, scopes).
Policy
Rules are evaluated per change in file order; the first live match wins. A rule matches when the id glob matches and the path glob matches any of: the path template, METHOD path, or the JSON pointer. A rule whose expires date has passed no longer matches, and a warning is attached to the result so the expiry is visible in the report instead of silently turning CI red.
Semver advice
| condition | bump |
|---|---|
| any breaking change | major |
| any potentially-breaking change, or any change in the "Added" category | minor |
| only other changes (deprecations, tightened responses, ...) | patch |
| nothing | none |
The suggested number is computed from the base spec's info.version when it is X.Y.Z. Allowed changes still count; policy affects CI status, not the truth about the API.