Skip to content

Commit effffd7

Browse files
feat(diff/breaking/changelog/summary): --auto-upgrade for cross-version specs (#923)
* feat(diff/breaking/changelog/summary): --auto-upgrade for cross-version specs When --auto-upgrade is set, both base and revision are canonicalised to the latest OpenAPI 3.x (via the same openapi3conv.Upgrade helper the new 'oasdiff upgrade' subcommand uses) right after load and before diff. This makes cross-version comparisons (e.g. a 3.0 base against a 3.1 revision) produce a meaningful result instead of a noisy diff dominated by dialect-level differences (nullable shape, type arrays, exclusiveMinimum, example/examples). The walker is idempotent on already-canonical specs, so calling it on a same-version pair is a safe no-op. Off by default; opt in per invocation. Applies to diff, breaking, changelog, and summary because all four flow through normalDiff / composedDiff. Tests cover the cross-version-equivalent case (a 3.0 spec and its 3.1 canonical form produce 'No changes detected' under --auto-upgrade) and the same-version idempotency case (output unchanged whether the flag is set or not). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(3.1): document --auto-upgrade for cross-version comparison Extend the 'Migrating from 3.0 to 3.1' section with a 'Comparing across versions with --auto-upgrade' subsection covering the new flag on diff/breaking/changelog/summary. Explains the use case (cross-version diff produces dialect-level noise without the flag) and that the walker is idempotent so setting the flag is safe even for same-version pairs. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e0964a8 commit effffd7

9 files changed

Lines changed: 127 additions & 1 deletion

File tree

data/upgrade/equivalent-31.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
openapi: 3.1.0
2+
info:
3+
title: upgrade-test
4+
version: '1.0'
5+
paths:
6+
/items:
7+
get:
8+
parameters:
9+
- in: query
10+
name: q
11+
schema:
12+
type: [string, "null"]
13+
responses:
14+
'200':
15+
description: ok
16+
content:
17+
application/json:
18+
schema:
19+
type: integer
20+
exclusiveMinimum: 0
21+
examples: [7]

docs/OPENAPI-31.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,52 @@ Changes are detected for all 3.1-specific fields:
4040
- **unevaluatedItems/unevaluatedProperties**: added/removed
4141
- **contentSchema/contentMediaType/contentEncoding**: added/removed/changed
4242

43+
## Migrating from 3.0 to 3.1
44+
45+
OpenAPI 3.1 changes the shape of several constructs:
46+
47+
| 3.0 | 3.1 |
48+
|---|---|
49+
| `nullable: true` on `type: string` | `type: ["string", "null"]` |
50+
| `exclusiveMinimum: true` + `minimum: 0` | `exclusiveMinimum: 0` (numeric) |
51+
| `example: 7` | `examples: [7]` |
52+
53+
A team migrating their spec from 3.0 to 3.1 has two problems oasdiff can help with: producing the migrated spec, and verifying the migration doesn't break clients.
54+
55+
### Converting a spec with `oasdiff upgrade`
56+
57+
The `upgrade` subcommand rewrites a 3.0 spec into the latest 3.x canonical form (currently 3.2.0). The transforms are idempotent, so running it on an already-canonical spec just bumps the version string.
58+
59+
```
60+
oasdiff upgrade old-spec.yaml > new-spec.yaml
61+
oasdiff upgrade old-spec.yaml --format json > new-spec.json
62+
cat old-spec.yaml | oasdiff upgrade -
63+
```
64+
65+
The output goes to stdout; redirect to a file to keep it. The default output format is `yaml`; pass `--format json` for JSON.
66+
67+
The walker handles 3.0 → 3.x only. Swagger 2.0 → 3.0 is out of scope.
68+
69+
Available since `v1.16.0`.
70+
71+
### Comparing across versions with `--auto-upgrade`
72+
73+
`diff`, `breaking`, `changelog`, and `summary` accept an `--auto-upgrade` flag that runs the same canonicalisation on both specs in-memory right after load. This makes cross-version comparisons (e.g. a 3.0 base against a 3.1 revision) produce a meaningful result instead of a noisy diff dominated by dialect-level differences.
74+
75+
```
76+
# old.yaml is 3.0, new.yaml is 3.1
77+
oasdiff breaking old.yaml new.yaml --auto-upgrade
78+
oasdiff changelog old.yaml new.yaml --auto-upgrade
79+
oasdiff diff old.yaml new.yaml --auto-upgrade
80+
oasdiff summary old.yaml new.yaml --auto-upgrade
81+
```
82+
83+
Without the flag, the diff surfaces the 3.0→3.1 dialect rewrites (`nullable` becomes `type: ["string", "null"]`, etc.) as if they were schema changes. With the flag, both sides are canonicalised first, so only the genuine schema-level differences remain.
84+
85+
The flag is off by default; opt in per invocation. Safe to set even when both specs are already the same version: the walker is idempotent on already-canonical input.
86+
87+
Available since `v1.16.0`.
88+
4389
## Caveats
4490

4591
The following 3.1 features are not yet fully supported by [`kin-openapi`](https://github.com/getkin/kin-openapi) (the parser oasdiff uses) and therefore do not appear in oasdiff diffs:

docs/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,14 @@ Pre-built binaries for macOS, Linux, and Windows (both x86_64 and arm64) are on
6262
Grouped by what you're trying to do. New to oasdiff? Start with **Commands**.
6363

6464
### Commands
65-
The six top-level subcommands.
65+
The seven top-level subcommands.
6666

6767
- [`diff`](DIFF.md) — full diff between two OpenAPI specs (output: html, json, markdown, markup, text, or yaml — default yaml)
6868
- [`summary`](DIFF.md) — high-level count of changes between two specs (built on the diff engine; same shared options)
6969
- [`breaking`](BREAKING-CHANGES.md) — only breaking changes
7070
- [`changelog`](BREAKING-CHANGES.md) — every significant change, breaking or not, in human-readable form
7171
- [`flatten`](ALLOF.md) — replace `allOf` schemas with a merged equivalent
72+
- [`upgrade`](OPENAPI-31.md#converting-a-spec-with-oasdiff-upgrade) — canonicalize an OpenAPI 3.0 spec to the latest 3.x
7273
- [`checks`](CHECKS.md) — list the rules oasdiff uses to classify changes ([customize them](CUSTOMIZING-CHECKS.md))
7374

7475
### Inputs

internal/cmd_flags.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func addCommonDiffFlags(cmd *cobra.Command) {
2222
cmd.PersistentFlags().Bool("case-insensitive-headers", false, "case-insensitive header name comparison")
2323
cmd.PersistentFlags().StringSlice("exclude-extensions", nil, "OpenAPI Extension names to exclude from diff (e.g., x-internal)")
2424
cmd.PersistentFlags().Bool("allow-external-refs", true, "allow external $refs in specs; disable to prevent SSRF when processing untrusted specs")
25+
cmd.PersistentFlags().Bool("auto-upgrade", false, "canonicalize both specs to the latest OpenAPI 3.x before diffing; useful for cross-version comparisons (e.g. 3.0 vs 3.1)")
2526

2627
addHiddenFlattenFlag(cmd)
2728
addHiddenCircularDepFlag(cmd)

internal/diff.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ func normalDiff(loader *openapi3.Loader, flags *Flags) (*diffResult, *ReturnErro
116116
s2.Spec = s1.Spec
117117
}
118118

119+
autoUpgradeSpecs(flags.getAutoUpgrade(), s1, s2)
120+
119121
diffReport, operationsSources, err := diff.GetWithOperationsSourcesMap(flags.toConfig(), s1, s2)
120122
if err != nil {
121123
return nil, getErrDiffFailed(err)
@@ -140,6 +142,11 @@ func composedDiff(loader *openapi3.Loader, flags *Flags) (*diffResult, *ReturnEr
140142
return nil, getErrFailedToLoadSpecs("revision", flags.getRevision().Path, err)
141143
}
142144

145+
if flags.getAutoUpgrade() {
146+
autoUpgradeSpecs(true, s1...)
147+
autoUpgradeSpecs(true, s2...)
148+
}
149+
143150
diffReport, operationsSources, err := diff.GetPathsDiff(flags.toConfig(), s1, s2)
144151
if err != nil {
145152
return nil, getErrDiffFailed(err)

internal/flags.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ func (flags *Flags) getAllowExternalRefs() bool {
7171
return flags.v.GetBool("allow-external-refs")
7272
}
7373

74+
func (flags *Flags) getAutoUpgrade() bool {
75+
return flags.v.GetBool("auto-upgrade")
76+
}
77+
7478
func (flags *Flags) getIncludeChecks() []string {
7579
return fixViperStringSlice(flags.v.GetStringSlice("include-checks"))
7680
}

internal/run_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,32 @@ func Test_UpgradeCmdInvalid(t *testing.T) {
348348
require.Contains(t, stderr.String(), "failed to load original spec")
349349
}
350350

351+
func Test_AutoUpgradeBreaking_CrossVersionEquivalent(t *testing.T) {
352+
// simple-30.yaml and equivalent-31.yaml describe the same API; the latter
353+
// is the 3.1 canonical form of the former (nullable -> type array, etc).
354+
// With --auto-upgrade, both specs are canonicalised so the dialect diff
355+
// disappears and oasdiff reports no changes.
356+
var stdout bytes.Buffer
357+
require.Zero(t, internal.Run(cmdToArgs("oasdiff breaking ../data/upgrade/simple-30.yaml ../data/upgrade/equivalent-31.yaml --auto-upgrade --fail-on ERR"), &stdout, io.Discard))
358+
require.Contains(t, stdout.String(), "No changes detected")
359+
}
360+
361+
func Test_AutoUpgradeChangelog_CrossVersionEquivalent(t *testing.T) {
362+
var stdout bytes.Buffer
363+
require.Zero(t, internal.Run(cmdToArgs("oasdiff changelog ../data/upgrade/simple-30.yaml ../data/upgrade/equivalent-31.yaml --auto-upgrade --fail-on ERR"), &stdout, io.Discard))
364+
require.Contains(t, stdout.String(), "No changes detected")
365+
}
366+
367+
func Test_AutoUpgradeDiff_SameVersionIsHarmless(t *testing.T) {
368+
// When both specs are already on the same version, --auto-upgrade still
369+
// canonicalises them (idempotent). The diff result must not change.
370+
withoutFlag := bytes.Buffer{}
371+
require.Zero(t, internal.Run(cmdToArgs("oasdiff summary ../data/upgrade/already-31.yaml ../data/upgrade/already-31.yaml"), &withoutFlag, io.Discard))
372+
withFlag := bytes.Buffer{}
373+
require.Zero(t, internal.Run(cmdToArgs("oasdiff summary ../data/upgrade/already-31.yaml ../data/upgrade/already-31.yaml --auto-upgrade"), &withFlag, io.Discard))
374+
require.Equal(t, withoutFlag.String(), withFlag.String())
375+
}
376+
351377
func Test_Checks(t *testing.T) {
352378
require.Zero(t, internal.Run(cmdToArgs("oasdiff checks -l ru --tags decrease,parameters --severity info,warn,error"), io.Discard, io.Discard))
353379
}

internal/upgrade.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,25 @@ func runUpgrade(flags *Flags, stdout io.Writer) (bool, *ReturnError) {
5858
return false, nil
5959
}
6060

61+
// autoUpgradeSpecs canonicalizes the given specs to the latest OpenAPI 3.x
62+
// when --auto-upgrade is set. The walker is idempotent on already-canonical
63+
// specs, so calling it on a same-version pair is a safe no-op (the version
64+
// string bumps to the latest 3.x). Used by diff/breaking/changelog/summary
65+
// to make cross-version comparisons (e.g. 3.0 vs 3.1) just work.
66+
//
67+
// Nil entries are skipped; callers don't have to filter.
68+
func autoUpgradeSpecs(enabled bool, specs ...*load.SpecInfo) {
69+
if !enabled {
70+
return
71+
}
72+
for _, s := range specs {
73+
if s == nil || s.Spec == nil {
74+
continue
75+
}
76+
openapi3conv.Upgrade(s.Spec)
77+
}
78+
}
79+
6180
func outputUpgradedSpec(stdout io.Writer, spec *openapi3.T, format string) *ReturnError {
6281
// Reuse the flatten output path: both subcommands serialize a full
6382
// *openapi3.T to JSON/YAML and the rendering is identical. If a future

internal/viper.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ type Config struct {
206206
StripPrefixRevision string `mapstructure:"strip-prefix-revision"`
207207
IncludePathParams bool `mapstructure:"include-path-params"`
208208
AllowExternalRefs bool `mapstructure:"allow-external-refs"`
209+
AutoUpgrade bool `mapstructure:"auto-upgrade"`
209210
Template string `mapstructure:"template"`
210211
}
211212

0 commit comments

Comments
 (0)