Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions cmd/openapi/commands/openapi/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ With --minor-only, only performs cross-minor version upgrades:
- 3.1.x → 3.2.0 (cross-minor upgrade)
- 3.2.x → no change (same minor version, skip patch upgrades)

With --version, upgrades to a specific OpenAPI version instead of the latest:
- openapi spec upgrade --version 3.1.0 spec.yaml
(upgrades a 3.0.x spec to 3.1.0)

Note: --version and --minor-only are mutually exclusive.

The upgrade process includes:
- Updating the OpenAPI version field
- Converting nullable properties to proper JSON Schema format
Expand All @@ -44,19 +50,26 @@ Output options:
}

var (
minorOnly bool
writeInPlace bool
minorOnly bool
writeInPlace bool
targetVersion string
)

func init() {
upgradeCmd.Flags().BoolVar(&minorOnly, "minor-only", false, "only upgrade across minor versions, skip patch-level upgrades within same minor")
upgradeCmd.Flags().BoolVarP(&writeInPlace, "write", "w", false, "write result in-place to input file")
upgradeCmd.Flags().StringVarP(&targetVersion, "version", "V", "", "target OpenAPI version to upgrade to (default latest)")
}

func runUpgrade(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
inputFile := inputFileFromArgs(args)

if targetVersion != "" && minorOnly {
fmt.Fprintf(os.Stderr, "Error: --version and --minor-only are mutually exclusive\n")
os.Exit(1)
}

outputFile := outputFileFromArgs(args)

processor, err := NewOpenAPIProcessor(inputFile, outputFile, writeInPlace)
Expand All @@ -65,13 +78,27 @@ func runUpgrade(cmd *cobra.Command, args []string) {
os.Exit(1)
}

if err := upgradeOpenAPI(ctx, processor, !minorOnly); err != nil {
var opts []openapi.Option[openapi.UpgradeOptions]
if targetVersion != "" {
opts = append(opts, openapi.WithUpgradeTargetVersion(targetVersion))
// Enable same-minor upgrades so patch-level targets work as expected.
// Without this, --version 3.1.2 on a 3.1.0 doc would be silently
// skipped because they share the same minor version.
opts = append(opts, openapi.WithUpgradeSameMinorVersion())
} else if !minorOnly {
// By default, upgrade all versions including patch upgrades (e.g., 3.2.0 → 3.2.1)
opts = append(opts, openapi.WithUpgradeSameMinorVersion())
}
// When minorOnly is true, only cross-minor upgrades are performed
// Patch upgrades within the same minor version (e.g., 3.2.0 → 3.2.1) are skipped

if err := upgradeOpenAPI(ctx, processor, opts...); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, upgradeSameMinorVersion bool) error {
func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, opts ...openapi.Option[openapi.UpgradeOptions]) error {
// Load the OpenAPI document
doc, validationErrors, err := processor.LoadDocument(ctx)
if err != nil {
Expand All @@ -84,15 +111,6 @@ func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, upgradeSam
// Report validation errors but continue with upgrade
processor.ReportValidationErrors(validationErrors)

// Prepare upgrade options
var opts []openapi.Option[openapi.UpgradeOptions]
if upgradeSameMinorVersion {
// By default, upgrade all versions including patch upgrades (e.g., 3.2.0 → 3.2.1)
opts = append(opts, openapi.WithUpgradeSameMinorVersion())
}
// When minorOnly is true, only cross-minor upgrades are performed
// Patch upgrades within the same minor version (e.g., 3.2.0 → 3.2.1) are skipped

// Perform the upgrade
originalVersion := doc.OpenAPI
upgraded, err := openapi.Upgrade(ctx, doc, opts...)
Expand Down
166 changes: 166 additions & 0 deletions cmd/openapi/commands/openapi/upgrade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package openapi

import (
"bytes"
"strings"
"testing"

"github.com/speakeasy-api/openapi/openapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestUpgradeOpenAPI_ValidVersionTransition(t *testing.T) {
t.Parallel()

tests := []struct {
name string
inputDoc string
opts []openapi.Option[openapi.UpgradeOptions]
expectedVersion string
expectUpgraded bool
}{
{
name: "upgrades 3.0.3 to latest by default",
inputDoc: `openapi: "3.0.3"
info:
title: Test
version: "1.0"
paths: {}
`,
opts: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSameMinorVersion()},
expectedVersion: openapi.Version,
expectUpgraded: true,
},
{
name: "upgrades 3.1.0 to latest by default",
inputDoc: `openapi: "3.1.0"
info:
title: Test
version: "1.0"
paths: {}
`,
opts: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSameMinorVersion()},
expectedVersion: openapi.Version,
expectUpgraded: true,
},
{
name: "upgrades 3.0.3 to target version 3.1.0",
inputDoc: `openapi: "3.0.3"
info:
title: Test
version: "1.0"
paths: {}
`,
opts: []openapi.Option[openapi.UpgradeOptions]{
openapi.WithUpgradeTargetVersion("3.1.0"),
openapi.WithUpgradeSameMinorVersion(),
},
expectedVersion: "3.1.0",
expectUpgraded: true,
},
{
name: "no upgrade needed when already at target version",
inputDoc: `openapi: "3.1.0"
info:
title: Test
version: "1.0"
paths: {}
`,
opts: []openapi.Option[openapi.UpgradeOptions]{
openapi.WithUpgradeTargetVersion("3.1.0"),
openapi.WithUpgradeSameMinorVersion(),
},
expectedVersion: "3.1.0",
expectUpgraded: false,
},
{
name: "minor-only skips same-minor upgrade",
inputDoc: `openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths: {}
`,
opts: nil, // no WithUpgradeSameMinorVersion
expectedVersion: "3.2.0",
expectUpgraded: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var stdout, stderr bytes.Buffer
processor := &OpenAPIProcessor{
InputFile: "-",
ReadFromStdin: true,
WriteToStdout: true,
Stdin: strings.NewReader(tt.inputDoc),
Stdout: &stdout,
Stderr: &stderr,
}

err := upgradeOpenAPI(t.Context(), processor, tt.opts...)
require.NoError(t, err, "upgradeOpenAPI should succeed")

assert.Contains(t, stdout.String(), "openapi: \""+tt.expectedVersion+"\"",
"output should contain the expected version")

if tt.expectUpgraded {
assert.Contains(t, stderr.String(), "Successfully upgraded",
"stderr should report successful upgrade")
} else {
assert.Contains(t, stderr.String(), "No upgrade needed",
"stderr should report no upgrade needed")
}
})
}
}

func TestUpgradeOpenAPI_InvalidVersionTransition(t *testing.T) {
t.Parallel()

tests := []struct {
name string
inputDoc string
opts []openapi.Option[openapi.UpgradeOptions]
expectedErr string
}{
{
name: "cannot downgrade version",
inputDoc: `openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths: {}
`,
opts: []openapi.Option[openapi.UpgradeOptions]{
openapi.WithUpgradeTargetVersion("3.1.0"),
openapi.WithUpgradeSameMinorVersion(),
},
expectedErr: "cannot downgrade",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var stdout, stderr bytes.Buffer
processor := &OpenAPIProcessor{
InputFile: "-",
ReadFromStdin: true,
WriteToStdout: true,
Stdin: strings.NewReader(tt.inputDoc),
Stdout: &stdout,
Stderr: &stderr,
}

err := upgradeOpenAPI(t.Context(), processor, tt.opts...)
require.Error(t, err, "upgradeOpenAPI should return an error")
assert.Contains(t, err.Error(), tt.expectedErr, "error should contain expected message")
})
}
}