Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changesets/1793041349-df3a8c75.yaml
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 6 additions & 10 deletions pkg/generate/cli_config_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
}}
}
199 changes: 199 additions & 0 deletions pkg/generate/typescript_config_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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 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{
map[string]any{"import": "Acme"},
}},
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{
map[string]any{"package": "@acme/sdk"},
}},
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{
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",
},
"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"},
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",
},
"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()
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"},
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": {
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", "option2"}},
},
"advancedFlags empty": {
target: "mcp-typescript",
cfg: map[string]any{"advancedFlags": []any{}},
},
}

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))
})
}
}
39 changes: 23 additions & 16 deletions templates/templates/mcp-typescript/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -464,6 +448,29 @@ function getConfigFields(
};
}

// @ts-ignore
function validateConfig(cfg: Record<string, any>): 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.
Expand Down
58 changes: 34 additions & 24 deletions templates/templates/typescriptv2/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -895,6 +871,40 @@ function getConfigFields(
};
}

// @ts-ignore
function validateConfig(cfg: Record<string, any>): 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`;
}
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 (!imp || typeof imp !== "string") {
return `usageSDKInitImports[${i}].import must be a non-empty string`;
}
if (
type &&
!["typeImport", "packageImport", "aliasImport"].includes(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. */
Expand Down
Loading