diff --git a/cmd/openapi/commands/openapi/upgrade.go b/cmd/openapi/commands/openapi/upgrade.go index 3a5e21a..21bc6b6 100644 --- a/cmd/openapi/commands/openapi/upgrade.go +++ b/cmd/openapi/commands/openapi/upgrade.go @@ -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 @@ -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) @@ -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 { @@ -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...) diff --git a/cmd/openapi/commands/openapi/upgrade_test.go b/cmd/openapi/commands/openapi/upgrade_test.go new file mode 100644 index 0000000..27e7bd8 --- /dev/null +++ b/cmd/openapi/commands/openapi/upgrade_test.go @@ -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") + }) + } +}