From 8b65e0b4df132443883e6c02a8b0174ad7aa913d Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 27 Nov 2025 11:15:56 +0000 Subject: [PATCH 1/3] feat: custom code --- configuration.go | 116 ++++++--- configuration_test.go | 40 +++ io.go | 22 +- io_test.go | 57 +++-- lockfile.go | 69 ++---- lockfile/integrity.go | 167 +++++++++++++ lockfile/io.go | 81 +++++++ lockfile/lockfile.go | 110 +++++++++ lockfile/lockfile_test.go | 139 +++++++++++ schemas/gen.config.schema.json | 431 ++++++++++++++++++--------------- tools/schema-gen/main.go | 133 +++++++++- upgrades.go | 6 +- upgrades_test.go | 8 +- 13 files changed, 1058 insertions(+), 321 deletions(-) create mode 100644 configuration_test.go create mode 100644 lockfile/integrity.go create mode 100644 lockfile/io.go create mode 100644 lockfile/lockfile.go create mode 100644 lockfile/lockfile_test.go diff --git a/configuration.go b/configuration.go index a90c450..27af8f4 100644 --- a/configuration.go +++ b/configuration.go @@ -6,6 +6,7 @@ import ( "github.com/mitchellh/mapstructure" "github.com/speakeasy-api/openapi/pointer" + jsg "github.com/swaggest/jsonschema-go" ) const ( @@ -43,21 +44,35 @@ const ( SDKInitStyleBuilder SDKInitStyle = "builder" ) +// ServerIndex is a type that can be either a string (server ID) or an integer (server index) +type ServerIndex string + +func (ServerIndex) PrepareJSONSchema(schema *jsg.Schema) error { + // Set the type to an array of types [string, integer] + schema.Type = &jsg.Type{ + SliceOfSimpleTypeValues: []jsg.SimpleType{jsg.String, jsg.Integer}, + } + schema.WithDescription("Controls which server is shown in usage snippets. If unset, no server will be shown. If an integer, it will be used as the server index. Otherwise, it will look for a matching server ID.") + return nil +} + type UsageSnippets struct { - OptionalPropertyRendering OptionalPropertyRenderingOption `yaml:"optionalPropertyRendering"` - SDKInitStyle SDKInitStyle `yaml:"sdkInitStyle"` - ServerToShowInSnippets string `yaml:"serverToShowInSnippets,omitempty"` // If unset, no server will be shown, if an integer, use as server_idx, else look for a matching id - AdditionalProperties map[string]any `yaml:",inline"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility + _ struct{} `additionalProperties:"true" description:"Configuration for usage snippets"` + OptionalPropertyRendering OptionalPropertyRenderingOption `yaml:"optionalPropertyRendering" enum:"always,never,withExample" description:"Controls how optional properties are rendered in usage snippets"` + SDKInitStyle SDKInitStyle `yaml:"sdkInitStyle" enum:"constructor,builder" description:"Controls how the SDK initialization is depicted in usage snippets"` + ServerToShowInSnippets ServerIndex `yaml:"serverToShowInSnippets,omitempty"` // If unset, no server will be shown, if an integer, use as server_idx, else look for a matching id + AdditionalProperties map[string]any `yaml:",inline" jsonschema:"-"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility } type Fixes struct { - NameResolutionDec2023 bool `yaml:"nameResolutionDec2023,omitempty"` - NameResolutionFeb2025 bool `yaml:"nameResolutionFeb2025"` - ParameterOrderingFeb2024 bool `yaml:"parameterOrderingFeb2024"` - RequestResponseComponentNamesFeb2024 bool `yaml:"requestResponseComponentNamesFeb2024"` - SecurityFeb2025 bool `yaml:"securityFeb2025"` - SharedErrorComponentsApr2025 bool `yaml:"sharedErrorComponentsApr2025"` - AdditionalProperties map[string]any `yaml:",inline"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility + _ struct{} `additionalProperties:"true" description:"Fixes applied to the SDK generation"` + NameResolutionDec2023 bool `yaml:"nameResolutionDec2023,omitempty" description:"Enables name resolution fixes from December 2023"` + NameResolutionFeb2025 bool `yaml:"nameResolutionFeb2025" description:"Enables name resolution fixes from February 2025"` + ParameterOrderingFeb2024 bool `yaml:"parameterOrderingFeb2024" description:"Enables parameter ordering fixes from February 2024"` + RequestResponseComponentNamesFeb2024 bool `yaml:"requestResponseComponentNamesFeb2024" description:"Enables request and response component naming fixes from February 2024"` + SecurityFeb2025 bool `yaml:"securityFeb2025" description:"Enables fixes and refactoring for security that were introduced in February 2025"` + SharedErrorComponentsApr2025 bool `yaml:"sharedErrorComponentsApr2025" description:"Enables fixes that mean that when a component is used in both 2XX and 4XX responses, only the top level component will be duplicated to the errors scope as opposed to the entire component tree"` + AdditionalProperties map[string]any `yaml:",inline" jsonschema:"-"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility } func (f *Fixes) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -78,15 +93,32 @@ func (f *Fixes) UnmarshalYAML(unmarshal func(interface{}) error) error { } type Auth struct { - OAuth2ClientCredentialsEnabled bool `yaml:"oAuth2ClientCredentialsEnabled"` - OAuth2PasswordEnabled bool `yaml:"oAuth2PasswordEnabled"` - HoistGlobalSecurity bool `yaml:"hoistGlobalSecurity"` + _ struct{} `additionalProperties:"false" description:"Authentication configuration"` + OAuth2ClientCredentialsEnabled bool `yaml:"oAuth2ClientCredentialsEnabled" description:"Enables support for OAuth2 client credentials grant type"` + OAuth2PasswordEnabled bool `yaml:"oAuth2PasswordEnabled" description:"Enables support for OAuth2 resource owner password credentials grant type"` + HoistGlobalSecurity bool `yaml:"hoistGlobalSecurity" description:"Enables hoisting of operation-level security schemes to global level when no global security is defined"` } type Tests struct { - GenerateTests bool `yaml:"generateTests"` - GenerateNewTests bool `yaml:"generateNewTests"` - SkipResponseBodyAssertions bool `yaml:"skipResponseBodyAssertions"` + _ struct{} `additionalProperties:"true" description:"Test generation configuration"` + GenerateTests bool `yaml:"generateTests" description:"Enables generation of tests"` + GenerateNewTests bool `yaml:"generateNewTests" description:"Enables generation of new tests for any new operations in the OpenAPI specification"` + SkipResponseBodyAssertions bool `yaml:"skipResponseBodyAssertions" description:"Skip asserting that the client got the same response bodies returned by the mock server"` + AdditionalProperties map[string]any `yaml:",inline" jsonschema:"-"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility +} + +// PersistentEdits configures whether user edits to generated SDKs persist across regenerations +// When enabled, user changes are preserved via 3-way merge with Git tracking +type PersistentEdits struct { + _ struct{} `additionalProperties:"true" description:"Configures whether user edits to generated SDKs persist across regenerations"` + // Enabled allows user edits to generated SDK code to persist through regeneration + // Requires Git repository and creates a pristine branch for tracking + Enabled bool `yaml:"enabled,omitempty" description:"Enables preservation of user edits across SDK regenerations. Requires Git repository."` + + // PristineBranch specifies the Git branch name for tracking pristine generated code + // Defaults to "sdk-pristine" if not specified + PristineBranch string `yaml:"pristineBranch,omitempty" description:"The Git branch name for tracking pristine generated code. Defaults to 'sdk-pristine' if not specified."` + AdditionalProperties map[string]any `yaml:",inline" jsonschema:"-"` // Captures any additional properties } type AllOfMergeStrategy string @@ -97,49 +129,56 @@ const ( ) type Schemas struct { - AllOfMergeStrategy AllOfMergeStrategy `yaml:"allOfMergeStrategy"` + _ struct{} `additionalProperties:"false" description:"Schema processing configuration"` + AllOfMergeStrategy AllOfMergeStrategy `yaml:"allOfMergeStrategy" enum:"deepMerge,shallowMerge" description:"Controls how allOf schemas are merged"` } type Generation struct { + _ struct{} `additionalProperties:"true" description:"Generation configuration"` DevContainers *DevContainers `yaml:"devContainers,omitempty"` - BaseServerURL string `yaml:"baseServerUrl,omitempty"` - SDKClassName string `yaml:"sdkClassName,omitempty"` - MaintainOpenAPIOrder bool `yaml:"maintainOpenAPIOrder,omitempty"` - DeduplicateErrors bool `yaml:"deduplicateErrors,omitempty"` + BaseServerURL string `yaml:"baseServerUrl,omitempty" description:"The base URL of the server. This value will be used if global servers are not defined in the spec."` + SDKClassName string `yaml:"sdkClassName,omitempty" description:"Generated name of the root SDK class"` + MaintainOpenAPIOrder bool `yaml:"maintainOpenAPIOrder,omitempty" description:"Maintains the order of parameters and fields in the OpenAPI specification"` + DeduplicateErrors bool `yaml:"deduplicateErrors,omitempty" description:"Deduplicates errors that have the same schema"` UsageSnippets *UsageSnippets `yaml:"usageSnippets,omitempty"` - UseClassNamesForArrayFields bool `yaml:"useClassNamesForArrayFields,omitempty"` + UseClassNamesForArrayFields bool `yaml:"useClassNamesForArrayFields,omitempty" description:"Use class names for array fields instead of the child's schema type"` Fixes *Fixes `yaml:"fixes,omitempty"` Auth *Auth `yaml:"auth,omitempty"` - SkipErrorSuffix bool `yaml:"skipErrorSuffix,omitempty"` - InferSSEOverload bool `yaml:"inferSSEOverload,omitempty"` - SDKHooksConfigAccess bool `yaml:"sdkHooksConfigAccess,omitempty"` + SkipErrorSuffix bool `yaml:"skipErrorSuffix,omitempty" description:"Skips the automatic addition of an error suffix to error types"` + InferSSEOverload bool `yaml:"inferSSEOverload,omitempty" description:"Generates an overload if generator detects that the request body field 'stream: true' is used for client intent to request 'text/event-stream' response"` + SDKHooksConfigAccess bool `yaml:"sdkHooksConfigAccess,omitempty" description:"Enables access to the SDK configuration from hooks"` Schemas Schemas `yaml:"schemas"` - RequestBodyFieldName string `yaml:"requestBodyFieldName"` + RequestBodyFieldName string `yaml:"requestBodyFieldName" description:"The name of the field to use for the request body in generated SDKs"` // Mock server generation configuration. MockServer *MockServer `yaml:"mockServer,omitempty"` - Tests Tests `yaml:"tests,omitempty"` + // PersistentEdits configures whether user edits persist across regenerations + PersistentEdits *PersistentEdits `yaml:"persistentEdits,omitempty"` + Tests Tests `yaml:"tests,omitempty"` - AdditionalProperties map[string]any `yaml:",inline"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility + AdditionalProperties map[string]any `yaml:",inline" jsonschema:"-"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility } type DevContainers struct { - Enabled bool `yaml:"enabled"` + _ struct{} `additionalProperties:"true" description:"Dev container configuration"` + Enabled bool `yaml:"enabled" description:"Whether dev containers are enabled"` // This can be a local path or a remote URL - SchemaPath string `yaml:"schemaPath"` - AdditionalProperties map[string]any `yaml:",inline"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility + SchemaPath string `yaml:"schemaPath" description:"Path to the schema file for the dev container"` + AdditionalProperties map[string]any `yaml:",inline" jsonschema:"-"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility } // Generation configuration for the inter-templated mockserver target for test generation. type MockServer struct { + _ struct{} `additionalProperties:"false" description:"Mock server generation configuration"` // Disables the code generation of the mockserver target. - Disabled bool `yaml:"disabled"` + Disabled bool `yaml:"disabled" description:"Disables the code generation of the mock server target"` } type LanguageConfig struct { - Version string `yaml:"version"` - Cfg map[string]any `yaml:",inline"` + _ struct{} `additionalProperties:"true" description:"Language-specific SDK configuration"` + Version string `yaml:"version" description:"SDK version"` + Cfg map[string]any `yaml:",inline" jsonschema:"-"` } type SDKGenConfigField struct { @@ -157,9 +196,10 @@ type SDKGenConfigField struct { // Ensure you update schema/gen.config.schema.json on changes type Configuration struct { - ConfigVersion string `yaml:"configVersion"` - Generation Generation `yaml:"generation"` - Languages map[string]LanguageConfig `yaml:",inline"` + _ struct{} `title:"Gen YAML Configuration Schema" additionalProperties:"false"` + ConfigVersion string `yaml:"configVersion" description:"The version of the configuration file" minLength:"1" required:"true"` + Generation Generation `yaml:"generation" required:"true"` + Languages map[string]LanguageConfig `yaml:",inline" jsonschema:"-"` New map[string]bool `yaml:"-"` } diff --git a/configuration_test.go b/configuration_test.go new file mode 100644 index 0000000..9898c49 --- /dev/null +++ b/configuration_test.go @@ -0,0 +1,40 @@ +package config_test + +//go:generate sh -c "cd tools/schema-gen && go run . -type config -out ../../schemas/gen.config.schema.json" + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestConfigSchemaInSync verifies that gen.config.schema.json is in sync with +// what the schema generator produces from the Configuration struct. +func TestConfigSchemaInSync(t *testing.T) { + // Generate schema from current Go structs + cmd := exec.Command("go", "run", ".", "-type", "config", "-out", "-") + cmd.Dir = filepath.Join("tools", "schema-gen") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + require.NoError(t, err, "schema generator failed: %s", stderr.String()) + + // Read the committed schema + committedPath := filepath.Join("schemas", "gen.config.schema.json") + committedBytes, err := os.ReadFile(committedPath) + require.NoError(t, err, "Failed to read committed schema") + + // Compare byte-for-byte + generated := stdout.Bytes() + require.Equal(t, string(committedBytes), string(generated), + "Generated config schema does not match committed schemas/gen.config.schema.json.\n"+ + "Run: cd tools/schema-gen && go run . -type config -out ../../schemas/gen.config.schema.json\n"+ + "Then commit the updated file.") +} diff --git a/io.go b/io.go index 2c730b5..3abf117 100644 --- a/io.go +++ b/io.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" + "github.com/speakeasy-api/sdk-gen-config/lockfile" "github.com/speakeasy-api/sdk-gen-config/workspace" "gopkg.in/yaml.v3" ) @@ -262,9 +263,18 @@ func Load(dir string, opts ...Option) (*Config, error) { return nil, fmt.Errorf("could not unmarshal gen.yaml: %w", err) } - var lockFile LockFile - if err := yaml.Unmarshal(lockFileRes.Data, &lockFile); err != nil { - return nil, fmt.Errorf("could not unmarshal gen.lock: %w", err) + var lockOpts []lockfile.LoadOption + if o.FS != nil { + lockOpts = append(lockOpts, lockfile.WithFileSystem(o.FS)) + } + + lock, err := lockfile.Load(lockFileRes.Data, lockOpts...) + if err != nil { + return nil, fmt.Errorf("could not parse gen.lock: %w", err) + } + + if o.FS != nil { + _ = lockfile.PopulateMissingChecksums(lock, o.FS) } cfg.New = newForLang @@ -288,14 +298,14 @@ func Load(dir string, opts ...Option) (*Config, error) { } } - if lockFile.Features == nil { - lockFile.Features = make(map[string]map[string]string) + if lock.Features == nil { + lock.Features = make(map[string]map[string]string) } config := &Config{ Config: cfg, ConfigPath: configRes.Path, - LockFile: &lockFile, + LockFile: lock, } if o.transformerFunc != nil { diff --git a/io_test.go b/io_test.go index 01bbfac..0ee1882 100644 --- a/io_test.go +++ b/io_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" + "github.com/speakeasy-api/openapi/sequencedmap" + "github.com/speakeasy-api/sdk-gen-config/lockfile" "github.com/speakeasy-api/sdk-gen-config/testutils" "github.com/speakeasy-api/sdk-gen-config/workspace" "github.com/stretchr/testify/assert" @@ -17,6 +19,7 @@ func TestLoad_Success(t *testing.T) { getUUID = func() string { return "123" } + lockfile.GetUUID = getUUID type args struct { langs []string @@ -85,10 +88,11 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, ".speakeasy/gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, - ID: "123", - Management: Management{}, - Features: make(map[string]map[string]string), + LockVersion: lockfile.LockV2, + ID: "123", + Management: Management{}, + Features: make(map[string]map[string]string), + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -140,7 +144,7 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, "gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "123", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -148,7 +152,8 @@ func TestLoad_Success(t *testing.T) { SpeakeasyVersion: "1.3.1", ReleaseVersion: "1.3.0", }, - Features: make(map[string]map[string]string), + Features: make(map[string]map[string]string), + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -201,7 +206,7 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, ".speakeasy/gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "123", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -214,6 +219,7 @@ func TestLoad_Success(t *testing.T) { "core": "2.90.0", }, }, + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -267,7 +273,7 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, ".speakeasy/gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "0f8fad5b-d9cb-469f-a165-70867728950e", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -280,6 +286,7 @@ func TestLoad_Success(t *testing.T) { "core": "2.90.0", }, }, + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -342,10 +349,11 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, ".speakeasy/gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, - ID: "123", - Management: Management{}, - Features: make(map[string]map[string]string), + LockVersion: lockfile.LockV2, + ID: "123", + Management: Management{}, + Features: make(map[string]map[string]string), + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -408,10 +416,11 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, ".gen/gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, - ID: "123", - Management: Management{}, - Features: make(map[string]map[string]string), + LockVersion: lockfile.LockV2, + ID: "123", + Management: Management{}, + Features: make(map[string]map[string]string), + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -464,7 +473,7 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), filepath.Dir(testDir), "gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "0f8fad5b-d9cb-469f-a165-70867728950e", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -477,6 +486,7 @@ func TestLoad_Success(t *testing.T) { "core": "2.90.0", }, }, + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -533,7 +543,7 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, "gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "123", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -546,6 +556,7 @@ func TestLoad_Success(t *testing.T) { "core": "2.90.0", }, }, + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -604,7 +615,7 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, ".speakeasy/gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "0f8fad5b-d9cb-469f-a165-70867728950e", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -617,6 +628,7 @@ func TestLoad_Success(t *testing.T) { "core": "2.90.0", }, }, + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -673,7 +685,7 @@ func TestLoad_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, ".speakeasy/gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "0f8fad5b-d9cb-469f-a165-70867728950e", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -686,6 +698,7 @@ func TestLoad_Success(t *testing.T) { "core": "2.90.0", }, }, + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, }, @@ -732,6 +745,7 @@ func TestLoad_BackwardsCompatibility_Success(t *testing.T) { getUUID = func() string { return "123" } + lockfile.GetUUID = getUUID // Create new config file in .speakeasy dir speakeasyDir := filepath.Join(os.TempDir(), testDir, workspace.SpeakeasyFolder) @@ -792,7 +806,7 @@ func TestLoad_BackwardsCompatibility_Success(t *testing.T) { }, ConfigPath: filepath.Join(os.TempDir(), testDir, "gen.yaml"), LockFile: &LockFile{ - LockVersion: Version, + LockVersion: lockfile.LockV2, ID: "123", Management: Management{ DocChecksum: "2bba3b8f9d211b02569b3f9aff0d34b4", @@ -805,6 +819,7 @@ func TestLoad_BackwardsCompatibility_Success(t *testing.T) { "core": "2.90.0", }, }, + TrackedFiles: sequencedmap.New[string, TrackedFile](), }, }, cfg) _, err = os.Stat(filepath.Join(rootDir, "gen.yaml")) diff --git a/lockfile.go b/lockfile.go index ff93a1c..80efd22 100644 --- a/lockfile.go +++ b/lockfile.go @@ -1,65 +1,34 @@ package config import ( - "github.com/google/uuid" - "github.com/speakeasy-api/openapi/sequencedmap" - "gopkg.in/yaml.v3" + "github.com/speakeasy-api/sdk-gen-config/lockfile" ) -type LockFile struct { - LockVersion string `yaml:"lockVersion"` - ID string `yaml:"id"` - Management Management `yaml:"management"` - Features map[string]map[string]string `yaml:"features,omitempty"` - GeneratedFiles []string `yaml:"generatedFiles,omitempty"` - GeneratedFileHashes []string `yaml:"generatedFileHashes,omitempty"` - Examples Examples `yaml:"examples,omitempty"` - ExamplesVersion string `yaml:"examplesVersion,omitempty"` - GeneratedTests GeneratedTests `yaml:"generatedTests,omitempty"` - AdditionalProperties map[string]any `yaml:",inline"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility - - ReleaseNotes string `yaml:"releaseNotes,omitempty"` -} - -type Management struct { - DocChecksum string `yaml:"docChecksum,omitempty"` - DocVersion string `yaml:"docVersion,omitempty"` - SpeakeasyVersion string `yaml:"speakeasyVersion,omitempty"` - GenerationVersion string `yaml:"generationVersion,omitempty"` - ReleaseVersion string `yaml:"releaseVersion,omitempty"` - ConfigChecksum string `yaml:"configChecksum,omitempty"` - RepoURL string `yaml:"repoURL,omitempty"` - RepoSubDirectory string `yaml:"repoSubDirectory,omitempty"` - InstallationURL string `yaml:"installationURL,omitempty"` - Published bool `yaml:"published,omitempty"` - AdditionalProperties map[string]any `yaml:",inline"` // Captures any additional properties that are not explicitly defined for backwards/forwards compatibility -} - type ( - Examples = *sequencedmap.Map[string, *sequencedmap.Map[string, OperationExamples]] - GeneratedTests = *sequencedmap.Map[string, string] + LockFile = lockfile.LockFile + Management = lockfile.Management + Examples = lockfile.Examples + GeneratedTests = lockfile.GeneratedTests + TrackedFiles = lockfile.TrackedFiles + TrackedFile = lockfile.TrackedFile + OperationExamples = lockfile.OperationExamples + ParameterExamples = lockfile.ParameterExamples + LockfileOption = lockfile.LoadOption ) -type OperationExamples struct { - Parameters *ParameterExamples `yaml:"parameters,omitempty"` - RequestBody *sequencedmap.Map[string, yaml.Node] `yaml:"requestBody,omitempty"` - Responses *sequencedmap.Map[string, *sequencedmap.Map[string, yaml.Node]] `yaml:"responses,omitempty"` -} +var getUUID = lockfile.GetUUID -type ParameterExamples struct { - Path *sequencedmap.Map[string, yaml.Node] `yaml:"path,omitempty"` - Query *sequencedmap.Map[string, yaml.Node] `yaml:"query,omitempty"` - Header *sequencedmap.Map[string, yaml.Node] `yaml:"header,omitempty"` +func NewLockFile() *LockFile { + return lockfile.New() } -var getUUID = func() string { - return uuid.NewString() +func WithLockfileFileSystem(fs FS) LockfileOption { + return lockfile.WithFileSystem(fs) } -func NewLockFile() *LockFile { - return &LockFile{ - LockVersion: v2, - ID: getUUID(), - Features: map[string]map[string]string{}, +func LoadLockfile(data []byte, fileSystem FS) (*LockFile, error) { + if fileSystem != nil { + return lockfile.Load(data, lockfile.WithFileSystem(fileSystem)) } + return lockfile.Load(data) } diff --git a/lockfile/integrity.go b/lockfile/integrity.go new file mode 100644 index 0000000..5a24851 --- /dev/null +++ b/lockfile/integrity.go @@ -0,0 +1,167 @@ +package lockfile + +import ( + "bufio" + "crypto/sha1" // nolint:gosec // sha1 is intentional for lockfile compatibility + "encoding/hex" + "fmt" + "io" + "io/fs" +) + +// ComputeFileChecksum returns a checksum string like "sha1:" +// by hashing the normalized contents of root/relPath using the provided filesystem. +func ComputeFileChecksum(fileSystem fs.FS, relPath string) (string, error) { + f, err := fileSystem.Open(relPath) + if err != nil { + return "", fmt.Errorf("open %s: %w", relPath, err) + } + defer f.Close() + + sumHex, err := HashNormalizedSHA1(f) + if err != nil { + return "", fmt.Errorf("hash %s: %w", relPath, err) + } + return "sha1:" + sumHex, nil +} + +// HashNormalizedSHA1 computes SHA1 over a canonicalized stream: +// - Strip UTF-8 BOM only if present at the very beginning +// - Convert CRLF and lone CR to LF +// - Ignore the presence of a single trailing LF (drop it if present) +func HashNormalizedSHA1(r io.Reader) (string, error) { + br := bufio.NewReaderSize(r, 64*1024) + + // Strip UTF-8 BOM if present at the very start. + if b, err := br.Peek(3); err == nil && len(b) >= 3 && + b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF { + _, _ = br.Discard(3) + } + + h := sha1.New() + + // State across chunks + const readBufSize = 64 * 1024 + in := make([]byte, readBufSize) + out := make([]byte, 0, readBufSize) + + var prevCR bool // previous byte was '\r' not yet emitted + var pending byte // last normalized byte not yet written (for final-LF handling) + var havePending bool // whether pending is valid + + flushOut := func() error { + if len(out) == 0 { + return nil + } + if _, err := h.Write(out); err != nil { + return err + } + out = out[:0] + return nil + } + + emit := func(b byte) error { + // Buffer everything except keep the last byte in pending. + if havePending { + out = append(out, pending) + if len(out) >= 32*1024 { + if err := flushOut(); err != nil { + return err + } + } + } + pending = b + havePending = true + return nil + } + + for { + n, err := br.Read(in) + if n > 0 { + buf := in[:n] + for i := 0; i < len(buf); i++ { + c := buf[i] + + if prevCR { + if c == '\n' { + // CRLF -> emit LF once + if err := emit('\n'); err != nil { + return "", err + } + prevCR = false + continue + } + // Lone CR -> treat as newline + if err := emit('\n'); err != nil { + return "", err + } + prevCR = false + // fallthrough to handle current c normally + } + + if c == '\r' { + prevCR = true + continue + } + // Normal path: pass through, but normalize LF as-is + if err := emit(c); err != nil { + return "", err + } + } + // Flush buffered out bytes opportunistically + if err := flushOut(); err != nil { + return "", err + } + } + if err != nil { + if err == io.EOF { + break + } + return "", err + } + } + + // Handle final pending states. + if prevCR { + // File ended with CR -> normalize to LF + if err := emit('\n'); err != nil { + return "", err + } + } + + // Flush everything but drop exactly one final LF if present. + if havePending && pending != '\n' { + out = append(out, pending) + } + if err := flushOut(); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// PopulateMissingChecksums computes last_write_checksum for any TrackedFiles entries +// where LastWriteChecksum is empty. The fileSystem should be rooted at the directory containing +// the generated files (parent of .speakeasy/). +func PopulateMissingChecksums(lf *LockFile, fileSystem fs.FS) error { + if lf.TrackedFiles == nil { + return nil + } + + for path := range lf.TrackedFiles.Keys() { + tf, ok := lf.TrackedFiles.Get(path) + if !ok { + continue + } + + if tf.LastWriteChecksum == "" { + checksum, err := ComputeFileChecksum(fileSystem, path) + if err != nil { + continue + } + tf.LastWriteChecksum = checksum + lf.TrackedFiles.Set(path, tf) + } + } + return nil +} diff --git a/lockfile/io.go b/lockfile/io.go new file mode 100644 index 0000000..755100e --- /dev/null +++ b/lockfile/io.go @@ -0,0 +1,81 @@ +package lockfile + +import ( + "fmt" + "io/fs" + + "github.com/speakeasy-api/openapi/sequencedmap" + "gopkg.in/yaml.v3" +) + +type LoadOption func(*loadOptions) + +type loadOptions struct { + fileSystem fs.FS +} + +func WithFileSystem(fileSystem fs.FS) LoadOption { + return func(o *loadOptions) { + o.fileSystem = fileSystem + } +} + +func Load(data []byte, opts ...LoadOption) (*LockFile, error) { + o := &loadOptions{} + for _, opt := range opts { + opt(o) + } + + var lf LockFile + if err := yaml.Unmarshal(data, &lf); err != nil { + return nil, fmt.Errorf("could not unmarshal lockfile: %w", err) + } + + if lf.AdditionalProperties != nil { + delete(lf.AdditionalProperties, "generatedFileHashes") + } + + if lf.TrackedFiles == nil { + lf.TrackedFiles = sequencedmap.New[string, TrackedFile]() + } + + // Migrate old fields to new structure + for path := range lf.TrackedFiles.Keys() { + tf, ok := lf.TrackedFiles.Get(path) + if !ok { + continue + } + + modified := false + + // Check if integrity exists in AdditionalProperties + if tf.AdditionalProperties != nil { + if integrity, ok := tf.AdditionalProperties["integrity"].(string); ok { + // Migrate to LastWriteChecksum if not already set + if tf.LastWriteChecksum == "" { + tf.LastWriteChecksum = integrity + } + // Remove from AdditionalProperties + delete(tf.AdditionalProperties, "integrity") + modified = true + } + + // Migrate old "pristine_blob_hash" to "pristine_git_object" + if pristineBlobHash, ok := tf.AdditionalProperties["pristine_blob_hash"].(string); ok { + // Migrate to PristineGitObject if not already set + if tf.PristineGitObject == "" { + tf.PristineGitObject = pristineBlobHash + } + // Remove from AdditionalProperties + delete(tf.AdditionalProperties, "pristine_blob_hash") + modified = true + } + } + + if modified { + lf.TrackedFiles.Set(path, tf) + } + } + + return &lf, nil +} diff --git a/lockfile/lockfile.go b/lockfile/lockfile.go new file mode 100644 index 0000000..983c9a4 --- /dev/null +++ b/lockfile/lockfile.go @@ -0,0 +1,110 @@ +package lockfile + +import ( + "github.com/google/uuid" + "github.com/speakeasy-api/openapi/sequencedmap" + "gopkg.in/yaml.v3" +) + +const ( + LockV2 = "2.0.0" +) + +type ( + Examples = *sequencedmap.Map[string, *sequencedmap.Map[string, OperationExamples]] + GeneratedTests = *sequencedmap.Map[string, string] + TrackedFiles = *sequencedmap.Map[string, TrackedFile] +) + +type TrackedFile struct { + // Identity (The "Breadcrumb") + // UUID embedded in the file header. Allows detecting "Moves/Renames". + ID string `yaml:"id,omitempty"` + + // The Dirty Check (Optimization) + // The SHA-1 of the file content exactly as written to disk last time. + // If Disk_SHA == LastWriteChecksum, we skip the merge (Fast Path). + LastWriteChecksum string `yaml:"last_write_checksum,omitempty"` + + // The O(1) Lookup Key + // Stores the Blob Hash of the file from the PREVIOUS run. + // Only populated if persistentEdits is enabled. + PristineGitObject string `yaml:"pristine_git_object,omitempty"` + + // Deleted indicates the user deleted this file from disk. + // Set pre-generation by scanning disk vs lockfile entries. + // When true, the generator should not regenerate this file. + Deleted bool `yaml:"deleted,omitempty"` + + // MovedTo indicates the user moved/renamed this file to a new path. + // Set pre-generation by scanning @generated-id headers on disk. + // The generator should write to the new path instead of the original. + MovedTo string `yaml:"moved_to,omitempty"` + + AdditionalProperties map[string]any `yaml:",inline"` +} + +type PersistentEdits struct { + // Maps to refs/speakeasy/gen/ + GenerationID string `yaml:"generation_id,omitempty"` + + // Parent Commit (Links history for compression) + PristineCommitHash string `yaml:"pristine_commit_hash,omitempty"` + + // Content Checksum (Enables No-Op/Determinism check) + PristineTreeHash string `yaml:"pristine_tree_hash,omitempty"` +} + +type LockFile struct { + LockVersion string `yaml:"lockVersion"` + ID string `yaml:"id"` + Management Management `yaml:"management"` + PersistentEdits *PersistentEdits `yaml:"persistentEdits,omitempty"` + Features map[string]map[string]string `yaml:"features,omitempty"` + TrackedFiles TrackedFiles `yaml:"trackedFiles,omitempty"` + Examples Examples `yaml:"examples,omitempty"` + ExamplesVersion string `yaml:"examplesVersion,omitempty"` + GeneratedTests GeneratedTests `yaml:"generatedTests,omitempty"` + AdditionalProperties map[string]any `yaml:",inline"` + + ReleaseNotes string `yaml:"releaseNotes,omitempty"` +} + +type Management struct { + DocChecksum string `yaml:"docChecksum,omitempty"` + DocVersion string `yaml:"docVersion,omitempty"` + SpeakeasyVersion string `yaml:"speakeasyVersion,omitempty"` + GenerationVersion string `yaml:"generationVersion,omitempty"` + ReleaseVersion string `yaml:"releaseVersion,omitempty"` + ConfigChecksum string `yaml:"configChecksum,omitempty"` + RepoURL string `yaml:"repoURL,omitempty"` + RepoSubDirectory string `yaml:"repoSubDirectory,omitempty"` + InstallationURL string `yaml:"installationURL,omitempty"` + Published bool `yaml:"published,omitempty"` + AdditionalProperties map[string]any `yaml:",inline"` +} + +type OperationExamples struct { + Parameters *ParameterExamples `yaml:"parameters,omitempty"` + RequestBody *sequencedmap.Map[string, yaml.Node] `yaml:"requestBody,omitempty"` + Responses *sequencedmap.Map[string, *sequencedmap.Map[string, yaml.Node]] `yaml:"responses,omitempty"` +} + +type ParameterExamples struct { + Path *sequencedmap.Map[string, yaml.Node] `yaml:"path,omitempty"` + Query *sequencedmap.Map[string, yaml.Node] `yaml:"query,omitempty"` + Header *sequencedmap.Map[string, yaml.Node] `yaml:"header,omitempty"` +} + +var GetUUID = func() string { + return uuid.NewString() +} + +func New() *LockFile { + return &LockFile{ + LockVersion: LockV2, + ID: GetUUID(), + Features: map[string]map[string]string{}, + TrackedFiles: sequencedmap.New[string, TrackedFile](), + } +} diff --git a/lockfile/lockfile_test.go b/lockfile/lockfile_test.go new file mode 100644 index 0000000..00c4efc --- /dev/null +++ b/lockfile/lockfile_test.go @@ -0,0 +1,139 @@ +package lockfile_test + +import ( + "testing" + + "github.com/speakeasy-api/sdk-gen-config/lockfile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad_MigratesIntegrityToLastWriteChecksum(t *testing.T) { + // Legacy lockfile YAML with 'integrity' field + legacyYAML := []byte(` +lockVersion: "2.0.0" +id: "test-uuid" +management: {} +trackedFiles: + "src/client.go": + integrity: "sha1:abc123hash" + extra: "data" +`) + + lf, err := lockfile.Load(legacyYAML) + require.NoError(t, err) + require.NotNil(t, lf) + + // Verify the file exists in tracked files + tf, ok := lf.TrackedFiles.Get("src/client.go") + require.True(t, ok) + + // Verify migration + assert.Equal(t, "sha1:abc123hash", tf.LastWriteChecksum, "Should migrate integrity to last_write_checksum") + + // Verify cleanup + _, exists := tf.AdditionalProperties["integrity"] + assert.False(t, exists, "Should remove integrity from AdditionalProperties") + + // Verify other properties preserved + assert.Equal(t, "data", tf.AdditionalProperties["extra"]) +} + +func TestLoad_NewStructure(t *testing.T) { + // New lockfile YAML with 3-way merge fields + newYAML := []byte(` +lockVersion: "2.0.0" +id: "test-uuid" +management: {} +persistentEdits: + generation_id: "uuid-550e" + pristine_commit_hash: "a1b2c3" + pristine_tree_hash: "tree-e5f6" +trackedFiles: + "pkg/models/user.go": + id: "uuid-breadcrumb-123" + last_write_checksum: "sha1:file-hash-789" + pristine_git_object: "blob-123" +`) + + lf, err := lockfile.Load(newYAML) + require.NoError(t, err) + + // Verify persistentEdits + require.NotNil(t, lf.PersistentEdits) + assert.Equal(t, "uuid-550e", lf.PersistentEdits.GenerationID) + assert.Equal(t, "a1b2c3", lf.PersistentEdits.PristineCommitHash) + assert.Equal(t, "tree-e5f6", lf.PersistentEdits.PristineTreeHash) + + // Verify tracked file + tf, ok := lf.TrackedFiles.Get("pkg/models/user.go") + require.True(t, ok) + + assert.Equal(t, "uuid-breadcrumb-123", tf.ID) + assert.Equal(t, "sha1:file-hash-789", tf.LastWriteChecksum) + assert.Equal(t, "blob-123", tf.PristineGitObject) +} + +func TestLoad_MigratesPristineBlobHashToGitObject(t *testing.T) { + // Legacy lockfile YAML with 'pristine_blob_hash' field + legacyYAML := []byte(` +lockVersion: "2.0.0" +id: "test-uuid" +management: {} +trackedFiles: + "src/model.go": + id: "file-uuid-123" + pristine_blob_hash: "git-hash-456" + last_write_checksum: "sha1:file-hash-789" +`) + + lf, err := lockfile.Load(legacyYAML) + require.NoError(t, err) + require.NotNil(t, lf) + + // Verify the file exists in tracked files + tf, ok := lf.TrackedFiles.Get("src/model.go") + require.True(t, ok) + + // Verify migration + assert.Equal(t, "git-hash-456", tf.PristineGitObject, "Should migrate pristine_blob_hash to pristine_git_object") + + // Verify cleanup + _, exists := tf.AdditionalProperties["pristine_blob_hash"] + assert.False(t, exists, "Should remove pristine_blob_hash from AdditionalProperties") + + // Verify other properties preserved + assert.Equal(t, "file-uuid-123", tf.ID) + assert.Equal(t, "sha1:file-hash-789", tf.LastWriteChecksum) +} + +func TestLoad_OmitsEmptyPersistentEdits(t *testing.T) { + // Lockfile YAML without persistentEdits section + yamlWithoutPE := []byte(` +lockVersion: "2.0.0" +id: "test-uuid" +management: {} +trackedFiles: + "src/file.go": + id: "file-uuid" + last_write_checksum: "sha1:hash" +`) + + lf, err := lockfile.Load(yamlWithoutPE) + require.NoError(t, err) + require.NotNil(t, lf) + + // Verify persistentEdits is nil when not present + assert.Nil(t, lf.PersistentEdits, "PersistentEdits should be nil when not in YAML") +} + +func TestNew_CreatesValidLockFile(t *testing.T) { + lf := lockfile.New() + require.NotNil(t, lf) + + assert.Equal(t, "2.0.0", lf.LockVersion) + assert.NotEmpty(t, lf.ID) + assert.NotNil(t, lf.TrackedFiles) + assert.Equal(t, 0, lf.TrackedFiles.Len()) + assert.Nil(t, lf.PersistentEdits, "PersistentEdits should be nil in new lockfile") +} diff --git a/schemas/gen.config.schema.json b/schemas/gen.config.schema.json index 742942b..d730448 100644 --- a/schemas/gen.config.schema.json +++ b/schemas/gen.config.schema.json @@ -1,251 +1,296 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Gen YAML Configuration Schema", - "type": "object", - "additionalProperties": false, - "properties": { - "configVersion": { - "type": "string", - "description": "The version of the configuration file", - "minLength": 1 - }, - "generation": { - "$ref": "#/$defs/generation" - }, - "go": { - "$ref": "./languages/go.schema.json", - "description": "Configuration specific to Go SDK" - }, - "typescript": { - "$ref": "./languages/typescript.schema.json", - "description": "Configuration specific to TypeScript SDK" - }, - "python": { - "$ref": "./languages/python.schema.json", - "description": "Configuration specific to Python SDK" - }, - "java": { - "$ref": "./languages/java.schema.json", - "description": "Configuration specific to Java SDK" - }, - "csharp": { - "$ref": "./languages/csharp.schema.json", - "description": "Configuration specific to C# SDK" - }, - "unity": { - "$ref": "./languages/unity.schema.json", - "description": "Configuration specific to Unity SDK" - }, - "php": { - "$ref": "./languages/php.schema.json", - "description": "Configuration specific to PHP SDK" - }, - "ruby": { - "$ref": "./languages/ruby.schema.json", - "description": "Configuration specific to Ruby SDK" + "$defs": { + "SdkGenConfigAuth": { + "additionalProperties": false, + "description": "Authentication configuration", + "properties": { + "hoistGlobalSecurity": { + "description": "Enables hoisting of operation-level security schemes to global level when no global security is defined", + "type": "boolean" + }, + "oAuth2ClientCredentialsEnabled": { + "description": "Enables support for OAuth2 client credentials grant type", + "type": "boolean" + }, + "oAuth2PasswordEnabled": { + "description": "Enables support for OAuth2 resource owner password credentials grant type", + "type": "boolean" + } + }, + "type": "object" }, - "postman": { - "$ref": "./languages/postman.schema.json", - "description": "Configuration specific to Postman Collections" + "SdkGenConfigDevContainers": { + "additionalProperties": true, + "description": "Dev container configuration", + "properties": { + "enabled": { + "description": "Whether dev containers are enabled", + "type": "boolean" + }, + "schemaPath": { + "description": "Path to the schema file for the dev container", + "type": "string" + } + }, + "type": "object" }, - "terraform": { - "$ref": "./languages/terraform.schema.json", - "description": "Configuration specific to Terraform Providers" - } - }, - "required": ["configVersion", "generation"], - "$defs": { - "generation": { - "type": "object", - "description": "Generation configuration", + "SdkGenConfigFixes": { "additionalProperties": true, + "description": "Fixes applied to the SDK generation", "properties": { - "devContainers": { - "$ref": "#/$defs/devContainers" + "nameResolutionDec2023": { + "description": "Enables name resolution fixes from December 2023", + "type": "boolean" }, - "baseServerURL": { - "type": "string", - "description": "The base URL of the server. This value will be used if global servers are not defined in the spec." + "nameResolutionFeb2025": { + "description": "Enables name resolution fixes from February 2025", + "type": "boolean" }, - "sdkClassName": { - "type": "string", - "description": "Generated name of the root SDK class" + "parameterOrderingFeb2024": { + "description": "Enables parameter ordering fixes from February 2024", + "type": "boolean" }, - "maintainOpenAPIOrder": { - "type": "boolean", - "description": "Maintains the order of parameters and fields in the OpenAPI specification" + "requestResponseComponentNamesFeb2024": { + "description": "Enables request and response component naming fixes from February 2024", + "type": "boolean" }, - "deduplicateErrors": { - "type": "boolean", - "description": "Deduplicates errors that have the same schema" + "securityFeb2025": { + "description": "Enables fixes and refactoring for security that were introduced in February 2025", + "type": "boolean" }, - "usageSnippets": { - "$ref": "#/$defs/usageSnippets" + "sharedErrorComponentsApr2025": { + "description": "Enables fixes that mean that when a component is used in both 2XX and 4XX responses, only the top level component will be duplicated to the errors scope as opposed to the entire component tree", + "type": "boolean" + } + }, + "type": "object" + }, + "SdkGenConfigGeneration": { + "additionalProperties": true, + "description": "Generation configuration", + "properties": { + "auth": { + "$ref": "#/$defs/SdkGenConfigAuth" }, - "useClassNamesForArrayFields": { - "type": "boolean", - "description": "Use class names for array fields instead of the child's schema type" + "baseServerUrl": { + "description": "The base URL of the server. This value will be used if global servers are not defined in the spec.", + "type": "string" }, - "fixes": { - "$ref": "#/$defs/fixes" + "deduplicateErrors": { + "description": "Deduplicates errors that have the same schema", + "type": "boolean" }, - "auth": { - "$ref": "#/$defs/auth" + "devContainers": { + "$ref": "#/$defs/SdkGenConfigDevContainers" }, - "skipErrorSuffix": { - "type": "boolean", - "description": "Skips the automatic addition of an error suffix to error types" + "fixes": { + "$ref": "#/$defs/SdkGenConfigFixes" }, "inferSSEOverload": { - "type": "boolean", - "description": "Generates an overload if generator detects that the request body field 'stream: true' is used for client intent to request 'text/event-stream' response" + "description": "Generates an overload if generator detects that the request body field 'stream: true' is used for client intent to request 'text/event-stream' response", + "type": "boolean" }, - "sdkHooksConfigAccess": { - "type": "boolean", - "description": "Enables access to the SDK configuration from hooks" + "maintainOpenAPIOrder": { + "description": "Maintains the order of parameters and fields in the OpenAPI specification", + "type": "boolean" }, - "schemas": { - "$ref": "#/$defs/schemas" + "mockServer": { + "$ref": "#/$defs/SdkGenConfigMockServer" + }, + "persistentEdits": { + "$ref": "#/$defs/SdkGenConfigPersistentEdits" }, "requestBodyFieldName": { - "type": "string", - "description": "The name of the field to use for the request body in generated SDKs" + "description": "The name of the field to use for the request body in generated SDKs", + "type": "string" }, - "mockServer": { - "$ref": "#/$defs/mockServer" + "schemas": { + "$ref": "#/$defs/SdkGenConfigSchemas" + }, + "sdkClassName": { + "description": "Generated name of the root SDK class", + "type": "string" + }, + "sdkHooksConfigAccess": { + "description": "Enables access to the SDK configuration from hooks", + "type": "boolean" + }, + "skipErrorSuffix": { + "description": "Skips the automatic addition of an error suffix to error types", + "type": "boolean" }, "tests": { - "$ref": "#/$defs/tests" - } - } - }, - "devContainers": { - "type": "object", - "description": "Dev container configuration", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether dev containers are enabled" + "$ref": "#/$defs/SdkGenConfigTests" }, - "schemaPath": { - "type": "string", - "description": "Path to the schema file for the dev container" + "usageSnippets": { + "$ref": "#/$defs/SdkGenConfigUsageSnippets" + }, + "useClassNamesForArrayFields": { + "description": "Use class names for array fields instead of the child's schema type", + "type": "boolean" } }, - "additionalProperties": true + "type": "object" }, - "usageSnippets": { - "type": "object", - "description": "Configuration for usage snippets", + "SdkGenConfigLanguageConfig": { + "additionalProperties": true, + "description": "Language-specific SDK configuration", "properties": { - "optionalPropertyRendering": { - "type": "string", - "enum": ["always", "never", "withExample"], - "description": "Controls how optional properties are rendered in usage snippets" - }, - "sdkInitStyle": { - "type": "string", - "enum": ["constructor", "builder"], - "description": "Controls how the SDK initialization is depicted in usage snippets" + "Cfg": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] }, - "serverToShowInSnippets": { - "type": ["string", "integer"], - "description": "Controls which server is shown in usage snippets. If unset, no server will be shown. If an integer, it will be used as the server index. Otherwise, it will look for a matching server ID." + "version": { + "description": "SDK version", + "type": "string" } }, - "additionalProperties": true + "type": "object" }, - "fixes": { - "type": "object", - "description": "Fixes applied to the SDK generation", + "SdkGenConfigMockServer": { + "additionalProperties": false, + "description": "Mock server generation configuration", "properties": { - "nameResolutionDec2023": { - "type": "boolean", - "description": "Enables name resolution fixes from December 2023" - }, - "nameResolutionFeb2025": { - "type": "boolean", - "description": "Enables name resolution fixes from February 2025" - }, - "parameterOrderingFeb2024": { - "type": "boolean", - "description": "Enables parameter ordering fixes from February 2024" - }, - "requestResponseComponentNamesFeb2024": { - "type": "boolean", - "description": "Enables request and response component naming fixes from February 2024" - }, - "securityFeb2025": { - "type": "boolean", - "description": "Enables fixes and refactoring for security that were introduced in February 2025" - }, - "sharedErrorComponentsApr2025": { - "type": "boolean", - "description": "Enables fixes that mean that when a component is used in both 2XX and 4XX responses, only the top level component will be duplicated to the errors scope as opposed to the entire component tree" + "disabled": { + "description": "Disables the code generation of the mock server target", + "type": "boolean" } }, - "additionalProperties": true + "type": "object" }, - "auth": { - "type": "object", - "description": "Authentication configuration", + "SdkGenConfigPersistentEdits": { + "additionalProperties": true, + "description": "Configures whether user edits to generated SDKs persist across regenerations", "properties": { - "oAuth2ClientCredentialsEnabled": { - "type": "boolean", - "description": "Enables support for OAuth2 client credentials grant type" - }, - "oAuth2PasswordEnabled": { - "type": "boolean", - "description": "Enables support for OAuth2 resource owner password credentials grant type" + "enabled": { + "description": "Enables preservation of user edits across SDK regenerations. Requires Git repository.", + "type": "boolean" }, - "hoistGlobalSecurity": { - "type": "boolean", - "description": "Enables hoisting of operation-level security schemes to global level when no global security is defined" + "pristineBranch": { + "description": "The Git branch name for tracking pristine generated code. Defaults to 'sdk-pristine' if not specified.", + "type": "string" } }, - "additionalProperties": false + "type": "object" }, - "schemas": { - "type": "object", + "SdkGenConfigSchemas": { + "additionalProperties": false, "description": "Schema processing configuration", "properties": { "allOfMergeStrategy": { - "type": "string", - "enum": ["deepMerge", "shallowMerge"], - "description": "Controls how allOf schemas are merged" + "description": "Controls how allOf schemas are merged", + "enum": [ + "deepMerge", + "shallowMerge" + ], + "type": "string" } }, - "additionalProperties": false + "type": "object" }, - "mockServer": { - "type": "object", - "description": "Mock server generation configuration", + "SdkGenConfigServerIndex": { + "description": "Controls which server is shown in usage snippets. If unset, no server will be shown. If an integer, it will be used as the server index. Otherwise, it will look for a matching server ID.", + "type": [ + "string", + "integer" + ] + }, + "SdkGenConfigTests": { + "additionalProperties": true, + "description": "Test generation configuration", "properties": { - "disabled": { - "type": "boolean", - "description": "Disables the code generation of the mock server target" + "generateNewTests": { + "description": "Enables generation of new tests for any new operations in the OpenAPI specification", + "type": "boolean" + }, + "generateTests": { + "description": "Enables generation of tests", + "type": "boolean" + }, + "skipResponseBodyAssertions": { + "description": "Skip asserting that the client got the same response bodies returned by the mock server", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "tests": { - "type": "object", - "description": "Test generation configuration", + "SdkGenConfigUsageSnippets": { + "additionalProperties": true, + "description": "Configuration for usage snippets", "properties": { - "generateTests": { - "type": "boolean", - "description": "Enables generation of tests" + "optionalPropertyRendering": { + "description": "Controls how optional properties are rendered in usage snippets", + "enum": [ + "always", + "never", + "withExample" + ], + "type": "string" }, - "generateNewTests": { - "type": "boolean", - "description": "Enables generation of new tests for any new operations in the OpenAPI specification" + "sdkInitStyle": { + "description": "Controls how the SDK initialization is depicted in usage snippets", + "enum": [ + "constructor", + "builder" + ], + "type": "string" }, - "skipResponseBodyAssertions": { - "type": "boolean", - "description": "Skip asserting that the client got the same response bodies returned by the mock server" + "serverToShowInSnippets": { + "$ref": "#/$defs/SdkGenConfigServerIndex" } }, - "additionalProperties": false + "type": "object" } - } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "configVersion": { + "description": "The version of the configuration file", + "minLength": 1, + "type": "string" + }, + "csharp": { + "$ref": "./languages/csharp.schema.json" + }, + "generation": { + "$ref": "#/$defs/SdkGenConfigGeneration" + }, + "go": { + "$ref": "./languages/go.schema.json" + }, + "java": { + "$ref": "./languages/java.schema.json" + }, + "php": { + "$ref": "./languages/php.schema.json" + }, + "postman": { + "$ref": "./languages/postman.schema.json" + }, + "python": { + "$ref": "./languages/python.schema.json" + }, + "ruby": { + "$ref": "./languages/ruby.schema.json" + }, + "terraform": { + "$ref": "./languages/terraform.schema.json" + }, + "typescript": { + "$ref": "./languages/typescript.schema.json" + }, + "unity": { + "$ref": "./languages/unity.schema.json" + } + }, + "required": [ + "configVersion", + "generation" + ], + "title": "Gen YAML Configuration Schema", + "type": "object" } diff --git a/tools/schema-gen/main.go b/tools/schema-gen/main.go index 0a6b4c2..4fcdab1 100644 --- a/tools/schema-gen/main.go +++ b/tools/schema-gen/main.go @@ -7,24 +7,44 @@ import ( "os" "strings" + "github.com/speakeasy-api/sdk-gen-config" "github.com/speakeasy-api/sdk-gen-config/workflow" jsg "github.com/swaggest/jsonschema-go" ) func main() { var ( - out string + out string + schemaType string ) flag.StringVar(&out, "out", "-", "output file path or - for stdout") + flag.StringVar(&schemaType, "type", "workflow", "schema type to generate: workflow or config") flag.Parse() r := jsg.Reflector{} - schema, err := r.Reflect(workflow.Workflow{}, func(rc *jsg.ReflectContext) { - // Use yaml tags for property names to match the Go structs - rc.PropertyNameTag = "yaml" - rc.InlineRefs = false // Use $ref for reusable definitions - }) + var ( + schema jsg.Schema + err error + ) + + // Customize reflection based on type + switch schemaType { + case "workflow": + schema, err = r.Reflect(workflow.Workflow{}, func(rc *jsg.ReflectContext) { + rc.PropertyNameTag = "yaml" + rc.InlineRefs = false + }) + case "config": + schema, err = r.Reflect(config.Configuration{}, func(rc *jsg.ReflectContext) { + rc.PropertyNameTag = "yaml" + rc.InlineRefs = false + }) + default: + fmt.Fprintf(os.Stderr, "unknown schema type: %s\n", schemaType) + os.Exit(1) + } + if err != nil { fmt.Fprintf(os.Stderr, "reflect: %v\n", err) os.Exit(1) @@ -35,8 +55,46 @@ func main() { if schemaMap == nil { schemaMap = &jsg.Schema{} } - schemaMap.WithTitle("Speakeasy Workflow Schema") - schemaMap.WithAdditionalProperties(jsg.SchemaOrBool{TypeBoolean: boolPtr(false)}) + + // If the root schema is a reference (common with InlineRefs=false), + // unwrap it to make the properties top-level + if schemaMap.Ref != nil { + refName := strings.TrimPrefix(*schemaMap.Ref, "#/definitions/") + if def, ok := schemaMap.Definitions[refName]; ok { + defObj := def.TypeObject + if defObj != nil { + // Copy essential fields from definition to root + schemaMap.Properties = defObj.Properties + schemaMap.Required = defObj.Required + schemaMap.AdditionalProperties = defObj.AdditionalProperties + schemaMap.Description = defObj.Description + schemaMap.Title = defObj.Title + schemaMap.Type = defObj.Type + + // Remove the reference + schemaMap.Ref = nil + + // Optionally remove the definition itself since it's now at root + delete(schemaMap.Definitions, refName) + } + } + } + + if schemaType == "workflow" { + if schemaMap.Title == nil { + schemaMap.WithTitle("Speakeasy Workflow Schema") + } + if schemaMap.AdditionalProperties == nil { + schemaMap.WithAdditionalProperties(jsg.SchemaOrBool{TypeBoolean: boolPtr(false)}) + } + } else if schemaType == "config" { + if schemaMap.Title == nil { + schemaMap.WithTitle("Gen YAML Configuration Schema") + } + if schemaMap.AdditionalProperties == nil { + schemaMap.WithAdditionalProperties(jsg.SchemaOrBool{TypeBoolean: boolPtr(false)}) + } + } b, err := json.MarshalIndent(schemaMap, "", " ") if err != nil { @@ -61,6 +119,11 @@ func main() { updateRefs(result) } + // Config-specific post-processing + if schemaType == "config" { + patchConfigSchema(result) + } + b, err = json.MarshalIndent(result, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "final marshal: %v\n", err) @@ -101,3 +164,57 @@ func updateRefs(v interface{}) { } } } + +// patchConfigSchema injects the specific language references and cleans up AdditionalProperties fields. +func patchConfigSchema(root map[string]interface{}) { + // Helper function to process any schema object + processSchema := func(schema map[string]interface{}) { + props, ok := schema["properties"].(map[string]interface{}) + if !ok { + return + } + + // Remove AdditionalProperties field (from yaml:",inline" map fields) + // and set additionalProperties: true to allow unknown properties + if _, exists := props["AdditionalProperties"]; exists { + delete(props, "AdditionalProperties") + schema["additionalProperties"] = true + } + } + + // Apply language patches to ROOT properties only + rootProps, ok := root["properties"].(map[string]interface{}) + if ok { + // Remove the synthetic Languages property (comes from inline map in Go struct) + delete(rootProps, "Languages") + + languages := map[string]string{ + "go": "Go", + "typescript": "TypeScript", + "python": "Python", + "java": "Java", + "csharp": "C#", + "unity": "Unity", + "php": "PHP", + "ruby": "Ruby", + "postman": "Postman Collections", + "terraform": "Terraform Providers", + } + for lang := range languages { + rootProps[lang] = map[string]interface{}{ + "$ref": fmt.Sprintf("./languages/%s.schema.json", lang), + } + } + } + + // Apply AdditionalProperties cleanup to root and all $defs + processSchema(root) + + if defs, ok := root["$defs"].(map[string]interface{}); ok { + for _, d := range defs { + if schema, ok := d.(map[string]interface{}); ok { + processSchema(schema) + } + } + } +} diff --git a/upgrades.go b/upgrades.go index 07b8e2e..b605116 100644 --- a/upgrades.go +++ b/upgrades.go @@ -3,6 +3,8 @@ package config import ( "errors" "fmt" + + "github.com/speakeasy-api/sdk-gen-config/lockfile" ) var ErrFailedUpgrade = errors.New("failed to upgrade config") @@ -111,8 +113,8 @@ func upgradeToV200(cfg map[string]any, uf UpgradeFunc) (string, map[string]any, } newLockFile := map[string]any{ - "lockVersion": v2, - "id": getUUID(), + "lockVersion": lockfile.LockV2, + "id": lockfile.GetUUID(), } delete(cfg, "configVersion") diff --git a/upgrades_test.go b/upgrades_test.go index 4e62899..64a0a34 100644 --- a/upgrades_test.go +++ b/upgrades_test.go @@ -3,6 +3,7 @@ package config import ( "testing" + "github.com/speakeasy-api/sdk-gen-config/lockfile" "github.com/stretchr/testify/assert" ) @@ -10,6 +11,7 @@ func Test_upgrade_Success(t *testing.T) { getUUID = func() string { return "123" } + lockfile.GetUUID = getUUID type args struct { currentVersion string @@ -57,7 +59,7 @@ func Test_upgrade_Success(t *testing.T) { }, }, wantLockFile: map[string]any{ - "lockVersion": v2, + "lockVersion": lockfile.LockV2, "id": "123", "management": map[string]any{ "docChecksum": "123", @@ -90,7 +92,7 @@ func Test_upgrade_Success(t *testing.T) { }, }, wantLockFile: map[string]any{ - "lockVersion": v2, + "lockVersion": lockfile.LockV2, "id": "123", "management": map[string]any{ "releaseVersion": "0.0.1", @@ -146,7 +148,7 @@ func Test_upgrade_Success(t *testing.T) { }, }, wantLockFile: map[string]any{ - "lockVersion": v2, + "lockVersion": lockfile.LockV2, "id": "123", "management": map[string]any{ "docChecksum": "123", From 8738efcc5c6cc0aaae455c7d2d61edf6838f41e4 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 27 Nov 2025 11:33:58 +0000 Subject: [PATCH 2/3] chore: remove unnecessary code --- lockfile/io.go | 42 ------------------------- lockfile/lockfile_test.go | 64 --------------------------------------- 2 files changed, 106 deletions(-) diff --git a/lockfile/io.go b/lockfile/io.go index 755100e..c9cd473 100644 --- a/lockfile/io.go +++ b/lockfile/io.go @@ -31,51 +31,9 @@ func Load(data []byte, opts ...LoadOption) (*LockFile, error) { return nil, fmt.Errorf("could not unmarshal lockfile: %w", err) } - if lf.AdditionalProperties != nil { - delete(lf.AdditionalProperties, "generatedFileHashes") - } - if lf.TrackedFiles == nil { lf.TrackedFiles = sequencedmap.New[string, TrackedFile]() } - // Migrate old fields to new structure - for path := range lf.TrackedFiles.Keys() { - tf, ok := lf.TrackedFiles.Get(path) - if !ok { - continue - } - - modified := false - - // Check if integrity exists in AdditionalProperties - if tf.AdditionalProperties != nil { - if integrity, ok := tf.AdditionalProperties["integrity"].(string); ok { - // Migrate to LastWriteChecksum if not already set - if tf.LastWriteChecksum == "" { - tf.LastWriteChecksum = integrity - } - // Remove from AdditionalProperties - delete(tf.AdditionalProperties, "integrity") - modified = true - } - - // Migrate old "pristine_blob_hash" to "pristine_git_object" - if pristineBlobHash, ok := tf.AdditionalProperties["pristine_blob_hash"].(string); ok { - // Migrate to PristineGitObject if not already set - if tf.PristineGitObject == "" { - tf.PristineGitObject = pristineBlobHash - } - // Remove from AdditionalProperties - delete(tf.AdditionalProperties, "pristine_blob_hash") - modified = true - } - } - - if modified { - lf.TrackedFiles.Set(path, tf) - } - } - return &lf, nil } diff --git a/lockfile/lockfile_test.go b/lockfile/lockfile_test.go index 00c4efc..e8afa57 100644 --- a/lockfile/lockfile_test.go +++ b/lockfile/lockfile_test.go @@ -8,37 +8,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoad_MigratesIntegrityToLastWriteChecksum(t *testing.T) { - // Legacy lockfile YAML with 'integrity' field - legacyYAML := []byte(` -lockVersion: "2.0.0" -id: "test-uuid" -management: {} -trackedFiles: - "src/client.go": - integrity: "sha1:abc123hash" - extra: "data" -`) - - lf, err := lockfile.Load(legacyYAML) - require.NoError(t, err) - require.NotNil(t, lf) - - // Verify the file exists in tracked files - tf, ok := lf.TrackedFiles.Get("src/client.go") - require.True(t, ok) - - // Verify migration - assert.Equal(t, "sha1:abc123hash", tf.LastWriteChecksum, "Should migrate integrity to last_write_checksum") - - // Verify cleanup - _, exists := tf.AdditionalProperties["integrity"] - assert.False(t, exists, "Should remove integrity from AdditionalProperties") - - // Verify other properties preserved - assert.Equal(t, "data", tf.AdditionalProperties["extra"]) -} - func TestLoad_NewStructure(t *testing.T) { // New lockfile YAML with 3-way merge fields newYAML := []byte(` @@ -74,39 +43,6 @@ trackedFiles: assert.Equal(t, "blob-123", tf.PristineGitObject) } -func TestLoad_MigratesPristineBlobHashToGitObject(t *testing.T) { - // Legacy lockfile YAML with 'pristine_blob_hash' field - legacyYAML := []byte(` -lockVersion: "2.0.0" -id: "test-uuid" -management: {} -trackedFiles: - "src/model.go": - id: "file-uuid-123" - pristine_blob_hash: "git-hash-456" - last_write_checksum: "sha1:file-hash-789" -`) - - lf, err := lockfile.Load(legacyYAML) - require.NoError(t, err) - require.NotNil(t, lf) - - // Verify the file exists in tracked files - tf, ok := lf.TrackedFiles.Get("src/model.go") - require.True(t, ok) - - // Verify migration - assert.Equal(t, "git-hash-456", tf.PristineGitObject, "Should migrate pristine_blob_hash to pristine_git_object") - - // Verify cleanup - _, exists := tf.AdditionalProperties["pristine_blob_hash"] - assert.False(t, exists, "Should remove pristine_blob_hash from AdditionalProperties") - - // Verify other properties preserved - assert.Equal(t, "file-uuid-123", tf.ID) - assert.Equal(t, "sha1:file-hash-789", tf.LastWriteChecksum) -} - func TestLoad_OmitsEmptyPersistentEdits(t *testing.T) { // Lockfile YAML without persistentEdits section yamlWithoutPE := []byte(` From 3ae4aca579cf0efa378ea856325eabbac954e2d6 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Mon, 1 Dec 2025 09:10:27 +0000 Subject: [PATCH 3/3] Update integrity.go --- lockfile/integrity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lockfile/integrity.go b/lockfile/integrity.go index 5a24851..ce985c9 100644 --- a/lockfile/integrity.go +++ b/lockfile/integrity.go @@ -2,7 +2,7 @@ package lockfile import ( "bufio" - "crypto/sha1" // nolint:gosec // sha1 is intentional for lockfile compatibility + "crypto/sha1" // nolint:gosec // sha1 is intentional as we're copying git (and this isn't a security thing) "encoding/hex" "fmt" "io"