From c08cb542b9a3f48fc455f376cb8d1d63b8a44464 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 10:31:09 -0400 Subject: [PATCH 1/8] fix(ruby): accept acronyms in module name validation The `module` validation pattern `^([A-Z][a-z0-9]+)+$` requires a lowercase letter or digit after every capital, so PascalCase names containing acronyms such as `MySDK` or `OpenAPI` fail gen.yaml validation even though the generator renders them correctly. Accept any name starting with an uppercase letter followed by letters or digits. --- .changesets/1793024465-e6de07b9.yaml | 10 +++++ pkg/generate/ruby_config_validation_test.go | 47 +++++++++++++++++++++ templates/templates/ruby/config.ts | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changesets/1793024465-e6de07b9.yaml create mode 100644 pkg/generate/ruby_config_validation_test.go diff --git a/.changesets/1793024465-e6de07b9.yaml b/.changesets/1793024465-e6de07b9.yaml new file mode 100644 index 000000000..8e2334e48 --- /dev/null +++ b/.changesets/1793024465-e6de07b9.yaml @@ -0,0 +1,10 @@ +id: 1793024465-e6de07b9 +features: + - core +targets: + - ruby +type: fix +bump: patch +description: accept acronyms such as SDK or API in the module name +author: 2ynn +date: "2026-10-26" diff --git a/pkg/generate/ruby_config_validation_test.go b/pkg/generate/ruby_config_validation_test.go new file mode 100644 index 000000000..46a0085b4 --- /dev/null +++ b/pkg/generate/ruby_config_validation_test.go @@ -0,0 +1,47 @@ +package generate_test + +import ( + "regexp" + "testing" + + "github.com/speakeasy-api/openapi-generation/v2/internal/types" + "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRubyModuleValidationRegex(t *testing.T) { + t.Parallel() + + fields, err := generate.GetLanguageConfigFields(types.NewTargetFromTemplate("ruby"), false) + require.NoError(t, err) + + var pattern string + for _, field := range fields { + if field.Name == "module" { + require.NotNil(t, field.ValidationRegex) + pattern = *field.ValidationRegex + } + } + require.NotEmpty(t, pattern) + + regex := regexp.MustCompile(pattern) + + testCases := map[string]bool{ + "OpenApiSdk": true, + "AcmeRubySDK": true, + "OpenAPI": true, + "SDK": true, + "Acme2": true, + "openApiSdk": false, + "Open_Api": false, + "Open::Api": false, + "Open-Api": false, + "2Acme": false, + "": false, + } + + for name, valid := range testCases { + assert.Equal(t, valid, regex.MatchString(name), "module %q", name) + } +} diff --git a/templates/templates/ruby/config.ts b/templates/templates/ruby/config.ts index 0d2eb7c59..b89b730c0 100644 --- a/templates/templates/ruby/config.ts +++ b/templates/templates/ruby/config.ts @@ -238,7 +238,7 @@ function getConfigFields( DefaultValue: "OpenApiSdk", Description: "The top level module names for your sdk https://ruby-doc.org/3.2.6/syntax/modules_and_classes_rdoc.html", - ValidationRegex: /^([A-Z][a-z0-9]+)+$/.source, + ValidationRegex: /^[A-Z][A-Za-z0-9]*$/.source, ValidationMessage: "Module names should be PascalCase and have no special chars", }, From 7f291ec2a2efbfa7275942ef17ed35d69a32112d Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 11:43:07 -0400 Subject: [PATCH 2/8] test(ruby): move module validation test next to GetLanguageConfigFields --- .../config_ruby_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pkg/{generate/ruby_config_validation_test.go => templates/config_ruby_test.go} (100%) diff --git a/pkg/generate/ruby_config_validation_test.go b/pkg/templates/config_ruby_test.go similarity index 100% rename from pkg/generate/ruby_config_validation_test.go rename to pkg/templates/config_ruby_test.go From fcffa1cd73c2f5492162d545117bef23f53a32e8 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 11:43:50 -0400 Subject: [PATCH 3/8] test(ruby): use templates package in module validation test --- pkg/templates/config_ruby_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/templates/config_ruby_test.go b/pkg/templates/config_ruby_test.go index 46a0085b4..be59fe1ca 100644 --- a/pkg/templates/config_ruby_test.go +++ b/pkg/templates/config_ruby_test.go @@ -1,11 +1,11 @@ -package generate_test +package templates_test import ( "regexp" "testing" "github.com/speakeasy-api/openapi-generation/v2/internal/types" - "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" + "github.com/speakeasy-api/openapi-generation/v2/pkg/templates" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -13,7 +13,7 @@ import ( func TestRubyModuleValidationRegex(t *testing.T) { t.Parallel() - fields, err := generate.GetLanguageConfigFields(types.NewTargetFromTemplate("ruby"), false) + fields, err := templates.GetLanguageConfigFields(types.NewTargetFromTemplate("ruby"), false) require.NoError(t, err) var pattern string From dfbc607e4cfaf3cef0ccd294bf914837968593f1 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 11:46:02 -0400 Subject: [PATCH 4/8] chore: check config field validation patterns compile in Go Every target's gen.yaml config fields may declare a ValidationRegex in config.ts. The patterns are authored as JavaScript regular expressions but the Speakeasy CLI compiles them with Go's regexp.MustCompile, so a JavaScript-only construct would panic the CLI, and a default value that fails its own pattern would reject a freshly initialised gen.yaml. Add a test over all available templates that compiles each pattern the way the CLI does and checks each default value against it. --- pkg/templates/config_test.go | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 pkg/templates/config_test.go diff --git a/pkg/templates/config_test.go b/pkg/templates/config_test.go new file mode 100644 index 000000000..eb55be3ec --- /dev/null +++ b/pkg/templates/config_test.go @@ -0,0 +1,49 @@ +package templates_test + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/speakeasy-api/openapi-generation/v2/internal/types" + "github.com/speakeasy-api/openapi-generation/v2/pkg/templates" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// RegExp.source emits "/" as the Unicode escape below, which the CLI rewrites before compiling the pattern with Go's regexp. +const escapedSlash = "\x5cu002f" + +func TestConfigFieldValidationRegexes(t *testing.T) { + t.Parallel() + + for _, name := range templates.GetAvailableTemplates() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + for _, newSDK := range []bool{false, true} { + fields, err := templates.GetLanguageConfigFields(types.NewTargetFromTemplate(name), newSDK) + require.NoError(t, err) + + for _, field := range fields { + if field.ValidationRegex == nil { + continue + } + + pattern := strings.ReplaceAll(*field.ValidationRegex, escapedSlash, "/") + regex, err := regexp.Compile(pattern) + require.NoError(t, err, "field %q (newSDK=%v)", field.Name, newSDK) + + if field.DefaultValue == nil { + continue + } + + defaultValue := fmt.Sprintf("%v", *field.DefaultValue) + assert.True(t, regex.MatchString(defaultValue), + "field %q (newSDK=%v): default %q does not match %s", field.Name, newSDK, defaultValue, pattern) + } + } + }) + } +} From 8f6797e6a355a1e28cc390ec8df535e696458fad Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 12:06:58 -0400 Subject: [PATCH 5/8] test: name validation regex tests after what they check --- .../{config_ruby_test.go => validation_regex_ruby_test.go} | 0 pkg/templates/{config_test.go => validation_regex_test.go} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename pkg/templates/{config_ruby_test.go => validation_regex_ruby_test.go} (100%) rename pkg/templates/{config_test.go => validation_regex_test.go} (95%) diff --git a/pkg/templates/config_ruby_test.go b/pkg/templates/validation_regex_ruby_test.go similarity index 100% rename from pkg/templates/config_ruby_test.go rename to pkg/templates/validation_regex_ruby_test.go diff --git a/pkg/templates/config_test.go b/pkg/templates/validation_regex_test.go similarity index 95% rename from pkg/templates/config_test.go rename to pkg/templates/validation_regex_test.go index eb55be3ec..efd34c713 100644 --- a/pkg/templates/config_test.go +++ b/pkg/templates/validation_regex_test.go @@ -15,7 +15,7 @@ import ( // RegExp.source emits "/" as the Unicode escape below, which the CLI rewrites before compiling the pattern with Go's regexp. const escapedSlash = "\x5cu002f" -func TestConfigFieldValidationRegexes(t *testing.T) { +func TestValidationRegexesCompileAndMatchDefaults(t *testing.T) { t.Parallel() for _, name := range templates.GetAvailableTemplates() { From 74d506c5856eb0dd3c1ec04463396285bc25e9ab Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 12:17:13 -0400 Subject: [PATCH 6/8] test: declare validation regex examples next to config fields Config fields in config.ts can now list ValidationExamples with valid and invalid values next to their ValidationRegex. The key is test-only: GetLanguageConfigFields decodes into SDKGenConfigField, which drops it, so the CLI and the gen.yaml JSON schema never see it. TestValidationRegexes reads the raw config.ts output of every template and of the common config fields, compiles each pattern the way the CLI does, and checks the default value and every example against it. The Ruby module name cases move from their own test into ruby/config.ts. --- pkg/templates/validation_regex_ruby_test.go | 47 -------- pkg/templates/validation_regex_test.go | 109 ++++++++++++++---- .../templates/common/common/typeDefs.d.ts | 4 + templates/templates/ruby/config.ts | 11 ++ 4 files changed, 104 insertions(+), 67 deletions(-) delete mode 100644 pkg/templates/validation_regex_ruby_test.go diff --git a/pkg/templates/validation_regex_ruby_test.go b/pkg/templates/validation_regex_ruby_test.go deleted file mode 100644 index be59fe1ca..000000000 --- a/pkg/templates/validation_regex_ruby_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package templates_test - -import ( - "regexp" - "testing" - - "github.com/speakeasy-api/openapi-generation/v2/internal/types" - "github.com/speakeasy-api/openapi-generation/v2/pkg/templates" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRubyModuleValidationRegex(t *testing.T) { - t.Parallel() - - fields, err := templates.GetLanguageConfigFields(types.NewTargetFromTemplate("ruby"), false) - require.NoError(t, err) - - var pattern string - for _, field := range fields { - if field.Name == "module" { - require.NotNil(t, field.ValidationRegex) - pattern = *field.ValidationRegex - } - } - require.NotEmpty(t, pattern) - - regex := regexp.MustCompile(pattern) - - testCases := map[string]bool{ - "OpenApiSdk": true, - "AcmeRubySDK": true, - "OpenAPI": true, - "SDK": true, - "Acme2": true, - "openApiSdk": false, - "Open_Api": false, - "Open::Api": false, - "Open-Api": false, - "2Acme": false, - "": false, - } - - for name, valid := range testCases { - assert.Equal(t, valid, regex.MatchString(name), "module %q", name) - } -} diff --git a/pkg/templates/validation_regex_test.go b/pkg/templates/validation_regex_test.go index efd34c713..a73657663 100644 --- a/pkg/templates/validation_regex_test.go +++ b/pkg/templates/validation_regex_test.go @@ -1,4 +1,4 @@ -package templates_test +package templates import ( "fmt" @@ -6,8 +6,9 @@ import ( "strings" "testing" + "github.com/mitchellh/mapstructure" + "github.com/speakeasy-api/openapi-generation/v2/internal/executor" "github.com/speakeasy-api/openapi-generation/v2/internal/types" - "github.com/speakeasy-api/openapi-generation/v2/pkg/templates" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,35 +16,103 @@ import ( // RegExp.source emits "/" as the Unicode escape below, which the CLI rewrites before compiling the pattern with Go's regexp. const escapedSlash = "\x5cu002f" -func TestValidationRegexesCompileAndMatchDefaults(t *testing.T) { +type validationField struct { + DefaultValue any + ValidationRegex *string + ValidationExamples struct { + Valid []string + Invalid []string + } +} + +func TestValidationRegexes(t *testing.T) { t.Parallel() - for _, name := range templates.GetAvailableTemplates() { + t.Run("common", func(t *testing.T) { + t.Parallel() + + e, err := executor.New(types.NewTargetFromTemplate("common"), "common/config.ts") + require.NoError(t, err) + + for _, newSDK := range []bool{false, true} { + rawFields, err := e.Run("getCommonConfigFields", newSDK) + require.NoError(t, err) + + checkValidationFields(t, decodeValidationFields(t, rawFields), newSDK) + } + }) + + for _, name := range GetAvailableTemplates() { t.Run(name, func(t *testing.T) { t.Parallel() + target := types.NewTargetFromTemplate(name) + e, err := executor.New(target, "config.ts") + require.NoError(t, err) + for _, newSDK := range []bool{false, true} { - fields, err := templates.GetLanguageConfigFields(types.NewTargetFromTemplate(name), newSDK) + _, err := GetLanguageConfigFields(target, newSDK) require.NoError(t, err) - for _, field := range fields { - if field.ValidationRegex == nil { - continue - } - - pattern := strings.ReplaceAll(*field.ValidationRegex, escapedSlash, "/") - regex, err := regexp.Compile(pattern) - require.NoError(t, err, "field %q (newSDK=%v)", field.Name, newSDK) + commonFields, err := getCommonConfigFields(newSDK) + require.NoError(t, err) - if field.DefaultValue == nil { - continue - } + rawFields, err := e.Run("getConfigFields", commonFields, newSDK) + require.NoError(t, err) - defaultValue := fmt.Sprintf("%v", *field.DefaultValue) - assert.True(t, regex.MatchString(defaultValue), - "field %q (newSDK=%v): default %q does not match %s", field.Name, newSDK, defaultValue, pattern) - } + checkValidationFields(t, decodeValidationFields(t, rawFields), newSDK) } }) } } + +func decodeValidationFields(t *testing.T, rawFields any) map[string]validationField { + t.Helper() + + rawFieldMap, ok := rawFields.(map[string]any) + require.True(t, ok, "config fields are %T", rawFields) + + fields := map[string]validationField{} + for key, rawField := range rawFieldMap { + // Common fields passed through unchanged arrive as Go structs and are checked by the "common" subtest. + if _, ok := rawField.(map[string]any); !ok { + continue + } + + var field validationField + require.NoError(t, mapstructure.Decode(rawField, &field), "field %q", key) + fields[key] = field + } + + return fields +} + +func checkValidationFields(t *testing.T, fields map[string]validationField, newSDK bool) { + t.Helper() + + for key, field := range fields { + if field.ValidationRegex == nil { + continue + } + + pattern := strings.ReplaceAll(*field.ValidationRegex, escapedSlash, "/") + regex, err := regexp.Compile(pattern) + require.NoError(t, err, "field %q (newSDK=%v)", key, newSDK) + + if field.DefaultValue != nil { + defaultValue := fmt.Sprintf("%v", field.DefaultValue) + assert.True(t, regex.MatchString(defaultValue), + "field %q (newSDK=%v): default %q does not match %s", key, newSDK, defaultValue, pattern) + } + + for _, value := range field.ValidationExamples.Valid { + assert.True(t, regex.MatchString(value), + "field %q (newSDK=%v): valid example %q does not match %s", key, newSDK, value, pattern) + } + + for _, value := range field.ValidationExamples.Invalid { + assert.False(t, regex.MatchString(value), + "field %q (newSDK=%v): invalid example %q matches %s", key, newSDK, value, pattern) + } + } +} diff --git a/templates/templates/common/common/typeDefs.d.ts b/templates/templates/common/common/typeDefs.d.ts index f54e0e682..03d8b95a4 100644 --- a/templates/templates/common/common/typeDefs.d.ts +++ b/templates/templates/common/common/typeDefs.d.ts @@ -2694,6 +2694,10 @@ declare global { SecretName?: string; ValidationRegex?: string; ValidationMessage?: string; + ValidationExamples?: { + valid?: string[]; + invalid?: string[]; + }; }; type SDKGenConfigFields = Record; diff --git a/templates/templates/ruby/config.ts b/templates/templates/ruby/config.ts index b89b730c0..6a87ac54e 100644 --- a/templates/templates/ruby/config.ts +++ b/templates/templates/ruby/config.ts @@ -241,6 +241,17 @@ function getConfigFields( ValidationRegex: /^[A-Z][A-Za-z0-9]*$/.source, ValidationMessage: "Module names should be PascalCase and have no special chars", + ValidationExamples: { + valid: ["OpenApiSdk", "AcmeRubySDK", "OpenAPI", "SDK", "Acme2"], + invalid: [ + "openApiSdk", + "Open_Api", + "Open::Api", + "Open-Api", + "2Acme", + "", + ], + }, }, additionalDependencies: { Name: "additionalDependencies", From dfc5455f434ab62a7ca8d675ea578141d3a6e52b Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 14:40:22 -0400 Subject: [PATCH 7/8] test: reject unknown keys in config field definitions Decode each raw config.ts field into SDKGenConfigField plus ValidationExamples with ErrorUnused, so a misspelled ValidationExamples key or example list fails the test instead of being silently dropped. config.ts files are not covered by the templates' tsconfig, so the TypeScript type does not catch these. ValidationFunc is accepted because two fields already declare it. Also match the CLI's raw string for the escaped slash, and drop the Ruby module example that repeats the default value. --- pkg/templates/validation_regex_test.go | 18 +++++++++++------- templates/templates/ruby/config.ts | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/templates/validation_regex_test.go b/pkg/templates/validation_regex_test.go index a73657663..a00355b92 100644 --- a/pkg/templates/validation_regex_test.go +++ b/pkg/templates/validation_regex_test.go @@ -9,20 +9,22 @@ import ( "github.com/mitchellh/mapstructure" "github.com/speakeasy-api/openapi-generation/v2/internal/executor" "github.com/speakeasy-api/openapi-generation/v2/internal/types" + config "github.com/speakeasy-api/sdk-gen-config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// RegExp.source emits "/" as the Unicode escape below, which the CLI rewrites before compiling the pattern with Go's regexp. -const escapedSlash = "\x5cu002f" +// goja's RegExp.source emits an unescaped "/" as the Unicode escape below, which the CLI rewrites before compiling the pattern with Go's regexp. +const escapedSlash = `\u002f` type validationField struct { - DefaultValue any - ValidationRegex *string - ValidationExamples struct { + config.SDKGenConfigField `mapstructure:",squash"` + ValidationExamples struct { Valid []string Invalid []string } + // Declared on some fields in config.ts but read by neither the generator nor the CLI. + ValidationFunc any } func TestValidationRegexes(t *testing.T) { @@ -80,7 +82,9 @@ func decodeValidationFields(t *testing.T, rawFields any) map[string]validationFi } var field validationField - require.NoError(t, mapstructure.Decode(rawField, &field), "field %q", key) + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ErrorUnused: true, Result: &field}) + require.NoError(t, err) + require.NoError(t, decoder.Decode(rawField), "field %q", key) fields[key] = field } @@ -100,7 +104,7 @@ func checkValidationFields(t *testing.T, fields map[string]validationField, newS require.NoError(t, err, "field %q (newSDK=%v)", key, newSDK) if field.DefaultValue != nil { - defaultValue := fmt.Sprintf("%v", field.DefaultValue) + defaultValue := fmt.Sprintf("%v", *field.DefaultValue) assert.True(t, regex.MatchString(defaultValue), "field %q (newSDK=%v): default %q does not match %s", key, newSDK, defaultValue, pattern) } diff --git a/templates/templates/ruby/config.ts b/templates/templates/ruby/config.ts index 6a87ac54e..548ac47a9 100644 --- a/templates/templates/ruby/config.ts +++ b/templates/templates/ruby/config.ts @@ -242,7 +242,7 @@ function getConfigFields( ValidationMessage: "Module names should be PascalCase and have no special chars", ValidationExamples: { - valid: ["OpenApiSdk", "AcmeRubySDK", "OpenAPI", "SDK", "Acme2"], + valid: ["AcmeRubySDK", "OpenAPI", "SDK", "Acme2"], invalid: [ "openApiSdk", "Open_Api", From 7fbe8a70004126e37645a67e99133c97eaf91353 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 16:56:25 -0400 Subject: [PATCH 8/8] test: fail when a config field has validation examples but no pattern Removing a field's ValidationRegex while keeping its examples skipped all of them, so the test stayed green after validation was dropped. Assert that fields without a pattern declare no examples. Also add the single-letter Ruby module name A as a valid example, the shortest name the pattern accepts. --- pkg/templates/validation_regex_test.go | 2 ++ templates/templates/ruby/config.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/templates/validation_regex_test.go b/pkg/templates/validation_regex_test.go index a00355b92..72d665f30 100644 --- a/pkg/templates/validation_regex_test.go +++ b/pkg/templates/validation_regex_test.go @@ -96,6 +96,8 @@ func checkValidationFields(t *testing.T, fields map[string]validationField, newS for key, field := range fields { if field.ValidationRegex == nil { + assert.Empty(t, field.ValidationExamples.Valid, "field %q (newSDK=%v): valid examples without a ValidationRegex", key, newSDK) + assert.Empty(t, field.ValidationExamples.Invalid, "field %q (newSDK=%v): invalid examples without a ValidationRegex", key, newSDK) continue } diff --git a/templates/templates/ruby/config.ts b/templates/templates/ruby/config.ts index 548ac47a9..39311d66b 100644 --- a/templates/templates/ruby/config.ts +++ b/templates/templates/ruby/config.ts @@ -242,7 +242,7 @@ function getConfigFields( ValidationMessage: "Module names should be PascalCase and have no special chars", ValidationExamples: { - valid: ["AcmeRubySDK", "OpenAPI", "SDK", "Acme2"], + valid: ["AcmeRubySDK", "OpenAPI", "SDK", "Acme2", "A"], invalid: [ "openApiSdk", "Open_Api",