Skip to content

Commit 1b319ca

Browse files
feat(openapi3conv): generalize API to any 3.x target version
Renames UpgradeTo31 to Upgrade and lifts the target version into the options struct, so callers can target 3.1, 3.2, or any future 3.x where representational rewrites can land in this package. UpgradeTo31 stays as a convenience wrapper. OpenAPI 3.2 introduced no breaking changes over 3.1 (purely additive: structured tags, streaming media types, additionalOperations, OAuth device flow, defaultMapping in discriminator). So 3.1 -> 3.2 is just a version-string change with no rewrites; the existing 3.0 -> 3.1 canonicalization is sufficient to reach a clean 3.2 form. Cross-major upgrades (3 -> 4 if/when v4 ships) are explicitly rejected with an error pointing at the openapi2conv pattern as the right home for that work. Same for downgrades and unparseable version strings. New tests cover: - TestUpgrade_CustomTarget — non-default 3.1.0 - TestUpgrade_TargetIs32 — 3.0 input, 3.2 target, rewrites still applied - TestUpgrade_SkipVersionBump — leaves doc.OpenAPI alone - TestUpgrade_RejectsCrossMajor — 4.0.0 target errors clearly - TestUpgrade_RejectsDowngrade — 3.1 -> 3.0 errors - TestUpgrade_RejectsInvalidVersion — non-semver target errors Updates package doc to spell out the in-scope/out-of-scope boundary (3.x ↔ 3.x in scope; cross-major out of scope; downgrades out of scope).
1 parent 49e0425 commit 1b319ca

3 files changed

Lines changed: 148 additions & 37 deletions

File tree

openapi3conv/doc.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
// Package openapi3conv canonicalizes an OpenAPI 3.0 document into the OpenAPI 3.1
2-
// representation. Schema-level constructs serialize differently between the two
3-
// versions but represent the same semantics; this package rewrites the 3.0 forms
4-
// into their 3.1 equivalents in place. The transformation is mechanical and
5-
// lossless — every 3.0 construct has a direct 3.1 form.
1+
// Package openapi3conv canonicalizes an OpenAPI 3.x document into the
2+
// representation of a chosen target version. Schema-level constructs serialize
3+
// differently between OpenAPI 3.0 and 3.1, but represent the same semantics;
4+
// this package rewrites the 3.0 forms into their 3.1 equivalents in place.
5+
// The transformation is mechanical and lossless — every 3.0 construct has a
6+
// direct 3.1 form. OpenAPI 3.2 is purely additive over 3.1, so 3.1 → 3.2 is
7+
// a version-string change with no further rewrites.
68
//
7-
// Use this when a downstream consumer (diff tools, validators, code generators)
8-
// needs a single canonical representation regardless of the source spec's
9-
// declared version.
9+
// Use this when a downstream consumer (diff tools, validators, code
10+
// generators) needs a single canonical representation regardless of the
11+
// source spec's declared version.
1012
//
11-
// The opposite direction (3.1 → 3.0) is lossy by nature and out of scope.
13+
// Scope:
14+
// - In scope: 3.0 ↔ 3.1 ↔ 3.2 ↔ any future 3.x where representational
15+
// differences are rewritable in place.
16+
// - Out of scope: 3.x → 3.0 (lossy by nature).
17+
// - Out of scope: cross-major upgrades (3 → 4 if/when v4 ships). Those
18+
// belong in a dedicated package mirroring openapi2conv (which converts
19+
// Swagger 2.0 documents to OpenAPI 3.0).
1220
package openapi3conv

openapi3conv/openapi3_conv.go

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,66 +3,95 @@ package openapi3conv
33
import (
44
"fmt"
55
"io"
6+
"strconv"
7+
"strings"
68

79
"github.com/getkin/kin-openapi/openapi3"
810
)
911

1012
// DefaultTargetVersion is the OpenAPI version string written when bumping the
11-
// document version. Set to the current 3.1 patch release; the OAI upgrade
12-
// guide uses the same.
13+
// document version. The OAI upgrade guide uses the current 3.1 patch release.
1314
const DefaultTargetVersion = "3.1.1"
1415

1516
// UpgradeOptions controls per-pass behaviour.
1617
type UpgradeOptions struct {
18+
// Target is the version string written when SkipVersionBump is false.
19+
// Defaults to DefaultTargetVersion. Currently any 3.x version is accepted;
20+
// representational rewrites only exist for 3.0 → 3.1, since 3.1 → 3.2 is
21+
// purely additive (no breaking changes). Future minor versions that
22+
// introduce representational changes can extend the dispatch in Upgrade.
23+
Target string
24+
1725
// SkipVersionBump leaves doc.OpenAPI unchanged. Useful for consumers
1826
// that want representations canonicalized while preserving the stated
1927
// version (e.g., a pre-diff normalization step).
2028
SkipVersionBump bool
2129

22-
// TargetVersion is the version string written when SkipVersionBump is
23-
// false. Defaults to DefaultTargetVersion.
24-
TargetVersion string
25-
2630
// Verbose, if non-nil, receives one line per rewrite for debugging.
2731
Verbose io.Writer
2832
}
2933

30-
// UpgradeTo31 rewrites every 3.0-form construct in doc into its 3.1 form
31-
// in place: bumps the version, replaces nullable: true with type arrays,
32-
// replaces boolean exclusiveMinimum/exclusiveMaximum with numeric form,
33-
// replaces example with examples. Idempotent on already-3.1 documents.
34+
// Upgrade canonicalizes doc into the representation of opts.Target in place.
3435
//
35-
// Returns an error only if the document is structurally invalid (e.g., nil
36-
// document).
37-
func UpgradeTo31(doc *openapi3.T) error {
38-
return UpgradeTo31WithOptions(doc, UpgradeOptions{})
39-
}
40-
41-
// UpgradeTo31WithOptions is the variant with explicit options.
42-
func UpgradeTo31WithOptions(doc *openapi3.T, opts UpgradeOptions) error {
36+
// The schema-level rewrites the walker applies (nullable → type array, boolean
37+
// exclusive bounds → numeric, example → examples) are idempotent and
38+
// convergent on the 3.1+ form. Calling Upgrade on an already-3.1 (or 3.2)
39+
// document is a no-op aside from the version bump.
40+
//
41+
// Cross-major upgrades are not supported. OpenAPI 4 (or any future major
42+
// version) will require a separate package mirroring the openapi2conv
43+
// pattern (which converts Swagger 2.0 documents to OpenAPI 3.0). Returning
44+
// an error here keeps that boundary explicit.
45+
func Upgrade(doc *openapi3.T, opts UpgradeOptions) error {
4346
if doc == nil {
4447
return fmt.Errorf("openapi3conv: doc is nil")
4548
}
4649

47-
target := opts.TargetVersion
50+
target := opts.Target
4851
if target == "" {
4952
target = DefaultTargetVersion
5053
}
5154

55+
srcMajor, srcMinor, err := parseVersion(doc.OpenAPI)
56+
if err != nil {
57+
return fmt.Errorf("openapi3conv: invalid doc.OpenAPI %q: %w", doc.OpenAPI, err)
58+
}
59+
tgtMajor, tgtMinor, err := parseVersion(target)
60+
if err != nil {
61+
return fmt.Errorf("openapi3conv: invalid Target %q: %w", target, err)
62+
}
63+
64+
if srcMajor != tgtMajor {
65+
return fmt.Errorf(
66+
"openapi3conv: cross-major upgrade not supported (%s -> %s); "+
67+
"a separate package is the right home for cross-major conversions "+
68+
"(see openapi2conv for the existing 2 -> 3 pattern)",
69+
doc.OpenAPI, target,
70+
)
71+
}
72+
if tgtMinor < srcMinor {
73+
return fmt.Errorf("openapi3conv: cannot downgrade %s to %s", doc.OpenAPI, target)
74+
}
75+
5276
w := &walker{
5377
visited: map[*openapi3.Schema]struct{}{},
5478
opts: opts,
5579
}
80+
w.walkDoc(doc)
5681

5782
if !opts.SkipVersionBump && doc.OpenAPI != target {
5883
w.logf("openapi: %s -> %s", doc.OpenAPI, target)
5984
doc.OpenAPI = target
6085
}
61-
62-
w.walkDoc(doc)
6386
return nil
6487
}
6588

89+
// UpgradeTo31 is a convenience wrapper for Upgrade with Target = "3.1.1".
90+
// Idempotent on already-3.1 documents.
91+
func UpgradeTo31(doc *openapi3.T) error {
92+
return Upgrade(doc, UpgradeOptions{})
93+
}
94+
6695
// UpgradeSchema canonicalizes a single schema (and its descendants) in place.
6796
// Exposed for callers that need to upgrade a sub-tree rather than a full
6897
// document — e.g., a diff tool comparing isolated schemas.
@@ -74,6 +103,24 @@ func UpgradeSchema(s *openapi3.Schema) {
74103
w.walkSchema(s)
75104
}
76105

106+
// parseVersion splits an OpenAPI version string ("3.0.3", "3.1.1", "3.2.0")
107+
// into major and minor integers. Patch and pre-release suffixes are ignored.
108+
func parseVersion(v string) (major, minor int, err error) {
109+
parts := strings.SplitN(v, ".", 3)
110+
if len(parts) < 2 {
111+
return 0, 0, fmt.Errorf("expected MAJOR.MINOR[.PATCH], got %q", v)
112+
}
113+
major, err = strconv.Atoi(parts[0])
114+
if err != nil {
115+
return 0, 0, fmt.Errorf("invalid major %q: %w", parts[0], err)
116+
}
117+
minor, err = strconv.Atoi(parts[1])
118+
if err != nil {
119+
return 0, 0, fmt.Errorf("invalid minor %q: %w", parts[1], err)
120+
}
121+
return major, minor, nil
122+
}
123+
77124
// walker carries cycle-tracking state and verbose output across the schema
78125
// graph. Each *Schema is visited at most once.
79126
type walker struct {

openapi3conv/openapi3_conv_test.go

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,30 +48,86 @@ paths: {}
4848
assert.Equal(t, "3.1.1", doc.OpenAPI)
4949
}
5050

51-
func TestUpgradeTo31_CustomTargetVersion(t *testing.T) {
51+
func TestUpgrade_CustomTarget(t *testing.T) {
5252
doc := loadV30(t, `
5353
openapi: 3.0.3
5454
info: {title: t, version: '1'}
5555
paths: {}
5656
`)
57-
require.NoError(t, openapi3conv.UpgradeTo31WithOptions(doc, openapi3conv.UpgradeOptions{
58-
TargetVersion: "3.1.0",
57+
require.NoError(t, openapi3conv.Upgrade(doc, openapi3conv.UpgradeOptions{
58+
Target: "3.1.0",
5959
}))
6060
assert.Equal(t, "3.1.0", doc.OpenAPI)
6161
}
6262

63-
func TestUpgradeTo31_SkipVersionBump(t *testing.T) {
63+
func TestUpgrade_TargetIs32(t *testing.T) {
64+
// 3.2 is purely additive over 3.1 — no schema-level rewrites between
65+
// them. Upgrade applies the 3.0 → 3.1 rewrites and writes the 3.2
66+
// version string. The resulting doc is canonical and version-correct.
6467
doc := loadV30(t, `
6568
openapi: 3.0.3
6669
info: {title: t, version: '1'}
6770
paths: {}
71+
components:
72+
schemas:
73+
Pet:
74+
type: string
75+
nullable: true
6876
`)
69-
require.NoError(t, openapi3conv.UpgradeTo31WithOptions(doc, openapi3conv.UpgradeOptions{
77+
require.NoError(t, openapi3conv.Upgrade(doc, openapi3conv.UpgradeOptions{
78+
Target: "3.2.0",
79+
}))
80+
assert.Equal(t, "3.2.0", doc.OpenAPI)
81+
assert.Equal(t, openapi3.Types{"string", "null"}, *doc.Components.Schemas["Pet"].Value.Type)
82+
}
83+
84+
func TestUpgrade_SkipVersionBump(t *testing.T) {
85+
doc := loadV30(t, `
86+
openapi: 3.0.3
87+
info: {title: t, version: '1'}
88+
paths: {}
89+
`)
90+
require.NoError(t, openapi3conv.Upgrade(doc, openapi3conv.UpgradeOptions{
7091
SkipVersionBump: true,
7192
}))
7293
assert.Equal(t, "3.0.3", doc.OpenAPI)
7394
}
7495

96+
func TestUpgrade_RejectsCrossMajor(t *testing.T) {
97+
// Cross-major upgrades belong in a dedicated package (mirror of
98+
// openapi2conv). Pre-pin that boundary with an explicit error so any
99+
// future 3 → 4 transition lands somewhere predictable.
100+
doc := loadV30(t, `
101+
openapi: 3.0.3
102+
info: {title: t, version: '1'}
103+
paths: {}
104+
`)
105+
err := openapi3conv.Upgrade(doc, openapi3conv.UpgradeOptions{Target: "4.0.0"})
106+
require.Error(t, err)
107+
assert.Contains(t, err.Error(), "cross-major")
108+
}
109+
110+
func TestUpgrade_RejectsDowngrade(t *testing.T) {
111+
doc := loadV30(t, `
112+
openapi: 3.1.1
113+
info: {title: t, version: '1'}
114+
paths: {}
115+
`)
116+
err := openapi3conv.Upgrade(doc, openapi3conv.UpgradeOptions{Target: "3.0.3"})
117+
require.Error(t, err)
118+
assert.Contains(t, err.Error(), "downgrade")
119+
}
120+
121+
func TestUpgrade_RejectsInvalidVersion(t *testing.T) {
122+
doc := loadV30(t, `
123+
openapi: 3.0.3
124+
info: {title: t, version: '1'}
125+
paths: {}
126+
`)
127+
err := openapi3conv.Upgrade(doc, openapi3conv.UpgradeOptions{Target: "not-a-version"})
128+
require.Error(t, err)
129+
}
130+
75131
// ---------------------------------------------------------------------------
76132
// Nullable rewrite
77133
// ---------------------------------------------------------------------------
@@ -400,7 +456,7 @@ func TestUpgradeTo31_CycleSafe(t *testing.T) {
400456
// Verbose logging
401457
// ---------------------------------------------------------------------------
402458

403-
func TestUpgradeTo31_VerboseLogsRewrites(t *testing.T) {
459+
func TestUpgrade_VerboseLogsRewrites(t *testing.T) {
404460
doc := loadV30(t, `
405461
openapi: 3.0.3
406462
info: {title: t, version: '1'}
@@ -413,7 +469,7 @@ components:
413469
example: fido
414470
`)
415471
var buf bytes.Buffer
416-
require.NoError(t, openapi3conv.UpgradeTo31WithOptions(doc, openapi3conv.UpgradeOptions{
472+
require.NoError(t, openapi3conv.Upgrade(doc, openapi3conv.UpgradeOptions{
417473
Verbose: &buf,
418474
}))
419475
out := buf.String()

0 commit comments

Comments
 (0)