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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ require (
github.com/speakeasy-api/huh v1.1.2
github.com/speakeasy-api/jq v0.1.1-0.20251107233444-84d7e49e84a4
github.com/speakeasy-api/openapi v1.11.3
github.com/speakeasy-api/openapi-generation/v2 v2.776.1
github.com/speakeasy-api/openapi-generation/v2 v2.776.3
github.com/speakeasy-api/openapi-overlay v0.10.3
github.com/speakeasy-api/sdk-gen-config v1.43.1
github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.7
Expand Down Expand Up @@ -156,7 +156,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
Expand Down Expand Up @@ -535,8 +535,8 @@ github.com/speakeasy-api/libopenapi v0.21.9-fixhiddencomps-fixed h1:PL/kpBY5vkBm
github.com/speakeasy-api/libopenapi v0.21.9-fixhiddencomps-fixed/go.mod h1:Gc8oQkjr2InxwumK0zOBtKN9gIlv9L2VmSVIUk2YxcU=
github.com/speakeasy-api/openapi v1.11.3 h1:7ExJYuJM3hiV35EROK3i+/w8SdEep3Ct9BivGaOkbNI=
github.com/speakeasy-api/openapi v1.11.3/go.mod h1:ITV3em4IFe1Hd4gX5Peq9TE7+Rfd/WIHZE/aqxNgihg=
github.com/speakeasy-api/openapi-generation/v2 v2.776.1 h1:3BUJ5aLRNBKut6EwgsOirnOC8AJKQ1mBu0dfH2Zlnqo=
github.com/speakeasy-api/openapi-generation/v2 v2.776.1/go.mod h1:3D4+XhJtPioeRySRO3CLJXo5fuupWNZYHvw8iYioYBc=
github.com/speakeasy-api/openapi-generation/v2 v2.776.3 h1:gomKKQd4vKthVl3T7Ydq+m/LH6BzygeAq4eZpOmRXOU=
github.com/speakeasy-api/openapi-generation/v2 v2.776.3/go.mod h1:3D4+XhJtPioeRySRO3CLJXo5fuupWNZYHvw8iYioYBc=
github.com/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
github.com/speakeasy-api/sdk-gen-config v1.43.1 h1:rhhv6mAVV2yl1I6TqHkpunnOnSXyH5milgzEA9vJPEo=
Expand Down
240 changes: 240 additions & 0 deletions integration/file_deletion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package integration_tests

import (
"os"
"path/filepath"
"testing"

"github.com/speakeasy-api/sdk-gen-config/workflow"
"github.com/stretchr/testify/require"
)

// TestFileDeletion_UnusedModelRemoved verifies that when a model is no longer used in the OpenAPI spec,
// the generated model file is deleted on regeneration.
func TestFileDeletion_UnusedModelRemoved(t *testing.T) {
t.Parallel()
temp := setupFileDeletionTestDir(t)

// Initial generation with both Pet and Owner models
err := execute(t, temp, "run", "-t", "all", "--pinned", "--skip-compile", "--output", "console").Run()
require.NoError(t, err, "Initial generation should succeed")

// Commit initial generation
gitCommitAll(t, temp, "initial generation")

// Verify both model files exist
petFile := filepath.Join(temp, "models", "components", "pet.go")
ownerFile := filepath.Join(temp, "models", "components", "owner.go")

require.FileExists(t, petFile, "Pet model file should exist after initial generation")
require.FileExists(t, ownerFile, "Owner model file should exist after initial generation")

t.Logf("Initial generation complete. Pet file: %s, Owner file: %s", petFile, ownerFile)

// List all generated files for debugging
t.Log("Generated files after initial generation:")
listGeneratedFiles(t, temp)

// Dump gen.lock after first generation
genLockPath := filepath.Join(temp, ".speakeasy", "gen.lock")
if content, err := os.ReadFile(genLockPath); err == nil {
t.Logf("gen.lock after initial generation:\n%s", string(content))
} else {
t.Logf("Could not read gen.lock: %v", err)
}

// Now update the spec to remove the Owner model usage
specWithoutOwner := `openapi: 3.0.3
info:
title: Test API
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/pets:
get:
summary: List pets
operationId: listPets
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
components:
schemas:
Pet:
type: object
properties:
id:
type: integer
name:
type: string
`
err = os.WriteFile(filepath.Join(temp, "spec.yaml"), []byte(specWithoutOwner), 0644)
require.NoError(t, err)
gitCommitAll(t, temp, "spec update: remove Owner model")

// Regenerate
t.Log("Regenerating without Owner model...")
err = execute(t, temp, "run", "-t", "all", "--pinned", "--skip-compile", "--output", "console").Run()
require.NoError(t, err, "Regeneration should succeed")

// List generated files after regeneration for debugging
t.Log("Generated files after regeneration:")
listGeneratedFiles(t, temp)

// Dump gen.lock after regeneration
if content, err := os.ReadFile(genLockPath); err == nil {
t.Logf("gen.lock after regeneration:\n%s", string(content))
} else {
t.Logf("Could not read gen.lock: %v", err)
}

// Pet model should still exist
require.FileExists(t, petFile, "Pet model file should still exist after regeneration")

// Owner model should be DELETED since it's no longer used
_, err = os.Stat(ownerFile)
require.True(t, os.IsNotExist(err), "Owner model file should be deleted when no longer used in spec. Error: %v", err)
}

// setupFileDeletionTestDir creates a test directory with an OpenAPI spec that uses two models
func setupFileDeletionTestDir(t *testing.T) string {
t.Helper()

temp := setupTestDir(t)

// Create an OpenAPI spec with TWO models: Pet and Owner
// Both are used by the /pets endpoint
specContent := `openapi: 3.0.3
info:
title: Test API
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/pets:
get:
summary: List pets
operationId: listPets
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
/owners:
get:
summary: List owners
operationId: listOwners
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Owner'
components:
schemas:
Pet:
type: object
properties:
id:
type: integer
name:
type: string
Owner:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
`
err := os.WriteFile(filepath.Join(temp, "spec.yaml"), []byte(specContent), 0644)
require.NoError(t, err)

// Create .speakeasy directory
err = os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0755)
require.NoError(t, err)

// Create workflow.yaml
workflowFile := &workflow.Workflow{
Version: workflow.WorkflowVersion,
Sources: map[string]workflow.Source{
"test-source": {
Inputs: []workflow.Document{
{Location: "spec.yaml"},
},
},
},
Targets: map[string]workflow.Target{
"test-target": {
Target: "go",
Source: "test-source",
},
},
}
err = workflow.Save(temp, workflowFile)
require.NoError(t, err)

// Create gen.yaml
genYamlContent := `configVersion: 2.0.0
generation:
sdkClassName: SDK
maintainOpenAPIOrder: true
usageSnippets:
optionalPropertyRendering: withExample
go:
version: 1.0.0
packageName: testsdk
`
err = os.WriteFile(filepath.Join(temp, "gen.yaml"), []byte(genYamlContent), 0644)
require.NoError(t, err)

// Create .genignore to exclude go.mod/go.sum from generation
genignoreContent := `go.mod
go.sum
`
err = os.WriteFile(filepath.Join(temp, ".genignore"), []byte(genignoreContent), 0644)
require.NoError(t, err)

// Initialize git repo
gitInit(t, temp)
gitCommitAll(t, temp, "initial commit")

return temp
}

// listGeneratedFiles lists all generated files for debugging purposes
func listGeneratedFiles(t *testing.T, dir string) {
t.Helper()
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip hidden directories
if info.IsDir() && info.Name()[0] == '.' {
return filepath.SkipDir
}
if !info.IsDir() {
relPath, _ := filepath.Rel(dir, path)
t.Logf(" %s", relPath)
}
return nil
})
if err != nil {
t.Logf("Error walking directory: %v", err)
}
}
4 changes: 0 additions & 4 deletions integration/multi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package integration_tests
import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/google/go-cmp/cmp"
Expand All @@ -13,9 +12,6 @@ import (
)

func TestMultiFileStability(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping test on Windows")
}
// If windows, skip
temp := setupTestDir(t)

Expand Down
41 changes: 29 additions & 12 deletions integration/patches_git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -723,16 +723,27 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) {
require.NoError(t, err, "git init --bare failed: %s", string(output))
t.Logf("Created bare remote at %s", remoteDir)

// Configure bare remote to not convert line endings
for _, args := range [][]string{
{"config", "core.autocrlf", "false"},
} {
cmd = exec.Command("git", args...)
cmd.Dir = remoteDir
cmd.CombinedOutput()
}

// Step 2: Clone to Environment A and set up for generation
cmd = exec.Command("git", "clone", remoteDir, ".")
cmd.Dir = envADir
output, err = cmd.CombinedOutput()
require.NoError(t, err, "git clone to envA failed: %s", string(output))

// Configure git user in envA
// Configure git user in envA and disable line ending conversion for consistency
for _, args := range [][]string{
{"config", "user.email", "[email protected]"},
{"config", "user.name", "Test User"},
{"config", "core.autocrlf", "false"},
{"config", "core.eol", "lf"},
} {
cmd = exec.Command("git", args...)
cmd.Dir = envADir
Expand Down Expand Up @@ -777,10 +788,12 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) {
output, err = cmd.CombinedOutput()
require.NoError(t, err, "git clone to envB failed: %s", string(output))

// Configure git user in envB
// Configure git user in envB and disable line ending conversion for consistency
for _, args := range [][]string{
{"config", "user.email", "[email protected]"},
{"config", "user.name", "Developer B"},
{"config", "core.autocrlf", "false"},
{"config", "core.eol", "lf"},
} {
cmd = exec.Command("git", args...)
cmd.Dir = envBDir
Expand All @@ -797,19 +810,21 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) {
}
t.Log("Verified: speakeasy refs not present in fresh clone (as expected)")

// Step 5: Modify a generated file in Environment B
sdkFile := filepath.Join(envBDir, "sdk.go")
require.FileExists(t, sdkFile, "sdk.go should exist in envB")
// Step 5: Modify a generated model file in Environment B
// We use a model file instead of sdk.go because version bumps modify sdk.go's
// version constants and can cause conflicts with edits near the package declaration.
petFile := filepath.Join(envBDir, "models", "components", "pet.go")
require.FileExists(t, petFile, "pet.go should exist in envB")

content, err := os.ReadFile(sdkFile)
content, err := os.ReadFile(petFile)
require.NoError(t, err)
originalID := extractGeneratedIDFromContent(content)
require.NotEmpty(t, originalID, "sdk.go should have @generated-id")
require.NotEmpty(t, originalID, "pet.go should have @generated-id")

modifiedContent := strings.Replace(string(content),
"package testsdk",
"package testsdk\n\n// ENVB_USER_EDIT: Developer B's customization", 1)
err = os.WriteFile(sdkFile, []byte(modifiedContent), 0644)
// Add a custom method at the end of the file
// We append to the very end of the file to avoid conflict with generated getters
modifiedContent := string(content) + "\n// ENVB_USER_EDIT: Developer B's customization\nfunc (p *Pet) CustomMethod() string {\n\treturn \"custom\"\n}\n"
err = os.WriteFile(petFile, []byte(modifiedContent), 0644)
require.NoError(t, err)
gitCommitAllInDir(t, envBDir, "developer B user edit")

Expand All @@ -820,10 +835,12 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) {
gitCommitAllInDir(t, envBDir, "generation 2 in envB")

// Step 7: Verify user edit was preserved (proving 3-way merge worked)
finalContent, err := os.ReadFile(sdkFile)
finalContent, err := os.ReadFile(petFile)
require.NoError(t, err)
require.Contains(t, string(finalContent), "ENVB_USER_EDIT: Developer B's customization",
"User modification should be preserved after generation (3-way merge worked)")
require.Contains(t, string(finalContent), "func (p *Pet) CustomMethod()",
"Custom method should be preserved after generation")

// Verify ID is preserved
finalID := extractGeneratedIDFromContent(finalContent)
Expand Down
Loading
Loading