diff --git a/integration/swagger_conversion_test.go b/integration/swagger_conversion_test.go new file mode 100644 index 000000000..7825d6ac3 --- /dev/null +++ b/integration/swagger_conversion_test.go @@ -0,0 +1,64 @@ +package integration_tests + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +func TestAutomaticSwaggerConversion(t *testing.T) { + t.Parallel() + temp := setupTestDir(t) + + // Create workflow file that uses the Swagger 2.0 document + workflowFile := &workflow.Workflow{ + Version: workflow.WorkflowVersion, + Sources: make(map[string]workflow.Source), + Targets: make(map[string]workflow.Target), + } + + // Copy the Swagger 2.0 test file + swaggerPath := "swagger.yaml" + err := copyFile("resources/swagger.yaml", filepath.Join(temp, swaggerPath)) + require.NoError(t, err) + + outputPath := filepath.Join(temp, "output.yaml") + workflowFile.Sources["swagger-source"] = workflow.Source{ + Inputs: []workflow.Document{ + { + Location: workflow.LocationString(swaggerPath), + }, + }, + Output: &outputPath, + } + + err = os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0o755) + require.NoError(t, err) + err = workflow.Save(temp, workflowFile) + require.NoError(t, err) + + args := []string{"run", "-s", "all", "--pinned", "--skip-compile"} + cmdErr := execute(t, temp, args...).Run() + require.NoError(t, cmdErr) + + // Read the output file and verify it was converted to OpenAPI 3.0 + content, err := os.ReadFile(outputPath) + require.NoError(t, err, "No readable file %s exists", outputPath) + + contentStr := string(content) + + // Verify it's OpenAPI 3.0, not Swagger 2.0 + require.Contains(t, contentStr, "openapi: 3.", "Output should be OpenAPI 3.x") + require.NotContains(t, contentStr, "swagger: \"2.0\"", "Output should not contain Swagger 2.0 declaration") + require.NotContains(t, contentStr, "swagger: '2.0'", "Output should not contain Swagger 2.0 declaration") + + // Verify some paths from the original Swagger doc are preserved + require.True(t, strings.Contains(contentStr, "/pet") || + strings.Contains(contentStr, "\"/pet\""), "Should contain /pet path") + require.True(t, strings.Contains(contentStr, "/store/inventory") || + strings.Contains(contentStr, "\"/store/inventory\""), "Should contain /store/inventory path") +} diff --git a/integration/workflow_test.go b/integration/workflow_test.go index c857ee94c..f206f8faf 100644 --- a/integration/workflow_test.go +++ b/integration/workflow_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/speakeasy-api/speakeasy/internal/utils" "os" "os/exec" "path/filepath" @@ -14,6 +13,8 @@ import ( "sync" "testing" + "github.com/speakeasy-api/speakeasy/internal/utils" + "github.com/speakeasy-api/speakeasy/cmd" "github.com/speakeasy-api/versioning-reports/versioning" "github.com/spf13/cobra" @@ -308,8 +309,10 @@ func execute(t *testing.T, wd string, args ...string) Runnable { // executeI is a helper function to execute the main.go file inline. It can help when debugging integration tests // We should not use it on multiple tests at once as they will share memory: this can create issues. // so we leave it around as a little helper method: swap out execute for executeI and debug breakpoints work -var mutex sync.Mutex -var rootCmd = cmd.CmdForTest(version, artifactArch) +var ( + mutex sync.Mutex + rootCmd = cmd.CmdForTest(version, artifactArch) +) func executeI(t *testing.T, wd string, args ...string) Runnable { mutex.Lock() @@ -479,6 +482,18 @@ func TestSpecWorkflows(t *testing.T) { "/pet/findByTags", }, }, + { + name: "test automatic swagger 2.0 conversion", + inputDocs: []string{ + "swagger.yaml", + }, + out: "output.yaml", + expectedPaths: []string{ + "/pet", + "/store/inventory", + "/user/login", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/run/source.go b/internal/run/source.go index 751aba1ca..4ba514ac4 100644 --- a/internal/run/source.go +++ b/internal/run/source.go @@ -25,6 +25,7 @@ import ( "github.com/speakeasy-api/speakeasy/internal/utils" "github.com/speakeasy-api/speakeasy/internal/validation" "github.com/speakeasy-api/speakeasy/internal/workflowTracking" + "github.com/speakeasy-api/speakeasy/pkg/transform" ) type SourceResultCallback func(sourceRes *SourceResult, sourceStep SourceStepID) error @@ -161,6 +162,16 @@ func (w *Workflow) RunSource(ctx context.Context, parentStep *workflowTracking.W currentDocument = sourceRes.OverlayResult.Location } + // Automatically convert Swagger 2.0 documents to OpenAPI 3.0 + // Note: This is handled here rather than as a transformation type in source.Transformations + // as we don't want to expose this as a controllable transformation in a workflow file + if !frozenSource { + currentDocument, err = maybeConvertSwagger(ctx, rootStep, currentDocument, logger) + if err != nil { + return "", nil, err + } + } + if len(source.Transformations) > 0 && !frozenSource { w.OnSourceResult(sourceRes, SourceStepTransform) currentDocument, err = NewTransform(rootStep, source).Do(ctx, currentDocument) @@ -324,6 +335,10 @@ func getTempApplyPath(path string) string { return filepath.Join(workflow.GetTempDir(), fmt.Sprintf("applied_%s%s", randStringBytes(10), filepath.Ext(path))) } +func getTempConvertedPath(path string) string { + return filepath.Join(workflow.GetTempDir(), fmt.Sprintf("converted_%s%s", randStringBytes(10), filepath.Ext(path))) +} + // Returns true if any of the source inputs are remote. func workflowSourceHasRemoteInputs(source workflow.Source) bool { for _, input := range source.Inputs { @@ -409,3 +424,41 @@ func maybeReformatDocument(ctx context.Context, documentPath string, rootStep *w return documentPath, false, nil } + +// maybeConvertSwagger checks if a document is Swagger 2.0 and automatically converts it to OpenAPI 3.0 +func maybeConvertSwagger(ctx context.Context, rootStep *workflowTracking.WorkflowStep, documentPath string, logger log.Logger) (string, error) { + isSwagger, err := schemas.IsSwaggerDocument(ctx, documentPath) + if err != nil { + logger.Warnf("failed to check if document is Swagger: %s", err.Error()) + return documentPath, nil + } + + if !isSwagger { + return documentPath, nil + } + + convertStep := rootStep.NewSubstep("Converting Swagger 2.0 to OpenAPI 3.0") + logger.Infof("Detected Swagger 2.0 document, automatically converting to OpenAPI 3.0...") + + convertedPath := getTempConvertedPath(documentPath) + if err := os.MkdirAll(filepath.Dir(convertedPath), os.ModePerm); err != nil { + convertStep.Fail() + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + convertedFile, err := os.Create(convertedPath) + if err != nil { + convertStep.Fail() + return "", fmt.Errorf("failed to create converted file: %w", err) + } + defer convertedFile.Close() + + yamlOut := utils.HasYAMLExt(documentPath) + if err := transform.ConvertSwagger(ctx, documentPath, yamlOut, convertedFile); err != nil { + convertStep.Fail() + return "", fmt.Errorf("failed to convert Swagger to OpenAPI: %w", err) + } + + convertStep.Succeed() + return convertedPath, nil +} diff --git a/internal/schemas/swagger.go b/internal/schemas/swagger.go new file mode 100644 index 000000000..15b2e0fba --- /dev/null +++ b/internal/schemas/swagger.go @@ -0,0 +1,36 @@ +package schemas + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// IsSwaggerDocument checks if a document is a Swagger 2.0 specification +func IsSwaggerDocument(ctx context.Context, schemaPath string) (bool, error) { + data, err := os.ReadFile(schemaPath) + if err != nil { + return false, fmt.Errorf("failed to read document: %w", err) + } + + // Try to parse as YAML first (works for both YAML and JSON) + var doc map[string]interface{} + if err := yaml.Unmarshal(data, &doc); err != nil { + // If YAML parsing fails, try JSON + if err := json.Unmarshal(data, &doc); err != nil { + return false, fmt.Errorf("failed to parse document: %w", err) + } + } + + // Check for the "swagger" field which indicates Swagger 2.0 + if swagger, ok := doc["swagger"]; ok { + if swaggerStr, ok := swagger.(string); ok && swaggerStr == "2.0" { + return true, nil + } + } + + return false, nil +} diff --git a/internal/schemas/swagger_test.go b/internal/schemas/swagger_test.go new file mode 100644 index 000000000..f873a1ed2 --- /dev/null +++ b/internal/schemas/swagger_test.go @@ -0,0 +1,48 @@ +package schemas + +import ( + "context" + "testing" +) + +func TestIsSwaggerDocument(t *testing.T) { + tests := []struct { + name string + path string + expected bool + wantErr bool + }{ + { + name: "Swagger 2.0 YAML file", + path: "../../integration/resources/swagger.yaml", + expected: true, + wantErr: false, + }, + { + name: "OpenAPI 3.0 YAML file", + path: "../../integration/resources/converted.yaml", + expected: false, + wantErr: false, + }, + { + name: "Non-existent file", + path: "../../integration/resources/nonexistent.yaml", + expected: false, + wantErr: true, + }, + } + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IsSwaggerDocument(ctx, tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("IsSwaggerDocument() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.expected { + t.Errorf("IsSwaggerDocument() = %v, expected %v", got, tt.expected) + } + }) + } +} diff --git a/pkg/transform/convertSwagger.go b/pkg/transform/convertSwagger.go index 3fc897fa5..4b467cf73 100644 --- a/pkg/transform/convertSwagger.go +++ b/pkg/transform/convertSwagger.go @@ -13,12 +13,22 @@ import ( // ConvertSwagger upgrades a Swagger 2.0 document to OpenAPI 3.0 using the speakeasy-api/openapi library func ConvertSwagger(ctx context.Context, schemaPath string, yamlOut bool, w io.Writer) error { - // Read the Swagger 2.0 document data, err := os.ReadFile(schemaPath) if err != nil { return fmt.Errorf("failed to read swagger document: %w", err) } + return ConvertSwaggerFromReader(ctx, bytes.NewReader(data), schemaPath, w, yamlOut) +} + +// ConvertSwaggerFromReader upgrades a Swagger 2.0 document to OpenAPI 3.0 from an io.Reader +func ConvertSwaggerFromReader(ctx context.Context, r io.Reader, schemaPath string, w io.Writer, yamlOut bool) error { + // Read all data from the reader + data, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("failed to read swagger document: %w", err) + } + // Unmarshal using swagger.Unmarshal (returns swagger doc, validation errors, and error) swaggerDoc, validationErrs, err := swagger.Unmarshal(ctx, bytes.NewReader(data)) if err != nil {