diff --git a/go.mod b/go.mod index 026abc9f8..634267483 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/getkin/kin-openapi v0.128.0 github.com/go-git/go-git/v5 v5.12.0 github.com/gofrs/flock v0.12.1 + github.com/google/go-cmp v0.6.0 github.com/google/go-github/v58 v58.0.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.7.0 @@ -38,7 +39,7 @@ require ( github.com/speakeasy-api/openapi-overlay v0.10.1 github.com/speakeasy-api/sdk-gen-config v1.31.1 github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.1 - github.com/speakeasy-api/speakeasy-core v0.19.8 + github.com/speakeasy-api/speakeasy-core v0.20.0 github.com/speakeasy-api/speakeasy-proxy v0.0.2 github.com/speakeasy-api/versioning-reports v0.6.0 github.com/spf13/cobra v1.9.1 @@ -133,7 +134,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 065c20036..1243431f1 100644 --- a/go.sum +++ b/go.sum @@ -589,8 +589,8 @@ github.com/speakeasy-api/sdk-gen-config v1.31.1 h1:peGHmojOH4OzCpKHoaQ++a7J6hLy6 github.com/speakeasy-api/sdk-gen-config v1.31.1/go.mod h1:e9PjnCRHGa4K4EFKVU+kKmihOZjJ2V4utcU+274+bnQ= github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.1 h1:cX+ip9YHkunvIHBALjDDIyEvo8rCLUCfIo7ldzcIeU4= github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.1/go.mod h1:k9JD6Rj0+Iizc5COoLZHyRIOGGITpKZ2qBuFFO8SqNI= -github.com/speakeasy-api/speakeasy-core v0.19.8 h1:JuHPTV9JRzdC2z0bRRjn9sf3f5K4twxSYBrxtSmGJiQ= -github.com/speakeasy-api/speakeasy-core v0.19.8/go.mod h1:KqEmQy04aCZJuUed8mt6sywpLcqbeQfRJk9UDWD0cCk= +github.com/speakeasy-api/speakeasy-core v0.20.0 h1:htH4AsRUIx1dciukesF2ICI81KkmLXg4NHYQ8m66woA= +github.com/speakeasy-api/speakeasy-core v0.20.0/go.mod h1:KqEmQy04aCZJuUed8mt6sywpLcqbeQfRJk9UDWD0cCk= github.com/speakeasy-api/speakeasy-go-sdk v1.8.1 h1:atzohw12oQ5ipaLb1q7ntTu4vvAgKDJsrvaUoOu6sw0= github.com/speakeasy-api/speakeasy-go-sdk v1.8.1/go.mod h1:XbzaM0sMjj8bGooz/uEtNkOh1FQiJK7RFuNG3LPBSAU= github.com/speakeasy-api/speakeasy-proxy v0.0.2 h1:u4rQ8lXvuYRCSxiLQGb5JxkZRwNIDlyh+pMFYD6OGjA= diff --git a/integration/multi_test.go b/integration/multi_test.go new file mode 100644 index 000000000..a6ef8bc53 --- /dev/null +++ b/integration/multi_test.go @@ -0,0 +1,86 @@ +package integration_tests + +import ( + "github.com/google/go-cmp/cmp" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +func TestMultiFileStability(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping test on Windows") + } + // If windows, skip + temp := setupTestDir(t) + + // Copy the multi-file OpenAPI spec files + err := copyFile("resources/multi_root.yaml", filepath.Join(temp, "multi_root.yaml")) + require.NoError(t, err) + err = copyFile("resources/multi_components.yaml", filepath.Join(temp, "multi_components.yaml")) + require.NoError(t, err) + + // Create a workflow file with multi-file input + workflowFile := &workflow.Workflow{ + Version: workflow.WorkflowVersion, + Sources: map[string]workflow.Source{ + "multi-file-source": { + Inputs: []workflow.Document{ + {Location: workflow.LocationString("multi_root.yaml")}, + }, + }, + }, + Targets: map[string]workflow.Target{ + "multi-file-target": { + Target: "typescript", + Source: "multi-file-source", + }, + }, + } + + err = os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0o755) + require.NoError(t, err) + err = workflow.Save(temp, workflowFile) + require.NoError(t, err) + + // Run the initial generation + var initialChecksums map[string]string + initialArgs := []string{"run", "-t", "all", "--force", "--pinned", "--skip-versioning", "--skip-compile"} + cmdErr := execute(t, temp, initialArgs...).Run() + require.NoError(t, cmdErr) + + // Calculate checksums of generated files + initialChecksums, err = filesToString(temp) + require.NoError(t, err) + + // Re-run the generation. We should have stable digests. + cmdErr = execute(t, temp, initialArgs...).Run() + require.NoError(t, cmdErr) + rerunChecksums, err := filesToString(temp) + require.NoError(t, err) + + // Compare checksums to ensure stability + require.Equal(t, initialChecksums, rerunChecksums, "Generated files should be identical for multi-file OpenAPI specs") + + // Test frozen workflow lock behavior + frozenArgs := []string{"run", "-t", "all", "--pinned", "--frozen-workflow-lockfile", "--skip-compile"} + cmdErr = execute(t, temp, frozenArgs...).Run() + require.NoError(t, cmdErr) + + // Calculate checksums after frozen run + frozenChecksums, err := filesToString(temp) + require.NoError(t, err) + + // exclude gen.lock -- we could (we do) reformat the document inside the frozen one + delete(frozenChecksums, ".speakeasy/gen.lock") + delete(initialChecksums, ".speakeasy/gen.lock") + + // Compare checksums + if diff := cmp.Diff(initialChecksums, frozenChecksums); diff != "" { + t.Fatalf("Generated files should be identical when using --frozen-workflow-lock with multi-file specs. Mismatch (-want +got):\n%s", diff) + } +} diff --git a/integration/resources/multi_components.yaml b/integration/resources/multi_components.yaml new file mode 100644 index 000000000..fe69cbf53 --- /dev/null +++ b/integration/resources/multi_components.yaml @@ -0,0 +1,18 @@ +components: + schemas: + TestResponse: + type: object + properties: + status: + type: string + enum: ["success", "error"] + data: + type: array + items: + type: object + properties: + value: + type: string + timestamp: + type: string + format: date-time \ No newline at end of file diff --git a/integration/resources/multi_root.yaml b/integration/resources/multi_root.yaml new file mode 100644 index 000000000..1b6c7a6f4 --- /dev/null +++ b/integration/resources/multi_root.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Multi-File Test API + version: 1.0.0 +paths: + /test: + get: + operationId: getTest + summary: Test endpoint + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: "multi_components.yaml#/components/schemas/TestResponse" +components: + schemas: + LocalSchema: + type: object + properties: + id: + type: string + name: + type: string \ No newline at end of file diff --git a/integration/workflow_registry_test.go b/integration/workflow_registry_test.go index a36ce63f8..0321772f6 100644 --- a/integration/workflow_registry_test.go +++ b/integration/workflow_registry_test.go @@ -1,8 +1,6 @@ package integration_tests import ( - "crypto/md5" - "fmt" "gopkg.in/yaml.v3" "os" "path/filepath" @@ -13,8 +11,6 @@ import ( ) func TestStability(t *testing.T) { - t.Skip("Skipping stability test until we can figure out how to make it work on CI") - t.Parallel() temp := setupTestDir(t) // Create a basic workflow file @@ -47,15 +43,15 @@ func TestStability(t *testing.T) { require.NoError(t, cmdErr) // Calculate checksums of generated files - initialChecksums, err = calculateChecksums(temp) + initialChecksums, err = filesToString(temp) require.NoError(t, err) // Re-run the generation. We should have stable digests. cmdErr = execute(t, temp, initialArgs...).Run() require.NoError(t, cmdErr) - rerunChecksums, err := calculateChecksums(temp) + rerunChecksums, err := filesToString(temp) require.NoError(t, err) - require.Equal(t, initialChecksums, rerunChecksums, "Generated files should be identical when using --frozen-workflow-lock") + require.Equal(t, initialChecksums, rerunChecksums, "Generated files should be identical") // Modify the workflow file to simulate a change // Shouldn't do anything; we'll validate that later. workflowFile.Sources["test-source"].Inputs[0].Location = "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.1/petstore.yaml" @@ -67,9 +63,14 @@ func TestStability(t *testing.T) { require.NoError(t, cmdErr) // Calculate checksums after frozen run - frozenChecksums, err := calculateChecksums(temp) + frozenChecksums, err := filesToString(temp) require.NoError(t, err) + // exclude gen.lock -- we could (we do) reformat the document inside the frozen one + delete(frozenChecksums, ".speakeasy/gen.lock") + delete(frozenChecksums, ".speakeasy\\gen.lock") // windows + delete(initialChecksums, ".speakeasy/gen.lock") + delete(initialChecksums, ".speakeasy\\gen.lock") // windows // Compare checksums require.Equal(t, initialChecksums, frozenChecksums, "Generated files should be identical when using --frozen-workflow-lock") } @@ -168,7 +169,7 @@ func TestRegistryFlow_JSON(t *testing.T) { require.NoError(t, cmdErr) } -func calculateChecksums(dir string) (map[string]string, error) { +func filesToString(dir string) (map[string]string, error) { checksums := make(map[string]string) err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -180,7 +181,7 @@ func calculateChecksums(dir string) (map[string]string, error) { return err } relPath, _ := filepath.Rel(dir, path) - checksums[relPath] = fmt.Sprintf("%x", md5.Sum(data)) + checksums[relPath] = string(data) } return nil }) diff --git a/integration/workflow_test.go b/integration/workflow_test.go index df3bca403..124e16d80 100644 --- a/integration/workflow_test.go +++ b/integration/workflow_test.go @@ -248,6 +248,8 @@ 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) diff --git a/internal/run/merge.go b/internal/run/merge.go index ccea8777c..92f715647 100644 --- a/internal/run/merge.go +++ b/internal/run/merge.go @@ -20,7 +20,10 @@ type Merge struct { ruleset string } -var _ SourceStep = Merge{} +type MergeResult struct { + Location string + InputSchemaLocation []string +} func NewMerge(w *Workflow, parentStep *workflowTracking.WorkflowStep, source workflow.Source, ruleset string) Merge { return Merge{ @@ -31,29 +34,29 @@ func NewMerge(w *Workflow, parentStep *workflowTracking.WorkflowStep, source wor } } -func (m Merge) Do(ctx context.Context, _ string) (string, error) { +func (m Merge) Do(ctx context.Context, _ string) (result MergeResult, err error) { mergeStep := m.parentStep.NewSubstep("Merge Documents") - mergeLocation := m.source.GetTempMergeLocation() + result.Location = m.source.GetTempMergeLocation() - log.From(ctx).Infof("Merging %d schemas into %s...", len(m.source.Inputs), mergeLocation) + log.From(ctx).Infof("Merging %d schemas into %s...", len(m.source.Inputs), result.Location) - inSchemas := []string{} for _, input := range m.source.Inputs { - resolvedPath, err := schemas.ResolveDocument(ctx, input, nil, mergeStep) + var resolvedPath string + resolvedPath, err = schemas.ResolveDocument(ctx, input, nil, mergeStep) if err != nil { - return "", err + return } - inSchemas = append(inSchemas, resolvedPath) + result.InputSchemaLocation = append(result.InputSchemaLocation, resolvedPath) } mergeStep.NewSubstep(fmt.Sprintf("Merge %d documents", len(m.source.Inputs))) - if err := mergeDocuments(ctx, inSchemas, mergeLocation, m.ruleset, m.workflow.ProjectDir, m.workflow.SkipGenerateLintReport); err != nil { - return "", err + if err = mergeDocuments(ctx, result.InputSchemaLocation, result.Location, m.ruleset, m.workflow.ProjectDir, m.workflow.SkipGenerateLintReport); err != nil { + return } - return mergeLocation, nil + return result, nil } func mergeDocuments(ctx context.Context, inSchemas []string, outFile, defaultRuleset, workingDir string, skipGenerateLintReport bool) error { diff --git a/internal/run/overlay.go b/internal/run/overlay.go index 8748cd841..fb462d46e 100644 --- a/internal/run/overlay.go +++ b/internal/run/overlay.go @@ -21,8 +21,6 @@ type Overlay struct { source workflow.Source } -var _ SourceStep = Overlay{} - func NewOverlay(parentStep *workflowTracking.WorkflowStep, source workflow.Source) Overlay { return Overlay{ parentStep: parentStep, @@ -30,27 +28,32 @@ func NewOverlay(parentStep *workflowTracking.WorkflowStep, source workflow.Sourc } } -func (o Overlay) Do(ctx context.Context, inputPath string) (string, error) { +type OverlayResult struct { + Location string + InputSchemaLocation []string +} + +func (o Overlay) Do(ctx context.Context, inputPath string) (result OverlayResult, err error) { overlayStep := o.parentStep.NewSubstep("Applying Overlays") overlayLocation := o.source.GetTempOverlayLocation() + result.Location = overlayLocation log.From(ctx).Infof("Applying %d overlays into %s...", len(o.source.Overlays), overlayLocation) - var err error var overlaySchemas []string for _, overlay := range o.source.Overlays { overlayFilePath := "" if overlay.Document != nil { overlayFilePath, err = schemas.ResolveDocument(ctx, *overlay.Document, nil, overlayStep) if err != nil { - return "", err + return } } else if overlay.FallbackCodeSamples != nil { // Make temp file for the overlay output overlayFilePath = filepath.Join(workflow.GetTempDir(), fmt.Sprintf("fallback_code_samples_overlay_%s.yaml", randStringBytes(10))) - if err := os.MkdirAll(filepath.Dir(overlayFilePath), 0o755); err != nil { - return "", err + if err = os.MkdirAll(filepath.Dir(overlayFilePath), 0o755); err != nil { + return } err = defaultcodesamples.DefaultCodeSamples(ctx, defaultcodesamples.DefaultCodeSamplesFlags{ @@ -60,21 +63,22 @@ func (o Overlay) Do(ctx context.Context, inputPath string) (string, error) { }) if err != nil { log.From(ctx).Errorf("failed to generate default code samples: %s", err.Error()) - return "", err + return } } overlaySchemas = append(overlaySchemas, overlayFilePath) + result.InputSchemaLocation = append(result.InputSchemaLocation, overlayFilePath) } overlayStep.NewSubstep(fmt.Sprintf("Apply %d overlay(s)", len(o.source.Overlays))) - if err := overlayDocument(ctx, inputPath, overlaySchemas, overlayLocation); err != nil { - return "", err + if err = overlayDocument(ctx, inputPath, overlaySchemas, overlayLocation); err != nil { + return } overlayStep.Succeed() - return overlayLocation, nil + return } func overlayDocument(ctx context.Context, schema string, overlayFiles []string, outFile string) error { diff --git a/internal/run/run.go b/internal/run/run.go index ec77da9d0..96e556567 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -25,6 +25,10 @@ import ( "github.com/speakeasy-api/speakeasy/internal/workflowTracking" ) +type SourceStep interface { + Do(ctx context.Context, inputPath string) (string, error) +} + const speakeasySelf = "speakeasy-self" func ParseSourcesAndTargets() ([]string, []string, error) { diff --git a/internal/run/source.go b/internal/run/source.go index d0943a568..24156f5c1 100644 --- a/internal/run/source.go +++ b/internal/run/source.go @@ -50,10 +50,13 @@ const ( type SourceResult struct { Source string // The merged OAS spec that was input to the source contents as a string - InputSpec string - LintResult *validation.ValidationResult - ChangeReport *reports.ReportResult - Diagnosis suggestions.Diagnosis + InputSpec string + LintResult *validation.ValidationResult + ChangeReport *reports.ReportResult + Diagnosis suggestions.Diagnosis + OverlayResult OverlayResult + MergeResult MergeResult + CLIVersion string // The path to the output OAS spec OutputPath string } @@ -63,10 +66,6 @@ type LintingError struct { Document string } -type SourceStep interface { - Do(ctx context.Context, inputPath string) (string, error) -} - func (e *LintingError) Error() string { errString := e.Err.Error() if strings.Contains(e.Err.Error(), "spec type not supported by libopenapi") { @@ -129,11 +128,13 @@ func (w *Workflow) RunSource(ctx context.Context, parentStep *workflowTracking.W outputLocation = reformattedLocation } } + sourceRes.MergeResult.InputSchemaLocation = []string{currentDocument} } else { - currentDocument, err = NewMerge(w, rootStep, source, rulesetToUse).Do(ctx, currentDocument) + sourceRes.MergeResult, err = NewMerge(w, rootStep, source, rulesetToUse).Do(ctx, currentDocument) if err != nil { return "", nil, err } + currentDocument = sourceRes.MergeResult.Location } sourceRes.InputSpec, err = utils.ReadFileToString(currentDocument) @@ -143,10 +144,11 @@ func (w *Workflow) RunSource(ctx context.Context, parentStep *workflowTracking.W if len(source.Overlays) > 0 && !w.FrozenWorkflowLock { w.OnSourceResult(sourceRes, SourceStepOverlay) - currentDocument, err = NewOverlay(rootStep, source).Do(ctx, currentDocument) + sourceRes.OverlayResult, err = NewOverlay(rootStep, source).Do(ctx, currentDocument) if err != nil { return "", nil, err } + currentDocument = sourceRes.OverlayResult.Location } if len(source.Transformations) > 0 && !w.FrozenWorkflowLock { @@ -157,11 +159,12 @@ func (w *Workflow) RunSource(ctx context.Context, parentStep *workflowTracking.W } } - if err := writeToOutputLocation(ctx, currentDocument, outputLocation); err != nil { - return "", nil, fmt.Errorf("failed to write to output location: %w %s?", err, outputLocation) + if !w.FrozenWorkflowLock { + if err := writeToOutputLocation(ctx, currentDocument, outputLocation); err != nil { + return "", nil, fmt.Errorf("failed to write to output location: %w %s", err, outputLocation) + } } - currentDocument = outputLocation - sourceRes.OutputPath = currentDocument + sourceRes.OutputPath = outputLocation if !w.SkipLinting { w.OnSourceResult(sourceRes, SourceStepLint) @@ -182,7 +185,7 @@ func (w *Workflow) RunSource(ctx context.Context, parentStep *workflowTracking.W w.OnSourceResult(sourceRes, SourceStepUpload) if !w.SkipSnapshot { - err = w.snapshotSource(ctx, rootStep, sourceID, source, currentDocument) + err = w.snapshotSource(ctx, rootStep, sourceID, source, sourceRes) if err != nil && !errors.Is(err, ocicommon.ErrAccessGated) { logger.Warnf("failed to snapshot source: %s", err.Error()) } @@ -299,6 +302,10 @@ func writeToOutputLocation(ctx context.Context, documentPath string, outputLocat if documentPath == outputLocation { return nil } + // Make sure the outputLocation directory exists + if err := os.MkdirAll(filepath.Dir(outputLocation), os.ModePerm); err != nil { + return err + } // If we have yaml and need json, convert it if utils.HasYAMLExt(documentPath) && !utils.HasYAMLExt(outputLocation) { diff --git a/internal/run/sourceTracking.go b/internal/run/sourceTracking.go index 91ee986e7..97d2fc3f7 100644 --- a/internal/run/sourceTracking.go +++ b/internal/run/sourceTracking.go @@ -22,15 +22,56 @@ import ( "github.com/speakeasy-api/speakeasy/internal/changes" "github.com/speakeasy-api/speakeasy/internal/config" "github.com/speakeasy-api/speakeasy/internal/env" - "github.com/speakeasy-api/speakeasy/internal/git" "github.com/speakeasy-api/speakeasy/internal/github" "github.com/speakeasy-api/speakeasy/internal/log" "github.com/speakeasy-api/speakeasy/internal/reports" "github.com/speakeasy-api/speakeasy/internal/workflowTracking" "github.com/speakeasy-api/speakeasy/registry" - "go.uber.org/zap" ) +// embedSourceConfig implements bundler.EmbedSourceConfig interface +type embedSourceConfig struct { + originalSource *workflow.Source + localizedSource *workflow.Source +} + +func (e *embedSourceConfig) WorkflowSource() *workflow.Source { + return e.originalSource +} + +func (e *embedSourceConfig) LocalizedWorkflowSource() *workflow.Source { + return e.localizedSource +} + +// createLocalizedSource creates a localized version of the source based on the source result +func createLocalizedSource(originalSource workflow.Source, sourceResult *SourceResult) *workflow.Source { + localizedSource := originalSource // Copy the original source structure + + // Replace inputs with the merged input files from the source result + if len(sourceResult.MergeResult.InputSchemaLocation) > 0 { + localizedSource.Inputs = make([]workflow.Document, len(sourceResult.MergeResult.InputSchemaLocation)) + for i, inputPath := range sourceResult.MergeResult.InputSchemaLocation { + localizedSource.Inputs[i] = workflow.Document{ + Location: workflow.LocationString(inputPath), + } + } + } + + // Copy overlay results from the source result if they exist + if len(sourceResult.OverlayResult.InputSchemaLocation) > 0 { + localizedSource.Overlays = make([]workflow.Overlay, len(sourceResult.OverlayResult.InputSchemaLocation)) + for i, overlayPath := range sourceResult.OverlayResult.InputSchemaLocation { + localizedSource.Overlays[i] = workflow.Overlay{ + Document: &workflow.Document{ + Location: workflow.LocationString(overlayPath), + }, + } + } + } + + return &localizedSource +} + func (w *Workflow) computeChanges(ctx context.Context, rootStep *workflowTracking.WorkflowStep, targetLock workflow.TargetLock, newDocPath string) (r *reports.ReportResult, err error) { changesStep := rootStep.NewSubstep("Computing Document Changes") if !registry.IsRegistryEnabled(ctx) { @@ -102,7 +143,7 @@ func (w *Workflow) computeChanges(ctx context.Context, rootStep *workflowTrackin return } -func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTracking.WorkflowStep, sourceID string, source workflow.Source, documentPath string) (err error) { +func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTracking.WorkflowStep, sourceID string, source workflow.Source, sourceResult *SourceResult) (err error) { registryStep := parentStep.NewSubstep("Tracking OpenAPI Changes") if !registry.IsRegistryEnabled(ctx) { @@ -171,25 +212,35 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack } pl := bundler.NewPipeline(&bundler.PipelineOptions{}) - memfs := fsextras.NewMemFS() - - registryStep.NewSubstep("Snapshotting OpenAPI Revision") + resolved := fsextras.NewMemFS() + registryStep.NewSubstep("Snapshotting Resolved Layer") - rootDocumentPath, err := pl.Localize(ctx, memfs, bundler.LocalizeOptions{ - DocumentPath: documentPath, + rootDocumentPath, err := pl.Localize(ctx, resolved, bundler.LocalizeOptions{ + DocumentPath: sourceResult.OutputPath, + OutputRoot: bundler.BundleRoot.String(), }) if err != nil { return fmt.Errorf("error localizing openapi document: %w", err) } - gitRepo, err := git.NewLocalRepository(w.ProjectDir) + // Create localized source based on the source result + localizedSource := createLocalizedSource(source, sourceResult) + + // Create embed source config with original and localized sources + embedConfig := &embedSourceConfig{ + originalSource: &source, + localizedSource: localizedSource, + } + + // snapshot the source + err = pl.EmbedSource(ctx, resolved, embedConfig) if err != nil { - log.From(ctx).Debug("error sniffing git repository", zap.Error(err)) + log.From(ctx).Warnf("warning: couldn't embedding source in openapi document: %s", err.Error()) } - rootDocument, err := memfs.Open(filepath.Join(bundler.BundleRoot.String(), "openapi.yaml")) + rootDocument, err := resolved.Open(filepath.Join(bundler.BundleRoot.String(), "openapi.yaml")) if errors.Is(err, fs.ErrNotExist) { - rootDocument, err = memfs.Open(filepath.Join(bundler.BundleRoot.String(), "openapi.json")) + rootDocument, err = resolved.Open(filepath.Join(bundler.BundleRoot.String(), "openapi.json")) } if err != nil { return fmt.Errorf("error opening root document: %w", err) @@ -200,19 +251,11 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack return fmt.Errorf("error extracting annotations from openapi document: %w", err) } - revision := "" - if gitRepo != nil { - revision, err = gitRepo.HeadHash() - if err != nil { - log.From(ctx).Debug("error sniffing head commit hash", zap.Error(err)) - } - } - annotations.Revision = revision annotations.BundleRoot = strings.TrimPrefix(rootDocumentPath, string(os.PathSeparator)) // Always add the openapi document version as a tag tags = append(tags, annotations.Version) - err = pl.BuildOCIImage(ctx, bundler.NewReadWriteFS(memfs, memfs), &bundler.OCIBuildOptions{ + err = pl.BuildOCIImage(ctx, bundler.NewReadWriteFS(resolved, resolved), &bundler.OCIBuildOptions{ Tags: tags, Annotations: annotations, MediaType: ocicommon.MediaTypeOpenAPIBundleV0, @@ -231,7 +274,7 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack reg = strings.TrimPrefix(reg, "https://") substepStore := registryStep.NewSubstep("Storing OpenAPI Revision") - pushResult, err := pl.PushOCIImage(ctx, memfs, &bundler.OCIPushOptions{ + pushResult, err := pl.PushOCIImage(ctx, resolved, &bundler.OCIPushOptions{ Tags: tags, Registry: reg, Access: ocicommon.NewRepositoryAccess(apiKey, namespaceName, ocicommon.RepositoryAccessOptions{ diff --git a/internal/run/testdata/openapi.yaml b/internal/run/testdata/openapi.yaml new file mode 100644 index 000000000..ced56f055 --- /dev/null +++ b/internal/run/testdata/openapi.yaml @@ -0,0 +1,93 @@ +openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 + description: A test API for integration testing +servers: + - url: https://api.example.com/v1 + description: Production server +paths: + /users: + get: + summary: List users + operationId: getUsers + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + post: + summary: Create user + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '201': + description: User created + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /users/{id}: + get: + summary: Get user by ID + operationId: getUserById + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found +components: + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + description: User ID + name: + type: string + description: User name + email: + type: string + format: email + description: User email + createdAt: + type: string + format: date-time + description: User creation timestamp + CreateUserRequest: + type: object + required: + - name + - email + properties: + name: + type: string + description: User name + email: + type: string + format: email + description: User email \ No newline at end of file diff --git a/internal/run/testdata/overlay.yaml b/internal/run/testdata/overlay.yaml new file mode 100644 index 000000000..a3e83e942 --- /dev/null +++ b/internal/run/testdata/overlay.yaml @@ -0,0 +1,39 @@ +overlay: 1.0.0 +info: + title: Test API Overlay + version: 1.0.0 +actions: + - target: '$.info' + update: + x-speakeasy-name-override: TestAPI + x-speakeasy-retries: + strategy: backoff + backoff: + initialInterval: 500 + maxInterval: 60000 + maxElapsedTime: 3600000 + exponent: 1.5 + statusCodes: + - 408 + - 429 + - 5XX + retryConnectionErrors: true + - target: '$.paths["/users"].get' + update: + x-speakeasy-pagination: + type: offsetLimit + inputs: + - name: page + in: query + type: integer + - name: limit + in: query + type: integer + outputs: + results: $.users + - target: '$.components.schemas.User.properties' + update: + metadata: + type: object + description: Additional user metadata + additionalProperties: true \ No newline at end of file