From e31caf369951374a00c35d7432f8fdef1398691a Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 4 Jun 2025 14:13:04 +0100 Subject: [PATCH 1/6] wip --- go.mod | 4 ++++ integration/workflow_registry_test.go | 4 +--- internal/run/merge.go | 25 ++++++++++++++----------- internal/run/overlay.go | 26 +++++++++++++++----------- internal/run/source.go | 24 +++++++++++++----------- internal/run/sourceTracking.go | 27 ++++++++++++++++++--------- 6 files changed, 65 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 026abc9f8..6dafb6369 100644 --- a/go.mod +++ b/go.mod @@ -274,3 +274,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect oras.land/oras-go/v2 v2.5.0 // indirect ) + +replace ( + github.com/speakeasy-api/speakeasy-core => ../speakeasy-core +) diff --git a/integration/workflow_registry_test.go b/integration/workflow_registry_test.go index a36ce63f8..5eafc26f3 100644 --- a/integration/workflow_registry_test.go +++ b/integration/workflow_registry_test.go @@ -13,8 +13,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 @@ -43,7 +41,7 @@ func TestStability(t *testing.T) { // 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() + cmdErr := executeI(t, temp, initialArgs...).Run() require.NoError(t, cmdErr) // Calculate checksums of generated files 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/source.go b/internal/run/source.go index d0943a568..306d661ab 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 { @@ -182,7 +184,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()) } diff --git a/internal/run/sourceTracking.go b/internal/run/sourceTracking.go index 91ee986e7..4dc0d2879 100644 --- a/internal/run/sourceTracking.go +++ b/internal/run/sourceTracking.go @@ -102,7 +102,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,12 +171,21 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack } pl := bundler.NewPipeline(&bundler.PipelineOptions{}) - memfs := fsextras.NewMemFS() + resolved := fsextras.NewMemFS() + registryStep.NewSubstep("Snapshotting Resolved Layer") - registryStep.NewSubstep("Snapshotting OpenAPI Revision") + 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) + } - rootDocumentPath, err := pl.Localize(ctx, memfs, bundler.LocalizeOptions{ - DocumentPath: documentPath, + // snapshot the source input + pl.Localize(ctx, resolved, bundler.LocalizeOptions{ + DocumentPath: sourceResult.OutputPath, + OutputRoot: bundler.WorkflowSourceRoot.String(), }) if err != nil { return fmt.Errorf("error localizing openapi document: %w", err) @@ -187,9 +196,9 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack log.From(ctx).Debug("error sniffing git repository", zap.Error(err)) } - 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) @@ -212,7 +221,7 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack // 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 +240,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{ From 7fa2cab222dcafc67942741ca903c4e5c87662af Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Mon, 30 Jun 2025 14:53:29 +0100 Subject: [PATCH 2/6] chore: bundler provenence --- go.mod | 4 +- go.sum | 2 - integration/workflow_registry_test.go | 17 ++--- internal/run/run.go | 4 ++ internal/run/sourceTracking.go | 72 +++++++++++++++------ internal/run/testdata/openapi.yaml | 93 +++++++++++++++++++++++++++ internal/run/testdata/overlay.yaml | 39 +++++++++++ 7 files changed, 199 insertions(+), 32 deletions(-) create mode 100644 internal/run/testdata/openapi.yaml create mode 100644 internal/run/testdata/overlay.yaml diff --git a/go.mod b/go.mod index 6dafb6369..085a2bd00 100644 --- a/go.mod +++ b/go.mod @@ -275,6 +275,4 @@ require ( oras.land/oras-go/v2 v2.5.0 // indirect ) -replace ( - github.com/speakeasy-api/speakeasy-core => ../speakeasy-core -) +replace github.com/speakeasy-api/speakeasy-core => ../speakeasy-core diff --git a/go.sum b/go.sum index 065c20036..3cd427bc3 100644 --- a/go.sum +++ b/go.sum @@ -589,8 +589,6 @@ 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-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/workflow_registry_test.go b/integration/workflow_registry_test.go index 5eafc26f3..466b5017f 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" @@ -45,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" @@ -65,9 +63,12 @@ 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(initialChecksums, ".speakeasy/gen.lock") // Compare checksums require.Equal(t, initialChecksums, frozenChecksums, "Generated files should be identical when using --frozen-workflow-lock") } @@ -166,7 +167,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 { @@ -178,7 +179,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/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/sourceTracking.go b/internal/run/sourceTracking.go index 4dc0d2879..e206397df 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) { @@ -182,18 +223,19 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack return fmt.Errorf("error localizing openapi document: %w", err) } - // snapshot the source input - pl.Localize(ctx, resolved, bundler.LocalizeOptions{ - DocumentPath: sourceResult.OutputPath, - OutputRoot: bundler.WorkflowSourceRoot.String(), - }) - if err != nil { - return fmt.Errorf("error localizing openapi document: %w", err) + // 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, } - gitRepo, err := git.NewLocalRepository(w.ProjectDir) + // 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 embed source in openapi document: %s", err.Error()) } rootDocument, err := resolved.Open(filepath.Join(bundler.BundleRoot.String(), "openapi.yaml")) @@ -209,14 +251,6 @@ 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) 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 From f1132ca8723430d1e845cb6f3651f7925fbee95a Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Mon, 30 Jun 2025 16:38:42 +0100 Subject: [PATCH 3/6] chore: more tests, source provenance tracking --- go.mod | 6 +- go.sum | 2 + integration/multi_test.go | 81 +++++++++++++++++++++ integration/resources/multi_components.yaml | 18 +++++ integration/resources/multi_root.yaml | 25 +++++++ internal/run/source.go | 13 +++- internal/run/sourceTracking.go | 2 +- 7 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 integration/multi_test.go create mode 100644 integration/resources/multi_components.yaml create mode 100644 integration/resources/multi_root.yaml diff --git a/go.mod b/go.mod index 085a2bd00..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 @@ -274,5 +274,3 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect oras.land/oras-go/v2 v2.5.0 // indirect ) - -replace github.com/speakeasy-api/speakeasy-core => ../speakeasy-core diff --git a/go.sum b/go.sum index 3cd427bc3..1243431f1 100644 --- a/go.sum +++ b/go.sum @@ -589,6 +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.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..8b77dcafc --- /dev/null +++ b/integration/multi_test.go @@ -0,0 +1,81 @@ +package integration_tests + +import ( + "github.com/google/go-cmp/cmp" + "os" + "path/filepath" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +func TestMultiFileStability(t *testing.T) { + 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 := executeI(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 = executeI(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/internal/run/source.go b/internal/run/source.go index 306d661ab..24156f5c1 100644 --- a/internal/run/source.go +++ b/internal/run/source.go @@ -159,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) @@ -301,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 e206397df..97d2fc3f7 100644 --- a/internal/run/sourceTracking.go +++ b/internal/run/sourceTracking.go @@ -235,7 +235,7 @@ func (w *Workflow) snapshotSource(ctx context.Context, parentStep *workflowTrack // snapshot the source err = pl.EmbedSource(ctx, resolved, embedConfig) if err != nil { - log.From(ctx).Warnf("warning: couldn't embed source in openapi document: %s", err.Error()) + log.From(ctx).Warnf("warning: couldn't embedding source in openapi document: %s", err.Error()) } rootDocument, err := resolved.Open(filepath.Join(bundler.BundleRoot.String(), "openapi.yaml")) From 37c5379decc2ead74d863ae6e4a076c07e60acd3 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 1 Jul 2025 13:02:05 +0100 Subject: [PATCH 4/6] chore: heisen --- integration/multi_test.go | 4 ++-- integration/workflow_registry_test.go | 2 +- integration/workflow_test.go | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/integration/multi_test.go b/integration/multi_test.go index 8b77dcafc..0c72947f7 100644 --- a/integration/multi_test.go +++ b/integration/multi_test.go @@ -45,7 +45,7 @@ func TestMultiFileStability(t *testing.T) { // Run the initial generation var initialChecksums map[string]string initialArgs := []string{"run", "-t", "all", "--force", "--pinned", "--skip-versioning", "--skip-compile"} - cmdErr := executeI(t, temp, initialArgs...).Run() + cmdErr := execute(t, temp, initialArgs...).Run() require.NoError(t, cmdErr) // Calculate checksums of generated files @@ -63,7 +63,7 @@ func TestMultiFileStability(t *testing.T) { // Test frozen workflow lock behavior frozenArgs := []string{"run", "-t", "all", "--pinned", "--frozen-workflow-lockfile", "--skip-compile"} - cmdErr = executeI(t, temp, frozenArgs...).Run() + cmdErr = execute(t, temp, frozenArgs...).Run() require.NoError(t, cmdErr) // Calculate checksums after frozen run diff --git a/integration/workflow_registry_test.go b/integration/workflow_registry_test.go index 466b5017f..d7e5abd7c 100644 --- a/integration/workflow_registry_test.go +++ b/integration/workflow_registry_test.go @@ -39,7 +39,7 @@ func TestStability(t *testing.T) { // Run the initial generation var initialChecksums map[string]string initialArgs := []string{"run", "-t", "all", "--force", "--pinned", "--skip-versioning", "--skip-compile"} - cmdErr := executeI(t, temp, initialArgs...).Run() + cmdErr := execute(t, temp, initialArgs...).Run() require.NoError(t, cmdErr) // Calculate checksums of generated files 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) From 7d50f10cd5bc209d224ff8e9bdc83bff2eff8e7e Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 1 Jul 2025 13:43:25 +0100 Subject: [PATCH 5/6] chore: windows fix --- integration/workflow_registry_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration/workflow_registry_test.go b/integration/workflow_registry_test.go index d7e5abd7c..0321772f6 100644 --- a/integration/workflow_registry_test.go +++ b/integration/workflow_registry_test.go @@ -68,7 +68,9 @@ func TestStability(t *testing.T) { // 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") } From bc3c27a6e1c8c28629645f602d98b289fc7f808b Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 1 Jul 2025 14:28:31 +0100 Subject: [PATCH 6/6] chore: windows sucks --- integration/multi_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/integration/multi_test.go b/integration/multi_test.go index 0c72947f7..a6ef8bc53 100644 --- a/integration/multi_test.go +++ b/integration/multi_test.go @@ -4,6 +4,7 @@ import ( "github.com/google/go-cmp/cmp" "os" "path/filepath" + "runtime" "testing" "github.com/speakeasy-api/sdk-gen-config/workflow" @@ -11,6 +12,10 @@ import ( ) 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