From 20a299ef578d596e1b0c153e1f7377cafe0ed8fb Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 15:04:14 -0400 Subject: [PATCH 1/2] fix(typescript,mcp): validate usageSDKInitImports and advancedFlags in gen.yaml The typescriptv2 usageSDKInitImports and mcp-typescript advancedFlags config fields declared their checks as a ValidationFunc on the field definition. Nothing reads that key: the field definitions are decoded into sdk-gen-config's SDKGenConfigField, which has no such field, so the checks never ran and malformed values were accepted. Move both checks into a target-level validateConfig, the hook the generator already runs for gen.yaml on other targets, and skip absent or null values, which templating already treats as empty. --- .changesets/1793041349-df3a8c75.yaml | 11 ++ pkg/generate/cli_config_validation_test.go | 16 +-- .../typescript_config_validation_test.go | 135 ++++++++++++++++++ templates/templates/mcp-typescript/config.ts | 39 ++--- templates/templates/typescriptv2/config.ts | 55 +++---- 5 files changed, 206 insertions(+), 50 deletions(-) create mode 100644 .changesets/1793041349-df3a8c75.yaml create mode 100644 pkg/generate/typescript_config_validation_test.go diff --git a/.changesets/1793041349-df3a8c75.yaml b/.changesets/1793041349-df3a8c75.yaml new file mode 100644 index 000000000..469a32b83 --- /dev/null +++ b/.changesets/1793041349-df3a8c75.yaml @@ -0,0 +1,11 @@ +id: 1793041349-df3a8c75 +features: + - core +targets: + - typescriptv2 + - mcp-typescript +type: fix +bump: patch +description: validate usageSDKInitImports and advancedFlags in gen.yaml +author: 2ynn +date: "2026-10-26" diff --git a/pkg/generate/cli_config_validation_test.go b/pkg/generate/cli_config_validation_test.go index 1a9606836..fe9fd3cf5 100644 --- a/pkg/generate/cli_config_validation_test.go +++ b/pkg/generate/cli_config_validation_test.go @@ -338,7 +338,7 @@ func TestValidateConfigCLIReleaseDistributionValidation(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - err := g.validateConfig(context.Background(), newCLITestConfig(tc.cfg), "cli") + err := g.validateConfig(context.Background(), newLanguageTestConfig("cli", tc.cfg), "cli") require.Error(t, err) var validationErr *pkgerrors.ValidationError @@ -396,19 +396,15 @@ func TestValidateConfigCLIReleaseDistributionValidationSuccess(t *testing.T) { for name, cfg := range testCases { t.Run(name, func(t *testing.T) { t.Parallel() - assert.NoError(t, g.validateConfig(context.Background(), newCLITestConfig(cfg), "cli")) + assert.NoError(t, g.validateConfig(context.Background(), newLanguageTestConfig("cli", cfg), "cli")) }) } } -func newCLITestConfig(cliCfg map[string]any) config.Config { - baseConfig := &config.Configuration{ +func newLanguageTestConfig(target string, cfg map[string]any) config.Config { + return config.Config{Config: &config.Configuration{ Languages: map[string]config.LanguageConfig{ - "cli": { - Cfg: cliCfg, - }, + target: {Cfg: cfg}, }, - } - - return config.Config{Config: baseConfig} + }} } diff --git a/pkg/generate/typescript_config_validation_test.go b/pkg/generate/typescript_config_validation_test.go new file mode 100644 index 000000000..e82c5a92d --- /dev/null +++ b/pkg/generate/typescript_config_validation_test.go @@ -0,0 +1,135 @@ +package generate + +import ( + "context" + "testing" + + pkgerrors "github.com/speakeasy-api/openapi-generation/v2/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateConfigTypeScriptFieldValidation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + target string + cfg map[string]any + errMsg string + }{ + "usageSDKInitImports must be an array": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": "acme"}, + errMsg: "usageSDKInitImports must be an array", + }, + "usageSDKInitImports items must be objects": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{"acme"}}, + errMsg: "usageSDKInitImports[0] must be an object", + }, + "usageSDKInitImports items require package": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"import": "Acme"}, + }}, + errMsg: "usageSDKInitImports[0].package must be a non-empty string", + }, + "usageSDKInitImports items require import": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": "@acme/sdk"}, + }}, + errMsg: "usageSDKInitImports[0].import must be a non-empty string", + }, + "usageSDKInitImports items reject unknown type": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": "@acme/sdk", "import": "Acme"}, + map[string]any{"package": "@acme/sdk", "import": "Acme", "type": "defaultImport"}, + }}, + errMsg: "usageSDKInitImports[1].type must be one of: typeImport, packageImport, aliasImport", + }, + "advancedFlags must be an array": { + target: "mcp-typescript", + cfg: map[string]any{"advancedFlags": "verbose"}, + errMsg: "advancedFlags must be an array", + }, + "advancedFlags items must be strings": { + target: "mcp-typescript", + cfg: map[string]any{"advancedFlags": []any{1}}, + errMsg: "advancedFlags[0] must be a string", + }, + "advancedFlags items must be alphanumeric": { + target: "mcp-typescript", + cfg: map[string]any{"advancedFlags": []any{"verbose", "dry-run"}}, + errMsg: "advancedFlags[1] must contain only letters or numbers", + }, + } + + g, err := New() + require.NoError(t, err) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + err := g.validateConfig(context.Background(), newLanguageTestConfig(tc.target, tc.cfg), tc.target) + require.Error(t, err) + + var validationErr *pkgerrors.ValidationError + require.ErrorAs(t, err, &validationErr) + assert.Equal(t, "validation error: gen.yaml validation error for "+tc.target+": "+tc.errMsg, err.Error()) + }) + } +} + +func TestValidateConfigTypeScriptFieldValidationSuccess(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + target string + cfg map[string]any + }{ + "usageSDKInitImports absent": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2"}, + }, + "usageSDKInitImports null": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": nil}, + }, + "usageSDKInitImports empty": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{}}, + }, + "usageSDKInitImports valid": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": "@acme/sdk", "import": "Acme"}, + map[string]any{"package": "@acme/sdk", "import": "AcmeOptions", "type": "packageImport"}, + }}, + }, + "advancedFlags absent": { + target: "mcp-typescript", + cfg: map[string]any{}, + }, + "advancedFlags null": { + target: "mcp-typescript", + cfg: map[string]any{"advancedFlags": nil}, + }, + "advancedFlags valid": { + target: "mcp-typescript", + cfg: map[string]any{"advancedFlags": []any{"verbose", "loneQueryParam"}}, + }, + } + + g, err := New() + require.NoError(t, err) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.NoError(t, g.validateConfig(context.Background(), newLanguageTestConfig(tc.target, tc.cfg), tc.target)) + }) + } +} diff --git a/templates/templates/mcp-typescript/config.ts b/templates/templates/mcp-typescript/config.ts index 45c608890..7e327fd95 100644 --- a/templates/templates/mcp-typescript/config.ts +++ b/templates/templates/mcp-typescript/config.ts @@ -417,22 +417,6 @@ function getConfigFields( Description: "A list of flags that are considered advanced and should not be exposed to the user.", DefaultValue: [], - ValidationFunc: (value: any): string | null => { - if (!Array.isArray(value)) { - return "advancedFlags must be an array"; - } - const validPattern = /^[a-zA-Z0-9]+$/; - for (let i = 0; i < value.length; i++) { - const item = value[i]; - if (typeof item !== "string") { - return `advancedFlags[${i}] must be a string`; - } - if (!validPattern.test(item)) { - return `advancedFlags[${i}] must contain only letters or numbers`; - } - } - return null; - }, }, fixEnumNameSanitization: { Name: "fixEnumNameSanitization", @@ -464,6 +448,29 @@ function getConfigFields( }; } +// @ts-ignore +function validateConfig(cfg: Record): string { + const advancedFlags = cfg.advancedFlags; + if (advancedFlags === undefined || advancedFlags === null) { + return ""; + } + + if (!Array.isArray(advancedFlags)) { + return "advancedFlags must be an array"; + } + const validPattern = /^[a-zA-Z0-9]+$/; + for (let i = 0; i < advancedFlags.length; i++) { + const item = advancedFlags[i]; + if (typeof item !== "string") { + return `advancedFlags[${i}] must be a string`; + } + if (!validPattern.test(item)) { + return `advancedFlags[${i}] must contain only letters or numbers`; + } + } + return ""; +} + /** * Hardcoded configuration overlay for the target. This is used to configure * required settings for the target that are intentionally not exposed. diff --git a/templates/templates/typescriptv2/config.ts b/templates/templates/typescriptv2/config.ts index 9ec4d8ef1..f0e0cd7f3 100644 --- a/templates/templates/typescriptv2/config.ts +++ b/templates/templates/typescriptv2/config.ts @@ -687,30 +687,6 @@ function getConfigFields( DefaultValue: [], Description: "Array of imports to add when usageSDKInit is configured. Each import should have 'package', 'import', and optionally 'type' fields.", - ValidationFunc: (value: any): string | null => { - if (!Array.isArray(value)) { - return "usageSDKInitImports must be an array"; - } - for (let i = 0; i < value.length; i++) { - const item = value[i]; - if (typeof item !== "object" || item === null) { - return `usageSDKInitImports[${i}] must be an object`; - } - if (!item.package || typeof item.package !== "string") { - return `usageSDKInitImports[${i}].package must be a non-empty string`; - } - if (!item.import || typeof item.import !== "string") { - return `usageSDKInitImports[${i}].import must be a non-empty string`; - } - if ( - item.type && - !["typeImport", "packageImport", "aliasImport"].includes(item.type) - ) { - return `usageSDKInitImports[${i}].type must be one of: typeImport, packageImport, aliasImport`; - } - } - return null; - }, }, sseFlatResponse: { Name: "sseFlatResponse", @@ -895,6 +871,37 @@ function getConfigFields( }; } +// @ts-ignore +function validateConfig(cfg: Record): string { + const usageSDKInitImports = cfg.usageSDKInitImports; + if (usageSDKInitImports === undefined || usageSDKInitImports === null) { + return ""; + } + + if (!Array.isArray(usageSDKInitImports)) { + return "usageSDKInitImports must be an array"; + } + for (let i = 0; i < usageSDKInitImports.length; i++) { + const item = usageSDKInitImports[i]; + if (typeof item !== "object" || item === null) { + return `usageSDKInitImports[${i}] must be an object`; + } + if (!item.package || typeof item.package !== "string") { + return `usageSDKInitImports[${i}].package must be a non-empty string`; + } + if (!item.import || typeof item.import !== "string") { + return `usageSDKInitImports[${i}].import must be a non-empty string`; + } + if ( + item.type && + !["typeImport", "packageImport", "aliasImport"].includes(item.type) + ) { + return `usageSDKInitImports[${i}].type must be one of: typeImport, packageImport, aliasImport`; + } + } + return ""; +} + /** Represents the initial target implementation configuration presented to * the generator from the target. Customer configuration keys and values from * getConfigFields are fetched prior so those values are available. */ From 45e0c214252759e07a622d08d75594130658fcfd Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 26 Oct 2026 17:25:24 -0400 Subject: [PATCH 2/2] fix(typescript): accept capitalized usageSDKInitImports keys in validateConfig templateUsageSDKInit resolves package || Package, import || Import and type || Type, so validate the same effective values. Add cases for capitalized and mixed-case keys, every import type, empty and non-string values, null entries, and empty advancedFlags names. --- .../typescript_config_validation_test.go | 66 ++++++++++++++++++- templates/templates/typescriptv2/config.ts | 11 ++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/pkg/generate/typescript_config_validation_test.go b/pkg/generate/typescript_config_validation_test.go index e82c5a92d..1537a9c47 100644 --- a/pkg/generate/typescript_config_validation_test.go +++ b/pkg/generate/typescript_config_validation_test.go @@ -27,6 +27,11 @@ func TestValidateConfigTypeScriptFieldValidation(t *testing.T) { cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{"acme"}}, errMsg: "usageSDKInitImports[0] must be an object", }, + "usageSDKInitImports items must not be null": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{nil}}, + errMsg: "usageSDKInitImports[0] must be an object", + }, "usageSDKInitImports items require package": { target: "typescript", cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ @@ -34,6 +39,20 @@ func TestValidateConfigTypeScriptFieldValidation(t *testing.T) { }}, errMsg: "usageSDKInitImports[0].package must be a non-empty string", }, + "usageSDKInitImports items reject empty package": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": "", "import": "Acme"}, + }}, + errMsg: "usageSDKInitImports[0].package must be a non-empty string", + }, + "usageSDKInitImports items reject non-string package": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": 1, "import": "Acme"}, + }}, + errMsg: "usageSDKInitImports[0].package must be a non-empty string", + }, "usageSDKInitImports items require import": { target: "typescript", cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ @@ -41,6 +60,20 @@ func TestValidateConfigTypeScriptFieldValidation(t *testing.T) { }}, errMsg: "usageSDKInitImports[0].import must be a non-empty string", }, + "usageSDKInitImports items reject empty import": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": "@acme/sdk", "Import": ""}, + }}, + errMsg: "usageSDKInitImports[0].import must be a non-empty string", + }, + "usageSDKInitImports items reject non-string import": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": "@acme/sdk", "import": 1}, + }}, + errMsg: "usageSDKInitImports[0].import must be a non-empty string", + }, "usageSDKInitImports items reject unknown type": { target: "typescript", cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ @@ -49,6 +82,13 @@ func TestValidateConfigTypeScriptFieldValidation(t *testing.T) { }}, errMsg: "usageSDKInitImports[1].type must be one of: typeImport, packageImport, aliasImport", }, + "usageSDKInitImports items reject unknown capitalized type": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"Package": "@acme/sdk", "Import": "Acme", "Type": "defaultImport"}, + }}, + errMsg: "usageSDKInitImports[0].type must be one of: typeImport, packageImport, aliasImport", + }, "advancedFlags must be an array": { target: "mcp-typescript", cfg: map[string]any{"advancedFlags": "verbose"}, @@ -64,6 +104,11 @@ func TestValidateConfigTypeScriptFieldValidation(t *testing.T) { cfg: map[string]any{"advancedFlags": []any{"verbose", "dry-run"}}, errMsg: "advancedFlags[1] must contain only letters or numbers", }, + "advancedFlags items must not be empty": { + target: "mcp-typescript", + cfg: map[string]any{"advancedFlags": []any{""}}, + errMsg: "advancedFlags[0] must contain only letters or numbers", + }, } g, err := New() @@ -107,6 +152,21 @@ func TestValidateConfigTypeScriptFieldValidationSuccess(t *testing.T) { cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ map[string]any{"package": "@acme/sdk", "import": "Acme"}, map[string]any{"package": "@acme/sdk", "import": "AcmeOptions", "type": "packageImport"}, + map[string]any{"package": "@acme/sdk", "import": "AcmeTypes", "type": "typeImport"}, + map[string]any{"package": "@acme/sdk", "import": "acme", "type": "aliasImport"}, + }}, + }, + "usageSDKInitImports capitalized keys": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"Package": "@acme/sdk", "Import": "Acme", "Type": "packageImport"}, + }}, + }, + "usageSDKInitImports mixed-case keys": { + target: "typescript", + cfg: map[string]any{"templateVersion": "v2", "usageSDKInitImports": []any{ + map[string]any{"package": "@acme/sdk", "Import": "Acme", "type": "packageImport"}, + map[string]any{"package": "", "Package": "@acme/sdk", "import": "AcmeOptions"}, }}, }, "advancedFlags absent": { @@ -119,7 +179,11 @@ func TestValidateConfigTypeScriptFieldValidationSuccess(t *testing.T) { }, "advancedFlags valid": { target: "mcp-typescript", - cfg: map[string]any{"advancedFlags": []any{"verbose", "loneQueryParam"}}, + cfg: map[string]any{"advancedFlags": []any{"verbose", "loneQueryParam", "option2"}}, + }, + "advancedFlags empty": { + target: "mcp-typescript", + cfg: map[string]any{"advancedFlags": []any{}}, }, } diff --git a/templates/templates/typescriptv2/config.ts b/templates/templates/typescriptv2/config.ts index f0e0cd7f3..410ec0bc8 100644 --- a/templates/templates/typescriptv2/config.ts +++ b/templates/templates/typescriptv2/config.ts @@ -886,15 +886,18 @@ function validateConfig(cfg: Record): string { if (typeof item !== "object" || item === null) { return `usageSDKInitImports[${i}] must be an object`; } - if (!item.package || typeof item.package !== "string") { + const pkg = item.package || item.Package; + const imp = item.import || item.Import; + const type = item.type || item.Type; + if (!pkg || typeof pkg !== "string") { return `usageSDKInitImports[${i}].package must be a non-empty string`; } - if (!item.import || typeof item.import !== "string") { + if (!imp || typeof imp !== "string") { return `usageSDKInitImports[${i}].import must be a non-empty string`; } if ( - item.type && - !["typeImport", "packageImport", "aliasImport"].includes(item.type) + type && + !["typeImport", "packageImport", "aliasImport"].includes(type) ) { return `usageSDKInitImports[${i}].type must be one of: typeImport, packageImport, aliasImport`; }