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/templates/validation_regex_test.go b/pkg/templates/validation_regex_test.go new file mode 100644 index 000000000..72d665f30 --- /dev/null +++ b/pkg/templates/validation_regex_test.go @@ -0,0 +1,124 @@ +package templates + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "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" +) + +// 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 { + 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) { + t.Parallel() + + 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} { + _, err := GetLanguageConfigFields(target, newSDK) + require.NoError(t, err) + + commonFields, err := getCommonConfigFields(newSDK) + require.NoError(t, err) + + rawFields, err := e.Run("getConfigFields", commonFields, newSDK) + require.NoError(t, err) + + 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 + 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 + } + + return fields +} + +func checkValidationFields(t *testing.T, fields map[string]validationField, newSDK bool) { + t.Helper() + + 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 + } + + 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 0d2eb7c59..39311d66b 100644 --- a/templates/templates/ruby/config.ts +++ b/templates/templates/ruby/config.ts @@ -238,9 +238,20 @@ 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", + ValidationExamples: { + valid: ["AcmeRubySDK", "OpenAPI", "SDK", "Acme2", "A"], + invalid: [ + "openApiSdk", + "Open_Api", + "Open::Api", + "Open-Api", + "2Acme", + "", + ], + }, }, additionalDependencies: { Name: "additionalDependencies",