From 351dda719df8450567aec579f5ad81b2debf963e Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 18:34:04 -0500 Subject: [PATCH 01/71] test(client/typescript): add tsc type-check harness --- .../generators/typescript/tscheck_test.go | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 internal/client/generators/typescript/tscheck_test.go diff --git a/internal/client/generators/typescript/tscheck_test.go b/internal/client/generators/typescript/tscheck_test.go new file mode 100644 index 00000000..0a19b2d2 --- /dev/null +++ b/internal/client/generators/typescript/tscheck_test.go @@ -0,0 +1,113 @@ +package typescript + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func writeTree(t *testing.T, dir string, files map[string]string) { + t.Helper() + + for name, content := range files { + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +const probeTSConfig = `{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "strict": true, + "moduleResolution": "bundler", + "noEmit": true + }, + "include": ["src/**/*"] +} +` + +func TestTypeCheckAcceptsValidTypeScript(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "tsconfig.json": probeTSConfig, + "src/a.ts": "export const n: number = 1;\n", + }) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("expected valid TypeScript to compile cleanly, got:\n%v", errs) + } +} + +func TestTypeCheckRejectsInvalidTypeScript(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "tsconfig.json": probeTSConfig, + "src/a.ts": "export const n: number = 'not a number';\n", + }) + + errs := typeCheck(t, dir) + if len(errs) == 0 { + t.Fatal("expected a type error, got none") + } +} + +// findTSC returns the argv prefix used to invoke the TypeScript compiler, or +// skips the test when no compiler is available. CI installs Node so the gate is +// live there; local runs without Node degrade to a skip rather than a failure. +func findTSC(t *testing.T) []string { + t.Helper() + + if path, err := exec.LookPath("tsc"); err == nil { + return []string{path} + } + + if path, err := exec.LookPath("npx"); err == nil { + return []string{path, "--no-install", "tsc"} + } + + t.Skip("neither tsc nor npx found on PATH; skipping TypeScript type check") + + return nil +} + +// typeCheck runs tsc against dir and returns one entry per reported error. +func typeCheck(t *testing.T, dir string) []string { + t.Helper() + + argv := findTSC(t) + argv = append(argv, "--noEmit", "-p", "tsconfig.json") + + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Dir = dir + + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + + var errs []string + + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "error TS") { + errs = append(errs, strings.TrimSpace(line)) + } + } + + // tsc exited non-zero but emitted nothing parseable: surface it verbatim so + // a broken toolchain is not mistaken for a clean run. + if len(errs) == 0 { + t.Fatalf("tsc failed with no parseable diagnostics: %v\n%s", err, out) + } + + return errs +} From b8fe3f65db1c6a5c5ff9b2eebba73a40136e321f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 18:37:38 -0500 Subject: [PATCH 02/71] test(client/typescript): add generator fixture corpus --- .../generators/typescript/fixtures_test.go | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 internal/client/generators/typescript/fixtures_test.go diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go new file mode 100644 index 00000000..73833d57 --- /dev/null +++ b/internal/client/generators/typescript/fixtures_test.go @@ -0,0 +1,121 @@ +package typescript + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/xraph/forge/internal/client" +) + +func TestGateFixturesCoverKnownDefects(t *testing.T) { + want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming"} + + got := make(map[string]bool) + for _, f := range gateFixtures() { + got[f.Name] = true + } + + for _, name := range want { + if !got[name] { + t.Errorf("fixture %q missing from corpus", name) + } + } +} + +func TestGenerateToProducesTSConfig(t *testing.T) { + dir := generateTo(t, gateFixtures()[0]) + + if _, err := os.Stat(filepath.Join(dir, "tsconfig.json")); err != nil { + t.Fatalf("expected generated tsconfig.json: %v", err) + } +} + +type gateFixture struct { + Name string + Spec *client.APISpec + Config client.GeneratorConfig +} + +// baseSpec returns a spec exercising path params, query params, a request body, +// and a $ref response. +func baseSpec() *client.APISpec { + user := &client.Schema{ + Type: "object", + Required: []string{"id"}, + Properties: map[string]*client.Schema{ + "id": {Type: "string"}, + "user_id": {Type: "string"}, + "created_at": {Type: "string", Format: "date-time"}, + }, + } + + return &client.APISpec{ + Info: client.APIInfo{Title: "Probe API", Version: "1.0.0", Description: "probe"}, + Endpoints: []client.Endpoint{ + { + Method: "GET", Path: "/users/{id}", OperationID: "users.get", + PathParams: []client.Parameter{{Name: "id", Schema: &client.Schema{Type: "string"}, Required: true}}, + QueryParams: []client.Parameter{{Name: "include_deleted", Schema: &client.Schema{Type: "boolean"}}}, + Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}}, + }, + { + Method: "POST", Path: "/users", OperationID: "users.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}, + Responses: map[int]*client.Response{201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}}, + }, + }, + Schemas: map[string]*client.Schema{"User": user}, + } +} + +func baseConfig() client.GeneratorConfig { + cfg := client.DefaultConfig() + cfg.Language = "typescript" + cfg.PackageName = "probe" + + return cfg +} + +func gateFixtures() []gateFixture { + oddKeys := baseSpec() + oddKeys.Schemas["Weird"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "content-type": {Type: "string"}, + "3dtiles": {Type: "string"}, + }} + + withAuth := baseSpec() + withAuth.Security = []client.SecurityScheme{{Type: "http", Name: "bearerAuth", Scheme: "bearer"}} + + apiName := baseConfig() + apiName.APIName = "APIClient" + + noStreaming := baseConfig() + noStreaming.IncludeStreaming = false + + return []gateFixture{ + {Name: "default", Spec: baseSpec(), Config: baseConfig()}, + {Name: "apiname", Spec: baseSpec(), Config: apiName}, + {Name: "odd-keys", Spec: oddKeys, Config: baseConfig()}, + {Name: "with-auth", Spec: withAuth, Config: baseConfig()}, + {Name: "no-streaming", Spec: baseSpec(), Config: noStreaming}, + } +} + +func generateTo(t *testing.T, f gateFixture) string { + t.Helper() + + out, err := NewGenerator().Generate(context.Background(), f.Spec, f.Config) + if err != nil { + t.Fatalf("%s: generate: %v", f.Name, err) + } + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + return dir +} From c89efa11ebfa61bfd93b9a675f56a16c63322396 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 18:43:24 -0500 Subject: [PATCH 03/71] fix(client/typescript): emit AuthConfig whenever it is referenced --- .../client/generators/typescript/gate_test.go | 31 +++++++++++ .../client/generators/typescript/generator.go | 54 +++++++++++++------ 2 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 internal/client/generators/typescript/gate_test.go diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go new file mode 100644 index 00000000..5016af42 --- /dev/null +++ b/internal/client/generators/typescript/gate_test.go @@ -0,0 +1,31 @@ +package typescript + +import ( + "strings" + "testing" +) + +// errorsMentioning returns the subset of errs containing needle. +func errorsMentioning(errs []string, needle string) []string { + var out []string + + for _, e := range errs { + if strings.Contains(e, needle) { + out = append(out, e) + } + } + + return out +} + +func TestNoDanglingAuthConfig(t *testing.T) { + for _, f := range gateFixtures() { + t.Run(f.Name, func(t *testing.T) { + errs := typeCheck(t, generateTo(t, f)) + + if bad := errorsMentioning(errs, "AuthConfig"); len(bad) > 0 { + t.Errorf("AuthConfig is referenced but not exported:\n%s", strings.Join(bad, "\n")) + } + }) + } +} diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index a763162f..de10b582 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -378,8 +378,10 @@ func (g *Generator) generateTypes(spec *client.APISpec, config client.GeneratorC buf.WriteString("\n") } - // Auth config interface - if config.IncludeAuth && client.NeedsAuthConfig(spec) { + // Auth config interface. Emitted whenever auth is enabled, because + // ClientConfig.auth and the client.ts import are both gated on IncludeAuth + // alone — a narrower condition here leaves those references unresolved. + if config.IncludeAuth { buf.WriteString("export interface AuthConfig {\n") buf.WriteString(" bearerToken?: string;\n") buf.WriteString(" apiKey?: string;\n") @@ -791,32 +793,50 @@ func (g *Generator) generateClient(spec *client.APISpec, config client.Generator var buf strings.Builder buf.WriteString("import { HTTPClient, RequestConfig } from './fetch';\n") - buf.WriteString("import { ClientConfig, AuthConfig } from './types';\n") + + if config.IncludeAuth { + buf.WriteString("import { ClientConfig, AuthConfig } from './types';\n") + } else { + buf.WriteString("import { ClientConfig } from './types';\n") + } + buf.WriteString("import { createError } from './errors';\n\n") buf.WriteString(fmt.Sprintf("export class %s {\n", config.APIName)) buf.WriteString(" protected httpClient: HTTPClient;\n") - buf.WriteString(" private auth?: AuthConfig;\n\n") + + if config.IncludeAuth { + buf.WriteString(" private auth?: AuthConfig;\n\n") + } else { + buf.WriteString("\n") + } buf.WriteString(" constructor(config: ClientConfig) {\n") - buf.WriteString(" this.auth = config.auth;\n") + + if config.IncludeAuth { + buf.WriteString(" this.auth = config.auth;\n") + } + buf.WriteString(" this.httpClient = new HTTPClient(\n") buf.WriteString(" config.baseURL,\n") buf.WriteString(" config.timeout || 30000\n") buf.WriteString(" );\n\n") - buf.WriteString(" // Setup auth headers\n") - buf.WriteString(" if (this.auth?.bearerToken) {\n") - buf.WriteString(" this.httpClient.setDefaultHeader('Authorization', `Bearer ${this.auth.bearerToken}`);\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.auth?.apiKey) {\n") - buf.WriteString(" this.httpClient.setDefaultHeader('X-API-Key', this.auth.apiKey);\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.auth?.customHeaders) {\n") - buf.WriteString(" for (const [key, value] of Object.entries(this.auth.customHeaders)) {\n") - buf.WriteString(" this.httpClient.setDefaultHeader(key, value);\n") - buf.WriteString(" }\n") - buf.WriteString(" }\n") + if config.IncludeAuth { + buf.WriteString(" // Setup auth headers\n") + buf.WriteString(" if (this.auth?.bearerToken) {\n") + buf.WriteString(" this.httpClient.setDefaultHeader('Authorization', `Bearer ${this.auth.bearerToken}`);\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.auth?.apiKey) {\n") + buf.WriteString(" this.httpClient.setDefaultHeader('X-API-Key', this.auth.apiKey);\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.auth?.customHeaders) {\n") + buf.WriteString(" for (const [key, value] of Object.entries(this.auth.customHeaders)) {\n") + buf.WriteString(" this.httpClient.setDefaultHeader(key, value);\n") + buf.WriteString(" }\n") + buf.WriteString(" }\n") + } + buf.WriteString(" }\n\n") buf.WriteString(" protected async request(config: RequestConfig): Promise {\n") From 99d2d1193ba80166d89c943fe0a7d0a188915454 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 18:48:03 -0500 Subject: [PATCH 04/71] fix(client/typescript): extend the configured client class in rest.ts --- internal/client/generators/typescript/gate_test.go | 14 ++++++++++++++ internal/client/generators/typescript/rest.go | 9 +++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 5016af42..d4d81ce1 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -29,3 +29,17 @@ func TestNoDanglingAuthConfig(t *testing.T) { }) } } + +func TestRESTExtendsConfiguredClientClass(t *testing.T) { + for _, f := range gateFixtures() { + t.Run(f.Name, func(t *testing.T) { + errs := typeCheck(t, generateTo(t, f)) + + for _, needle := range []string{"has no exported member 'Client'", "Property 'request' does not exist"} { + if bad := errorsMentioning(errs, needle); len(bad) > 0 { + t.Errorf("REST client does not extend the configured class:\n%s", strings.Join(bad, "\n")) + } + } + }) + } +} diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 13f6cf8e..58758076 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -107,12 +107,17 @@ func (r *RESTGenerator) generateOperationIDFromPath(endpoint client.Endpoint) st func (r *RESTGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) string { var buf strings.Builder + base := config.APIName + if base == "" { + base = "Client" + } + buf.WriteString("import { RequestConfig } from './fetch';\n") - buf.WriteString("import { Client } from './client';\n") + fmt.Fprintf(&buf, "import { %s } from './client';\n", base) buf.WriteString("import * as types from './types';\n\n") // Extend the main client class - buf.WriteString("export class RESTClient extends Client {\n") + fmt.Fprintf(&buf, "export class RESTClient extends %s {\n", base) // Build endpoint tree from all endpoints tree := r.buildEndpointTree(spec.Endpoints) From c8e873c2146cc2b6c323836e5c54b3c13b0c08c6 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 18:53:15 -0500 Subject: [PATCH 05/71] fix(client/typescript): quote non-identifier property keys in types.ts --- .../client/generators/typescript/gate_test.go | 42 +++++++++++++++++++ .../client/generators/typescript/generator.go | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index d4d81ce1..22be22e4 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -1,6 +1,7 @@ package typescript import ( + "context" "strings" "testing" ) @@ -43,3 +44,44 @@ func TestRESTExtendsConfiguredClientClass(t *testing.T) { }) } } + +func TestTypesQuoteNonIdentifierKeys(t *testing.T) { + var fixture gateFixture + + for _, f := range gateFixtures() { + if f.Name == "odd-keys" { + fixture = f + } + } + + out, err := NewGenerator().Generate(context.Background(), fixture.Spec, fixture.Config) + if err != nil { + t.Fatal(err) + } + + types := out.Files["src/types.ts"] + + if !strings.Contains(types, "'content-type'?: string;") { + t.Errorf("expected quoted 'content-type' key, got:\n%s", types) + } + + if !strings.Contains(types, "'3dtiles'?: string;") { + t.Errorf("expected quoted '3dtiles' key, got:\n%s", types) + } + + errs := typeCheck(t, generateTo(t, fixture)) + + // Verify the syntax errors we fixed are gone + if bad := errorsMentioning(errs, "TS1131"); len(bad) > 0 { + t.Errorf("should not have TS1131 errors:\n%s", strings.Join(bad, "\n")) + } + if bad := errorsMentioning(errs, "TS1351"); len(bad) > 0 { + t.Errorf("should not have TS1351 errors:\n%s", strings.Join(bad, "\n")) + } + if bad := errorsMentioning(errs, "TS1109"); len(bad) > 0 { + t.Errorf("should not have TS1109 errors:\n%s", strings.Join(bad, "\n")) + } + if bad := errorsMentioning(errs, "TS1128"); len(bad) > 0 { + t.Errorf("should not have TS1128 errors:\n%s", strings.Join(bad, "\n")) + } +} diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index de10b582..ca5b4d64 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -642,7 +642,7 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec } tsType := g.schemaToTSType(prop, spec) - buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", propName, optional, tsType)) + buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(propName), optional, tsType)) } buf.WriteString("}\n") From 7812acb397724701a2aa81fc1ae97c3c712dc962 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 18:58:53 -0500 Subject: [PATCH 06/71] fix(client/typescript): escape quotes and backslashes in property keys --- .../generators/typescript/fixtures_test.go | 2 ++ .../client/generators/typescript/gate_test.go | 16 ++++++++++++---- internal/client/generators/typescript/rest.go | 9 ++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 73833d57..57bf7efa 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -86,6 +86,8 @@ func gateFixtures() []gateFixture { oddKeys.Schemas["Weird"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ "content-type": {Type: "string"}, "3dtiles": {Type: "string"}, + "it's": {Type: "string"}, + "back\\slash": {Type: "string"}, }} withAuth := baseSpec() diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 22be22e4..153b2d34 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -61,12 +61,20 @@ func TestTypesQuoteNonIdentifierKeys(t *testing.T) { types := out.Files["src/types.ts"] - if !strings.Contains(types, "'content-type'?: string;") { - t.Errorf("expected quoted 'content-type' key, got:\n%s", types) + if !strings.Contains(types, "\"content-type\"?: string;") { + t.Errorf("expected quoted \"content-type\" key, got:\n%s", types) } - if !strings.Contains(types, "'3dtiles'?: string;") { - t.Errorf("expected quoted '3dtiles' key, got:\n%s", types) + if !strings.Contains(types, "\"3dtiles\"?: string;") { + t.Errorf("expected quoted \"3dtiles\" key, got:\n%s", types) + } + + if !strings.Contains(types, "\"it's\"?: string;") { + t.Errorf("expected properly escaped \"it's\" key, got:\n%s", types) + } + + if !strings.Contains(types, "\"back\\\\slash\"?: string;") { + t.Errorf("expected properly escaped \"back\\\\slash\" key, got:\n%s", types) } errs := typeCheck(t, generateTo(t, fixture)) diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 58758076..caa9ce77 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -1,6 +1,7 @@ package typescript import ( + "encoding/json" "fmt" "sort" "strings" @@ -152,12 +153,18 @@ func isValidTSIdentifier(name string) bool { // tsPropertyKey returns name quoted if it is not a valid TypeScript identifier, // so keys like "platform-admin" or "3dtiles" emit as valid property names. +// Uses json.Marshal to properly escape quotes and backslashes. func tsPropertyKey(name string) string { if isValidTSIdentifier(name) { return name } - return "'" + name + "'" + b, err := json.Marshal(name) + if err != nil { + return "'" + name + "'" // unreachable for strings; keep the old shape as a fallback + } + + return string(b) } // generateTreeNode recursively generates TypeScript object literals. From b93b8f2670ce870e877a9bb1a18d9abf5771a672 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:05:06 -0500 Subject: [PATCH 07/71] fix(client/typescript): declare require in the Node fallback path --- internal/client/generators/typescript/channels.go | 6 ++++++ internal/client/generators/typescript/gate_test.go | 12 ++++++++++++ internal/client/generators/typescript/presence.go | 6 ++++++ internal/client/generators/typescript/rooms.go | 6 ++++++ internal/client/generators/typescript/sse.go | 6 ++++++ internal/client/generators/typescript/typing.go | 6 ++++++ internal/client/generators/typescript/websocket.go | 6 ++++++ 7 files changed, 48 insertions(+) diff --git a/internal/client/generators/typescript/channels.go b/internal/client/generators/typescript/channels.go index 1a9b9b5f..8a16d9fd 100644 --- a/internal/client/generators/typescript/channels.go +++ b/internal/client/generators/typescript/channels.go @@ -40,6 +40,9 @@ func (c *ChannelsGenerator) generatePolyfillSetup() string { buf.WriteString("// Lazy-loaded WebSocket implementation\n") buf.WriteString("let _WebSocketImpl: typeof WebSocket | null = null;\n\n") + buf.WriteString("// Node.js CommonJS fallback. Phase 4 replaces this with dynamic import().\n") + buf.WriteString("declare const require: ((id: string) => any) | undefined;\n\n") + buf.WriteString("function getWebSocket(): typeof WebSocket {\n") buf.WriteString(" if (_WebSocketImpl) return _WebSocketImpl;\n") buf.WriteString(" \n") @@ -47,6 +50,9 @@ func (c *ChannelsGenerator) generatePolyfillSetup() string { buf.WriteString(" _WebSocketImpl = window.WebSocket;\n") buf.WriteString(" } else {\n") buf.WriteString(" try {\n") + buf.WriteString(" if (typeof require === 'undefined') {\n") + buf.WriteString(" throw new Error('No WebSocket implementation available in this environment.');\n") + buf.WriteString(" }\n") buf.WriteString(" // eslint-disable-next-line @typescript-eslint/no-var-requires\n") buf.WriteString(" _WebSocketImpl = require('ws');\n") buf.WriteString(" } catch {\n") diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 153b2d34..72f35833 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -45,6 +45,18 @@ func TestRESTExtendsConfiguredClientClass(t *testing.T) { } } +func TestNoUndeclaredRequire(t *testing.T) { + for _, f := range gateFixtures() { + t.Run(f.Name, func(t *testing.T) { + errs := typeCheck(t, generateTo(t, f)) + + if bad := errorsMentioning(errs, "Cannot find name 'require'"); len(bad) > 0 { + t.Errorf("generated code uses an undeclared require:\n%s", strings.Join(bad, "\n")) + } + }) + } +} + func TestTypesQuoteNonIdentifierKeys(t *testing.T) { var fixture gateFixture diff --git a/internal/client/generators/typescript/presence.go b/internal/client/generators/typescript/presence.go index affea7eb..999f1f75 100644 --- a/internal/client/generators/typescript/presence.go +++ b/internal/client/generators/typescript/presence.go @@ -50,6 +50,9 @@ func (p *PresenceGenerator) generatePolyfillSetup() string { buf.WriteString("// Lazy-loaded WebSocket implementation\n") buf.WriteString("let _WebSocketImpl: typeof WebSocket | null = null;\n\n") + buf.WriteString("// Node.js CommonJS fallback. Phase 4 replaces this with dynamic import().\n") + buf.WriteString("declare const require: ((id: string) => any) | undefined;\n\n") + buf.WriteString("function getWebSocket(): typeof WebSocket {\n") buf.WriteString(" if (_WebSocketImpl) return _WebSocketImpl;\n") buf.WriteString(" \n") @@ -57,6 +60,9 @@ func (p *PresenceGenerator) generatePolyfillSetup() string { buf.WriteString(" _WebSocketImpl = window.WebSocket;\n") buf.WriteString(" } else {\n") buf.WriteString(" try {\n") + buf.WriteString(" if (typeof require === 'undefined') {\n") + buf.WriteString(" throw new Error('No WebSocket implementation available in this environment.');\n") + buf.WriteString(" }\n") buf.WriteString(" // eslint-disable-next-line @typescript-eslint/no-var-requires\n") buf.WriteString(" _WebSocketImpl = require('ws');\n") buf.WriteString(" } catch {\n") diff --git a/internal/client/generators/typescript/rooms.go b/internal/client/generators/typescript/rooms.go index 43d7fdb2..201cc60a 100644 --- a/internal/client/generators/typescript/rooms.go +++ b/internal/client/generators/typescript/rooms.go @@ -40,6 +40,9 @@ func (r *RoomsGenerator) generatePolyfillSetup() string { buf.WriteString("// Lazy-loaded WebSocket implementation\n") buf.WriteString("let _WebSocketImpl: typeof WebSocket | null = null;\n\n") + buf.WriteString("// Node.js CommonJS fallback. Phase 4 replaces this with dynamic import().\n") + buf.WriteString("declare const require: ((id: string) => any) | undefined;\n\n") + buf.WriteString("function getWebSocket(): typeof WebSocket {\n") buf.WriteString(" if (_WebSocketImpl) return _WebSocketImpl;\n") buf.WriteString(" \n") @@ -47,6 +50,9 @@ func (r *RoomsGenerator) generatePolyfillSetup() string { buf.WriteString(" _WebSocketImpl = window.WebSocket;\n") buf.WriteString(" } else {\n") buf.WriteString(" try {\n") + buf.WriteString(" if (typeof require === 'undefined') {\n") + buf.WriteString(" throw new Error('No WebSocket implementation available in this environment.');\n") + buf.WriteString(" }\n") buf.WriteString(" // eslint-disable-next-line @typescript-eslint/no-var-requires\n") buf.WriteString(" _WebSocketImpl = require('ws');\n") buf.WriteString(" } catch {\n") diff --git a/internal/client/generators/typescript/sse.go b/internal/client/generators/typescript/sse.go index 76ccfeeb..568b9813 100644 --- a/internal/client/generators/typescript/sse.go +++ b/internal/client/generators/typescript/sse.go @@ -50,6 +50,9 @@ func (s *SSEGenerator) generatePolyfillSetup() string { buf.WriteString("// Lazy-loaded EventSource implementation\n") buf.WriteString("let _EventSourceImpl: typeof EventSource | null = null;\n\n") + buf.WriteString("// Node.js CommonJS fallback. Phase 4 replaces this with dynamic import().\n") + buf.WriteString("declare const require: ((id: string) => any) | undefined;\n\n") + buf.WriteString("function getEventSource(): typeof EventSource {\n") buf.WriteString(" if (_EventSourceImpl) return _EventSourceImpl;\n") buf.WriteString(" \n") @@ -58,6 +61,9 @@ func (s *SSEGenerator) generatePolyfillSetup() string { buf.WriteString(" } else {\n") buf.WriteString(" // Node.js - use dynamic require for compatibility\n") buf.WriteString(" try {\n") + buf.WriteString(" if (typeof require === 'undefined') {\n") + buf.WriteString(" throw new Error('No EventSource implementation available in this environment.');\n") + buf.WriteString(" }\n") buf.WriteString(" // eslint-disable-next-line @typescript-eslint/no-var-requires\n") buf.WriteString(" const esModule = require('eventsource');\n") buf.WriteString(" _EventSourceImpl = esModule.default || esModule;\n") diff --git a/internal/client/generators/typescript/typing.go b/internal/client/generators/typescript/typing.go index 56e564f6..001483ee 100644 --- a/internal/client/generators/typescript/typing.go +++ b/internal/client/generators/typescript/typing.go @@ -50,6 +50,9 @@ func (t *TypingGenerator) generatePolyfillSetup() string { buf.WriteString("// Lazy-loaded WebSocket implementation\n") buf.WriteString("let _WebSocketImpl: typeof WebSocket | null = null;\n\n") + buf.WriteString("// Node.js CommonJS fallback. Phase 4 replaces this with dynamic import().\n") + buf.WriteString("declare const require: ((id: string) => any) | undefined;\n\n") + buf.WriteString("function getWebSocket(): typeof WebSocket {\n") buf.WriteString(" if (_WebSocketImpl) return _WebSocketImpl;\n") buf.WriteString(" \n") @@ -57,6 +60,9 @@ func (t *TypingGenerator) generatePolyfillSetup() string { buf.WriteString(" _WebSocketImpl = window.WebSocket;\n") buf.WriteString(" } else {\n") buf.WriteString(" try {\n") + buf.WriteString(" if (typeof require === 'undefined') {\n") + buf.WriteString(" throw new Error('No WebSocket implementation available in this environment.');\n") + buf.WriteString(" }\n") buf.WriteString(" // eslint-disable-next-line @typescript-eslint/no-var-requires\n") buf.WriteString(" _WebSocketImpl = require('ws');\n") buf.WriteString(" } catch {\n") diff --git a/internal/client/generators/typescript/websocket.go b/internal/client/generators/typescript/websocket.go index d01bb906..f4f7ccc4 100644 --- a/internal/client/generators/typescript/websocket.go +++ b/internal/client/generators/typescript/websocket.go @@ -50,6 +50,9 @@ func (w *WebSocketGenerator) generatePolyfillSetup() string { buf.WriteString("// Lazy-loaded WebSocket implementation\n") buf.WriteString("let _WebSocketImpl: typeof WebSocket | null = null;\n\n") + buf.WriteString("// Node.js CommonJS fallback. Phase 4 replaces this with dynamic import().\n") + buf.WriteString("declare const require: ((id: string) => any) | undefined;\n\n") + buf.WriteString("function getWebSocket(): typeof WebSocket {\n") buf.WriteString(" if (_WebSocketImpl) return _WebSocketImpl;\n") buf.WriteString(" \n") @@ -58,6 +61,9 @@ func (w *WebSocketGenerator) generatePolyfillSetup() string { buf.WriteString(" } else {\n") buf.WriteString(" // Node.js - use dynamic require for compatibility\n") buf.WriteString(" try {\n") + buf.WriteString(" if (typeof require === 'undefined') {\n") + buf.WriteString(" throw new Error('No WebSocket implementation available in this environment.');\n") + buf.WriteString(" }\n") buf.WriteString(" // eslint-disable-next-line @typescript-eslint/no-var-requires\n") buf.WriteString(" _WebSocketImpl = require('ws');\n") buf.WriteString(" } catch {\n") From 4cf62b0a6d2e210bf93886fd46e6662085c380e3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:13:08 -0500 Subject: [PATCH 08/71] fix(client/typescript): sort map iteration for deterministic output --- .../generators/typescript/determinism_test.go | 34 +++++++++++++++++++ .../client/generators/typescript/generator.go | 25 +++++++++++--- .../generators/typescript/pagination.go | 7 ++-- internal/client/generators/typescript/sse.go | 6 ++-- 4 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 internal/client/generators/typescript/determinism_test.go diff --git a/internal/client/generators/typescript/determinism_test.go b/internal/client/generators/typescript/determinism_test.go new file mode 100644 index 00000000..ea5151b0 --- /dev/null +++ b/internal/client/generators/typescript/determinism_test.go @@ -0,0 +1,34 @@ +package typescript + +import ( + "context" + "testing" +) + +func TestGenerationIsDeterministic(t *testing.T) { + for _, f := range gateFixtures() { + t.Run(f.Name, func(t *testing.T) { + first, err := NewGenerator().Generate(context.Background(), f.Spec, f.Config) + if err != nil { + t.Fatal(err) + } + + for i := 1; i < 12; i++ { + next, err := NewGenerator().Generate(context.Background(), f.Spec, f.Config) + if err != nil { + t.Fatal(err) + } + + if len(next.Files) != len(first.Files) { + t.Fatalf("run %d: file count changed: %d != %d", i, len(next.Files), len(first.Files)) + } + + for name, content := range first.Files { + if next.Files[name] != content { + t.Fatalf("run %d: %s differs from run 0", i, name) + } + } + } + }) + } +} diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index ca5b4d64..27c31e07 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "sort" "strings" "github.com/xraph/forge/errors" @@ -11,6 +12,19 @@ import ( "github.com/xraph/forge/internal/client/generators" ) +// sortedKeys returns the keys of m in ascending order. Generated output must be +// byte-identical across runs, and Go randomizes map iteration. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Strings(keys) + + return keys +} + // Generator generates TypeScript clients. type Generator struct{} @@ -266,12 +280,12 @@ func (g *Generator) generatePackageJSON(spec *client.APISpec, config client.Gene var depsJSONSb strings.Builder - for name, version := range deps { + for _, name := range sortedKeys(deps) { if !first { depsJSONSb.WriteString(",\n") } - depsJSONSb.WriteString(fmt.Sprintf(" \"%s\": \"%s\"", name, version)) + depsJSONSb.WriteString(fmt.Sprintf(" \"%s\": \"%s\"", name, deps[name])) first = false } @@ -372,8 +386,8 @@ func (g *Generator) generateTypes(spec *client.APISpec, config client.GeneratorC } // Generate types from schemas - for name, schema := range spec.Schemas { - typeCode := g.schemaToTypeScript(name, schema, spec) + for _, name := range sortedKeys(spec.Schemas) { + typeCode := g.schemaToTypeScript(name, spec.Schemas[name], spec) buf.WriteString(typeCode) buf.WriteString("\n") } @@ -633,7 +647,8 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec case "object": buf.WriteString(fmt.Sprintf("export interface %s {\n", name)) - for propName, prop := range schema.Properties { + for _, propName := range sortedKeys(schema.Properties) { + prop := schema.Properties[propName] required := contains(schema.Required, propName) optional := "" diff --git a/internal/client/generators/typescript/pagination.go b/internal/client/generators/typescript/pagination.go index f1c4e790..6a51908f 100644 --- a/internal/client/generators/typescript/pagination.go +++ b/internal/client/generators/typescript/pagination.go @@ -232,8 +232,11 @@ func (p *PaginationGenerator) getArrayItemType(endpoint client.Endpoint, spec *c if resp, ok := endpoint.Responses[200]; ok { if media, ok := resp.Content["application/json"]; ok && media.Schema != nil { if media.Schema.Properties != nil { - // Look for data/items/results array - for propName, prop := range media.Schema.Properties { + // Look for data/items/results array. Iterate keys in a fixed + // order so the chosen property (and thus the generated + // return type) is deterministic when multiple match. + for _, propName := range sortedKeys(media.Schema.Properties) { + prop := media.Schema.Properties[propName] nameLower := strings.ToLower(propName) if (nameLower == "data" || nameLower == "items" || nameLower == "results") && prop.Type == "array" && prop.Items != nil { diff --git a/internal/client/generators/typescript/sse.go b/internal/client/generators/typescript/sse.go index 568b9813..ed1ccaa2 100644 --- a/internal/client/generators/typescript/sse.go +++ b/internal/client/generators/typescript/sse.go @@ -313,7 +313,8 @@ func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.AP buf.WriteString(" };\n\n") // Register event listeners for each event type - for eventName, schema := range sse.EventSchemas { + for _, eventName := range sortedKeys(sse.EventSchemas) { + schema := sse.EventSchemas[eventName] typeName := s.getSchemaTypeName(schema, spec) buf.WriteString(fmt.Sprintf(" this.eventSource.addEventListener('%s', (event: MessageEvent) => {\n", eventName)) @@ -355,7 +356,8 @@ func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.AP buf.WriteString(" }\n\n") // Generate on methods for each event type - for eventName, schema := range sse.EventSchemas { + for _, eventName := range sortedKeys(sse.EventSchemas) { + schema := sse.EventSchemas[eventName] typeName := s.getSchemaTypeName(schema, spec) methodName := s.toPascalCase(eventName) From 84545c0515101f3fff1ce4c8a70f851faa6c6c6f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:19:33 -0500 Subject: [PATCH 09/71] fix(client/typescript): url-encode path parameters --- internal/client/generators/typescript/rest.go | 4 +++- .../client/generators/typescript/rest_test.go | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index caa9ce77..97dbefc9 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -552,7 +552,9 @@ func (r *RESTGenerator) generatePathExpression(endpoint client.Endpoint) string for _, param := range endpoint.PathParams { paramName := r.toTSParamName(param.Name) placeholder := fmt.Sprintf("{%s}", param.Name) - path = strings.ReplaceAll(path, placeholder, "${"+paramName+"}") + // Path segments must be escaped: an unencoded '/' or '?' in a value + // silently changes which route the request reaches. + path = strings.ReplaceAll(path, placeholder, "${encodeURIComponent(String("+paramName+"))}") } return fmt.Sprintf("`%s`", path) diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index 5a3c9802..dbbe1d7b 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -191,8 +191,8 @@ func TestRESTGenerator_WithParameters(t *testing.T) { assert.Contains(t, code, "startdate?: string | undefined") assert.Contains(t, code, "enddate?: string | undefined") - // Verify path template - assert.Contains(t, code, "/api/workspaces/${workspaceid}/connections/${connectionid}/billing/users/${externalid}") + // Verify path template with URL encoding + assert.Contains(t, code, "/api/workspaces/${encodeURIComponent(String(workspaceid))}/connections/${encodeURIComponent(String(connectionid))}/billing/users/${encodeURIComponent(String(externalid))}") // Verify query params handling assert.Contains(t, code, "queryParams: Record = {}") @@ -442,3 +442,20 @@ func TestRESTGenerator_ReturnTypes(t *testing.T) { assert.Contains(t, code, "return this.request(config)") assert.Contains(t, code, "await this.request(config)") } + +func TestPathParamsAreURLEncoded(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "T", Version: "1"}, + Endpoints: []client.Endpoint{{ + Method: "GET", + Path: "/files/{path}", + OperationID: "files.get", + PathParams: []client.Parameter{{Name: "path", Schema: &client.Schema{Type: "string"}, Required: true}}, + Responses: map[int]*client.Response{204: {Description: "ok"}}, + }}, + } + + code := NewRESTGenerator().Generate(spec, client.DefaultConfig()) + + assert.Contains(t, code, "${encodeURIComponent(String(path))}") +} From bfbb5eb8085c302a3acec72dc5a1104cd05a8d97 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:30:24 -0500 Subject: [PATCH 10/71] fix(client/typescript): stop leaf insertion from discarding a namespace --- internal/client/generators/typescript/rest.go | 21 +++++++- .../client/generators/typescript/rest_test.go | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 97dbefc9..b989d0e9 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -49,8 +49,25 @@ func (r *RESTGenerator) buildEndpointTree(endpoints []client.Endpoint) *Endpoint func (r *RESTGenerator) insertIntoTree(node *EndpointNode, parts []string, endpoint *client.Endpoint) { if len(parts) == 1 { // Leaf node - actual method - node.Children[parts[0]] = &EndpointNode{ - MethodName: parts[0], + name := parts[0] + + if existing := node.Children[name]; existing != nil && !existing.IsLeaf { + // A namespace already occupies this name (e.g. "users.active.list" + // was inserted before "users"). Keep the namespace and hang the + // method inside it under its own name, rather than discarding the + // subtree. This mirrors the leaf-then-branch conversion below, so + // both insertion orders produce the same tree shape. + existing.Children[name] = &EndpointNode{ + MethodName: name, + Endpoint: endpoint, + IsLeaf: true, + } + + return + } + + node.Children[name] = &EndpointNode{ + MethodName: name, Endpoint: endpoint, IsLeaf: true, } diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index dbbe1d7b..6193cdf2 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -443,6 +443,55 @@ func TestRESTGenerator_ReturnTypes(t *testing.T) { assert.Contains(t, code, "await this.request(config)") } +func TestEndpointTreeKeepsBothOrders(t *testing.T) { + // Reuses the same two operation IDs as TestRESTGenerator_ConflictingOperationIDs + // ("users" and "users.active.list") but exercises both insertion orders. + // + // "users" first, then "users.active.list" (leaf-then-branch) is the order + // already covered by TestRESTGenerator_ConflictingOperationIDs and is the + // direction insertIntoTree already handled: the leaf gets converted into a + // branch and the original method is re-inserted inside it. + // + // "users.active.list" first, then "users" (branch-then-leaf) is the + // direction that was broken: the leaf-insertion case at len(parts) == 1 + // unconditionally overwrote node.Children["users"], discarding the + // "active.list" branch built by the first endpoint entirely. + // + // Since spec.Endpoints ordering is not guaranteed (map iteration during + // spec construction, declaration order, etc.), the tree - and therefore + // the generated code - must not depend on which order the endpoints were + // inserted in. This test asserts that directly by comparing the two + // outputs for byte-for-byte equality, rather than just checking that both + // pieces of the tree happen to be present. + mk := func(opID string) client.Endpoint { + return client.Endpoint{ + Method: "GET", Path: "/x", OperationID: opID, + Responses: map[int]*client.Response{204: {Description: "ok"}}, + } + } + + gen := NewRESTGenerator() + config := client.DefaultConfig() + + branchThenLeaf := gen.Generate(&client.APISpec{ + Info: client.APIInfo{Title: "T", Version: "1"}, + Endpoints: []client.Endpoint{mk("users.active.list"), mk("users")}, + }, config) + + leafThenBranch := gen.Generate(&client.APISpec{ + Info: client.APIInfo{Title: "T", Version: "1"}, + Endpoints: []client.Endpoint{mk("users"), mk("users.active.list")}, + }, config) + + for name, code := range map[string]string{"branch-then-leaf": branchThenLeaf, "leaf-then-branch": leafThenBranch} { + assert.Contains(t, code, "active: {", "%s: nested namespace must survive", name) + assert.Contains(t, code, "list: async (", "%s: sibling method under the namespace must survive", name) + assert.Contains(t, code, "users: async (", "%s: the single-segment 'users' method must survive", name) + } + + assert.Equal(t, leafThenBranch, branchThenLeaf, "tree shape (and generated code) must not depend on insertion order") +} + func TestPathParamsAreURLEncoded(t *testing.T) { spec := &client.APISpec{ Info: client.APIInfo{Title: "T", Version: "1"}, From 032caac81b9b4c5495e9d7fc378cf33705793078 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:37:48 -0500 Subject: [PATCH 11/71] fix(client/typescript): preserve interior caps in case conversion --- .../client/generators/typescript/naming.go | 68 +++++++++++++++++++ .../generators/typescript/naming_test.go | 39 +++++++++++ .../generators/typescript/pagination.go | 22 +----- internal/client/generators/typescript/rest.go | 22 +----- .../client/generators/typescript/rest_test.go | 16 ++--- internal/client/generators/typescript/sse.go | 14 +--- .../client/generators/typescript/websocket.go | 14 +--- .../generators/typescript/webtransport.go | 14 +--- 8 files changed, 120 insertions(+), 89 deletions(-) create mode 100644 internal/client/generators/typescript/naming.go create mode 100644 internal/client/generators/typescript/naming_test.go diff --git a/internal/client/generators/typescript/naming.go b/internal/client/generators/typescript/naming.go new file mode 100644 index 00000000..ab67c1b5 --- /dev/null +++ b/internal/client/generators/typescript/naming.go @@ -0,0 +1,68 @@ +package typescript + +import "strings" + +// splitWords breaks name on separators and on lower-to-upper boundaries, so an +// already-camelCase name round-trips instead of being flattened. Only non-empty +// words are ever appended, so callers may safely index words[i][:1]. +func splitWords(name string) []string { + var ( + words []string + cur strings.Builder + ) + + flush := func() { + if cur.Len() > 0 { + words = append(words, cur.String()) + cur.Reset() + } + } + + runes := []rune(name) + for i, r := range runes { + switch { + case r == '_' || r == '-' || r == ' ' || r == '.': + flush() + case i > 0 && r >= 'A' && r <= 'Z' && runes[i-1] >= 'a' && runes[i-1] <= 'z': + flush() + cur.WriteRune(r) + default: + cur.WriteRune(r) + } + } + + flush() + + return words +} + +// toCamel converts name to camelCase, preserving interior capitalisation. +func toCamel(name string) string { + words := splitWords(name) + if len(words) == 0 { + return name + } + + var out strings.Builder + + out.WriteString(strings.ToLower(words[0][:1]) + words[0][1:]) + + for _, w := range words[1:] { + out.WriteString(strings.ToUpper(w[:1]) + w[1:]) + } + + return out.String() +} + +// toPascal converts name to PascalCase, preserving interior capitalisation. +func toPascal(name string) string { + words := splitWords(name) + + var out strings.Builder + + for _, w := range words { + out.WriteString(strings.ToUpper(w[:1]) + w[1:]) + } + + return out.String() +} diff --git a/internal/client/generators/typescript/naming_test.go b/internal/client/generators/typescript/naming_test.go new file mode 100644 index 00000000..9cf2fa23 --- /dev/null +++ b/internal/client/generators/typescript/naming_test.go @@ -0,0 +1,39 @@ +package typescript + +import "testing" + +func TestToCamel(t *testing.T) { + cases := []struct{ in, want string }{ + {"user_id", "userId"}, + {"user-id", "userId"}, + {"userId", "userId"}, // already camel: must be preserved, not lowercased + {"UserID", "userID"}, // leading cap dropped, interior caps kept + {"id", "id"}, + {"", ""}, + {"_", "_"}, // separators only: no words found, name returned unchanged, no panic + {"--", "--"}, // separators only: no words found, name returned unchanged, no panic + } + + for _, c := range cases { + if got := toCamel(c.in); got != c.want { + t.Errorf("toCamel(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestToPascal(t *testing.T) { + cases := []struct{ in, want string }{ + {"user_id", "UserId"}, + {"message.created", "MessageCreated"}, + {"userId", "UserId"}, + {"", ""}, + {"_", ""}, + {"--", ""}, + } + + for _, c := range cases { + if got := toPascal(c.in); got != c.want { + t.Errorf("toPascal(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/client/generators/typescript/pagination.go b/internal/client/generators/typescript/pagination.go index 6a51908f..be694ab1 100644 --- a/internal/client/generators/typescript/pagination.go +++ b/internal/client/generators/typescript/pagination.go @@ -284,25 +284,5 @@ func (p *PaginationGenerator) getTypeName(schema *client.Schema, spec *client.AP // toCamelCase converts a string to camelCase. func (p *PaginationGenerator) toCamelCase(s string) string { - parts := strings.FieldsFunc(s, func(r rune) bool { - return r == '_' || r == '-' || r == ' ' - }) - - if len(parts) == 0 { - return s - } - - result := strings.ToLower(parts[0]) - - var resultSb291 strings.Builder - - for i := 1; i < len(parts); i++ { - if len(parts[i]) > 0 { - resultSb291.WriteString(strings.ToUpper(parts[i][:1]) + strings.ToLower(parts[i][1:])) - } - } - - result += resultSb291.String() - - return result + return toCamel(s) } diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index b989d0e9..5b0b84c6 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -643,25 +643,5 @@ func (r *RESTGenerator) toTSParamName(name string) string { // toCamelCase converts a string to camelCase. func (r *RESTGenerator) toCamelCase(s string) string { - parts := strings.FieldsFunc(s, func(r rune) bool { - return r == '_' || r == '-' || r == ' ' - }) - - if len(parts) == 0 { - return s - } - - result := strings.ToLower(parts[0]) - - var resultSb292 strings.Builder - - for i := 1; i < len(parts); i++ { - if len(parts[i]) > 0 { - resultSb292.WriteString(strings.ToUpper(parts[i][:1]) + strings.ToLower(parts[i][1:])) - } - } - - result += resultSb292.String() - - return result + return toCamel(s) } diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index 6193cdf2..764b9ccb 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -185,19 +185,19 @@ func TestRESTGenerator_WithParameters(t *testing.T) { assert.Contains(t, code, "usage: async (") // Verify parameters are included - assert.Contains(t, code, "workspaceid: string") - assert.Contains(t, code, "connectionid: string") - assert.Contains(t, code, "externalid: string") - assert.Contains(t, code, "startdate?: string | undefined") - assert.Contains(t, code, "enddate?: string | undefined") + assert.Contains(t, code, "workspaceId: string") + assert.Contains(t, code, "connectionId: string") + assert.Contains(t, code, "externalId: string") + assert.Contains(t, code, "startDate?: string | undefined") + assert.Contains(t, code, "endDate?: string | undefined") // Verify path template with URL encoding - assert.Contains(t, code, "/api/workspaces/${encodeURIComponent(String(workspaceid))}/connections/${encodeURIComponent(String(connectionid))}/billing/users/${encodeURIComponent(String(externalid))}") + assert.Contains(t, code, "/api/workspaces/${encodeURIComponent(String(workspaceId))}/connections/${encodeURIComponent(String(connectionId))}/billing/users/${encodeURIComponent(String(externalId))}") // Verify query params handling assert.Contains(t, code, "queryParams: Record = {}") - assert.Contains(t, code, "if (startdate !== undefined)") - assert.Contains(t, code, "if (enddate !== undefined)") + assert.Contains(t, code, "if (startDate !== undefined)") + assert.Contains(t, code, "if (endDate !== undefined)") } func TestRESTGenerator_WithRequestBody(t *testing.T) { diff --git a/internal/client/generators/typescript/sse.go b/internal/client/generators/typescript/sse.go index ed1ccaa2..7c00abe8 100644 --- a/internal/client/generators/typescript/sse.go +++ b/internal/client/generators/typescript/sse.go @@ -522,17 +522,5 @@ func (s *SSEGenerator) getSchemaTypeName(schema *client.Schema, spec *client.API // toPascalCase converts a string to PascalCase. func (s *SSEGenerator) toPascalCase(str string) string { - parts := strings.FieldsFunc(str, func(r rune) bool { - return r == '_' || r == '-' || r == ' ' - }) - - var resultSb strings.Builder - - for _, part := range parts { - if len(part) > 0 { - resultSb.WriteString(strings.ToUpper(part[:1]) + strings.ToLower(part[1:])) - } - } - - return resultSb.String() + return toPascal(str) } diff --git a/internal/client/generators/typescript/websocket.go b/internal/client/generators/typescript/websocket.go index f4f7ccc4..a2a159c9 100644 --- a/internal/client/generators/typescript/websocket.go +++ b/internal/client/generators/typescript/websocket.go @@ -652,17 +652,5 @@ func (w *WebSocketGenerator) getSchemaTypeName(schema *client.Schema, spec *clie // toPascalCase converts a string to PascalCase. func (w *WebSocketGenerator) toPascalCase(s string) string { - parts := strings.FieldsFunc(s, func(r rune) bool { - return r == '_' || r == '-' || r == ' ' - }) - - var resultSb strings.Builder - - for _, part := range parts { - if len(part) > 0 { - resultSb.WriteString(strings.ToUpper(part[:1]) + strings.ToLower(part[1:])) - } - } - - return resultSb.String() + return toPascal(s) } diff --git a/internal/client/generators/typescript/webtransport.go b/internal/client/generators/typescript/webtransport.go index 402b86ba..01cf9e55 100644 --- a/internal/client/generators/typescript/webtransport.go +++ b/internal/client/generators/typescript/webtransport.go @@ -947,17 +947,5 @@ func (w *WebTransportGenerator) getSchemaTypeName(schema *client.Schema, spec *c // toPascalCase converts a string to PascalCase. func (w *WebTransportGenerator) toPascalCase(str string) string { - parts := strings.FieldsFunc(str, func(r rune) bool { - return r == '_' || r == '-' || r == ' ' - }) - - var resultSb strings.Builder - - for _, part := range parts { - if len(part) > 0 { - resultSb.WriteString(strings.ToUpper(part[:1]) + strings.ToLower(part[1:])) - } - } - - return resultSb.String() + return toPascal(str) } From 69aa504aedc077a97d4c24ac5c26dcfefab31c4f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:43:10 -0500 Subject: [PATCH 12/71] fix(client/typescript): slice first rune, not first byte, in case conversion --- .../client/generators/typescript/naming.go | 42 ++++++++++++++++--- .../generators/typescript/naming_test.go | 10 +++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/internal/client/generators/typescript/naming.go b/internal/client/generators/typescript/naming.go index ab67c1b5..3f98a228 100644 --- a/internal/client/generators/typescript/naming.go +++ b/internal/client/generators/typescript/naming.go @@ -1,10 +1,42 @@ package typescript -import "strings" +import ( + "strings" + "unicode" +) + +// lowerFirst returns w with its first rune lowercased, leaving the rest +// untouched. Operates on runes, not bytes, so multi-byte leading characters +// are not corrupted. +func lowerFirst(w string) string { + r := []rune(w) + if len(r) == 0 { + return w + } + + r[0] = unicode.ToLower(r[0]) + + return string(r) +} + +// upperFirst returns w with its first rune uppercased, leaving the rest +// untouched. Operates on runes, not bytes, so multi-byte leading characters +// are not corrupted. +func upperFirst(w string) string { + r := []rune(w) + if len(r) == 0 { + return w + } + + r[0] = unicode.ToUpper(r[0]) + + return string(r) +} // splitWords breaks name on separators and on lower-to-upper boundaries, so an // already-camelCase name round-trips instead of being flattened. Only non-empty -// words are ever appended, so callers may safely index words[i][:1]. +// words are ever appended, so callers may safely pass words[i] to lowerFirst / +// upperFirst without an emptiness check. func splitWords(name string) []string { var ( words []string @@ -45,10 +77,10 @@ func toCamel(name string) string { var out strings.Builder - out.WriteString(strings.ToLower(words[0][:1]) + words[0][1:]) + out.WriteString(lowerFirst(words[0])) for _, w := range words[1:] { - out.WriteString(strings.ToUpper(w[:1]) + w[1:]) + out.WriteString(upperFirst(w)) } return out.String() @@ -61,7 +93,7 @@ func toPascal(name string) string { var out strings.Builder for _, w := range words { - out.WriteString(strings.ToUpper(w[:1]) + w[1:]) + out.WriteString(upperFirst(w)) } return out.String() diff --git a/internal/client/generators/typescript/naming_test.go b/internal/client/generators/typescript/naming_test.go index 9cf2fa23..bf5295fb 100644 --- a/internal/client/generators/typescript/naming_test.go +++ b/internal/client/generators/typescript/naming_test.go @@ -12,6 +12,12 @@ func TestToCamel(t *testing.T) { {"", ""}, {"_", "_"}, // separators only: no words found, name returned unchanged, no panic {"--", "--"}, // separators only: no words found, name returned unchanged, no panic + {"...", "..."}, // separators only, no panic + {"_a_", "a"}, + {"123abc", "123abc"}, // leading digit: no case boundary, no panic + {"Ábc", "ábc"}, // multi-byte leading rune must be lowercased by rune, not by byte + {"ábc", "ábc"}, // already-lowercase multi-byte leading rune: unchanged + {"café_id", "caféId"}, // multi-byte rune mid-word must survive untouched } for _, c := range cases { @@ -29,6 +35,10 @@ func TestToPascal(t *testing.T) { {"", ""}, {"_", ""}, {"--", ""}, + {"...", ""}, + {"_a_", "A"}, + {"123abc", "123abc"}, // leading digit: ToUpper is a no-op, no panic + {"ábc_id", "ÁbcId"}, // multi-byte leading rune must be uppercased by rune, not by byte } for _, c := range cases { From e314ed9b1e8ab374c33e5dc766ee483871b322a2 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:48:03 -0500 Subject: [PATCH 13/71] chore(client/typescript): remove unreachable generateEndpointMethod --- internal/client/generators/typescript/rest.go | 124 ------------------ 1 file changed, 124 deletions(-) diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 5b0b84c6..75fa5da3 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -345,130 +345,6 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien } } -// generateEndpointMethod generates a TypeScript method for an endpoint. -func (r *RESTGenerator) generateEndpointMethod(endpoint client.Endpoint, spec *client.APISpec, config client.GeneratorConfig) string { - var buf strings.Builder - - methodName := r.generateMethodName(endpoint) - - // JSDoc comment - buf.WriteString(" /**\n") - - if endpoint.Summary != "" { - buf.WriteString(fmt.Sprintf(" * %s\n", endpoint.Summary)) - } - - if endpoint.Description != "" { - buf.WriteString(fmt.Sprintf(" * %s\n", endpoint.Description)) - } - - if endpoint.Deprecated { - buf.WriteString(" * @deprecated\n") - } - - buf.WriteString(" */\n") - - // Method signature with optional options parameter - params := r.generateParameters(endpoint, spec) - if params != "" { - params += ", " - } - - params += "options?: { signal?: AbortSignal; retry?: { maxAttempts?: number } }" - - returnType := r.generateReturnType(endpoint, spec) - - buf.WriteString(fmt.Sprintf(" async %s(%s): Promise<%s> {\n", methodName, params, returnType)) - - // Build URL - pathExpr := r.generatePathExpression(endpoint) - buf.WriteString(fmt.Sprintf(" let path = %s;\n", pathExpr)) - - // Add query parameters - if len(endpoint.QueryParams) > 0 { - buf.WriteString(" const queryParams: Record = {};\n") - - for _, param := range endpoint.QueryParams { - paramName := r.toTSParamName(param.Name) - if param.Required { - buf.WriteString(fmt.Sprintf(" queryParams['%s'] = %s;\n", param.Name, paramName)) - } else { - buf.WriteString(fmt.Sprintf(" if (%s !== undefined) {\n", paramName)) - buf.WriteString(fmt.Sprintf(" queryParams['%s'] = %s;\n", param.Name, paramName)) - buf.WriteString(" }\n") - } - } - - buf.WriteString(" const queryString = new URLSearchParams(queryParams).toString();\n") - buf.WriteString(" if (queryString) {\n") - buf.WriteString(" path += '?' + queryString;\n") - buf.WriteString(" }\n") - } - - // Build request config - buf.WriteString(" const config: RequestConfig = {\n") - buf.WriteString(fmt.Sprintf(" method: '%s',\n", strings.ToUpper(endpoint.Method))) - buf.WriteString(" url: path,\n") - - // Add request body - if endpoint.RequestBody != nil { - buf.WriteString(" body,\n") - } - - // Add custom headers - if len(endpoint.HeaderParams) > 0 { - buf.WriteString(" headers: {\n") - - for _, param := range endpoint.HeaderParams { - paramName := r.toTSParamName(param.Name) - if param.Required { - buf.WriteString(fmt.Sprintf(" '%s': %s,\n", param.Name, paramName)) - } else { - buf.WriteString(fmt.Sprintf(" ...(%s ? { '%s': %s } : {}),\n", paramName, param.Name, paramName)) - } - } - - buf.WriteString(" },\n") - } - - // Add signal and retry from options - buf.WriteString(" signal: options?.signal,\n") - buf.WriteString(" retry: options?.retry,\n") - - buf.WriteString(" };\n\n") - - // Make request - if returnType != "void" { - buf.WriteString(" return this.request<") - buf.WriteString(returnType) - buf.WriteString(">(config);\n") - } else { - buf.WriteString(" await this.request(config);\n") - } - - buf.WriteString(" }\n") - - return buf.String() -} - -// generateMethodName generates a method name from an endpoint. -func (r *RESTGenerator) generateMethodName(endpoint client.Endpoint) string { - if endpoint.OperationID != "" { - return r.toCamelCase(endpoint.OperationID) - } - - // Generate from path and method - path := strings.TrimPrefix(endpoint.Path, "/") - path = strings.ReplaceAll(path, "/", "_") - path = strings.ReplaceAll(path, "{", "") - path = strings.ReplaceAll(path, "}", "") - path = strings.ReplaceAll(path, "-", "_") - - method := strings.ToLower(endpoint.Method) - - return r.toCamelCase(method + "_" + path) -} - // hasBodyParam reports whether the endpoint carries a JSON request body, which // is the sole condition under which a `body` parameter is generated. func (r *RESTGenerator) hasBodyParam(endpoint *client.Endpoint) bool { From 6b5fbbea76958eb28e01926967ee3a4c36486e21 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:53:08 -0500 Subject: [PATCH 14/71] fix(client/typescript): keep timeouts with caller signals, throw real errors --- .../generators/typescript/fetch_client.go | 32 +++++++++++++------ .../client/generators/typescript/gate_test.go | 16 ++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index 13f97ee0..02efbefd 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -21,6 +21,21 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c // Imports buf.WriteString("// Base HTTP client using native fetch\n\n") + // HTTPError class + buf.WriteString("/** Error thrown for non-2xx responses. */\n") + buf.WriteString("export class HTTPError extends Error {\n") + buf.WriteString(" readonly statusCode: number;\n") + buf.WriteString(" readonly code: string;\n") + buf.WriteString(" readonly details: unknown;\n\n") + buf.WriteString(" constructor(statusCode: number, message: string, code: string, details: unknown) {\n") + buf.WriteString(" super(message);\n") + buf.WriteString(" this.name = 'HTTPError';\n") + buf.WriteString(" this.statusCode = statusCode;\n") + buf.WriteString(" this.code = code;\n") + buf.WriteString(" this.details = details;\n") + buf.WriteString(" }\n") + buf.WriteString("}\n\n") + // RequestConfig interface buf.WriteString("export interface RequestConfig {\n") buf.WriteString(" method: string;\n") @@ -151,8 +166,13 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" const controller = new AbortController();\n") buf.WriteString(" const timeoutId = setTimeout(() => controller.abort(), this.timeout);\n\n") - buf.WriteString(" // Use provided signal or create new one\n") - buf.WriteString(" const signal = requestConfig.signal || controller.signal;\n\n") + buf.WriteString(" // Combine the caller's signal with the timeout signal; using the\n") + buf.WriteString(" // caller's alone would silently disable the timeout.\n") + buf.WriteString(" const signal = requestConfig.signal\n") + buf.WriteString(" ? (AbortSignal as any).any\n") + buf.WriteString(" ? (AbortSignal as any).any([requestConfig.signal, controller.signal])\n") + buf.WriteString(" : requestConfig.signal\n") + buf.WriteString(" : controller.signal;\n\n") buf.WriteString(" try {\n") buf.WriteString(" // Make fetch request\n") @@ -228,13 +248,7 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" const code = errorData.code || '';\n") buf.WriteString(" const details = errorData.details || errorData;\n\n") - buf.WriteString(" // This will be enhanced by error taxonomy generator\n") - buf.WriteString(" throw {\n") - buf.WriteString(" statusCode: response.status,\n") - buf.WriteString(" message,\n") - buf.WriteString(" code,\n") - buf.WriteString(" details,\n") - buf.WriteString(" };\n") + buf.WriteString(" throw new HTTPError(response.status, message, code, details);\n") buf.WriteString(" }\n\n") // Should retry method diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 72f35833..de8f3df8 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -105,3 +105,19 @@ func TestTypesQuoteNonIdentifierKeys(t *testing.T) { t.Errorf("should not have TS1128 errors:\n%s", strings.Join(bad, "\n")) } } + +func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { + code := NewFetchClientGenerator().GenerateBaseClient(baseSpec(), baseConfig()) + + if strings.Contains(code, "requestConfig.signal || controller.signal") { + t.Error("a caller-supplied signal must not replace the timeout signal") + } + + if !strings.Contains(code, "class HTTPError extends Error") { + t.Error("error responses must throw a real Error subclass") + } + + if !strings.Contains(code, "throw new HTTPError(") { + t.Error("handleErrorResponse must throw HTTPError") + } +} From dc615cb6a6a51a954449e408ed5a55db5c7d09cf Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 19:58:54 -0500 Subject: [PATCH 15/71] fix(client/typescript): forward abort signals manually when AbortSignal.any is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback must combine the caller's signal with the timeout signal, not yield the caller's signal alone — doing that silently disables the request timeout on any runtime without AbortSignal.any. --- .../generators/typescript/fetch_client.go | 28 +++++++++++++++++-- .../client/generators/typescript/gate_test.go | 12 ++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index 02efbefd..23029454 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -54,6 +54,30 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" retryableStatusCodes?: number[];\n") buf.WriteString("}\n\n") + // combineSignals helper: honours both the caller's signal and the timeout + // signal on every runtime, including ones without AbortSignal.any. + buf.WriteString("// Combines two abort signals so that either aborting the caller's\n") + buf.WriteString("// signal or the request timeout aborts the request. Falls back to\n") + buf.WriteString("// manual forwarding on runtimes without AbortSignal.any.\n") + buf.WriteString("const combineSignals = (a: AbortSignal, b: AbortSignal): AbortSignal => {\n") + buf.WriteString(" const anyFn = (AbortSignal as any).any;\n") + buf.WriteString(" if (typeof anyFn === 'function') {\n") + buf.WriteString(" return anyFn.call(AbortSignal, [a, b]);\n") + buf.WriteString(" }\n") + buf.WriteString(" // Manual fallback: forward whichever aborts first.\n") + buf.WriteString(" const merged = new AbortController();\n") + buf.WriteString(" const forwardAbort = (source: AbortSignal) => {\n") + buf.WriteString(" if (source.aborted) {\n") + buf.WriteString(" merged.abort((source as any).reason);\n") + buf.WriteString(" return;\n") + buf.WriteString(" }\n") + buf.WriteString(" source.addEventListener('abort', () => merged.abort((source as any).reason), { once: true });\n") + buf.WriteString(" };\n") + buf.WriteString(" forwardAbort(a);\n") + buf.WriteString(" forwardAbort(b);\n") + buf.WriteString(" return merged.signal;\n") + buf.WriteString("};\n\n") + // Interceptor interfaces if config.Interceptors { buf.WriteString("export interface RequestInterceptor {\n") @@ -169,9 +193,7 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // Combine the caller's signal with the timeout signal; using the\n") buf.WriteString(" // caller's alone would silently disable the timeout.\n") buf.WriteString(" const signal = requestConfig.signal\n") - buf.WriteString(" ? (AbortSignal as any).any\n") - buf.WriteString(" ? (AbortSignal as any).any([requestConfig.signal, controller.signal])\n") - buf.WriteString(" : requestConfig.signal\n") + buf.WriteString(" ? combineSignals(requestConfig.signal, controller.signal)\n") buf.WriteString(" : controller.signal;\n\n") buf.WriteString(" try {\n") diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index de8f3df8..97eb9970 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -120,4 +120,16 @@ func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { if !strings.Contains(code, "throw new HTTPError(") { t.Error("handleErrorResponse must throw HTTPError") } + + if strings.Contains(code, ": requestConfig.signal\n") { + t.Error("the AbortSignal.any-unavailable fallback must not yield the caller's signal alone; that silently disables the timeout") + } + + if !strings.Contains(code, "forwardAbort") { + t.Error("expected a manual signal-forwarding fallback (forwardAbort) for runtimes without AbortSignal.any") + } + + if strings.Count(code, "combineSignals") == 0 { + t.Error("expected a combineSignals helper") + } } From 8587005c9f60007ff9e1dfeb25555ba2e29d0b0e Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 20:05:49 -0500 Subject: [PATCH 16/71] fix(client/typescript): dispose fallback abort listeners to stop them leaking on reused signals --- .../generators/typescript/fetch_client.go | 37 ++++++++++++++----- .../client/generators/typescript/gate_test.go | 16 ++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index 23029454..b6367742 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -55,27 +55,41 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString("}\n\n") // combineSignals helper: honours both the caller's signal and the timeout - // signal on every runtime, including ones without AbortSignal.any. + // signal on every runtime, including ones without AbortSignal.any. Returns + // a disposable pair so the fallback's abort listeners can be removed once + // the request settles, instead of leaking on a long-lived caller signal. buf.WriteString("// Combines two abort signals so that either aborting the caller's\n") buf.WriteString("// signal or the request timeout aborts the request. Falls back to\n") - buf.WriteString("// manual forwarding on runtimes without AbortSignal.any.\n") - buf.WriteString("const combineSignals = (a: AbortSignal, b: AbortSignal): AbortSignal => {\n") + buf.WriteString("// manual forwarding on runtimes without AbortSignal.any. The caller\n") + buf.WriteString("// must call dispose() once the request settles so fallback listeners\n") + buf.WriteString("// don't accumulate on a reused, long-lived AbortController.\n") + buf.WriteString("const combineSignals = (a: AbortSignal, b: AbortSignal): { signal: AbortSignal; dispose: () => void } => {\n") buf.WriteString(" const anyFn = (AbortSignal as any).any;\n") buf.WriteString(" if (typeof anyFn === 'function') {\n") - buf.WriteString(" return anyFn.call(AbortSignal, [a, b]);\n") + buf.WriteString(" return { signal: anyFn.call(AbortSignal, [a, b]), dispose: () => {} };\n") buf.WriteString(" }\n") buf.WriteString(" // Manual fallback: forward whichever aborts first.\n") buf.WriteString(" const merged = new AbortController();\n") + buf.WriteString(" const cleanups: Array<() => void> = [];\n") + buf.WriteString(" const dispose = () => {\n") + buf.WriteString(" for (const c of cleanups) c();\n") + buf.WriteString(" cleanups.length = 0;\n") + buf.WriteString(" };\n") buf.WriteString(" const forwardAbort = (source: AbortSignal) => {\n") buf.WriteString(" if (source.aborted) {\n") buf.WriteString(" merged.abort((source as any).reason);\n") buf.WriteString(" return;\n") buf.WriteString(" }\n") - buf.WriteString(" source.addEventListener('abort', () => merged.abort((source as any).reason), { once: true });\n") + buf.WriteString(" const onAbort = () => {\n") + buf.WriteString(" merged.abort((source as any).reason);\n") + buf.WriteString(" dispose();\n") + buf.WriteString(" };\n") + buf.WriteString(" source.addEventListener('abort', onAbort);\n") + buf.WriteString(" cleanups.push(() => source.removeEventListener('abort', onAbort));\n") buf.WriteString(" };\n") buf.WriteString(" forwardAbort(a);\n") buf.WriteString(" forwardAbort(b);\n") - buf.WriteString(" return merged.signal;\n") + buf.WriteString(" return { signal: merged.signal, dispose };\n") buf.WriteString("};\n\n") // Interceptor interfaces @@ -192,9 +206,10 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // Combine the caller's signal with the timeout signal; using the\n") buf.WriteString(" // caller's alone would silently disable the timeout.\n") - buf.WriteString(" const signal = requestConfig.signal\n") + buf.WriteString(" const combined = requestConfig.signal\n") buf.WriteString(" ? combineSignals(requestConfig.signal, controller.signal)\n") - buf.WriteString(" : controller.signal;\n\n") + buf.WriteString(" : { signal: controller.signal, dispose: () => {} };\n") + buf.WriteString(" const signal = combined.signal;\n\n") buf.WriteString(" try {\n") buf.WriteString(" // Make fetch request\n") @@ -213,7 +228,8 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" }\n\n") } - buf.WriteString(" clearTimeout(timeoutId);\n\n") + buf.WriteString(" clearTimeout(timeoutId);\n") + buf.WriteString(" combined.dispose();\n\n") buf.WriteString(" // Handle non-OK responses\n") buf.WriteString(" if (!response.ok) {\n") @@ -233,7 +249,8 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" return await response.text() as any;\n") buf.WriteString(" } catch (error) {\n") - buf.WriteString(" clearTimeout(timeoutId);\n\n") + buf.WriteString(" clearTimeout(timeoutId);\n") + buf.WriteString(" combined.dispose();\n\n") // Apply error interceptors if config.Interceptors { diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 97eb9970..8765e67e 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -132,4 +132,20 @@ func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { if strings.Count(code, "combineSignals") == 0 { t.Error("expected a combineSignals helper") } + + if !strings.Contains(code, "dispose: () => void") { + t.Error("combineSignals must return a disposable pair, not a bare signal, so fallback listeners can be removed") + } + + if !strings.Contains(code, "combined.dispose()") { + t.Error("expected the caller to dispose of the combined signal wherever the timeout is cleared") + } + + if got := strings.Count(code, "combined.dispose()"); got < 2 { + t.Errorf("expected combined.dispose() on both the success and error exit paths, found %d occurrence(s)", got) + } + + if strings.Contains(code, "{ once: true }") { + t.Error("fallback abort listeners must be removed explicitly via dispose, not left to { once: true } which leaks on non-abort exits") + } } From 20ccca8d76987a598b17e0316fd08c3c5cf6b7c5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 20:16:45 -0500 Subject: [PATCH 17/71] fix(client/typescript): gate AuthConfig in the streaming generators --- .../client/generators/typescript/channels.go | 41 ++++++++++++------- .../generators/typescript/fixtures_test.go | 6 ++- .../client/generators/typescript/generator.go | 19 +++++++++ .../client/generators/typescript/presence.go | 40 +++++++++++------- .../client/generators/typescript/rooms.go | 41 ++++++++++++------- .../generators/typescript/streaming_client.go | 30 ++++++++++---- .../client/generators/typescript/typing.go | 40 +++++++++++------- 7 files changed, 149 insertions(+), 68 deletions(-) diff --git a/internal/client/generators/typescript/channels.go b/internal/client/generators/typescript/channels.go index 8a16d9fd..35c1cb15 100644 --- a/internal/client/generators/typescript/channels.go +++ b/internal/client/generators/typescript/channels.go @@ -100,11 +100,12 @@ func (c *ChannelsGenerator) generatePolyfillSetup() string { } // generateImports generates import statements for the channel client. -func (c *ChannelsGenerator) generateImports(_ client.GeneratorConfig) string { +func (c *ChannelsGenerator) generateImports(config client.GeneratorConfig) string { var buf strings.Builder buf.WriteString("// Channel client for pub/sub messaging\n\n") - buf.WriteString("import { ConnectionState, AuthConfig } from './types';\n\n") + buf.WriteString(tsImportLine(config, "ConnectionState", "AuthConfig")) + buf.WriteString("\n\n") return buf.String() } @@ -158,8 +159,12 @@ func (c *ChannelsGenerator) generateTypes(config client.GeneratorConfig) string buf.WriteString("export interface ChannelClientConfig {\n") buf.WriteString(" /** Base URL for the WebSocket connection */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: AuthConfig;\n") + + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: AuthConfig;\n") + } + buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n") buf.WriteString(" connectionTimeout?: number;\n") @@ -291,11 +296,13 @@ func (c *ChannelsGenerator) generateChannelClient(spec *client.APISpec, config c buf.WriteString(fmt.Sprintf(" let wsURL = this.config.baseURL.replace(/^http/, 'ws') + '%s';\n\n", wsPath)) // Add auth to URL - buf.WriteString(" // Add auth to URL for browser compatibility\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") - buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") - buf.WriteString(" }\n\n") + if config.IncludeAuth { + buf.WriteString(" // Add auth to URL for browser compatibility\n") + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") + buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") + buf.WriteString(" }\n\n") + } // Setup connection timeout buf.WriteString(" // Setup connection timeout\n") @@ -316,12 +323,16 @@ func (c *ChannelsGenerator) generateChannelClient(spec *client.APISpec, config c buf.WriteString(" this.ws = new WS(wsURL);\n") buf.WriteString(" } else {\n") buf.WriteString(" const headers: Record = {};\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.config.auth?.apiKey) {\n") - buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") - buf.WriteString(" }\n") + + if config.IncludeAuth { + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.config.auth?.apiKey) {\n") + buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") + buf.WriteString(" }\n") + } + buf.WriteString(" this.ws = new (WS as any)(wsURL, { headers }) as WebSocket;\n") buf.WriteString(" }\n\n") diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 57bf7efa..4fdb7ec4 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -10,7 +10,7 @@ import ( ) func TestGateFixturesCoverKnownDefects(t *testing.T) { - want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming"} + want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming"} got := make(map[string]bool) for _, f := range gateFixtures() { @@ -99,12 +99,16 @@ func gateFixtures() []gateFixture { noStreaming := baseConfig() noStreaming.IncludeStreaming = false + noAuthStreaming := baseConfig() + noAuthStreaming.IncludeAuth = false + return []gateFixture{ {Name: "default", Spec: baseSpec(), Config: baseConfig()}, {Name: "apiname", Spec: baseSpec(), Config: apiName}, {Name: "odd-keys", Spec: oddKeys, Config: baseConfig()}, {Name: "with-auth", Spec: withAuth, Config: baseConfig()}, {Name: "no-streaming", Spec: baseSpec(), Config: noStreaming}, + {Name: "no-auth-streaming", Spec: baseSpec(), Config: noAuthStreaming}, } } diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 27c31e07..f54ac6b5 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -12,6 +12,25 @@ import ( "github.com/xraph/forge/internal/client/generators" ) +// tsImportLine builds an `import { ... } from './types';` line from names, +// omitting "AuthConfig" when auth is disabled while preserving every other +// listed name and their order. Shared by the streaming generators (rooms, +// presence, typing, channels, streaming_client), which each import a +// different, sometimes long, set of names from './types'. +func tsImportLine(config client.GeneratorConfig, names ...string) string { + kept := make([]string, 0, len(names)) + + for _, name := range names { + if name == "AuthConfig" && !config.IncludeAuth { + continue + } + + kept = append(kept, name) + } + + return fmt.Sprintf("import { %s } from './types';", strings.Join(kept, ", ")) +} + // sortedKeys returns the keys of m in ascending order. Generated output must be // byte-identical across runs, and Go randomizes map iteration. func sortedKeys[V any](m map[string]V) []string { diff --git a/internal/client/generators/typescript/presence.go b/internal/client/generators/typescript/presence.go index 999f1f75..a258b77d 100644 --- a/internal/client/generators/typescript/presence.go +++ b/internal/client/generators/typescript/presence.go @@ -29,13 +29,14 @@ func (p *PresenceGenerator) Generate(spec *client.APISpec, config client.Generat } // generateImports generates import statements for the presence client. -func (p *PresenceGenerator) generateImports(_ client.GeneratorConfig) string { +func (p *PresenceGenerator) generateImports(config client.GeneratorConfig) string { var buf strings.Builder buf.WriteString(p.generatePolyfillSetup()) buf.WriteString("\n") buf.WriteString("// Presence client for tracking user online status\n\n") - buf.WriteString("import { ConnectionState, AuthConfig, UserPresence } from './types';\n\n") + buf.WriteString(tsImportLine(config, "ConnectionState", "AuthConfig", "UserPresence")) + buf.WriteString("\n\n") return buf.String() } @@ -141,8 +142,11 @@ func (p *PresenceGenerator) generateTypes(spec *client.APISpec, config client.Ge buf.WriteString("export interface PresenceClientConfig {\n") buf.WriteString(" /** Base URL for the WebSocket connection */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: AuthConfig;\n") + + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: AuthConfig;\n") + } buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n") buf.WriteString(" connectionTimeout?: number;\n") @@ -266,11 +270,13 @@ func (p *PresenceGenerator) generatePresenceClient(spec *client.APISpec, config buf.WriteString(fmt.Sprintf(" let wsURL = this.config.baseURL.replace(/^http/, 'ws') + '%s';\n\n", wsPath)) - buf.WriteString(" // Add auth to URL for browser compatibility\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") - buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") - buf.WriteString(" }\n\n") + if config.IncludeAuth { + buf.WriteString(" // Add auth to URL for browser compatibility\n") + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") + buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") + buf.WriteString(" }\n\n") + } buf.WriteString(" // Setup connection timeout\n") buf.WriteString(" this.connectionTimeoutId = setTimeout(() => {\n") @@ -290,12 +296,16 @@ func (p *PresenceGenerator) generatePresenceClient(spec *client.APISpec, config buf.WriteString(" this.ws = new WS(wsURL);\n") buf.WriteString(" } else {\n") buf.WriteString(" const headers: Record = {};\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.config.auth?.apiKey) {\n") - buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") - buf.WriteString(" }\n") + + if config.IncludeAuth { + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.config.auth?.apiKey) {\n") + buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") + buf.WriteString(" }\n") + } + buf.WriteString(" this.ws = new (WS as any)(wsURL, { headers }) as WebSocket;\n") buf.WriteString(" }\n\n") diff --git a/internal/client/generators/typescript/rooms.go b/internal/client/generators/typescript/rooms.go index 201cc60a..4902fcf3 100644 --- a/internal/client/generators/typescript/rooms.go +++ b/internal/client/generators/typescript/rooms.go @@ -100,11 +100,12 @@ func (r *RoomsGenerator) generatePolyfillSetup() string { } // generateImports generates import statements for the room client. -func (r *RoomsGenerator) generateImports(_ *client.APISpec, _ client.GeneratorConfig) string { +func (r *RoomsGenerator) generateImports(_ *client.APISpec, config client.GeneratorConfig) string { var buf strings.Builder buf.WriteString("// Room client for managing chat rooms\n\n") - buf.WriteString("import { ConnectionState, AuthConfig, Message, Member, Room, RoomOptions, HistoryQuery } from './types';\n\n") + buf.WriteString(tsImportLine(config, "ConnectionState", "AuthConfig", "Message", "Member", "Room", "RoomOptions", "HistoryQuery")) + buf.WriteString("\n\n") return buf.String() } @@ -145,8 +146,12 @@ func (r *RoomsGenerator) generateTypes(_ *client.APISpec, config client.Generato buf.WriteString("export interface RoomClientConfig {\n") buf.WriteString(" /** Base URL for the WebSocket connection */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: AuthConfig;\n") + + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: AuthConfig;\n") + } + buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n") buf.WriteString(" connectionTimeout?: number;\n") buf.WriteString(" /** Request timeout in ms (default: 10000) */\n") @@ -295,11 +300,13 @@ func (r *RoomsGenerator) generateRoomClient(spec *client.APISpec, config client. buf.WriteString(fmt.Sprintf(" let wsURL = this.config.baseURL.replace(/^http/, 'ws') + '%s';\n\n", wsPath)) // Add auth to URL - buf.WriteString(" // Add auth to URL for browser compatibility\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") - buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") - buf.WriteString(" }\n\n") + if config.IncludeAuth { + buf.WriteString(" // Add auth to URL for browser compatibility\n") + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") + buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") + buf.WriteString(" }\n\n") + } // Setup connection timeout buf.WriteString(" // Setup connection timeout\n") @@ -321,12 +328,16 @@ func (r *RoomsGenerator) generateRoomClient(spec *client.APISpec, config client. buf.WriteString(" } else {\n") buf.WriteString(" // Node.js: can pass headers\n") buf.WriteString(" const headers: Record = {};\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.config.auth?.apiKey) {\n") - buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") - buf.WriteString(" }\n") + + if config.IncludeAuth { + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.config.auth?.apiKey) {\n") + buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") + buf.WriteString(" }\n") + } + buf.WriteString(" this.ws = new (WS as any)(wsURL, { headers }) as WebSocket;\n") buf.WriteString(" }\n\n") diff --git a/internal/client/generators/typescript/streaming_client.go b/internal/client/generators/typescript/streaming_client.go index 66cd62c4..cca8cd88 100644 --- a/internal/client/generators/typescript/streaming_client.go +++ b/internal/client/generators/typescript/streaming_client.go @@ -32,7 +32,8 @@ func (s *StreamingClientGenerator) generateImports(spec *client.APISpec, config var buf strings.Builder buf.WriteString("// Unified streaming client composing all streaming features\n\n") - buf.WriteString("import { ConnectionState, AuthConfig } from './types';\n") + buf.WriteString(tsImportLine(config, "ConnectionState", "AuthConfig")) + buf.WriteString("\n") // StreamingClient always extends EventEmitter and uses emit/on, so the // import must be present regardless of the StateManagement feature flag. @@ -69,8 +70,11 @@ func (s *StreamingClientGenerator) generateTypes(spec *client.APISpec, config cl buf.WriteString("export interface StreamingClientConfig {\n") buf.WriteString(" /** Base URL for WebSocket connections */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: AuthConfig;\n") + + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: AuthConfig;\n") + } if config.Features.Reconnection { buf.WriteString(" /** Maximum reconnection attempts */\n") @@ -252,7 +256,10 @@ func (s *StreamingClientGenerator) generateStreamingClient(spec *client.APISpec, buf.WriteString(" // Initialize room client\n") buf.WriteString(" this.rooms = new RoomClient({\n") buf.WriteString(" baseURL: config.baseURL,\n") - buf.WriteString(" auth: config.auth,\n") + + if config.IncludeAuth { + buf.WriteString(" auth: config.auth,\n") + } if config.Features.Reconnection { buf.WriteString(" maxReconnectAttempts: config.maxReconnectAttempts,\n") @@ -268,7 +275,10 @@ func (s *StreamingClientGenerator) generateStreamingClient(spec *client.APISpec, buf.WriteString(" // Initialize presence client\n") buf.WriteString(" this.presence = new PresenceClient({\n") buf.WriteString(" baseURL: config.baseURL,\n") - buf.WriteString(" auth: config.auth,\n") + + if config.IncludeAuth { + buf.WriteString(" auth: config.auth,\n") + } if config.Features.Reconnection { buf.WriteString(" maxReconnectAttempts: config.maxReconnectAttempts,\n") @@ -284,7 +294,10 @@ func (s *StreamingClientGenerator) generateStreamingClient(spec *client.APISpec, buf.WriteString(" // Initialize typing client\n") buf.WriteString(" this.typing = new TypingClient({\n") buf.WriteString(" baseURL: config.baseURL,\n") - buf.WriteString(" auth: config.auth,\n") + + if config.IncludeAuth { + buf.WriteString(" auth: config.auth,\n") + } if config.Features.Reconnection { buf.WriteString(" maxReconnectAttempts: config.maxReconnectAttempts,\n") @@ -300,7 +313,10 @@ func (s *StreamingClientGenerator) generateStreamingClient(spec *client.APISpec, buf.WriteString(" // Initialize channel client\n") buf.WriteString(" this.channels = new ChannelClient({\n") buf.WriteString(" baseURL: config.baseURL,\n") - buf.WriteString(" auth: config.auth,\n") + + if config.IncludeAuth { + buf.WriteString(" auth: config.auth,\n") + } if config.Features.Reconnection { buf.WriteString(" maxReconnectAttempts: config.maxReconnectAttempts,\n") diff --git a/internal/client/generators/typescript/typing.go b/internal/client/generators/typescript/typing.go index 001483ee..425badf7 100644 --- a/internal/client/generators/typescript/typing.go +++ b/internal/client/generators/typescript/typing.go @@ -29,13 +29,14 @@ func (t *TypingGenerator) Generate(spec *client.APISpec, config client.Generator } // generateImports generates import statements for the typing client. -func (t *TypingGenerator) generateImports(_ client.GeneratorConfig) string { +func (t *TypingGenerator) generateImports(config client.GeneratorConfig) string { var buf strings.Builder buf.WriteString(t.generatePolyfillSetup()) buf.WriteString("\n") buf.WriteString("// Typing indicator client for real-time typing status\n\n") - buf.WriteString("import { ConnectionState, AuthConfig } from './types';\n\n") + buf.WriteString(tsImportLine(config, "ConnectionState", "AuthConfig")) + buf.WriteString("\n\n") return buf.String() } @@ -132,8 +133,11 @@ func (t *TypingGenerator) generateTypes(_ *client.APISpec, config client.Generat buf.WriteString("export interface TypingClientConfig {\n") buf.WriteString(" /** Base URL for the WebSocket connection */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: AuthConfig;\n") + + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: AuthConfig;\n") + } buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n") buf.WriteString(" connectionTimeout?: number;\n") @@ -261,11 +265,13 @@ func (t *TypingGenerator) generateTypingClient(spec *client.APISpec, config clie buf.WriteString(fmt.Sprintf(" let wsURL = this.config.baseURL.replace(/^http/, 'ws') + '%s';\n\n", wsPath)) - buf.WriteString(" // Add auth to URL for browser compatibility\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") - buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") - buf.WriteString(" }\n\n") + if config.IncludeAuth { + buf.WriteString(" // Add auth to URL for browser compatibility\n") + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n") + buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") + buf.WriteString(" }\n\n") + } buf.WriteString(" // Setup connection timeout\n") buf.WriteString(" this.connectionTimeoutId = setTimeout(() => {\n") @@ -285,12 +291,16 @@ func (t *TypingGenerator) generateTypingClient(spec *client.APISpec, config clie buf.WriteString(" this.ws = new WS(wsURL);\n") buf.WriteString(" } else {\n") buf.WriteString(" const headers: Record = {};\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.config.auth?.apiKey) {\n") - buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") - buf.WriteString(" }\n") + + if config.IncludeAuth { + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.config.auth?.apiKey) {\n") + buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") + buf.WriteString(" }\n") + } + buf.WriteString(" this.ws = new (WS as any)(wsURL, { headers }) as WebSocket;\n") buf.WriteString(" }\n\n") From b84ed6b2712bb5bc753f7ed2e84ef4cf6736ebd4 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 20:27:55 -0500 Subject: [PATCH 18/71] test(client/typescript): cover websocket and sse generation in the gate --- .../generators/typescript/fixtures_test.go | 29 ++++++++++++++++++- .../client/generators/typescript/gate_test.go | 27 +++++++++++++++++ internal/client/generators/typescript/sse.go | 2 +- .../client/generators/typescript/websocket.go | 2 +- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 4fdb7ec4..57024371 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -10,7 +10,7 @@ import ( ) func TestGateFixturesCoverKnownDefects(t *testing.T) { - want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming"} + want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse"} got := make(map[string]bool) for _, f := range gateFixtures() { @@ -102,6 +102,32 @@ func gateFixtures() []gateFixture { noAuthStreaming := baseConfig() noAuthStreaming.IncludeAuth = false + wsSSE := baseSpec() + wsSSE.WebSockets = []client.WebSocketEndpoint{ + { + ID: "chat", + Path: "/ws/chat", + Summary: "Chat room WebSocket", + SendSchema: &client.Schema{ + Ref: "#/components/schemas/User", + }, + ReceiveSchema: &client.Schema{ + Ref: "#/components/schemas/User", + }, + }, + } + wsSSE.SSEs = []client.SSEEndpoint{ + { + ID: "notifications", + Path: "/sse/notifications", + Summary: "Notification stream", + EventSchemas: map[string]*client.Schema{ + "created": {Ref: "#/components/schemas/User"}, + "updated": {Ref: "#/components/schemas/User"}, + }, + }, + } + return []gateFixture{ {Name: "default", Spec: baseSpec(), Config: baseConfig()}, {Name: "apiname", Spec: baseSpec(), Config: apiName}, @@ -109,6 +135,7 @@ func gateFixtures() []gateFixture { {Name: "with-auth", Spec: withAuth, Config: baseConfig()}, {Name: "no-streaming", Spec: baseSpec(), Config: noStreaming}, {Name: "no-auth-streaming", Spec: baseSpec(), Config: noAuthStreaming}, + {Name: "ws-sse", Spec: wsSSE, Config: baseConfig()}, } } diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 8765e67e..74f847e1 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -106,6 +106,33 @@ func TestTypesQuoteNonIdentifierKeys(t *testing.T) { } } +func TestWSSSEFixtureEmitsStreamingFiles(t *testing.T) { + var fixture gateFixture + + for _, f := range gateFixtures() { + if f.Name == "ws-sse" { + fixture = f + } + } + + if fixture.Name == "" { + t.Fatal("ws-sse fixture not found in gateFixtures()") + } + + out, err := NewGenerator().Generate(context.Background(), fixture.Spec, fixture.Config) + if err != nil { + t.Fatal(err) + } + + if _, ok := out.Files["src/websocket.ts"]; !ok { + t.Error("expected src/websocket.ts to be emitted by the ws-sse fixture") + } + + if _, ok := out.Files["src/sse.ts"]; !ok { + t.Error("expected src/sse.ts to be emitted by the ws-sse fixture") + } +} + func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { code := NewFetchClientGenerator().GenerateBaseClient(baseSpec(), baseConfig()) diff --git a/internal/client/generators/typescript/sse.go b/internal/client/generators/typescript/sse.go index 7c00abe8..5388dcf9 100644 --- a/internal/client/generators/typescript/sse.go +++ b/internal/client/generators/typescript/sse.go @@ -279,7 +279,7 @@ func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.AP buf.WriteString(" }\n") } - buf.WriteString(" this.eventSource = new (ES as any)(url, { headers });\n") + buf.WriteString(" this.eventSource = new (ES as any)(url, { headers }) as EventSource;\n") buf.WriteString(" }\n\n") // Event handlers diff --git a/internal/client/generators/typescript/websocket.go b/internal/client/generators/typescript/websocket.go index a2a159c9..ffe6d18f 100644 --- a/internal/client/generators/typescript/websocket.go +++ b/internal/client/generators/typescript/websocket.go @@ -287,7 +287,7 @@ func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint buf.WriteString(" if (this.config.auth?.apiKey) {\n") buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") buf.WriteString(" }\n") - buf.WriteString(" this.ws = new (WS as any)(url, { headers });\n") + buf.WriteString(" this.ws = new (WS as any)(url, { headers }) as WebSocket;\n") buf.WriteString(" }\n\n") // Use standard event handlers that work in both browser and Node.js From 97d12567df373e776662d21a0b1730f20dcd9316 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 20:33:19 -0500 Subject: [PATCH 19/71] fix(client/typescript): gate auth in the generated example test --- .../client/generators/typescript/gate_test.go | 59 +++++++++++++++++++ .../client/generators/typescript/testing.go | 31 ++++++---- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 74f847e1..019083d8 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -176,3 +176,62 @@ func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { t.Error("fallback abort listeners must be removed explicitly via dispose, not left to { once: true } which leaks on non-abort exits") } } + +func TestGenerateExampleTestGatesAuthWhenIncludeAuthFalse(t *testing.T) { + tg := NewTestingGenerator() + + // Test with IncludeAuth: false + cfg := baseConfig() + cfg.IncludeAuth = false + + code := tg.GenerateExampleTest(baseSpec(), cfg) + + // When auth is off, should not contain auth: property + if strings.Contains(code, "auth:") { + t.Error("GenerateExampleTest should not emit auth: when IncludeAuth is false") + } + + // Should still contain at least one it(...) block to be a valid test suite + if !strings.Contains(code, "it('should create a client instance'") { + t.Error("GenerateExampleTest should still contain first it(...) block when auth is off") + } + + // Test with IncludeAuth: true + cfg.IncludeAuth = true + + code = tg.GenerateExampleTest(baseSpec(), cfg) + + // When auth is on, should contain auth: property + if !strings.Contains(code, "auth:") { + t.Error("GenerateExampleTest should emit auth: when IncludeAuth is true") + } + + if !strings.Contains(code, "it('should set auth headers'") { + t.Error("GenerateExampleTest should contain 'should set auth headers' it block when IncludeAuth is true") + } +} + +func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { + tg := NewTestingGenerator() + + // Test with IncludeAuth: false + cfg := baseConfig() + cfg.IncludeAuth = false + + code := tg.GenerateTestUtils(baseSpec(), cfg) + + // When auth is off, should not contain auth: property in createMockClient + if strings.Contains(code, "auth:") { + t.Error("GenerateTestUtils should not emit auth: when IncludeAuth is false") + } + + // Test with IncludeAuth: true + cfg.IncludeAuth = true + + code = tg.GenerateTestUtils(baseSpec(), cfg) + + // When auth is on, should contain auth: property + if !strings.Contains(code, "auth:") { + t.Error("GenerateTestUtils should emit auth: when IncludeAuth is true") + } +} diff --git a/internal/client/generators/typescript/testing.go b/internal/client/generators/typescript/testing.go index f20f9ba4..a4e70022 100644 --- a/internal/client/generators/typescript/testing.go +++ b/internal/client/generators/typescript/testing.go @@ -60,15 +60,18 @@ func (t *TestingGenerator) GenerateExampleTest(spec *client.APISpec, config clie buf.WriteString(" expect(client).toBeDefined();\n") buf.WriteString(" });\n\n") - buf.WriteString(" it('should set auth headers', () => {\n") - buf.WriteString(" const client = new " + config.APIName + "({\n") - buf.WriteString(" baseURL: 'https://api.example.com',\n") - buf.WriteString(" auth: {\n") - buf.WriteString(" bearerToken: 'test-token',\n") - buf.WriteString(" },\n") - buf.WriteString(" });\n\n") - buf.WriteString(" expect(client).toBeDefined();\n") - buf.WriteString(" });\n") + if config.IncludeAuth { + buf.WriteString(" it('should set auth headers', () => {\n") + buf.WriteString(" const client = new " + config.APIName + "({\n") + buf.WriteString(" baseURL: 'https://api.example.com',\n") + buf.WriteString(" auth: {\n") + buf.WriteString(" bearerToken: 'test-token',\n") + buf.WriteString(" },\n") + buf.WriteString(" });\n\n") + buf.WriteString(" expect(client).toBeDefined();\n") + buf.WriteString(" });\n") + } + buf.WriteString("});\n") return buf.String() @@ -113,9 +116,13 @@ func (t *TestingGenerator) GenerateTestUtils(spec *client.APISpec, config client buf.WriteString("export function createMockClient(baseURL: string = 'https://api.test.com') {\n") buf.WriteString(" return {\n") buf.WriteString(" baseURL,\n") - buf.WriteString(" auth: {\n") - buf.WriteString(" bearerToken: 'test-token',\n") - buf.WriteString(" },\n") + + if config.IncludeAuth { + buf.WriteString(" auth: {\n") + buf.WriteString(" bearerToken: 'test-token',\n") + buf.WriteString(" },\n") + } + buf.WriteString(" };\n") buf.WriteString("}\n") From 3357b4fab51c893b535f0ba6be4af3701a28abc0 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 20:40:15 -0500 Subject: [PATCH 20/71] test(client/typescript): require generated clients to type-check in CI --- .github/workflows/go.yml | 8 ++++++++ .../client/generators/typescript/gate_test.go | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 95eeadcc..9308ed9e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -53,6 +53,14 @@ jobs: - name: Build run: go build -v ./... + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install TypeScript + run: npm install -g typescript@5.8.2 + - name: Run tests shell: bash run: | diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 019083d8..181b3057 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -211,6 +211,23 @@ func TestGenerateExampleTestGatesAuthWhenIncludeAuthFalse(t *testing.T) { } } +// TestGeneratedClientsTypeCheck is the full gate: every fixture in the corpus +// must generate a TypeScript client that type-checks with zero tsc errors. +// Tasks 1-12 each asserted the absence of one specific error class; this test +// asserts the absence of all of them, across every fixture, and is what CI +// runs to keep the corpus clean going forward. +func TestGeneratedClientsTypeCheck(t *testing.T) { + for _, f := range gateFixtures() { + t.Run(f.Name, func(t *testing.T) { + t.Parallel() + + if errs := typeCheck(t, generateTo(t, f)); len(errs) > 0 { + t.Errorf("%d type error(s):\n%s", len(errs), strings.Join(errs, "\n")) + } + }) + } +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() From 865c6cf91de4a5f4df3120d522e4b28e1d14a268 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 21:00:08 -0500 Subject: [PATCH 21/71] fix(client/typescript): gate AuthConfig in websocket.go and sse.go generators Two of the eight generators emitted `auth?: types.AuthConfig;` and read `this.config.auth` unconditionally, even when config.IncludeAuth is false. Since the type generator already gates AuthConfig out of types.ts when auth is disabled, this produced a non-compiling client (TS2694) whenever a spec has WebSocket or SSE endpoints and --no-auth is passed. All six other generators (rooms, presence, typing, channels, streaming_client, testing) were already gated; this closes the last two. The fixture corpus covered the auth axis and the WS/SSE axis separately but never crossed them, which is why the gate never caught this. Added a no-auth-ws-sse fixture crossing both axes, confirmed it reproduces the two TS2694 errors before the fix, and confirmed all 8 fixtures type-check clean after. Also folds in two related review findings: - streaming_client.go's JSDoc @example still showed `auth: { bearerToken }` regardless of config.IncludeAuth, advertising an option that doesn't exist on StreamingClientConfig with auth off. Gated the example line. - rest.go's tsPropertyKey had a dead `json.Marshal` error branch (Marshal never errors on a Go string). Removed it; behavior is unchanged for every input. Verified auth-ON output is byte-identical to eb67e91 across all six auth-enabled fixtures (diff -rq, zero differences). Verified auth-OFF generated websocket.ts has zero references to auth/AuthConfig; sse.ts has one inert comment only. --- .../generators/typescript/fixtures_test.go | 40 ++++++++++++------ internal/client/generators/typescript/rest.go | 7 ++-- internal/client/generators/typescript/sse.go | 38 ++++++++++------- .../generators/typescript/streaming_client.go | 6 ++- .../client/generators/typescript/websocket.go | 41 ++++++++++++------- 5 files changed, 86 insertions(+), 46 deletions(-) diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 57024371..25ec4da2 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -10,7 +10,7 @@ import ( ) func TestGateFixturesCoverKnownDefects(t *testing.T) { - want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse"} + want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse", "no-auth-ws-sse"} got := make(map[string]bool) for _, f := range gateFixtures() { @@ -102,8 +102,30 @@ func gateFixtures() []gateFixture { noAuthStreaming := baseConfig() noAuthStreaming.IncludeAuth = false - wsSSE := baseSpec() - wsSSE.WebSockets = []client.WebSocketEndpoint{ + noAuthWsSSE := baseConfig() + noAuthWsSSE.IncludeAuth = false + + return []gateFixture{ + {Name: "default", Spec: baseSpec(), Config: baseConfig()}, + {Name: "apiname", Spec: baseSpec(), Config: apiName}, + {Name: "odd-keys", Spec: oddKeys, Config: baseConfig()}, + {Name: "with-auth", Spec: withAuth, Config: baseConfig()}, + {Name: "no-streaming", Spec: baseSpec(), Config: noStreaming}, + {Name: "no-auth-streaming", Spec: baseSpec(), Config: noAuthStreaming}, + {Name: "ws-sse", Spec: wsSSESpec(), Config: baseConfig()}, + // Crosses the auth axis with the WS/SSE axis: neither "no-auth-streaming" + // nor "ws-sse" alone exercises AuthConfig gating in websocket.go/sse.go, + // which is exactly the gap that let AuthConfig ship ungated there. + {Name: "no-auth-ws-sse", Spec: wsSSESpec(), Config: noAuthWsSSE}, + } +} + +// wsSSESpec returns a fresh spec with a WebSocket and an SSE endpoint, built +// from baseSpec(). Called once per fixture that needs it so fixtures never +// share (and risk mutating) the same *client.APISpec. +func wsSSESpec() *client.APISpec { + spec := baseSpec() + spec.WebSockets = []client.WebSocketEndpoint{ { ID: "chat", Path: "/ws/chat", @@ -116,7 +138,7 @@ func gateFixtures() []gateFixture { }, }, } - wsSSE.SSEs = []client.SSEEndpoint{ + spec.SSEs = []client.SSEEndpoint{ { ID: "notifications", Path: "/sse/notifications", @@ -128,15 +150,7 @@ func gateFixtures() []gateFixture { }, } - return []gateFixture{ - {Name: "default", Spec: baseSpec(), Config: baseConfig()}, - {Name: "apiname", Spec: baseSpec(), Config: apiName}, - {Name: "odd-keys", Spec: oddKeys, Config: baseConfig()}, - {Name: "with-auth", Spec: withAuth, Config: baseConfig()}, - {Name: "no-streaming", Spec: baseSpec(), Config: noStreaming}, - {Name: "no-auth-streaming", Spec: baseSpec(), Config: noAuthStreaming}, - {Name: "ws-sse", Spec: wsSSE, Config: baseConfig()}, - } + return spec } func generateTo(t *testing.T, f gateFixture) string { diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 75fa5da3..8f8a3153 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -176,10 +176,9 @@ func tsPropertyKey(name string) string { return name } - b, err := json.Marshal(name) - if err != nil { - return "'" + name + "'" // unreachable for strings; keep the old shape as a fallback - } + // json.Marshal never errors on a Go string: strings are always valid + // UTF-8-marshalable values (invalid UTF-8 bytes are replaced, not rejected). + b, _ := json.Marshal(name) return string(b) } diff --git a/internal/client/generators/typescript/sse.go b/internal/client/generators/typescript/sse.go index 5388dcf9..45bc9f3e 100644 --- a/internal/client/generators/typescript/sse.go +++ b/internal/client/generators/typescript/sse.go @@ -120,8 +120,12 @@ func (s *SSEGenerator) generateBaseTypes(config client.GeneratorConfig) string { buf.WriteString("export interface SSEClientConfig {\n") buf.WriteString(" /** Base URL for SSE connection */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: types.AuthConfig;\n") + + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: types.AuthConfig;\n") + } + buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n") buf.WriteString(" connectionTimeout?: number;\n") @@ -221,12 +225,15 @@ func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.AP buf.WriteString(" // In browser, add auth as query params since EventSource doesn't support custom headers\n") buf.WriteString(" if (isBrowser) {\n") buf.WriteString(" const params = new URLSearchParams();\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" params.set('token', this.config.auth.bearerToken);\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.config.auth?.apiKey) {\n") - buf.WriteString(" params.set('apiKey', this.config.auth.apiKey);\n") - buf.WriteString(" }\n") + + if config.IncludeAuth { + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" params.set('token', this.config.auth.bearerToken);\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.config.auth?.apiKey) {\n") + buf.WriteString(" params.set('apiKey', this.config.auth.apiKey);\n") + buf.WriteString(" }\n") + } if config.Features.Reconnection { buf.WriteString(" if (this.lastEventId) {\n") @@ -266,12 +273,15 @@ func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.AP buf.WriteString(" 'Accept': 'text/event-stream',\n") buf.WriteString(" 'Cache-Control': 'no-cache',\n") buf.WriteString(" };\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.config.auth?.apiKey) {\n") - buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") - buf.WriteString(" }\n") + + if config.IncludeAuth { + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.config.auth?.apiKey) {\n") + buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") + buf.WriteString(" }\n") + } if config.Features.Reconnection { buf.WriteString(" if (this.lastEventId) {\n") diff --git a/internal/client/generators/typescript/streaming_client.go b/internal/client/generators/typescript/streaming_client.go index cca8cd88..96e027fb 100644 --- a/internal/client/generators/typescript/streaming_client.go +++ b/internal/client/generators/typescript/streaming_client.go @@ -172,7 +172,11 @@ func (s *StreamingClientGenerator) generateStreamingClient(spec *client.APISpec, buf.WriteString(" * ```typescript\n") buf.WriteString(" * const streaming = new StreamingClient({\n") buf.WriteString(" * baseURL: 'ws://localhost:8080',\n") - buf.WriteString(" * auth: { bearerToken: 'my-token' },\n") + + if config.IncludeAuth { + buf.WriteString(" * auth: { bearerToken: 'my-token' },\n") + } + buf.WriteString(" * });\n") buf.WriteString(" * \n") buf.WriteString(" * await streaming.connect();\n") diff --git a/internal/client/generators/typescript/websocket.go b/internal/client/generators/typescript/websocket.go index ffe6d18f..393f2dcd 100644 --- a/internal/client/generators/typescript/websocket.go +++ b/internal/client/generators/typescript/websocket.go @@ -128,8 +128,12 @@ func (w *WebSocketGenerator) generateBaseTypes(config client.GeneratorConfig) st buf.WriteString("export interface WebSocketClientConfig {\n") buf.WriteString(" /** Base URL for WebSocket connection */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: types.AuthConfig;\n") + + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: types.AuthConfig;\n") + } + buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n") buf.WriteString(" connectionTimeout?: number;\n") buf.WriteString(" /** Request timeout in ms (default: 10000) */\n") @@ -268,12 +272,17 @@ func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint buf.WriteString(" try {\n") buf.WriteString(" const WS = getWebSocket();\n") buf.WriteString(" \n") - buf.WriteString(" // Build URL with auth if needed\n") - buf.WriteString(" let url = wsURL;\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" const separator = url.includes('?') ? '&' : '?';\n") - buf.WriteString(" url += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") - buf.WriteString(" }\n\n") + + if config.IncludeAuth { + buf.WriteString(" // Build URL with auth if needed\n") + buf.WriteString(" let url = wsURL;\n") + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" const separator = url.includes('?') ? '&' : '?';\n") + buf.WriteString(" url += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") + buf.WriteString(" }\n\n") + } else { + buf.WriteString(" const url = wsURL;\n\n") + } // Node.js WebSocket supports headers, browser doesn't buf.WriteString(" if (isBrowser) {\n") @@ -281,12 +290,16 @@ func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint buf.WriteString(" } else {\n") buf.WriteString(" // Node.js: can pass headers\n") buf.WriteString(" const headers: Record = {};\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") - buf.WriteString(" }\n") - buf.WriteString(" if (this.config.auth?.apiKey) {\n") - buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") - buf.WriteString(" }\n") + + if config.IncludeAuth { + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n") + buf.WriteString(" }\n") + buf.WriteString(" if (this.config.auth?.apiKey) {\n") + buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n") + buf.WriteString(" }\n") + } + buf.WriteString(" this.ws = new (WS as any)(url, { headers }) as WebSocket;\n") buf.WriteString(" }\n\n") From 1f5766178937c7b48acf55b1a1e1d36b57eff21b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 23:53:13 -0500 Subject: [PATCH 22/71] fix(client/typescript): fail generation on schema names reserved by streaming types --- .../client/generators/typescript/gate_test.go | 45 ++++++++++++++ .../client/generators/typescript/generator.go | 58 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 181b3057..51faef87 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -4,6 +4,10 @@ import ( "context" "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xraph/forge/internal/client" ) // errorsMentioning returns the subset of errs containing needle. @@ -228,6 +232,47 @@ func TestGeneratedClientsTypeCheck(t *testing.T) { } } +// TestSchemaNameCollidesWithStreamingType asserts that a user schema named +// after a hardcoded streaming interface fails generation, rather than +// silently emitting two `export interface Message` declarations (a +// TypeScript duplicate-identifier error). +func TestSchemaNameCollidesWithStreamingType(t *testing.T) { + spec := baseSpec() + spec.Schemas["Message"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"body": {Type: "string"}}, + } + + cfg := baseConfig() // streaming on by default + + _, err := NewGenerator().Generate(context.Background(), spec, cfg) + + require.Error(t, err, "a user schema named Message collides with the generated streaming type") + assert.Contains(t, err.Error(), "Message") + assert.Contains(t, err.Error(), "collides") +} + +// TestSchemaNameReservedOnlyWithStreamingDisabledSucceeds is the +// counterpart to TestSchemaNameCollidesWithStreamingType: the same schema +// name is only a problem when the colliding streaming type is actually +// emitted. With streaming off, "Message" is just a schema name and +// generation must succeed. +func TestSchemaNameReservedOnlyWithStreamingDisabledSucceeds(t *testing.T) { + spec := baseSpec() + spec.Schemas["Message"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"body": {Type: "string"}}, + } + + cfg := baseConfig() + cfg.IncludeStreaming = false + + out, err := NewGenerator().Generate(context.Background(), spec, cfg) + + require.NoError(t, err, "a schema named Message must generate fine when streaming is disabled") + assert.Contains(t, out.Files["src/types.ts"], "export interface Message {") +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index f54ac6b5..cb71e1ce 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -44,6 +44,60 @@ func sortedKeys[V any](m map[string]V) []string { return keys } +// reservedStreamingTypeNames returns the six interface names +// generateStreamingTypes may emit verbatim: Message, Member, Room, +// RoomOptions, HistoryQuery, UserPresence. Not all six are emitted for every +// config — see checkSchemaNameCollisions, which reflects the actual +// per-name emission conditions rather than treating this list as a blanket +// reservation. Exposed so later tasks and tests share one list instead of +// duplicating the string literals. +func reservedStreamingTypeNames() []string { + return []string{"Message", "Member", "Room", "RoomOptions", "HistoryQuery", "UserPresence"} +} + +// checkSchemaNameCollisions reports schema names that collide with a +// streaming interface name that generateStreamingTypes will actually emit +// for this config. Message, Member, Room, and RoomOptions are emitted +// whenever Streaming.EnableRooms is set; HistoryQuery is additionally gated +// on Streaming.EnableHistory (nested under EnableRooms in +// generateStreamingTypes); UserPresence is gated independently on +// Streaming.EnablePresence. A name that is only reserved under a condition +// that's off for this config is not a collision — for example a schema +// named "Message" with streaming disabled entirely, or "HistoryQuery" with +// rooms on but history off, must generate successfully. +func checkSchemaNameCollisions(spec *client.APISpec, config client.GeneratorConfig) error { + if !config.HasAnyStreamingFeature() { + return nil + } + + reserved := make(map[string]bool, 6) + + if config.Streaming.EnableRooms { + reserved["Message"] = true + reserved["Member"] = true + reserved["Room"] = true + reserved["RoomOptions"] = true + + if config.Streaming.EnableHistory { + reserved["HistoryQuery"] = true + } + } + + if config.Streaming.EnablePresence { + reserved["UserPresence"] = true + } + + for _, name := range sortedKeys(spec.Schemas) { + if reserved[name] { + return fmt.Errorf( + "schema %q collides with a generated streaming type; rename the schema or disable streaming features", + name) + } + } + + return nil +} + // Generator generates TypeScript clients. type Generator struct{} @@ -102,6 +156,10 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, return nil, errors.New("config is invalid type") } + if err := checkSchemaNameCollisions(spec, config); err != nil { + return nil, err + } + genClient := &generators.GeneratedClient{ Files: make(map[string]string), Language: "typescript", From a84b237afa0f6885f5578e6a0be467f365f7a262 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 00:01:05 -0500 Subject: [PATCH 23/71] feat(client/typescript): emit property descriptions and deprecations as JSDoc --- .../client/generators/typescript/gate_test.go | 90 +++++++++++++++++++ .../client/generators/typescript/generator.go | 64 +++++++++++++ internal/client/ir.go | 1 + 3 files changed, 155 insertions(+) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 51faef87..66da4322 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -273,6 +273,96 @@ func TestSchemaNameReservedOnlyWithStreamingDisabledSucceeds(t *testing.T) { assert.Contains(t, out.Files["src/types.ts"], "export interface Message {") } +// TestPropertyJSDocIsEmitted asserts that per-property Description and +// Deprecated are rendered as a JSDoc block above the property, and that a +// property with neither yields no comment at all (never an empty /** */). +func TestPropertyJSDocIsEmitted(t *testing.T) { + spec := baseSpec() + spec.Schemas["Doc"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "kept": {Type: "string", Description: "The name of the thing."}, + "old": {Type: "string", Description: "Legacy field.", Deprecated: true}, + "plain": {Type: "string"}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, "/** The name of the thing. */\n kept?: string;") + assert.Contains(t, types, "@deprecated") + // A property with no description gets no comment at all — no empty /** */. + assert.NotContains(t, types, "/** */") + // And no dangling comment-only line for "plain" either. + assert.NotContains(t, types, "/**\n */") +} + +// TestPropertyJSDocEscapesCommentTerminator guards against the same class of +// defect Phase 1 fixed in tsPropertyKey: a description containing the literal +// "*/" would close the JSDoc block early and corrupt everything after it in +// types.ts. This is verified by actually running tsc against the generated +// output, not just by asserting on the string — a string-only assertion would +// not catch a case where escaping produces syntactically different but still +// broken output. +func TestPropertyJSDocEscapesCommentTerminator(t *testing.T) { + spec := baseSpec() + spec.Schemas["Doc"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "tricky": {Type: "string", Description: "Closes early with */ right in the middle."}, + }, + } + cfg := baseConfig() + + out, err := NewGenerator().Generate(context.Background(), spec, cfg) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + // The raw terminator must not appear inside the comment body unescaped. + assert.NotContains(t, types, "with */ right") + assert.Contains(t, types, `with *\/ right`) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "generated types.ts with an escaped */ in a description must still type-check cleanly") +} + +// TestPropertyJSDocPreservesBlankLinesInMultilineDescriptions documents a +// deliberate deviation from the brief's reference implementation, which skips +// empty lines within a multi-line description. A blank line in prose is a +// paragraph break, not noise; dropping it silently joins two paragraphs into +// one run-on paragraph. This generator instead keeps blank lines as bare " *" +// continuation lines, matching how JSDoc/TSDoc tooling (and hand-written +// JSDoc) represents paragraph breaks. +func TestPropertyJSDocPreservesBlankLinesInMultilineDescriptions(t *testing.T) { + spec := baseSpec() + spec.Schemas["Doc"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "para": {Type: "string", Description: "First paragraph.\n\nSecond paragraph."}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, " * First paragraph.\n *\n * Second paragraph.\n") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "multi-line JSDoc with a blank continuation line must still type-check cleanly") +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index cb71e1ce..1ed8e1c3 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -720,6 +720,12 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec var buf strings.Builder + // Schema-level description, rendered as a JSDoc block above the export. + // Schemas don't carry Deprecated as a standalone concept the way + // properties do here, but propertyJSDoc already handles "description + // only" cleanly, so it's reused as-is. + buf.WriteString(propertyJSDoc(schema, "")) + switch schema.Type { case "object": buf.WriteString(fmt.Sprintf("export interface %s {\n", name)) @@ -733,6 +739,8 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec optional = "?" } + buf.WriteString(propertyJSDoc(prop, " ")) + tsType := g.schemaToTSType(prop, spec) buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(propName), optional, tsType)) } @@ -753,6 +761,62 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec return buf.String() } +// escapeJSDocTerminator replaces "*/" with "*\/" so a description containing +// a literal comment terminator cannot close the JSDoc block early. Same class +// of defect Phase 1 fixed in tsPropertyKey for property names. +func escapeJSDocTerminator(s string) string { + return strings.ReplaceAll(s, "*/", "*\\/") +} + +// propertyJSDoc renders a schema's description and deprecation as a JSDoc +// block, or the empty string when there is nothing to say. An empty comment +// is worse than no comment, so both fields absent yields no output. +// +// Blank lines within a multi-line description are preserved as bare " *" +// continuation lines rather than dropped: a blank line in prose is a +// paragraph break, and silently joining paragraphs together would lose that +// structure. This matches how hand-written and tool-generated JSDoc/TSDoc +// represent paragraph breaks. +func propertyJSDoc(schema *client.Schema, indent string) string { + if schema == nil || (schema.Description == "" && !schema.Deprecated) { + return "" + } + + description := escapeJSDocTerminator(schema.Description) + + // Single-line form when there is only a description and it has no newline. + if description != "" && !schema.Deprecated && !strings.Contains(description, "\n") { + return fmt.Sprintf("%s/** %s */\n", indent, description) + } + + var buf strings.Builder + + fmt.Fprintf(&buf, "%s/**\n", indent) + + // Only split-and-render when there is an actual description: an empty + // Description with Deprecated set must not produce a stray blank " *" + // line before "@deprecated" — that blank line would carry no meaning, + // unlike a genuine blank line between two paragraphs of prose. + if description != "" { + for _, line := range strings.Split(description, "\n") { + if line == "" { + fmt.Fprintf(&buf, "%s *\n", indent) + continue + } + + fmt.Fprintf(&buf, "%s * %s\n", indent, line) + } + } + + if schema.Deprecated { + fmt.Fprintf(&buf, "%s * @deprecated\n", indent) + } + + fmt.Fprintf(&buf, "%s */\n", indent) + + return buf.String() +} + // schemaToTSType converts a schema to a TypeScript type string. func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) string { if schema == nil { diff --git a/internal/client/ir.go b/internal/client/ir.go index 84ff13fa..e99d837e 100644 --- a/internal/client/ir.go +++ b/internal/client/ir.go @@ -361,6 +361,7 @@ type Schema struct { Nullable bool ReadOnly bool WriteOnly bool + Deprecated bool MinLength *int MaxLength *int Minimum *float64 From 48bb49ecec75c8698f4d2490233cfd1316e09cc3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 00:15:04 -0500 Subject: [PATCH 24/71] feat(client/typescript): map binary and 64-bit integer formats to precise types --- .../client/generators/typescript/gate_test.go | 40 +++++++++++++++++++ .../client/generators/typescript/generator.go | 35 ++++++++++++++++ internal/client/generators/typescript/rest.go | 8 ++++ 3 files changed, 83 insertions(+) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 66da4322..eef425cd 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -363,6 +363,46 @@ func TestPropertyJSDocPreservesBlankLinesInMultilineDescriptions(t *testing.T) { assert.Empty(t, errs, "multi-line JSDoc with a blank continuation line must still type-check cleanly") } +// TestFormatDrivenTypes asserts that Schema.Format drives the emitted +// TypeScript type: a binary-format string becomes Blob (a DOM type upload +// callers can pass directly to FormData/fetch), a 64-bit integer format +// becomes string (values beyond Number.MAX_SAFE_INTEGER lose precision as a +// JS number), and date-time stays string (JSON.parse yields a string and +// nothing in the generated runtime revives it into a Date). +func TestFormatDrivenTypes(t *testing.T) { + spec := baseSpec() + spec.Schemas["Formats"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "blob": {Type: "string", Format: "binary"}, + "when": {Type: "string", Format: "date-time"}, + "big": {Type: "integer", Format: "int64"}, + "ordinary": {Type: "string"}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, "blob?: Blob;") + assert.Contains(t, types, "when?: string;") // ISO-8601 string, not Date + assert.Contains(t, types, "big?: string;") // int64 exceeds Number.MAX_SAFE_INTEGER + assert.Contains(t, types, "ordinary?: string;") + + // Blob is a DOM type, not a bare TS/ES type: confirm it actually resolves + // under the generated tsconfig's "lib": ["ES2020", "DOM"] rather than just + // asserting on the string. If DOM were ever dropped from lib, this would + // fail with "Cannot find name 'Blob'" while the string assertion above + // would still pass. + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "generated types.ts using Blob for a binary-format property must type-check cleanly") +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 1ed8e1c3..81aa56f8 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -44,6 +44,33 @@ func sortedKeys[V any](m map[string]V) []string { return keys } +// formatTSType maps an OpenAPI format to a TypeScript type, returning "" when +// the format carries no type information and the base type should be used. +// +// Two judgement calls are encoded here: +// - "date-time" is deliberately NOT mapped to "Date": JSON.parse produces a +// plain string, and nothing in the generated runtime revives it into a +// Date, so emitting Date would be a type that lies about the runtime +// value. It falls through to the ordinary string handling. +// - "int64"/"uint64" become "string" rather than "number": values beyond +// Number.MAX_SAFE_INTEGER (2^53-1) silently lose precision as a JS +// number. Carrying them as decimal strings matches what most other +// OpenAPI-to-TypeScript generators do for 64-bit integers. +func formatTSType(schema *client.Schema) string { + if schema == nil { + return "" + } + + switch schema.Format { + case "binary": + return "Blob" + case "int64", "uint64": + return "string" + } + + return "" +} + // reservedStreamingTypeNames returns the six interface names // generateStreamingTypes may emit verbatim: Message, Member, Room, // RoomOptions, HistoryQuery, UserPresence. Not all six are emitted for every @@ -879,6 +906,14 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) return result } + if ft := formatTSType(schema); ft != "" { + if schema.Nullable { + return ft + " | null" + } + + return ft + } + switch schema.Type { case "string": if len(schema.Enum) > 0 { diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 8f8a3153..83b9f4a7 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -482,6 +482,14 @@ func (r *RESTGenerator) schemaToTSType(schema *client.Schema, spec *client.APISp return "types." + parts[len(parts)-1] } + // NOTE: unlike generator.go's schemaToTSType, this implementation does not + // handle schema.Nullable anywhere (pre-existing behaviour, not addressed + // here), so the format branch below deliberately stays consistent with + // that and does not append " | null" either. + if ft := formatTSType(schema); ft != "" { + return ft + } + switch schema.Type { case "string": if len(schema.Enum) > 0 { From 172c41a5f3a8f84b2b28b20d3d2d3cff752eb737 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 00:25:04 -0500 Subject: [PATCH 25/71] feat(client/typescript): support numeric and boolean enums with escaped literals --- .../client/generators/typescript/gate_test.go | 107 ++++++++++++++++++ .../client/generators/typescript/generator.go | 76 ++++++++++--- internal/client/generators/typescript/rest.go | 21 ++-- 3 files changed, 179 insertions(+), 25 deletions(-) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index eef425cd..3a89bc9a 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -403,6 +403,113 @@ func TestFormatDrivenTypes(t *testing.T) { assert.Empty(t, errs, "generated types.ts using Blob for a binary-format property must type-check cleanly") } +// TestEnumsOfEveryScalarType asserts that an enum is rendered as a TS literal +// union regardless of the scalar type it decorates, not just for "string". +// Before this test, a numeric or boolean enum silently fell through to the +// bare base type (number/boolean), losing the literal union entirely, and a +// string enum value containing a quote broke the generated file because the +// old code hand-interpolated with fmt.Sprintf("'%v'", v). +// +// String literals are rendered double-quoted via json.Marshal (consistent +// with tsPropertyKey's escaping, and avoiding hand-rolled quote/backslash +// escaping) rather than single-quoted; both are valid TypeScript. +func TestEnumsOfEveryScalarType(t *testing.T) { + spec := baseSpec() + spec.Schemas["Enums"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "status": {Type: "string", Enum: []any{"active", "off"}}, + "code": {Type: "integer", Enum: []any{1, 2, 3}}, + "flag": {Type: "boolean", Enum: []any{true}}, + "quoted": {Type: "string", Enum: []any{"it's"}}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, `status?: "active" | "off";`) + assert.Contains(t, types, `code?: 1 | 2 | 3;`) + assert.Contains(t, types, `flag?: true;`) + assert.Contains(t, types, `quoted?: "it's";`) +} + +// TestEnumAdversarialValuesTypeCheck exercises the values that break a +// hand-rolled fmt.Sprintf("'%v'", v) enum renderer: an apostrophe, a double +// quote, a backslash, control characters (newline/tab), a non-ASCII value, a +// literal JSON null in the enum list, and a heterogeneous (mixed-type) enum. +// Assertions check both the exact rendered literal and that the resulting +// types.ts actually type-checks with tsc, since a string assertion alone +// would not catch a syntactically-broken-but-string-matching output. +func TestEnumAdversarialValuesTypeCheck(t *testing.T) { + spec := baseSpec() + spec.Schemas["Adversarial"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "apostrophe": {Type: "string", Enum: []any{"it's"}}, + "quote": {Type: "string", Enum: []any{`say "hi"`}}, + "backslash": {Type: "string", Enum: []any{`back\slash`}}, + "control": {Type: "string", Enum: []any{"line1\nline2\ttab"}}, + "nonascii": {Type: "string", Enum: []any{"café"}}, + "withNull": {Type: "string", Enum: []any{"a", nil}}, + // Mixed-type enum: each value is rendered as a literal of its own + // Go type, producing a heterogeneous TS literal union. This mirrors + // how JSON Schema/OpenAPI enum arrays are permitted to be + // heterogeneous, and TypeScript literal unions support mixing + // string/number/boolean literals natively. + "mixed": {Enum: []any{"a", 1, true}}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, `apostrophe?: "it's";`) + assert.Contains(t, types, `quote?: "say \"hi\"";`) + assert.Contains(t, types, `backslash?: "back\\slash";`) + assert.Contains(t, types, "control?: \"line1\\nline2\\ttab\";") + assert.Contains(t, types, `nonascii?: "café";`) + assert.Contains(t, types, `withNull?: "a" | null;`) + assert.Contains(t, types, `mixed?: "a" | 1 | true;`) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "enum literals containing quotes/backslashes/control characters/non-ASCII/null/mixed types must type-check cleanly") +} + +// TestEnumInQueryParamTypeChecks exercises rest.go's schemaToTSType (the +// second, independent implementation) with an enum on a query parameter, +// verifying it emits a literal union — not just generator.go's types.ts path. +func TestEnumInQueryParamTypeChecks(t *testing.T) { + spec := baseSpec() + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "GET", Path: "/widgets", OperationID: "widgets.list", + QueryParams: []client.Parameter{ + {Name: "state", Schema: &client.Schema{Type: "string", Enum: []any{"it's", "off"}}}, + }, + Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}}, + }) + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + assert.Contains(t, rest, `"it's" | "off"`) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "an enum on a query parameter (rest.go's schemaToTSType) must type-check cleanly") +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 81aa56f8..1cee1271 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -2,6 +2,7 @@ package typescript import ( "context" + "encoding/json" "fmt" "slices" "sort" @@ -71,6 +72,52 @@ func formatTSType(schema *client.Schema) string { return "" } +// enumTSType renders schema.Enum as a TypeScript literal union (e.g. +// `"active" | "off"` or `1 | 2 | 3`), or "" when the schema is not an enum. +// Shared by both schemaToTSType implementations (generator.go and rest.go) so +// the escaping logic — and the bug fix below — lives in exactly one place. +// +// String values are escaped via json.Marshal, the same mechanism +// tsPropertyKey uses to escape object keys. Interpolating with +// fmt.Sprintf("'%v'", v) — the code this replaces — breaks the entire +// generated file the moment a value contains a quote (e.g. "it's" produces +// the unterminated literal 'it's'); json.Marshal handles quotes, backslashes, +// control characters, and non-ASCII correctly without hand-rolled escaping. +// This also means string literals are double-quoted (`"active"`) rather than +// single-quoted — both are valid TypeScript, and this keeps the escaping +// consistent with tsPropertyKey rather than introducing a second scheme. +// +// Non-string scalars (bool, nil, numbers) are rendered with their natural +// literal form. A nil entry (JSON null is a legal enum member) renders as the +// TS literal `null`. An enum mixing types (e.g. a string, a number, and a +// bool in the same list) renders each value as a literal of its own type, +// producing a heterogeneous union — TypeScript literal unions support mixing +// literal kinds natively, and OpenAPI/JSON Schema does not forbid +// heterogeneous enum arrays. +func enumTSType(schema *client.Schema) string { + if schema == nil || len(schema.Enum) == 0 { + return "" + } + + parts := make([]string, 0, len(schema.Enum)) + + for _, v := range schema.Enum { + switch tv := v.(type) { + case string: + b, _ := json.Marshal(tv) + parts = append(parts, string(b)) + case bool: + parts = append(parts, fmt.Sprintf("%t", tv)) + case nil: + parts = append(parts, "null") + default: + parts = append(parts, fmt.Sprintf("%v", tv)) + } + } + + return strings.Join(parts, " | ") +} + // reservedStreamingTypeNames returns the six interface names // generateStreamingTypes may emit verbatim: Message, Member, Room, // RoomOptions, HistoryQuery, UserPresence. Not all six are emitted for every @@ -906,6 +953,21 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) return result } + // Enum wins over format: an enum lists the exact permitted literal + // values, which is strictly more specific type information than a format + // hint about how to interpret the base type. The two co-occurring is + // unusual (e.g. an int64-format integer enum, or a binary-format string + // enum — the latter doesn't meaningfully happen in practice), but when + // both are present the literal union is more useful to callers than the + // generic format-driven type, so it is checked first. + if et := enumTSType(schema); et != "" { + if schema.Nullable { + return et + " | null" + } + + return et + } + if ft := formatTSType(schema); ft != "" { if schema.Nullable { return ft + " | null" @@ -916,20 +978,6 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) switch schema.Type { case "string": - if len(schema.Enum) > 0 { - var values []string - for _, v := range schema.Enum { - values = append(values, fmt.Sprintf("'%v'", v)) - } - - result := strings.Join(values, " | ") - if schema.Nullable { - result += " | null" - } - - return result - } - if schema.Nullable { return "string | null" } diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 83b9f4a7..f578aea3 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -482,25 +482,24 @@ func (r *RESTGenerator) schemaToTSType(schema *client.Schema, spec *client.APISp return "types." + parts[len(parts)-1] } + // Enum wins over format for the same reason as generator.go's + // schemaToTSType: the enum lists the exact permitted literal values, + // which is more specific than a format hint on the base type. + // // NOTE: unlike generator.go's schemaToTSType, this implementation does not // handle schema.Nullable anywhere (pre-existing behaviour, not addressed - // here), so the format branch below deliberately stays consistent with - // that and does not append " | null" either. + // here), so neither the enum branch below nor the format branch appends + // " | null". + if et := enumTSType(schema); et != "" { + return et + } + if ft := formatTSType(schema); ft != "" { return ft } switch schema.Type { case "string": - if len(schema.Enum) > 0 { - var values []string - for _, v := range schema.Enum { - values = append(values, fmt.Sprintf("'%v'", v)) - } - - return strings.Join(values, " | ") - } - return "string" case "integer", "number": return "number" From cfdf6c208b7c0281e87d605f56303e58ab76133d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 00:34:50 -0500 Subject: [PATCH 26/71] feat(client/typescript): emit additionalProperties as index types --- .../client/generators/typescript/gate_test.go | 74 +++++++++ .../client/generators/typescript/generator.go | 151 ++++++++++++++++-- 2 files changed, 210 insertions(+), 15 deletions(-) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 3a89bc9a..45b13867 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -510,6 +510,80 @@ func TestEnumInQueryParamTypeChecks(t *testing.T) { assert.Empty(t, errs, "an enum on a query parameter (rest.go's schemaToTSType) must type-check cleanly") } +// TestAdditionalProperties asserts that Schema.AdditionalProperties (typed +// `any` in the IR because JSON Schema allows either a bool or a schema) +// drives an open-ended map type instead of being silently ignored: +// +// - properties absent, additionalProperties a schema -> Record +// - properties absent, additionalProperties true -> Record +// - properties present AND additionalProperties a schema -> an intersection +// of the declared properties with Record, NOT an interface with +// an index signature: `{ id: string } & Record` type-checks, +// but `interface { id: string; [key: string]: number }` does not — an index +// signature must be compatible with every declared property, and `id` is a +// string while the index signature promises number (TS2411). +// - additionalProperties false or absent -> unchanged, ordinary interface +// +// A property whose own (nested, unnamed) schema is an open-ended map is +// exercised too: schemaToTSType has its own, separate "object" branch from +// schemaToTypeScript's, and both must apply the same fix or a nested map +// property silently degrades to Record, losing the value type. +func TestAdditionalProperties(t *testing.T) { + spec := baseSpec() + spec.Schemas["OpenTyped"] = &client.Schema{ + Type: "object", + AdditionalProperties: &client.Schema{Type: "string"}, + } + spec.Schemas["OpenAny"] = &client.Schema{ + Type: "object", + AdditionalProperties: true, + } + spec.Schemas["Mixed"] = &client.Schema{ + Type: "object", + Required: []string{"id"}, + Properties: map[string]*client.Schema{"id": {Type: "string"}}, + AdditionalProperties: &client.Schema{Type: "number"}, + } + spec.Schemas["Closed"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"id": {Type: "string"}}, + AdditionalProperties: false, + } + spec.Schemas["HasMap"] = &client.Schema{ + Type: "object", + Required: []string{"tags"}, + Properties: map[string]*client.Schema{ + "tags": { + Type: "object", + AdditionalProperties: &client.Schema{Type: "string"}, + }, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, "export type OpenTyped = Record;") + assert.Contains(t, types, "export type OpenAny = Record;") + // Mixed keeps id AND folds in an index signature via intersection, not a + // (TS2411-invalid) interface with a conflicting index signature. + assert.Contains(t, types, "export type Mixed = {\n id: string;\n} & Record;") + assert.Contains(t, types, "id: string;") + assert.NotContains(t, types, "export type Closed = Record") + assert.Contains(t, types, "export interface Closed {") + // Nested case: schemaToTSType, not just schemaToTypeScript, must apply the + // value-type fix for a property whose own schema is an open-ended map. + assert.Contains(t, types, "tags: Record;") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "additionalProperties output (Record, Record, the Mixed intersection, and the nested HasMap.tags case) must type-check cleanly") +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 1cee1271..bb6018eb 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -786,6 +786,67 @@ export class EventEmitter { ` } +// additionalPropsSchema interprets Schema.AdditionalProperties, which the IR +// types as `any` because JSON Schema allows either a bool or a schema. +// Returns (valueSchema, allowed). A nil valueSchema with allowed=true means +// "any value". A nil valueSchema with allowed=false means additional +// properties are absent or explicitly disallowed — the ordinary closed +// interface case. +// +// The IR field is populated by copying shared.Schema.AdditionalProperties +// (also `any`) straight through in both spec_parser.go and introspector.go. +// shared.Schema has no custom UnmarshalJSON for that field, so when a spec is +// parsed from a JSON/YAML document (spec_parser.go), a schema-valued +// `additionalProperties` decodes via encoding/json's generic `any` handling +// to map[string]any, not *client.Schema — only a bool or a genuine +// *client.Schema constructed in Go (e.g. by the introspector, or by tests +// building the IR directly) take the other two branches. That map[string]any +// case is real and reachable in this codebase, but is deliberately not +// normalised into a *client.Schema here: doing so is a separate piece of +// work (re-running schema conversion on a raw map) outside this fix's scope. +func additionalPropsSchema(v any) (*client.Schema, bool) { + switch t := v.(type) { + case nil: + return nil, false + case bool: + return nil, t + case *client.Schema: + return t, true + } + + return nil, false +} + +// objectPropsLiteral renders schema.Properties as a TypeScript object type +// literal body ("{ ... }"), including per-property JSDoc. Shared by the +// ordinary `export interface` case and the additionalProperties intersection +// case, both of which need the identical property rendering — only the +// wrapper differs (interface vs. `{...} & Record`). +func (g *Generator) objectPropsLiteral(schema *client.Schema, spec *client.APISpec) string { + var buf strings.Builder + + buf.WriteString("{\n") + + for _, propName := range sortedKeys(schema.Properties) { + prop := schema.Properties[propName] + required := contains(schema.Required, propName) + + optional := "" + if !required { + optional = "?" + } + + buf.WriteString(propertyJSDoc(prop, " ")) + + tsType := g.schemaToTSType(prop, spec) + buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(propName), optional, tsType)) + } + + buf.WriteString("}") + + return buf.String() +} + // schemaToTypeScript converts a schema to TypeScript. func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec *client.APISpec) string { if schema == nil { @@ -802,24 +863,59 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec switch schema.Type { case "object": - buf.WriteString(fmt.Sprintf("export interface %s {\n", name)) - - for _, propName := range sortedKeys(schema.Properties) { - prop := schema.Properties[propName] - required := contains(schema.Required, propName) + valueSchema, allowed := additionalPropsSchema(schema.AdditionalProperties) + hasProps := len(schema.Properties) > 0 + + switch { + case allowed && !hasProps: + // No declared properties, open-ended map: a plain Record. A nil + // valueSchema means additionalProperties was `true` ("any" value). + valueType := "any" + if valueSchema != nil { + valueType = g.schemaToTSType(valueSchema, spec) + } - optional := "" - if !required { - optional = "?" + buf.WriteString(fmt.Sprintf("export type %s = Record;\n", name, valueType)) + + case allowed && hasProps: + // Declared properties AND an open-ended map: an interface with + // both `id: string` and `[key: string]: number` is rejected by + // TypeScript (TS2411) because an index signature must be + // compatible with every declared property. An intersection type + // sidesteps that entirely, so this is a `type` alias rather than + // an `interface` for schemas that take this branch — declaration + // merging and `implements X` are no longer available to + // consumers of this generated type. That is the correct + // trade-off (the alternative doesn't type-check), but it is a + // real, conscious API shape change for schemas with both + // declared properties and a typed additionalProperties. + valueType := "any" + if valueSchema != nil { + valueType = g.schemaToTSType(valueSchema, spec) } - buf.WriteString(propertyJSDoc(prop, " ")) + buf.WriteString(fmt.Sprintf("export type %s = %s & Record;\n", name, g.objectPropsLiteral(schema, spec), valueType)) - tsType := g.schemaToTSType(prop, spec) - buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(propName), optional, tsType)) - } + default: + buf.WriteString(fmt.Sprintf("export interface %s {\n", name)) + + for _, propName := range sortedKeys(schema.Properties) { + prop := schema.Properties[propName] + required := contains(schema.Required, propName) + + optional := "" + if !required { + optional = "?" + } + + buf.WriteString(propertyJSDoc(prop, " ")) + + tsType := g.schemaToTSType(prop, spec) + buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(propName), optional, tsType)) + } - buf.WriteString("}\n") + buf.WriteString("}\n") + } case "array": if schema.Items != nil { @@ -1011,11 +1107,36 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) return "any[]" case "object": + valueSchema, allowed := additionalPropsSchema(schema.AdditionalProperties) + + var result string + + switch { + case allowed && len(schema.Properties) > 0: + valueType := "any" + if valueSchema != nil { + valueType = g.schemaToTSType(valueSchema, spec) + } + + result = g.objectPropsLiteral(schema, spec) + " & Record" + + case allowed: + valueType := "any" + if valueSchema != nil { + valueType = g.schemaToTSType(valueSchema, spec) + } + + result = "Record" + + default: + result = "Record" + } + if schema.Nullable { - return "Record | null" + return "(" + result + ") | null" } - return "Record" + return result case "null": return "null" } From 5788bd5067dbbe946ec2ae1c7129a7c0ba08cb78 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 00:50:01 -0500 Subject: [PATCH 27/71] fix(client): normalise additionalProperties raw decoder output at the parser Schema.AdditionalProperties held a bool or a *client.Schema by the time it reached the TypeScript generator, but every production ingestion path fed it something else: spec_parser.go copied the raw JSON/YAML decoder output (map[string]any) straight through, and shared.Schema.AdditionalProperties had no yaml struct tag at all, so a YAML spec (the common on-disk format) never populated the field in the first place. A schema-valued additionalProperties in a real spec file silently downgraded to a closed, empty interface no matter how the last fix normalised the generator side. Fixes this at the source: shared.Schema.AdditionalProperties now carries an explicit yaml tag, and spec_parser.go's convertSchema re-marshals the raw decoder output through shared.Schema and recurses via the existing conversion, so nested items/properties/$ref/format/enums come along for free. A malformed additionalProperties that fails the round trip is logged, not silently dropped, and falls back to the original raw value so the rest of the document still parses successfully. --- .../client/generators/typescript/gate_test.go | 61 +++++ internal/client/spec_parser.go | 86 +++++-- internal/client/spec_parser_test.go | 225 ++++++++++++++++++ internal/shared/openapi.go | 16 +- 4 files changed, 371 insertions(+), 17 deletions(-) diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 45b13867..884173a3 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -2,6 +2,8 @@ package typescript import ( "context" + "os" + "path/filepath" "strings" "testing" @@ -584,6 +586,65 @@ func TestAdditionalProperties(t *testing.T) { assert.Empty(t, errs, "additionalProperties output (Record, Record, the Mixed intersection, and the nested HasMap.tags case) must type-check cleanly") } +// TestAdditionalPropertiesEndToEndFromParsedSpec is the end-to-end proof for +// the additionalProperties fix: it exercises the real ingestion path a user +// hits — an OpenAPI YAML *file on disk*, parsed by client.NewSpecParser(), +// not a hand-built *client.Schema fixture. TestAdditionalProperties above +// (and the generator-level fix it drove) only proves the generator does the +// right thing once Schema.AdditionalProperties already holds a *client.Schema +// or bool; it says nothing about whether a real spec file ever produces that +// shape in the first place. Before spec_parser.go's convertSchema normalised +// the raw decoder output (and before shared.Schema.AdditionalProperties +// carried an explicit yaml tag), this exact YAML document parsed to +// map[string]any (or, for YAML specifically, didn't populate the field at +// all — see TestSpecParserAdditionalProperties in the client package for +// that half of the fix), and the generator emitted an empty `export +// interface OpenTyped {}` instead of Record — silently, with +// no error anywhere in the pipeline. +func TestAdditionalPropertiesEndToEndFromParsedSpec(t *testing.T) { + const spec = ` +openapi: 3.1.0 +info: + title: AP End To End + version: 1.0.0 +paths: + /noop: + get: + summary: noop + responses: + '200': + description: ok +components: + schemas: + OpenTyped: + type: object + additionalProperties: + type: string +` + + dir := t.TempDir() + specFile := filepath.Join(dir, "openapi.yaml") + + require.NoError(t, os.WriteFile(specFile, []byte(spec), 0o644)) + + parsed, err := client.NewSpecParser().ParseFile(context.Background(), specFile) + require.NoError(t, err) + + out, err := NewGenerator().Generate(context.Background(), parsed, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, "export type OpenTyped = Record;") + assert.NotContains(t, types, "export interface OpenTyped") + + outDir := t.TempDir() + writeTree(t, outDir, out.Files) + + errs := typeCheck(t, outDir) + assert.Empty(t, errs, "a client generated from a real parsed OpenAPI YAML file with a schema-valued additionalProperties must type-check cleanly") +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/spec_parser.go b/internal/client/spec_parser.go index 3b44d048..0125b7e7 100644 --- a/internal/client/spec_parser.go +++ b/internal/client/spec_parser.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" "strings" @@ -451,20 +452,21 @@ func convertSchema(s *shared.Schema) *Schema { } schema := &Schema{ - Type: s.Type, - Format: s.Format, - Description: s.Description, - Required: s.Required, - Enum: s.Enum, - Default: s.Default, - Example: s.Example, - Nullable: s.Nullable, - ReadOnly: s.ReadOnly, - WriteOnly: s.WriteOnly, - Pattern: s.Pattern, - Ref: s.Ref, - AdditionalProperties: s.AdditionalProperties, - } + Type: s.Type, + Format: s.Format, + Description: s.Description, + Required: s.Required, + Enum: s.Enum, + Default: s.Default, + Example: s.Example, + Nullable: s.Nullable, + ReadOnly: s.ReadOnly, + WriteOnly: s.WriteOnly, + Pattern: s.Pattern, + Ref: s.Ref, + } + + schema.AdditionalProperties = normalizeAdditionalProperties(s.AdditionalProperties) if s.MinLength > 0 { minLen := s.MinLength @@ -525,6 +527,62 @@ func convertSchema(s *shared.Schema) *Schema { return schema } +// normalizeAdditionalProperties converts the raw decoder output for +// additionalProperties into the shape the rest of the pipeline (and +// ultimately the TypeScript generator's additionalPropsSchema) expects: a +// bool, or a *Schema. +// +// JSON Schema allows additionalProperties to be either a bool or a schema +// object, but shared.Schema.AdditionalProperties is typed `any`, and neither +// encoding/json nor yaml.v3 knows to decode an object-valued field typed +// `any` into *shared.Schema — both decode it generically instead: a JSON/YAML +// boolean becomes a Go bool (already the shape this IR wants, so it passes +// through unchanged), while a JSON/YAML object becomes map[string]any (JSON) +// or map[string]interface{} (YAML, once the field carries an explicit yaml +// tag — see the tag comment on shared.Schema.AdditionalProperties). Both are +// the same underlying Go type (map[string]interface{} *is* map[string]any), +// confirmed by exercising this exact field through both decoders. +// +// Rather than hand-walking that map a second time, the raw value is +// re-marshalled to JSON and unmarshalled into a shared.Schema, then run +// through convertSchema itself. That reuses the existing, already-correct +// conversion for every nested field (items, properties, $ref, format, +// enums, ...) for free and stays correct as shared.Schema grows, instead of +// drifting out of sync with a second, parallel conversion. +// +// A malformed document whose additionalProperties is neither a bool nor an +// object — invalid per the JSON Schema spec, but nothing stops a +// hand-written file from doing it — fails the round trip. That failure is +// not swallowed silently: it's logged, and the original raw value is +// returned unchanged, so behaviour for that one field is exactly what it +// was before this function existed (the generator's additionalPropsSchema +// falls through its default case for anything that isn't nil/bool/*Schema). +// The whole parse is deliberately not failed for this: a spec that +// previously parsed successfully (if imperfectly, on this one field) must +// keep parsing successfully — degrading gracefully on a single malformed +// keyword is preferable to turning it into a hard failure for the entire +// file. +func normalizeAdditionalProperties(v any) any { + switch v.(type) { + case nil, bool: + return v + } + + raw, err := json.Marshal(v) + if err != nil { + log.Printf("client: additionalProperties: marshal %T: %v", v, err) + return v + } + + var nested shared.Schema + if err := json.Unmarshal(raw, &nested); err != nil { + log.Printf("client: additionalProperties: decode as schema: %v", err) + return v + } + + return convertSchema(&nested) +} + func convertOAuthFlows(flows *shared.OAuthFlows) *OAuthFlows { if flows == nil { return nil diff --git a/internal/client/spec_parser_test.go b/internal/client/spec_parser_test.go index 3422383b..8ced4400 100644 --- a/internal/client/spec_parser_test.go +++ b/internal/client/spec_parser_test.go @@ -480,3 +480,228 @@ func TestSpecParserJSONFormat(t *testing.T) { t.Errorf("Expected 1 endpoint, got %d", len(spec.Endpoints)) } } + +// additionalPropertiesYAML and additionalPropertiesJSON are the same document +// in both formats, each declaring five schemas that exercise every shape +// additionalProperties can legally take, plus a nested case: +// - BoolTrue / BoolFalse: additionalProperties is a bare bool +// - TypedString: additionalProperties is a schema object ({type: string}) +// - RefValue: additionalProperties is a schema object that is itself a $ref +// - NestedArray: additionalProperties is a schema object with its own +// nested Items, proving the normalisation recurses rather than only +// handling one level +const additionalPropertiesYAML = ` +openapi: 3.1.0 +info: + title: AdditionalProperties Test + version: 1.0.0 +paths: + /noop: + get: + summary: noop + responses: + '200': + description: ok +components: + schemas: + User: + type: object + properties: + id: + type: string + BoolTrue: + type: object + additionalProperties: true + BoolFalse: + type: object + additionalProperties: false + TypedString: + type: object + additionalProperties: + type: string + RefValue: + type: object + additionalProperties: + $ref: '#/components/schemas/User' + NestedArray: + type: object + additionalProperties: + type: array + items: + type: string +` + +const additionalPropertiesJSON = `{ + "openapi": "3.1.0", + "info": { + "title": "AdditionalProperties Test", + "version": "1.0.0" + }, + "paths": { + "/noop": { + "get": { + "summary": "noop", + "responses": { + "200": { "description": "ok" } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { "id": { "type": "string" } } + }, + "BoolTrue": { + "type": "object", + "additionalProperties": true + }, + "BoolFalse": { + "type": "object", + "additionalProperties": false + }, + "TypedString": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "RefValue": { + "type": "object", + "additionalProperties": { "$ref": "#/components/schemas/User" } + }, + "NestedArray": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + } + } + } +}` + +// assertAdditionalPropertiesNormalised is shared by the YAML and JSON variants +// of TestSpecParserAdditionalProperties so both formats are held to the exact +// same expectations. +func assertAdditionalPropertiesNormalised(t *testing.T, spec *client.APISpec) { + t.Helper() + + boolTrue, ok := spec.Schemas["BoolTrue"] + if !ok { + t.Fatal("BoolTrue schema not found") + } + + if v, ok := boolTrue.AdditionalProperties.(bool); !ok || !v { + t.Errorf("BoolTrue.AdditionalProperties = %#v (%T), want bool(true)", boolTrue.AdditionalProperties, boolTrue.AdditionalProperties) + } + + boolFalse, ok := spec.Schemas["BoolFalse"] + if !ok { + t.Fatal("BoolFalse schema not found") + } + + if v, ok := boolFalse.AdditionalProperties.(bool); !ok || v { + t.Errorf("BoolFalse.AdditionalProperties = %#v (%T), want bool(false)", boolFalse.AdditionalProperties, boolFalse.AdditionalProperties) + } + + typedString, ok := spec.Schemas["TypedString"] + if !ok { + t.Fatal("TypedString schema not found") + } + + typedStringAP, ok := typedString.AdditionalProperties.(*client.Schema) + if !ok { + t.Fatalf("TypedString.AdditionalProperties = %#v (%T), want *client.Schema", typedString.AdditionalProperties, typedString.AdditionalProperties) + } + + if typedStringAP.Type != "string" { + t.Errorf("TypedString.AdditionalProperties.Type = %q, want \"string\"", typedStringAP.Type) + } + + refValue, ok := spec.Schemas["RefValue"] + if !ok { + t.Fatal("RefValue schema not found") + } + + refValueAP, ok := refValue.AdditionalProperties.(*client.Schema) + if !ok { + t.Fatalf("RefValue.AdditionalProperties = %#v (%T), want *client.Schema", refValue.AdditionalProperties, refValue.AdditionalProperties) + } + + if refValueAP.Ref != "#/components/schemas/User" { + t.Errorf("RefValue.AdditionalProperties.Ref = %q, want \"#/components/schemas/User\"", refValueAP.Ref) + } + + nestedArray, ok := spec.Schemas["NestedArray"] + if !ok { + t.Fatal("NestedArray schema not found") + } + + nestedArrayAP, ok := nestedArray.AdditionalProperties.(*client.Schema) + if !ok { + t.Fatalf("NestedArray.AdditionalProperties = %#v (%T), want *client.Schema", nestedArray.AdditionalProperties, nestedArray.AdditionalProperties) + } + + if nestedArrayAP.Type != "array" { + t.Errorf("NestedArray.AdditionalProperties.Type = %q, want \"array\"", nestedArrayAP.Type) + } + + if nestedArrayAP.Items == nil { + t.Fatal("NestedArray.AdditionalProperties.Items = nil, want a nested *client.Schema") + } + + if nestedArrayAP.Items.Type != "string" { + t.Errorf("NestedArray.AdditionalProperties.Items.Type = %q, want \"string\"", nestedArrayAP.Items.Type) + } +} + +// TestSpecParserAdditionalProperties asserts that SpecParser normalises +// Schema.AdditionalProperties for both a document-valued (schema) and a +// bool-valued additionalProperties, in both YAML and JSON. Before this fix, +// SpecParser copied the raw decoder output straight into the IR: a JSON/YAML +// boolean decodes to Go bool (already IR-shaped), but a JSON/YAML object +// decodes to map[string]any/map[string]interface{} — not *client.Schema — +// because shared.Schema.AdditionalProperties has no custom +// UnmarshalJSON/UnmarshalYAML for that field. The generator's +// additionalPropsSchema helper only recognises bool and *client.Schema, so a +// perfectly well-formed `additionalProperties: {type: string}` in a real spec +// file silently downgraded to "not allowed" (a closed interface) purely +// because of how the raw value arrived, not because of anything the spec +// author wrote. +func TestSpecParserAdditionalProperties(t *testing.T) { + t.Run("YAML", func(t *testing.T) { + tmpDir := t.TempDir() + specFile := filepath.Join(tmpDir, "openapi.yaml") + + if err := os.WriteFile(specFile, []byte(additionalPropertiesYAML), 0644); err != nil { + t.Fatalf("Failed to write spec file: %v", err) + } + + parser := client.NewSpecParser() + + spec, err := parser.ParseFile(context.Background(), specFile) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + assertAdditionalPropertiesNormalised(t, spec) + }) + + t.Run("JSON", func(t *testing.T) { + tmpDir := t.TempDir() + specFile := filepath.Join(tmpDir, "openapi.json") + + if err := os.WriteFile(specFile, []byte(additionalPropertiesJSON), 0644); err != nil { + t.Fatalf("Failed to write spec file: %v", err) + } + + parser := client.NewSpecParser() + + spec, err := parser.ParseFile(context.Background(), specFile) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + assertAdditionalPropertiesNormalised(t, spec) + }) +} diff --git a/internal/shared/openapi.go b/internal/shared/openapi.go index 9d546065..bc195e78 100644 --- a/internal/shared/openapi.go +++ b/internal/shared/openapi.go @@ -264,9 +264,19 @@ type Schema struct { Enum []any `json:"enum,omitempty"` // Object/Array properties - Properties map[string]*Schema `json:"properties,omitempty"` - AdditionalProperties any `json:"additionalProperties,omitempty"` - Items *Schema `json:"items,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty"` + // AdditionalProperties carries an explicit yaml tag (unlike its + // unexported-from-yaml.v3-matching sibling fields in this struct, which + // rely on yaml.v3's no-tag fallback of lowercasing the whole field name). + // That fallback only works by coincidence for already-lowercase, + // single-word keys like "type" or "required"; "additionalProperties" is + // camelCase in every real OpenAPI/JSON Schema document, so without this + // tag yaml.v3 silently leaves the field unset (nil) for every YAML spec + // — the common on-disk format — even though the identical document in + // JSON decodes it correctly via the existing json tag. Confirmed via a + // standalone repro before this tag was added. + AdditionalProperties any `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` + Items *Schema `json:"items,omitempty"` // Composition AllOf []Schema `json:"allOf,omitempty"` From fd795fc81ea7ad001134bcc99caaa7d9f99506ec Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 01:11:02 -0500 Subject: [PATCH 28/71] fix(shared): add yaml tags so OpenAPI/AsyncAPI YAML specs parse correctly gopkg.in/yaml.v3 falls back to strings.ToLower(FieldName) for any struct field with no explicit yaml tag. 62 exported fields across openapi.go and asyncapi.go had a json tag but no yaml tag, so every one of them was looked up under the wrong key and silently never populated when a spec was parsed from YAML (JSON specs were unaffected). Most severe: Schema.Ref (json:"$ref,omitempty") has no uppercase letter so it evaded a prior regex-based survey, but the same root cause applies - every $ref in every YAML OpenAPI spec was being dropped. Schema.OneOf/AnyOf/AllOf (polymorphism) and ReadOnly/WriteOnly were similarly broken. Added an explicit yaml tag matching each field's existing json tag exactly (including omitempty) to all 62 fields. No struct shape, json tag, or generator code changed. Added internal/shared/yaml_tags_test.go: a reflection-based guard (TestJSONYAMLTagParity) that walks every struct in both files and asserts every json-tagged field has a matching yaml tag, plus behavioural unmarshal tests for Schema.Ref, OneOf/AnyOf/AllOf, ReadOnly/WriteOnly, OpenAPISpec.ExternalDocs, Info.TermsOfService, Discriminator.PropertyName, AsyncAPIMessage.MessageID, and AsyncAPIComponents.SecuritySchemes. Added TestRefAndOneOfEndToEndFromParsedYAML in internal/client/generators/typescript, which parses a real OpenAPI YAML file via client.NewSpecParser().ParseFile, generates a TypeScript client, and confirms both $ref and oneOf resolve to real types (not any) and type-check with tsc. --- .../typescript/yaml_ref_oneof_e2e_test.go | 109 +++++ internal/shared/asyncapi.go | 82 ++-- internal/shared/openapi.go | 50 +-- internal/shared/yaml_tags_test.go | 371 ++++++++++++++++++ 4 files changed, 546 insertions(+), 66 deletions(-) create mode 100644 internal/client/generators/typescript/yaml_ref_oneof_e2e_test.go create mode 100644 internal/shared/yaml_tags_test.go diff --git a/internal/client/generators/typescript/yaml_ref_oneof_e2e_test.go b/internal/client/generators/typescript/yaml_ref_oneof_e2e_test.go new file mode 100644 index 00000000..6708cee0 --- /dev/null +++ b/internal/client/generators/typescript/yaml_ref_oneof_e2e_test.go @@ -0,0 +1,109 @@ +package typescript + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xraph/forge/internal/client" +) + +// refAndOneOfYAML is a real OpenAPI YAML document (not a hand-built +// *client.Schema fixture) that a user would write on disk: a component +// schema "Widget", a "Container" schema whose "direct" property is a bare +// $ref to Widget, and whose "poly" property is a oneOf between a $ref to +// Widget and an inline string schema. This exercises the exact ingestion +// path (client.NewSpecParser().ParseFile against a YAML file) that phase 2's +// review found broken: shared.Schema.Ref and shared.Schema.OneOf both lacked +// yaml tags, so yaml.v3's no-tag fallback looked them up under "ref" and +// "oneof" — neither of which appears in this document — and silently left +// both fields unset. +const refAndOneOfYAML = ` +openapi: 3.1.0 +info: + title: Ref OneOf E2E + version: 1.0.0 +paths: + /noop: + get: + summary: noop + responses: + '200': + description: ok +components: + schemas: + Widget: + type: object + properties: + name: + type: string + Container: + type: object + properties: + direct: + $ref: '#/components/schemas/Widget' + poly: + oneOf: + - $ref: '#/components/schemas/Widget' + - type: string +` + +// TestRefAndOneOfEndToEndFromParsedYAML is the end-to-end proof for the +// yaml-tag fix: it parses a real OpenAPI YAML file via +// client.NewSpecParser().ParseFile, generates a TypeScript client from the +// result, and asserts both that the emitted types actually reference the +// Widget schema (via $ref) and emit the oneOf union — and that the result +// type-checks with tsc. Before the fix, Schema.Ref and Schema.OneOf never +// populated from YAML, so "direct" and "poly" both silently degraded to +// `any`, losing all type information with no error anywhere in the pipeline. +func TestRefAndOneOfEndToEndFromParsedYAML(t *testing.T) { + dir := t.TempDir() + specFile := filepath.Join(dir, "openapi.yaml") + + require.NoError(t, os.WriteFile(specFile, []byte(refAndOneOfYAML), 0o644)) + + parsed, err := client.NewSpecParser().ParseFile(context.Background(), specFile) + require.NoError(t, err) + + // Prove the parser itself populated the previously-broken fields before + // even reaching the generator, so a generator-side fix could not mask a + // still-broken parser. + container, ok := parsed.Schemas["Container"] + require.True(t, ok, "Container schema not found in parsed spec") + + direct, ok := container.Properties["direct"] + require.True(t, ok, "Container.direct property not found") + assert.Equal(t, "#/components/schemas/Widget", direct.Ref, "Container.direct.$ref must survive YAML parsing") + + poly, ok := container.Properties["poly"] + require.True(t, ok, "Container.poly property not found") + require.Len(t, poly.OneOf, 2, "Container.poly.oneOf must survive YAML parsing with both branches") + assert.Equal(t, "#/components/schemas/Widget", poly.OneOf[0].Ref) + assert.Equal(t, "string", poly.OneOf[1].Type) + + out, err := NewGenerator().Generate(context.Background(), parsed, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + // The referenced schema is emitted as its own named type. + assert.Contains(t, types, "export interface Widget {") + + // The $ref property resolves to the named type, not `any`. + assert.Contains(t, types, "direct?: Widget;") + assert.NotContains(t, types, "direct?: any;") + + // The oneOf property emits a real union of the two branches, not `any`. + assert.Contains(t, types, "poly?: Widget | string;") + assert.NotContains(t, types, "poly?: any;") + + outDir := t.TempDir() + writeTree(t, outDir, out.Files) + + errs := typeCheck(t, outDir) + assert.Empty(t, errs, "a client generated from a real parsed OpenAPI YAML file using $ref and oneOf must type-check cleanly:\n%s", strings.Join(errs, "\n")) +} diff --git a/internal/shared/asyncapi.go b/internal/shared/asyncapi.go index ed02b8c0..212dd595 100644 --- a/internal/shared/asyncapi.go +++ b/internal/shared/asyncapi.go @@ -51,18 +51,18 @@ type AsyncAPIInfo struct { Title string `json:"title"` Description string `json:"description,omitempty"` Version string `json:"version"` - TermsOfService string `json:"termsOfService,omitempty"` + TermsOfService string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` Contact *Contact `json:"contact,omitempty"` License *License `json:"license,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` } // AsyncAPIServer represents a server in the AsyncAPI spec. type AsyncAPIServer struct { Host string `json:"host,omitempty"` Protocol string `json:"protocol"` // ws, wss, sse, http, https - ProtocolVersion string `json:"protocolVersion,omitempty"` + ProtocolVersion string `json:"protocolVersion,omitempty" yaml:"protocolVersion,omitempty"` Pathname string `json:"pathname,omitempty"` Description string `json:"description,omitempty"` Title string `json:"title,omitempty"` @@ -70,7 +70,7 @@ type AsyncAPIServer struct { Variables map[string]*ServerVariable `json:"variables,omitempty"` Security []AsyncAPISecurityRequirement `json:"security,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIServerBindings `json:"bindings,omitempty"` } @@ -84,12 +84,12 @@ type AsyncAPIServerBindings struct { type WebSocketServerBinding struct { Headers *Schema `json:"headers,omitempty"` Query *Schema `json:"query,omitempty"` - BindingVersion string `json:"bindingVersion,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // HTTPServerBinding represents HTTP-specific server configuration. type HTTPServerBinding struct { - BindingVersion string `json:"bindingVersion,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // AsyncAPIChannel represents a channel in the AsyncAPI spec. @@ -102,7 +102,7 @@ type AsyncAPIChannel struct { Servers []AsyncAPIServerReference `json:"servers,omitempty"` Parameters map[string]*AsyncAPIParameter `json:"parameters,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIChannelBindings `json:"bindings,omitempty"` } @@ -117,13 +117,13 @@ type WebSocketChannelBinding struct { Method string `json:"method,omitempty"` // GET, POST Query *Schema `json:"query,omitempty"` Headers *Schema `json:"headers,omitempty"` - BindingVersion string `json:"bindingVersion,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // HTTPChannelBinding represents HTTP-specific channel configuration. type HTTPChannelBinding struct { Method string `json:"method,omitempty"` // GET, POST, etc. - BindingVersion string `json:"bindingVersion,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // AsyncAPIServerReference references a server. @@ -150,7 +150,7 @@ type AsyncAPIOperation struct { Description string `json:"description,omitempty"` Security []AsyncAPISecurityRequirement `json:"security,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIOperationBindings `json:"bindings,omitempty"` Traits []AsyncAPIOperationTrait `json:"traits,omitempty"` Messages []AsyncAPIMessageReference `json:"messages,omitempty"` @@ -175,14 +175,14 @@ type AsyncAPIOperationBindings struct { // WebSocketOperationBinding represents WebSocket-specific operation configuration. type WebSocketOperationBinding struct { - BindingVersion string `json:"bindingVersion,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // HTTPOperationBinding represents HTTP-specific operation configuration. type HTTPOperationBinding struct { Method string `json:"method,omitempty"` Query *Schema `json:"query,omitempty"` - BindingVersion string `json:"bindingVersion,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // AsyncAPIOperationTrait represents reusable operation characteristics. @@ -192,7 +192,7 @@ type AsyncAPIOperationTrait struct { Description string `json:"description,omitempty"` Security []AsyncAPISecurityRequirement `json:"security,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIOperationBindings `json:"bindings,omitempty"` } @@ -211,17 +211,17 @@ type AsyncAPIOperationReplyAddress struct { // AsyncAPIMessage represents a message in the AsyncAPI spec. type AsyncAPIMessage struct { - MessageID string `json:"messageId,omitempty"` + MessageID string `json:"messageId,omitempty" yaml:"messageId,omitempty"` Headers *Schema `json:"headers,omitempty"` Payload *Schema `json:"payload,omitempty"` - CorrelationID *AsyncAPICorrelationID `json:"correlationId,omitempty"` - ContentType string `json:"contentType,omitempty"` + CorrelationID *AsyncAPICorrelationID `json:"correlationId,omitempty" yaml:"correlationId,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` Name string `json:"name,omitempty"` Title string `json:"title,omitempty"` Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIMessageBindings `json:"bindings,omitempty"` Examples []AsyncAPIMessageExample `json:"examples,omitempty"` Traits []AsyncAPIMessageTrait `json:"traits,omitempty"` @@ -241,14 +241,14 @@ type AsyncAPIMessageBindings struct { // WebSocketMessageBinding represents WebSocket-specific message configuration. type WebSocketMessageBinding struct { - BindingVersion string `json:"bindingVersion,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // HTTPMessageBinding represents HTTP-specific message configuration. type HTTPMessageBinding struct { Headers *Schema `json:"headers,omitempty"` - StatusCode int `json:"statusCode,omitempty"` - BindingVersion string `json:"bindingVersion,omitempty"` + StatusCode int `json:"statusCode,omitempty" yaml:"statusCode,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } // AsyncAPIMessageExample represents an example of a message. @@ -261,16 +261,16 @@ type AsyncAPIMessageExample struct { // AsyncAPIMessageTrait represents reusable message characteristics. type AsyncAPIMessageTrait struct { - MessageID string `json:"messageId,omitempty"` + MessageID string `json:"messageId,omitempty" yaml:"messageId,omitempty"` Headers *Schema `json:"headers,omitempty"` - CorrelationID *AsyncAPICorrelationID `json:"correlationId,omitempty"` - ContentType string `json:"contentType,omitempty"` + CorrelationID *AsyncAPICorrelationID `json:"correlationId,omitempty" yaml:"correlationId,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` Name string `json:"name,omitempty"` Title string `json:"title,omitempty"` Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIMessageBindings `json:"bindings,omitempty"` Examples []AsyncAPIMessageExample `json:"examples,omitempty"` } @@ -282,27 +282,27 @@ type AsyncAPIComponents struct { Channels map[string]*AsyncAPIChannel `json:"channels,omitempty"` Operations map[string]*AsyncAPIOperation `json:"operations,omitempty"` Messages map[string]*AsyncAPIMessage `json:"messages,omitempty"` - SecuritySchemes map[string]*AsyncAPISecurityScheme `json:"securitySchemes,omitempty"` + SecuritySchemes map[string]*AsyncAPISecurityScheme `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` Parameters map[string]*AsyncAPIParameter `json:"parameters,omitempty"` - CorrelationIDs map[string]*AsyncAPICorrelationID `json:"correlationIds,omitempty"` - OperationTraits map[string]*AsyncAPIOperationTrait `json:"operationTraits,omitempty"` - MessageTraits map[string]*AsyncAPIMessageTrait `json:"messageTraits,omitempty"` - ServerBindings map[string]*AsyncAPIServerBindings `json:"serverBindings,omitempty"` - ChannelBindings map[string]*AsyncAPIChannelBindings `json:"channelBindings,omitempty"` - OperationBindings map[string]*AsyncAPIOperationBindings `json:"operationBindings,omitempty"` - MessageBindings map[string]*AsyncAPIMessageBindings `json:"messageBindings,omitempty"` + CorrelationIDs map[string]*AsyncAPICorrelationID `json:"correlationIds,omitempty" yaml:"correlationIds,omitempty"` + OperationTraits map[string]*AsyncAPIOperationTrait `json:"operationTraits,omitempty" yaml:"operationTraits,omitempty"` + MessageTraits map[string]*AsyncAPIMessageTrait `json:"messageTraits,omitempty" yaml:"messageTraits,omitempty"` + ServerBindings map[string]*AsyncAPIServerBindings `json:"serverBindings,omitempty" yaml:"serverBindings,omitempty"` + ChannelBindings map[string]*AsyncAPIChannelBindings `json:"channelBindings,omitempty" yaml:"channelBindings,omitempty"` + OperationBindings map[string]*AsyncAPIOperationBindings `json:"operationBindings,omitempty" yaml:"operationBindings,omitempty"` + MessageBindings map[string]*AsyncAPIMessageBindings `json:"messageBindings,omitempty" yaml:"messageBindings,omitempty"` } // AsyncAPISecurityScheme defines a security scheme. type AsyncAPISecurityScheme struct { Type string `json:"type"` // userPassword, apiKey, X509, symmetricEncryption, asymmetricEncryption, httpApiKey, http, oauth2, openIdConnect Description string `json:"description,omitempty"` - Name string `json:"name,omitempty"` // For apiKey and httpApiKey - In string `json:"in,omitempty"` // For apiKey and httpApiKey: user, password, query, header, cookie - Scheme string `json:"scheme,omitempty"` // For http: bearer, basic, etc. - BearerFormat string `json:"bearerFormat,omitempty"` // For http bearer - Flows *AsyncAPIOAuthFlows `json:"flows,omitempty"` // For oauth2 - OpenIdConnectUrl string `json:"openIdConnectUrl,omitempty"` // For openIdConnect + Name string `json:"name,omitempty"` // For apiKey and httpApiKey + In string `json:"in,omitempty"` // For apiKey and httpApiKey: user, password, query, header, cookie + Scheme string `json:"scheme,omitempty"` // For http: bearer, basic, etc. + BearerFormat string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` // For http bearer + Flows *AsyncAPIOAuthFlows `json:"flows,omitempty"` // For oauth2 + OpenIdConnectUrl string `json:"openIdConnectUrl,omitempty" yaml:"openIdConnectUrl,omitempty"` // For openIdConnect Scopes []string `json:"scopes,omitempty"` } @@ -310,8 +310,8 @@ type AsyncAPISecurityScheme struct { type AsyncAPIOAuthFlows struct { Implicit *OAuthFlow `json:"implicit,omitempty"` Password *OAuthFlow `json:"password,omitempty"` - ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty"` - AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty"` + ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty" yaml:"clientCredentials,omitempty"` + AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty" yaml:"authorizationCode,omitempty"` } // AsyncAPISecurityRequirement lists required security schemes. @@ -321,5 +321,5 @@ type AsyncAPISecurityRequirement map[string][]string type AsyncAPITag struct { Name string `json:"name"` Description string `json:"description,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` } diff --git a/internal/shared/openapi.go b/internal/shared/openapi.go index bc195e78..e58254fc 100644 --- a/internal/shared/openapi.go +++ b/internal/shared/openapi.go @@ -154,7 +154,7 @@ type OpenAPISpec struct { Components *Components `json:"components,omitempty"` Security []SecurityRequirement `json:"security,omitempty"` Tags []OpenAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Webhooks map[string]*PathItem `json:"webhooks,omitempty"` } @@ -163,7 +163,7 @@ type Info struct { Title string `json:"title"` Description string `json:"description,omitempty"` Version string `json:"version"` - TermsOfService string `json:"termsOfService,omitempty"` + TermsOfService string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` Contact *Contact `json:"contact,omitempty"` License *License `json:"license,omitempty"` } @@ -241,25 +241,25 @@ type Schema struct { Description string `json:"description,omitempty"` Default any `json:"default,omitempty"` Nullable bool `json:"nullable,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - WriteOnly bool `json:"writeOnly,omitempty"` + ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + WriteOnly bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty"` Example any `json:"example,omitempty"` Deprecated bool `json:"deprecated,omitempty"` // Validation - MultipleOf float64 `json:"multipleOf,omitempty"` + MultipleOf float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` Maximum float64 `json:"maximum,omitempty"` - ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty"` + ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` Minimum float64 `json:"minimum,omitempty"` - ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty"` - MaxLength int `json:"maxLength,omitempty"` - MinLength int `json:"minLength,omitempty"` + ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` + MaxLength int `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` + MinLength int `json:"minLength,omitempty" yaml:"minLength,omitempty"` Pattern string `json:"pattern,omitempty"` - MaxItems int `json:"maxItems,omitempty"` - MinItems int `json:"minItems,omitempty"` - UniqueItems bool `json:"uniqueItems,omitempty"` - MaxProperties int `json:"maxProperties,omitempty"` - MinProperties int `json:"minProperties,omitempty"` + MaxItems int `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` + MinItems int `json:"minItems,omitempty" yaml:"minItems,omitempty"` + UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` + MaxProperties int `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` + MinProperties int `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` Required []string `json:"required,omitempty"` Enum []any `json:"enum,omitempty"` @@ -279,21 +279,21 @@ type Schema struct { Items *Schema `json:"items,omitempty"` // Composition - AllOf []Schema `json:"allOf,omitempty"` - AnyOf []Schema `json:"anyOf,omitempty"` - OneOf []Schema `json:"oneOf,omitempty"` + AllOf []Schema `json:"allOf,omitempty" yaml:"allOf,omitempty"` + AnyOf []Schema `json:"anyOf,omitempty" yaml:"anyOf,omitempty"` + OneOf []Schema `json:"oneOf,omitempty" yaml:"oneOf,omitempty"` Not *Schema `json:"not,omitempty"` // Discriminator (OpenAPI 3.1.0) Discriminator *Discriminator `json:"discriminator,omitempty"` // Reference - Ref string `json:"$ref,omitempty"` + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` } // Discriminator supports polymorphism. type Discriminator struct { - PropertyName string `json:"propertyName"` + PropertyName string `json:"propertyName" yaml:"propertyName"` Mapping map[string]string `json:"mapping,omitempty"` } @@ -302,7 +302,7 @@ type Example struct { Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Value any `json:"value,omitempty"` - ExternalValue string `json:"externalValue,omitempty"` + ExternalValue string `json:"externalValue,omitempty" yaml:"externalValue,omitempty"` } // Header describes a single header parameter. @@ -316,21 +316,21 @@ type Header struct { // Link represents a possible design-time link for a response. type Link struct { - OperationRef string `json:"operationRef,omitempty"` - OperationID string `json:"operationId,omitempty"` + OperationRef string `json:"operationRef,omitempty" yaml:"operationRef,omitempty"` + OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` Parameters map[string]any `json:"parameters,omitempty"` - RequestBody any `json:"requestBody,omitempty"` + RequestBody any `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` Description string `json:"description,omitempty"` Server *OpenAPIServer `json:"server,omitempty"` } // Encoding defines encoding for a property. type Encoding struct { - ContentType string `json:"contentType,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` Headers map[string]*Header `json:"headers,omitempty"` Style string `json:"style,omitempty"` Explode bool `json:"explode,omitempty"` - AllowReserved bool `json:"allowReserved,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` } // Components holds reusable objects for the API spec. diff --git a/internal/shared/yaml_tags_test.go b/internal/shared/yaml_tags_test.go new file mode 100644 index 00000000..15a323c5 --- /dev/null +++ b/internal/shared/yaml_tags_test.go @@ -0,0 +1,371 @@ +package shared + +import ( + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// sharedStructsUnderTest lists every exported struct type declared in +// openapi.go and asyncapi.go. TestJSONYAMLTagParity walks each one via +// reflection; add new structs here as they are introduced so the guard +// keeps covering the whole package. +var sharedStructsUnderTest = []any{ + // openapi.go + OpenAPIConfig{}, + OpenAPIServer{}, + ServerVariable{}, + SecurityScheme{}, + OAuthFlows{}, + OAuthFlow{}, + OpenAPITag{}, + ExternalDocs{}, + Contact{}, + License{}, + OpenAPISpec{}, + Info{}, + PathItem{}, + Operation{}, + Parameter{}, + RequestBody{}, + Response{}, + MediaType{}, + Schema{}, + Discriminator{}, + Example{}, + Header{}, + Link{}, + Encoding{}, + Components{}, + + // asyncapi.go + AsyncAPIConfig{}, + AsyncAPISpec{}, + AsyncAPIInfo{}, + AsyncAPIServer{}, + AsyncAPIServerBindings{}, + WebSocketServerBinding{}, + HTTPServerBinding{}, + AsyncAPIChannel{}, + AsyncAPIChannelBindings{}, + WebSocketChannelBinding{}, + HTTPChannelBinding{}, + AsyncAPIServerReference{}, + AsyncAPIParameter{}, + AsyncAPIOperation{}, + AsyncAPIChannelReference{}, + AsyncAPIMessageReference{}, + AsyncAPIOperationBindings{}, + WebSocketOperationBinding{}, + HTTPOperationBinding{}, + AsyncAPIOperationTrait{}, + AsyncAPIOperationReply{}, + AsyncAPIOperationReplyAddress{}, + AsyncAPIMessage{}, + AsyncAPICorrelationID{}, + AsyncAPIMessageBindings{}, + WebSocketMessageBinding{}, + HTTPMessageBinding{}, + AsyncAPIMessageExample{}, + AsyncAPIMessageTrait{}, + AsyncAPIComponents{}, + AsyncAPISecurityScheme{}, + AsyncAPIOAuthFlows{}, + AsyncAPITag{}, +} + +// yamlDecodeKey reproduces gopkg.in/yaml.v3's own field-name resolution: the +// name portion of an explicit `yaml` tag if present, otherwise the field name +// lowercased. This mirrors yaml.v3's fieldInfo logic in yaml.go so the test +// asserts against the library's real behaviour, not an assumption about it. +func yamlDecodeKey(field reflect.StructField) (key string, skip bool) { + tag, ok := field.Tag.Lookup("yaml") + if !ok { + return strings.ToLower(field.Name), false + } + + parts := strings.Split(tag, ",") + if parts[0] == "-" { + return "", true + } + + if parts[0] == "" { + return strings.ToLower(field.Name), false + } + + return parts[0], false +} + +// TestJSONYAMLTagParity is a reflection-based guard: for every exported field +// with a `json` tag (excluding json:"-") on every struct in +// sharedStructsUnderTest, a `yaml` tag must be present whose key matches the +// json tag's key exactly (including the value before the first comma, so +// "$ref" is compared as "$ref" not "ref"). Without an explicit yaml tag, +// gopkg.in/yaml.v3 falls back to strings.ToLower(FieldName) when decoding +// YAML, which silently diverges from the json key for anything but an +// already-lowercase single-word name or a name containing punctuation like +// "$ref". This test is the permanent guard against the next field someone +// adds to either struct without a matching yaml tag. +func TestJSONYAMLTagParity(t *testing.T) { + for _, sample := range sharedStructsUnderTest { + typ := reflect.TypeOf(sample) + + t.Run(typ.Name(), func(t *testing.T) { + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() { + continue + } + + jsonTag, hasJSON := field.Tag.Lookup("json") + if !hasJSON { + continue + } + + jsonName := strings.Split(jsonTag, ",")[0] + if jsonName == "-" || jsonName == "" { + continue + } + + yamlKey, skip := yamlDecodeKey(field) + if skip { + t.Errorf("field %s.%s: yaml tag is \"-\" but json tag is %q; field is unreachable from YAML", typ.Name(), field.Name, jsonTag) + continue + } + + if yamlKey != jsonName { + t.Errorf("field %s.%s: yaml-decoded key %q does not match json key %q (json tag %q) — add `yaml:%q` to this field", + typ.Name(), field.Name, yamlKey, jsonName, jsonTag, jsonTag) + } + } + }) + } +} + +// unmarshalYAML is a small helper shared by the behavioural tests below: it +// unmarshals doc into a fresh *T and fails the test on error. +func unmarshalYAML[T any](t *testing.T, doc string) *T { + t.Helper() + + var v T + if err := yaml.Unmarshal([]byte(doc), &v); err != nil { + t.Fatalf("yaml.Unmarshal failed: %v", err) + } + + return &v +} + +// TestSchemaRefFromYAML asserts that Schema.Ref populates from a YAML +// document's "$ref" key. Before the fix, Schema had no yaml tag for Ref at +// all (json:"$ref,omitempty"), so yaml.v3's no-tag fallback looked up the +// field under the key "ref" (strings.ToLower("Ref")) — which never matches +// "$ref" in a real document. Every $ref in every YAML OpenAPI spec was +// silently dropped. +func TestSchemaRefFromYAML(t *testing.T) { + const doc = ` +$ref: "#/components/schemas/Widget" +` + s := unmarshalYAML[Schema](t, doc) + + if s.Ref != "#/components/schemas/Widget" { + t.Errorf("Schema.Ref = %q, want %q", s.Ref, "#/components/schemas/Widget") + } +} + +// TestSchemaCompositionKeywordsFromYAML asserts that Schema.OneOf, AnyOf, and +// AllOf populate from YAML. Before the fix these had no yaml tags, so +// yaml.v3's fallback looked them up as "oneof", "anyof", "allof" — none of +// which appear in a real spec (the camelCase JSON Schema keywords are +// "oneOf", "anyOf", "allOf") — silently losing all polymorphism information. +func TestSchemaCompositionKeywordsFromYAML(t *testing.T) { + const doc = ` +oneOf: + - type: string +anyOf: + - type: integer +allOf: + - type: boolean +` + s := unmarshalYAML[Schema](t, doc) + + if len(s.OneOf) != 1 || s.OneOf[0].Type != "string" { + t.Errorf("Schema.OneOf = %#v, want one element with Type \"string\"", s.OneOf) + } + + if len(s.AnyOf) != 1 || s.AnyOf[0].Type != "integer" { + t.Errorf("Schema.AnyOf = %#v, want one element with Type \"integer\"", s.AnyOf) + } + + if len(s.AllOf) != 1 || s.AllOf[0].Type != "boolean" { + t.Errorf("Schema.AllOf = %#v, want one element with Type \"boolean\"", s.AllOf) + } +} + +// TestSchemaReadOnlyWriteOnlyFromYAML asserts that Schema.ReadOnly and +// Schema.WriteOnly populate from YAML's camelCase "readOnly"/"writeOnly" +// keys, not the no-tag fallback's "readonly"/"writeonly". +func TestSchemaReadOnlyWriteOnlyFromYAML(t *testing.T) { + const doc = ` +readOnly: true +writeOnly: true +` + s := unmarshalYAML[Schema](t, doc) + + if !s.ReadOnly { + t.Error("Schema.ReadOnly = false, want true") + } + + if !s.WriteOnly { + t.Error("Schema.WriteOnly = false, want true") + } +} + +// TestSchemaAdditionalPropertiesStaysWorkingFromYAML is a regression guard: +// AdditionalProperties already carried an explicit yaml tag before this +// change (a prior, narrower fix). It must keep working. +func TestSchemaAdditionalPropertiesStaysWorkingFromYAML(t *testing.T) { + const doc = ` +additionalProperties: + type: string +` + s := unmarshalYAML[Schema](t, doc) + + m, ok := s.AdditionalProperties.(map[string]any) + if !ok { + t.Fatalf("Schema.AdditionalProperties = %#v (%T), want map[string]any", s.AdditionalProperties, s.AdditionalProperties) + } + + if m["type"] != "string" { + t.Errorf("Schema.AdditionalProperties[\"type\"] = %#v, want \"string\"", m["type"]) + } +} + +// TestOpenAPISpecExternalDocsFromYAML asserts that OpenAPISpec.ExternalDocs +// populates from YAML's "externalDocs" key rather than the no-tag fallback's +// "externaldocs". +func TestOpenAPISpecExternalDocsFromYAML(t *testing.T) { + const doc = ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {} +externalDocs: + description: More info + url: https://example.com/docs +` + s := unmarshalYAML[OpenAPISpec](t, doc) + + if s.ExternalDocs == nil { + t.Fatal("OpenAPISpec.ExternalDocs = nil, want a populated *ExternalDocs") + } + + if s.ExternalDocs.URL != "https://example.com/docs" { + t.Errorf("OpenAPISpec.ExternalDocs.URL = %q, want %q", s.ExternalDocs.URL, "https://example.com/docs") + } +} + +// TestInfoTermsOfServiceFromYAML asserts that Info.TermsOfService populates +// from YAML's "termsOfService" key rather than the no-tag fallback's +// "termsofservice". +func TestInfoTermsOfServiceFromYAML(t *testing.T) { + const doc = ` +title: Test +version: 1.0.0 +termsOfService: https://example.com/terms +` + info := unmarshalYAML[Info](t, doc) + + if info.TermsOfService != "https://example.com/terms" { + t.Errorf("Info.TermsOfService = %q, want %q", info.TermsOfService, "https://example.com/terms") + } +} + +// TestDiscriminatorPropertyNameFromYAML asserts that +// Discriminator.PropertyName populates from YAML's "propertyName" key rather +// than the no-tag fallback's "propertyname". +func TestDiscriminatorPropertyNameFromYAML(t *testing.T) { + const doc = ` +propertyName: petType +` + d := unmarshalYAML[Discriminator](t, doc) + + if d.PropertyName != "petType" { + t.Errorf("Discriminator.PropertyName = %q, want %q", d.PropertyName, "petType") + } +} + +// TestAsyncAPIMessageIDFromYAML asserts that AsyncAPIMessage.MessageID +// populates from YAML's "messageId" key rather than the no-tag fallback's +// "messageid". +func TestAsyncAPIMessageIDFromYAML(t *testing.T) { + const doc = ` +messageId: userSignedUp +` + m := unmarshalYAML[AsyncAPIMessage](t, doc) + + if m.MessageID != "userSignedUp" { + t.Errorf("AsyncAPIMessage.MessageID = %q, want %q", m.MessageID, "userSignedUp") + } +} + +// TestAsyncAPIComponentsSecuritySchemesFromYAML asserts that +// AsyncAPIComponents.SecuritySchemes populates from YAML's "securitySchemes" +// key rather than the no-tag fallback's "securityschemes". +func TestAsyncAPIComponentsSecuritySchemesFromYAML(t *testing.T) { + const doc = ` +securitySchemes: + apiKey: + type: httpApiKey + name: X-API-Key + in: header +` + c := unmarshalYAML[AsyncAPIComponents](t, doc) + + scheme, ok := c.SecuritySchemes["apiKey"] + if !ok { + t.Fatal("AsyncAPIComponents.SecuritySchemes[\"apiKey\"] not found") + } + + if scheme.Type != "httpApiKey" { + t.Errorf("SecuritySchemes[\"apiKey\"].Type = %q, want \"httpApiKey\"", scheme.Type) + } +} + +// TestAsyncAPIInfoExternalDocsFromYAML asserts that +// AsyncAPIInfo.ExternalDocs populates from YAML — one of the nine parent +// structs where ExternalDocs was broken. +func TestAsyncAPIInfoExternalDocsFromYAML(t *testing.T) { + const doc = ` +title: Test +version: 1.0.0 +externalDocs: + url: https://example.com/docs +` + info := unmarshalYAML[AsyncAPIInfo](t, doc) + + if info.ExternalDocs == nil { + t.Fatal("AsyncAPIInfo.ExternalDocs = nil, want a populated *ExternalDocs") + } + + if info.ExternalDocs.URL != "https://example.com/docs" { + t.Errorf("AsyncAPIInfo.ExternalDocs.URL = %q, want %q", info.ExternalDocs.URL, "https://example.com/docs") + } +} + +// TestAsyncAPIServerProtocolVersionFromYAML asserts that +// AsyncAPIServer.ProtocolVersion populates from YAML's "protocolVersion" key +// rather than the no-tag fallback's "protocolversion". +func TestAsyncAPIServerProtocolVersionFromYAML(t *testing.T) { + const doc = ` +protocol: wss +protocolVersion: "1.0" +` + s := unmarshalYAML[AsyncAPIServer](t, doc) + + if s.ProtocolVersion != "1.0" { + t.Errorf("AsyncAPIServer.ProtocolVersion = %q, want %q", s.ProtocolVersion, "1.0") + } +} From c9db9e9273ee8fc1509dbab13e9b795290f9ef94 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 10:54:21 -0500 Subject: [PATCH 29/71] fix(shared): replace hardcoded struct list with AST walk in yaml tag guard TestJSONYAMLTagParity relied on a hand-maintained sharedStructsUnderTest list, so a brand-new struct added to openapi.go/asyncapi.go without a matching entry would silently skip the guard entirely - the same class of silent YAML data loss the test exists to catch. Replace it with a go/parser walk over both files so every struct is covered automatically, and add an omitempty option-list check alongside the existing key check. Also fix the 31 tagalign violations the yaml tag additions left behind. --- internal/shared/asyncapi.go | 34 ++-- internal/shared/openapi.go | 28 +-- internal/shared/yaml_tags_test.go | 319 ++++++++++++++++++++---------- 3 files changed, 243 insertions(+), 138 deletions(-) diff --git a/internal/shared/asyncapi.go b/internal/shared/asyncapi.go index 212dd595..958ba0fd 100644 --- a/internal/shared/asyncapi.go +++ b/internal/shared/asyncapi.go @@ -55,7 +55,7 @@ type AsyncAPIInfo struct { Contact *Contact `json:"contact,omitempty"` License *License `json:"license,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` } // AsyncAPIServer represents a server in the AsyncAPI spec. @@ -70,7 +70,7 @@ type AsyncAPIServer struct { Variables map[string]*ServerVariable `json:"variables,omitempty"` Security []AsyncAPISecurityRequirement `json:"security,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIServerBindings `json:"bindings,omitempty"` } @@ -211,17 +211,17 @@ type AsyncAPIOperationReplyAddress struct { // AsyncAPIMessage represents a message in the AsyncAPI spec. type AsyncAPIMessage struct { - MessageID string `json:"messageId,omitempty" yaml:"messageId,omitempty"` + MessageID string `json:"messageId,omitempty" yaml:"messageId,omitempty"` Headers *Schema `json:"headers,omitempty"` Payload *Schema `json:"payload,omitempty"` CorrelationID *AsyncAPICorrelationID `json:"correlationId,omitempty" yaml:"correlationId,omitempty"` - ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` Name string `json:"name,omitempty"` Title string `json:"title,omitempty"` Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIMessageBindings `json:"bindings,omitempty"` Examples []AsyncAPIMessageExample `json:"examples,omitempty"` Traits []AsyncAPIMessageTrait `json:"traits,omitempty"` @@ -247,7 +247,7 @@ type WebSocketMessageBinding struct { // HTTPMessageBinding represents HTTP-specific message configuration. type HTTPMessageBinding struct { Headers *Schema `json:"headers,omitempty"` - StatusCode int `json:"statusCode,omitempty" yaml:"statusCode,omitempty"` + StatusCode int `json:"statusCode,omitempty" yaml:"statusCode,omitempty"` BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` } @@ -261,16 +261,16 @@ type AsyncAPIMessageExample struct { // AsyncAPIMessageTrait represents reusable message characteristics. type AsyncAPIMessageTrait struct { - MessageID string `json:"messageId,omitempty" yaml:"messageId,omitempty"` + MessageID string `json:"messageId,omitempty" yaml:"messageId,omitempty"` Headers *Schema `json:"headers,omitempty"` CorrelationID *AsyncAPICorrelationID `json:"correlationId,omitempty" yaml:"correlationId,omitempty"` - ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` Name string `json:"name,omitempty"` Title string `json:"title,omitempty"` Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Tags []AsyncAPITag `json:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` Bindings *AsyncAPIMessageBindings `json:"bindings,omitempty"` Examples []AsyncAPIMessageExample `json:"examples,omitempty"` } @@ -282,15 +282,15 @@ type AsyncAPIComponents struct { Channels map[string]*AsyncAPIChannel `json:"channels,omitempty"` Operations map[string]*AsyncAPIOperation `json:"operations,omitempty"` Messages map[string]*AsyncAPIMessage `json:"messages,omitempty"` - SecuritySchemes map[string]*AsyncAPISecurityScheme `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` + SecuritySchemes map[string]*AsyncAPISecurityScheme `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` Parameters map[string]*AsyncAPIParameter `json:"parameters,omitempty"` - CorrelationIDs map[string]*AsyncAPICorrelationID `json:"correlationIds,omitempty" yaml:"correlationIds,omitempty"` - OperationTraits map[string]*AsyncAPIOperationTrait `json:"operationTraits,omitempty" yaml:"operationTraits,omitempty"` - MessageTraits map[string]*AsyncAPIMessageTrait `json:"messageTraits,omitempty" yaml:"messageTraits,omitempty"` - ServerBindings map[string]*AsyncAPIServerBindings `json:"serverBindings,omitempty" yaml:"serverBindings,omitempty"` - ChannelBindings map[string]*AsyncAPIChannelBindings `json:"channelBindings,omitempty" yaml:"channelBindings,omitempty"` + CorrelationIDs map[string]*AsyncAPICorrelationID `json:"correlationIds,omitempty" yaml:"correlationIds,omitempty"` + OperationTraits map[string]*AsyncAPIOperationTrait `json:"operationTraits,omitempty" yaml:"operationTraits,omitempty"` + MessageTraits map[string]*AsyncAPIMessageTrait `json:"messageTraits,omitempty" yaml:"messageTraits,omitempty"` + ServerBindings map[string]*AsyncAPIServerBindings `json:"serverBindings,omitempty" yaml:"serverBindings,omitempty"` + ChannelBindings map[string]*AsyncAPIChannelBindings `json:"channelBindings,omitempty" yaml:"channelBindings,omitempty"` OperationBindings map[string]*AsyncAPIOperationBindings `json:"operationBindings,omitempty" yaml:"operationBindings,omitempty"` - MessageBindings map[string]*AsyncAPIMessageBindings `json:"messageBindings,omitempty" yaml:"messageBindings,omitempty"` + MessageBindings map[string]*AsyncAPIMessageBindings `json:"messageBindings,omitempty" yaml:"messageBindings,omitempty"` } // AsyncAPISecurityScheme defines a security scheme. @@ -300,7 +300,7 @@ type AsyncAPISecurityScheme struct { Name string `json:"name,omitempty"` // For apiKey and httpApiKey In string `json:"in,omitempty"` // For apiKey and httpApiKey: user, password, query, header, cookie Scheme string `json:"scheme,omitempty"` // For http: bearer, basic, etc. - BearerFormat string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` // For http bearer + BearerFormat string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` // For http bearer Flows *AsyncAPIOAuthFlows `json:"flows,omitempty"` // For oauth2 OpenIdConnectUrl string `json:"openIdConnectUrl,omitempty" yaml:"openIdConnectUrl,omitempty"` // For openIdConnect Scopes []string `json:"scopes,omitempty"` diff --git a/internal/shared/openapi.go b/internal/shared/openapi.go index e58254fc..bf11859c 100644 --- a/internal/shared/openapi.go +++ b/internal/shared/openapi.go @@ -241,25 +241,25 @@ type Schema struct { Description string `json:"description,omitempty"` Default any `json:"default,omitempty"` Nullable bool `json:"nullable,omitempty"` - ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` - WriteOnly bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty"` + ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + WriteOnly bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty"` Example any `json:"example,omitempty"` Deprecated bool `json:"deprecated,omitempty"` // Validation - MultipleOf float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` + MultipleOf float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` Maximum float64 `json:"maximum,omitempty"` ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` Minimum float64 `json:"minimum,omitempty"` ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` - MaxLength int `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` - MinLength int `json:"minLength,omitempty" yaml:"minLength,omitempty"` + MaxLength int `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` + MinLength int `json:"minLength,omitempty" yaml:"minLength,omitempty"` Pattern string `json:"pattern,omitempty"` - MaxItems int `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` - MinItems int `json:"minItems,omitempty" yaml:"minItems,omitempty"` - UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` - MaxProperties int `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` - MinProperties int `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` + MaxItems int `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` + MinItems int `json:"minItems,omitempty" yaml:"minItems,omitempty"` + UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` + MaxProperties int `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` + MinProperties int `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` Required []string `json:"required,omitempty"` Enum []any `json:"enum,omitempty"` @@ -293,7 +293,7 @@ type Schema struct { // Discriminator supports polymorphism. type Discriminator struct { - PropertyName string `json:"propertyName" yaml:"propertyName"` + PropertyName string `json:"propertyName" yaml:"propertyName"` Mapping map[string]string `json:"mapping,omitempty"` } @@ -317,16 +317,16 @@ type Header struct { // Link represents a possible design-time link for a response. type Link struct { OperationRef string `json:"operationRef,omitempty" yaml:"operationRef,omitempty"` - OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` + OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` Parameters map[string]any `json:"parameters,omitempty"` - RequestBody any `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` + RequestBody any `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` Description string `json:"description,omitempty"` Server *OpenAPIServer `json:"server,omitempty"` } // Encoding defines encoding for a property. type Encoding struct { - ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` Headers map[string]*Header `json:"headers,omitempty"` Style string `json:"style,omitempty"` Explode bool `json:"explode,omitempty"` diff --git a/internal/shared/yaml_tags_test.go b/internal/shared/yaml_tags_test.go index 15a323c5..1374775f 100644 --- a/internal/shared/yaml_tags_test.go +++ b/internal/shared/yaml_tags_test.go @@ -1,144 +1,249 @@ package shared import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" "reflect" + "runtime" + "sort" + "strconv" "strings" "testing" "gopkg.in/yaml.v3" ) -// sharedStructsUnderTest lists every exported struct type declared in -// openapi.go and asyncapi.go. TestJSONYAMLTagParity walks each one via -// reflection; add new structs here as they are introduced so the guard -// keeps covering the whole package. -var sharedStructsUnderTest = []any{ - // openapi.go - OpenAPIConfig{}, - OpenAPIServer{}, - ServerVariable{}, - SecurityScheme{}, - OAuthFlows{}, - OAuthFlow{}, - OpenAPITag{}, - ExternalDocs{}, - Contact{}, - License{}, - OpenAPISpec{}, - Info{}, - PathItem{}, - Operation{}, - Parameter{}, - RequestBody{}, - Response{}, - MediaType{}, - Schema{}, - Discriminator{}, - Example{}, - Header{}, - Link{}, - Encoding{}, - Components{}, - - // asyncapi.go - AsyncAPIConfig{}, - AsyncAPISpec{}, - AsyncAPIInfo{}, - AsyncAPIServer{}, - AsyncAPIServerBindings{}, - WebSocketServerBinding{}, - HTTPServerBinding{}, - AsyncAPIChannel{}, - AsyncAPIChannelBindings{}, - WebSocketChannelBinding{}, - HTTPChannelBinding{}, - AsyncAPIServerReference{}, - AsyncAPIParameter{}, - AsyncAPIOperation{}, - AsyncAPIChannelReference{}, - AsyncAPIMessageReference{}, - AsyncAPIOperationBindings{}, - WebSocketOperationBinding{}, - HTTPOperationBinding{}, - AsyncAPIOperationTrait{}, - AsyncAPIOperationReply{}, - AsyncAPIOperationReplyAddress{}, - AsyncAPIMessage{}, - AsyncAPICorrelationID{}, - AsyncAPIMessageBindings{}, - WebSocketMessageBinding{}, - HTTPMessageBinding{}, - AsyncAPIMessageExample{}, - AsyncAPIMessageTrait{}, - AsyncAPIComponents{}, - AsyncAPISecurityScheme{}, - AsyncAPIOAuthFlows{}, - AsyncAPITag{}, +// astField describes one exported, named struct field discovered by parsing +// the shared package's own source, together with its raw json/yaml struct +// tags (unparsed, exactly as written). +type astField struct { + structName string + fieldName string + jsonTag string + yamlTag string + hasYAML bool +} + +// exprString renders a field type expression back to source-like text for +// error messages (used for embedded-field diagnostics), without pulling in +// go/printer for one call site. +func exprString(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.StarExpr: + return "*" + exprString(e.X) + case *ast.SelectorExpr: + return exprString(e.X) + "." + e.Sel.Name + default: + return "" + } +} + +// parseStructFields walks every top-level struct type declared in the given +// source files (resolved relative to this test file's own directory, so it +// works regardless of the test runner's working directory) and returns one +// astField per exported, named field. +// +// This is deliberately a source-level (go/parser) walk rather than a +// hand-maintained list of struct literals fed through reflection. A +// hardcoded list has to be updated by hand every time a new struct is added +// to openapi.go or asyncapi.go, and nothing enforces that: a forgotten entry +// means TestJSONYAMLTagParity passes silently on a brand-new struct with +// mismatched json/yaml tags — the same silent-data-loss bug class this test +// exists to catch, just one level up (missing struct instead of missing +// field). Parsing the files directly means every struct is covered +// automatically, with no list to maintain or forget. +func parseStructFields(t *testing.T, filenames ...string) []astField { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed to resolve this test file's own path") + } + + dir := filepath.Dir(thisFile) + + var fields []astField + + fset := token.NewFileSet() + + for _, name := range filenames { + path := filepath.Join(dir, name) + + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + for _, field := range structType.Fields.List { + if len(field.Names) == 0 { + // Embedded (anonymous) field: `SomeType` with no + // identifier of its own — e.g. `io.Reader` embedded + // directly. None of the structs in openapi.go or + // asyncapi.go currently embed anything this way, so + // this case is deliberately left unhandled rather + // than guessed at: fail loudly so a future embed + // forces a real decision (what json/yaml key would + // yaml.v3 and encoding/json even use for it) instead + // of being silently skipped by the guard. + t.Fatalf("%s: struct %s has an embedded field (%s) with no explicit name; parseStructFields does not handle embedding — add explicit support before relying on the guard for this struct", + path, typeSpec.Name.Name, exprString(field.Type)) + } + + var tagStr string + + if field.Tag != nil { + unquoted, err := strconv.Unquote(field.Tag.Value) + if err != nil { + t.Fatalf("%s: struct %s: unquote tag %s: %v", path, typeSpec.Name.Name, field.Tag.Value, err) + } + + tagStr = unquoted + } + + tag := reflect.StructTag(tagStr) + + jsonTag, hasJSON := tag.Lookup("json") + if !hasJSON { + continue + } + + yamlTag, hasYAML := tag.Lookup("yaml") + + // A single field declaration may name multiple fields + // sharing one type and tag, e.g. `A, B string + // \`json:"..."\``. Rare in this codebase but legal Go; + // each name gets its own astField since each is an + // independent struct field as far as json/yaml are + // concerned. + for _, ident := range field.Names { + if !ident.IsExported() { + continue + } + + fields = append(fields, astField{ + structName: typeSpec.Name.Name, + fieldName: ident.Name, + jsonTag: jsonTag, + yamlTag: yamlTag, + hasYAML: hasYAML, + }) + } + } + } + } + } + + return fields } // yamlDecodeKey reproduces gopkg.in/yaml.v3's own field-name resolution: the // name portion of an explicit `yaml` tag if present, otherwise the field name // lowercased. This mirrors yaml.v3's fieldInfo logic in yaml.go so the test // asserts against the library's real behaviour, not an assumption about it. -func yamlDecodeKey(field reflect.StructField) (key string, skip bool) { - tag, ok := field.Tag.Lookup("yaml") - if !ok { - return strings.ToLower(field.Name), false +func yamlDecodeKey(fieldName, yamlTag string, hasYAML bool) (key string, skip bool) { + if !hasYAML { + return strings.ToLower(fieldName), false } - parts := strings.Split(tag, ",") + parts := strings.Split(yamlTag, ",") if parts[0] == "-" { return "", true } if parts[0] == "" { - return strings.ToLower(field.Name), false + return strings.ToLower(fieldName), false } return parts[0], false } -// TestJSONYAMLTagParity is a reflection-based guard: for every exported field -// with a `json` tag (excluding json:"-") on every struct in -// sharedStructsUnderTest, a `yaml` tag must be present whose key matches the -// json tag's key exactly (including the value before the first comma, so -// "$ref" is compared as "$ref" not "ref"). Without an explicit yaml tag, -// gopkg.in/yaml.v3 falls back to strings.ToLower(FieldName) when decoding -// YAML, which silently diverges from the json key for anything but an -// already-lowercase single-word name or a name containing punctuation like -// "$ref". This test is the permanent guard against the next field someone -// adds to either struct without a matching yaml tag. +// sortedOptions returns a sorted copy of opts, so option-list comparisons +// (e.g. ["omitempty"] vs ["omitempty"]) don't depend on declaration order. +func sortedOptions(opts []string) []string { + out := append([]string(nil), opts...) + sort.Strings(out) + + return out +} + +// TestJSONYAMLTagParity is a source-level guard, driven by parseStructFields +// above: for every exported field with a `json` tag (excluding json:"-") on +// every struct declared in openapi.go and asyncapi.go, a `yaml` tag must be +// present whose key matches the json tag's key exactly (including the value +// before the first comma, so "$ref" is compared as "$ref" not "ref"). +// Without an explicit yaml tag, gopkg.in/yaml.v3 falls back to +// strings.ToLower(FieldName) when decoding YAML, which silently diverges +// from the json key for anything but an already-lowercase single-word name +// or a name containing punctuation like "$ref". +// +// Where a yaml tag is present, its option list (everything after the first +// comma — in practice, "omitempty") must also match the json tag's option +// list, so a field that drops "omitempty" on one side but not the other +// (still a real behavioural divergence between JSON and YAML output) gets +// caught too. +// +// This test is the permanent guard against the next field — or the next +// whole struct — someone adds to either file without a matching yaml tag. func TestJSONYAMLTagParity(t *testing.T) { - for _, sample := range sharedStructsUnderTest { - typ := reflect.TypeOf(sample) + fields := parseStructFields(t, "openapi.go", "asyncapi.go") + if len(fields) == 0 { + t.Fatal("parseStructFields returned no fields; the AST walk is broken") + } - t.Run(typ.Name(), func(t *testing.T) { - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - if !field.IsExported() { - continue - } + for _, f := range fields { + f := f - jsonTag, hasJSON := field.Tag.Lookup("json") - if !hasJSON { - continue - } + t.Run(f.structName+"."+f.fieldName, func(t *testing.T) { + jsonParts := strings.Split(f.jsonTag, ",") + jsonName := jsonParts[0] - jsonName := strings.Split(jsonTag, ",")[0] - if jsonName == "-" || jsonName == "" { - continue - } + if jsonName == "-" || jsonName == "" { + return + } - yamlKey, skip := yamlDecodeKey(field) - if skip { - t.Errorf("field %s.%s: yaml tag is \"-\" but json tag is %q; field is unreachable from YAML", typ.Name(), field.Name, jsonTag) - continue - } + yamlKey, skip := yamlDecodeKey(f.fieldName, f.yamlTag, f.hasYAML) + if skip { + t.Errorf("field %s.%s: yaml tag is \"-\" but json tag is %q; field is unreachable from YAML", f.structName, f.fieldName, f.jsonTag) - if yamlKey != jsonName { - t.Errorf("field %s.%s: yaml-decoded key %q does not match json key %q (json tag %q) — add `yaml:%q` to this field", - typ.Name(), field.Name, yamlKey, jsonName, jsonTag, jsonTag) - } + return + } + + if yamlKey != jsonName { + t.Errorf("field %s.%s: yaml-decoded key %q does not match json key %q (json tag %q) — add `yaml:%q` to this field", + f.structName, f.fieldName, yamlKey, jsonName, f.jsonTag, f.jsonTag) + } + + if !f.hasYAML { + return + } + + jsonOpts := sortedOptions(jsonParts[1:]) + yamlOpts := sortedOptions(strings.Split(f.yamlTag, ",")[1:]) + + if !reflect.DeepEqual(jsonOpts, yamlOpts) { + t.Errorf("field %s.%s: yaml tag options %v do not match json tag options %v (json %q, yaml %q)", + f.structName, f.fieldName, yamlOpts, jsonOpts, f.jsonTag, f.yamlTag) } }) } From f53569b14d9c33457a6f80d2ca3985fc952974e6 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 11:09:56 -0500 Subject: [PATCH 30/71] fix(shared): tag OAuthFlows fields and close yaml-tag-parity guard blind spot OAuthFlows (openapi.go) had four exported fields with no struct tags at all. yaml.v3's no-tag fallback made ClientCredentials/AuthorizationCode decode as nil from every YAML-sourced spec (it looked for "clientcredentials"/"authorizationcode", which never appear in a real document), while JSON marshal emitted capitalized Go field names instead of the spec-correct camelCase keys. The yaml-tag-parity guard in yaml_tags_test.go couldn't catch any of this: it skipped fields with no json tag before ever constructing a subtest for them, so a field with no tags of any kind was structurally invisible to it. - Add json/yaml tags to OAuthFlows' four fields with OpenAPI-correct camelCase names, and add the missing yaml tags on AsyncAPIOAuthFlows.Implicit/.Password found during the same sweep. - Extend the guard: every exported field on a struct in openapi.go/asyncapi.go must now declare an explicit json tag, unless its struct is in a new goOnlyStructs allowlist. AsyncAPIConfig is the only entry (constructed only via forge.WithAsyncAPI(...) in Go, never unmarshalled from a spec file). An "exempt all-tagless structs" heuristic was considered and rejected: OAuthFlows was itself all-tagless before this fix, so that heuristic would have exempted the very struct the guard needs to catch. - Extract the per-field check into runTagParityCheck behind a small tFailer interface so the new rule has direct unit test coverage (recordingT) independent of go/parser walking real files. --- internal/shared/asyncapi.go | 4 +- internal/shared/openapi.go | 8 +- internal/shared/yaml_tags_test.go | 497 ++++++++++++++++++++++++------ 3 files changed, 401 insertions(+), 108 deletions(-) diff --git a/internal/shared/asyncapi.go b/internal/shared/asyncapi.go index 958ba0fd..c77169ea 100644 --- a/internal/shared/asyncapi.go +++ b/internal/shared/asyncapi.go @@ -308,8 +308,8 @@ type AsyncAPISecurityScheme struct { // AsyncAPIOAuthFlows defines OAuth 2.0 flows (compatible with OpenAPI OAuthFlows). type AsyncAPIOAuthFlows struct { - Implicit *OAuthFlow `json:"implicit,omitempty"` - Password *OAuthFlow `json:"password,omitempty"` + Implicit *OAuthFlow `json:"implicit,omitempty" yaml:"implicit,omitempty"` + Password *OAuthFlow `json:"password,omitempty" yaml:"password,omitempty"` ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty" yaml:"clientCredentials,omitempty"` AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty" yaml:"authorizationCode,omitempty"` } diff --git a/internal/shared/openapi.go b/internal/shared/openapi.go index bf11859c..056569af 100644 --- a/internal/shared/openapi.go +++ b/internal/shared/openapi.go @@ -68,10 +68,10 @@ type SecurityScheme struct { // OAuthFlows defines OAuth 2.0 flows. type OAuthFlows struct { - Implicit *OAuthFlow - Password *OAuthFlow - ClientCredentials *OAuthFlow - AuthorizationCode *OAuthFlow + Implicit *OAuthFlow `json:"implicit,omitempty" yaml:"implicit,omitempty"` + Password *OAuthFlow `json:"password,omitempty" yaml:"password,omitempty"` + ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty" yaml:"clientCredentials,omitempty"` + AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty" yaml:"authorizationCode,omitempty"` } // OAuthFlow defines a single OAuth 2.0 flow. diff --git a/internal/shared/yaml_tags_test.go b/internal/shared/yaml_tags_test.go index 1374775f..4e987de6 100644 --- a/internal/shared/yaml_tags_test.go +++ b/internal/shared/yaml_tags_test.go @@ -1,6 +1,8 @@ package shared import ( + "encoding/json" + "fmt" "go/ast" "go/parser" "go/token" @@ -22,6 +24,7 @@ type astField struct { structName string fieldName string jsonTag string + hasJSON bool yamlTag string hasYAML bool } @@ -78,77 +81,96 @@ func parseStructFields(t *testing.T, filenames ...string) []astField { t.Fatalf("parse %s: %v", path, err) } - for _, decl := range file.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.TYPE { + fields = append(fields, extractFields(t, path, file)...) + } + + return fields +} + +// extractFields walks every top-level struct type declared in file and +// returns one astField per exported, named field. path is used only to +// prefix error/fatal messages so they point at the right source location. +// +// Unlike an earlier version of this function, a field with no json tag at +// all is still returned (with hasJSON: false) rather than skipped. Skipping +// it was the guard's blind spot: OAuthFlows.ClientCredentials and +// .AuthorizationCode had no struct tags whatsoever, so they never became an +// astField and therefore never became a TestJSONYAMLTagParity subtest — +// putting a deliberately wrong yaml tag on one of them didn't turn the guard +// red, because the field was invisible to it. Callers (runTagParityCheck) +// now decide what a tagless field means for a given struct. +func extractFields(t *testing.T, path string, file *ast.File) []astField { + t.Helper() + + var fields []astField + + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { continue } - for _, spec := range genDecl.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } - structType, ok := typeSpec.Type.(*ast.StructType) - if !ok { - continue + for _, field := range structType.Fields.List { + if len(field.Names) == 0 { + // Embedded (anonymous) field: `SomeType` with no + // identifier of its own — e.g. `io.Reader` embedded + // directly. None of the structs in openapi.go or + // asyncapi.go currently embed anything this way, so + // this case is deliberately left unhandled rather + // than guessed at: fail loudly so a future embed + // forces a real decision (what json/yaml key would + // yaml.v3 and encoding/json even use for it) instead + // of being silently skipped by the guard. + t.Fatalf("%s: struct %s has an embedded field (%s) with no explicit name; parseStructFields does not handle embedding — add explicit support before relying on the guard for this struct", + path, typeSpec.Name.Name, exprString(field.Type)) } - for _, field := range structType.Fields.List { - if len(field.Names) == 0 { - // Embedded (anonymous) field: `SomeType` with no - // identifier of its own — e.g. `io.Reader` embedded - // directly. None of the structs in openapi.go or - // asyncapi.go currently embed anything this way, so - // this case is deliberately left unhandled rather - // than guessed at: fail loudly so a future embed - // forces a real decision (what json/yaml key would - // yaml.v3 and encoding/json even use for it) instead - // of being silently skipped by the guard. - t.Fatalf("%s: struct %s has an embedded field (%s) with no explicit name; parseStructFields does not handle embedding — add explicit support before relying on the guard for this struct", - path, typeSpec.Name.Name, exprString(field.Type)) - } + var tagStr string - var tagStr string + if field.Tag != nil { + unquoted, err := strconv.Unquote(field.Tag.Value) + if err != nil { + t.Fatalf("%s: struct %s: unquote tag %s: %v", path, typeSpec.Name.Name, field.Tag.Value, err) + } - if field.Tag != nil { - unquoted, err := strconv.Unquote(field.Tag.Value) - if err != nil { - t.Fatalf("%s: struct %s: unquote tag %s: %v", path, typeSpec.Name.Name, field.Tag.Value, err) - } + tagStr = unquoted + } - tagStr = unquoted - } + tag := reflect.StructTag(tagStr) - tag := reflect.StructTag(tagStr) + jsonTag, hasJSON := tag.Lookup("json") + yamlTag, hasYAML := tag.Lookup("yaml") - jsonTag, hasJSON := tag.Lookup("json") - if !hasJSON { + // A single field declaration may name multiple fields + // sharing one type and tag, e.g. `A, B string + // \`json:"..."\``. Rare in this codebase but legal Go; + // each name gets its own astField since each is an + // independent struct field as far as json/yaml are + // concerned. + for _, ident := range field.Names { + if !ident.IsExported() { continue } - yamlTag, hasYAML := tag.Lookup("yaml") - - // A single field declaration may name multiple fields - // sharing one type and tag, e.g. `A, B string - // \`json:"..."\``. Rare in this codebase but legal Go; - // each name gets its own astField since each is an - // independent struct field as far as json/yaml are - // concerned. - for _, ident := range field.Names { - if !ident.IsExported() { - continue - } - - fields = append(fields, astField{ - structName: typeSpec.Name.Name, - fieldName: ident.Name, - jsonTag: jsonTag, - yamlTag: yamlTag, - hasYAML: hasYAML, - }) - } + fields = append(fields, astField{ + structName: typeSpec.Name.Name, + fieldName: ident.Name, + jsonTag: jsonTag, + hasJSON: hasJSON, + yamlTag: yamlTag, + hasYAML: hasYAML, + }) } } } @@ -187,24 +209,122 @@ func sortedOptions(opts []string) []string { return out } -// TestJSONYAMLTagParity is a source-level guard, driven by parseStructFields -// above: for every exported field with a `json` tag (excluding json:"-") on -// every struct declared in openapi.go and asyncapi.go, a `yaml` tag must be -// present whose key matches the json tag's key exactly (including the value -// before the first comma, so "$ref" is compared as "$ref" not "ref"). -// Without an explicit yaml tag, gopkg.in/yaml.v3 falls back to -// strings.ToLower(FieldName) when decoding YAML, which silently diverges -// from the json key for anything but an already-lowercase single-word name -// or a name containing punctuation like "$ref". +// goOnlyStructs lists structs in openapi.go/asyncapi.go that are exclusively +// constructed and consumed in Go code and are never unmarshalled from (or +// marshalled to) a spec file on disk. AsyncAPIConfig is populated only via +// forge.WithAsyncAPI(...) in application code and consumed only by the +// AsyncAPI generator — there is no YAML/JSON unmarshal call site for it +// anywhere in the codebase — so its fields are exempt from the "every +// exported field must declare an explicit json tag" rule enforced by +// runTagParityCheck below. // -// Where a yaml tag is present, its option list (everything after the first -// comma — in practice, "omitempty") must also match the json tag's option -// list, so a field that drops "omitempty" on one side but not the other -// (still a real behavioural divergence between JSON and YAML output) gets -// caught too. +// This is a small, explicit, reviewed allowlist rather than an automatic +// "skip a struct if every one of its fields is tagless" heuristic. That +// heuristic looks appealing at first — an all-tagless struct does look like +// a Go-only config type — but it is unsafe here: OAuthFlows, the very +// struct whose four untagged fields motivated this guard extension, also +// has every field tagless. A heuristic keyed on "all fields tagless" would +// have exempted exactly the struct this guard needs to catch. Adding an +// entry to this map is a deliberate decision made after confirming (as was +// done for AsyncAPIConfig — see the review notes on this change) that the +// struct is genuinely never serialized; don't add one just to silence a +// failure. +var goOnlyStructs = map[string]bool{ + "AsyncAPIConfig": true, +} + +// tFailer is the minimal subset of *testing.T that runTagParityCheck needs. +// Extracting it lets the check run either as a normal subtest (via +// *testing.T) or against recordingT in the guard's own regression tests +// (TestTaglessFieldInSpecStructFailsTagParityGuard, +// TestTaglessFieldInGoOnlyStructIsExempt), which assert on the check's +// pass/fail outcome directly instead of shelling out to a nested `go test`. +type tFailer interface { + Helper() + Errorf(format string, args ...any) + Fatalf(format string, args ...any) +} + +// runTagParityCheck is the per-field body of TestJSONYAMLTagParity. It +// enforces two rules, in order: +// +// 1. Every exported field on a struct not listed in goOnlyStructs must +// declare an explicit json tag. Before this rule existed, a field with +// no tags at all was invisible to the guard: extractFields's predecessor +// skipped it before ever constructing an astField, so it never became a +// subtest and no assertion — right or wrong — could run against it. That +// is exactly how OAuthFlows.ClientCredentials and .AuthorizationCode +// went unnoticed (arriving nil from every YAML-sourced spec) while a +// deliberately-wrong yaml tag planted on the same field during review +// left the old guard green. These are wire-format structs; every field +// should declare its own name rather than lean on encoding/json's +// lenient case-insensitive unmarshal fallback, which gopkg.in/yaml.v3 +// does not share. +// 2. For every field that does have a json tag (excluding json:"-"), a +// yaml tag must be present whose key matches the json tag's key exactly +// (including the value before the first comma, so "$ref" is compared as +// "$ref" not "ref"), and whose option list (in practice, "omitempty") +// matches too. This is the original TestJSONYAMLTagParity rule, +// unchanged: without an explicit yaml tag, yaml.v3 falls back to +// strings.ToLower(FieldName) when decoding, which silently diverges +// from the json key for anything but an already-lowercase single-word +// name. +func runTagParityCheck(t tFailer, f astField) { + t.Helper() + + if goOnlyStructs[f.structName] { + return + } + + if !f.hasJSON { + t.Fatalf("field %s.%s has no json tag; spec structs in openapi.go/asyncapi.go must declare an explicit wire-format name via `json:\"...\"` (or, if %s is genuinely never serialized to/from a spec file, add it to goOnlyStructs in yaml_tags_test.go)", + f.structName, f.fieldName, f.structName) + + return + } + + jsonParts := strings.Split(f.jsonTag, ",") + jsonName := jsonParts[0] + + if jsonName == "-" || jsonName == "" { + return + } + + yamlKey, skip := yamlDecodeKey(f.fieldName, f.yamlTag, f.hasYAML) + if skip { + t.Errorf("field %s.%s: yaml tag is \"-\" but json tag is %q; field is unreachable from YAML", f.structName, f.fieldName, f.jsonTag) + + return + } + + if yamlKey != jsonName { + t.Errorf("field %s.%s: yaml-decoded key %q does not match json key %q (json tag %q) — add `yaml:%q` to this field", + f.structName, f.fieldName, yamlKey, jsonName, f.jsonTag, f.jsonTag) + } + + if !f.hasYAML { + return + } + + jsonOpts := sortedOptions(jsonParts[1:]) + yamlOpts := sortedOptions(strings.Split(f.yamlTag, ",")[1:]) + + if !reflect.DeepEqual(jsonOpts, yamlOpts) { + t.Errorf("field %s.%s: yaml tag options %v do not match json tag options %v (json %q, yaml %q)", + f.structName, f.fieldName, yamlOpts, jsonOpts, f.jsonTag, f.yamlTag) + } +} + +// TestJSONYAMLTagParity is a source-level guard, driven by parseStructFields +// and runTagParityCheck above: every exported field on every struct declared +// in openapi.go and asyncapi.go must declare an explicit json tag (unless +// its struct is in goOnlyStructs), and wherever a json tag is present a +// matching yaml tag must be present too. See runTagParityCheck's doc comment +// for the full rule and its rationale. // // This test is the permanent guard against the next field — or the next -// whole struct — someone adds to either file without a matching yaml tag. +// whole struct — someone adds to either file without a matching yaml tag, +// or without any tag at all. func TestJSONYAMLTagParity(t *testing.T) { fields := parseStructFields(t, "openapi.go", "asyncapi.go") if len(fields) == 0 { @@ -215,36 +335,7 @@ func TestJSONYAMLTagParity(t *testing.T) { f := f t.Run(f.structName+"."+f.fieldName, func(t *testing.T) { - jsonParts := strings.Split(f.jsonTag, ",") - jsonName := jsonParts[0] - - if jsonName == "-" || jsonName == "" { - return - } - - yamlKey, skip := yamlDecodeKey(f.fieldName, f.yamlTag, f.hasYAML) - if skip { - t.Errorf("field %s.%s: yaml tag is \"-\" but json tag is %q; field is unreachable from YAML", f.structName, f.fieldName, f.jsonTag) - - return - } - - if yamlKey != jsonName { - t.Errorf("field %s.%s: yaml-decoded key %q does not match json key %q (json tag %q) — add `yaml:%q` to this field", - f.structName, f.fieldName, yamlKey, jsonName, f.jsonTag, f.jsonTag) - } - - if !f.hasYAML { - return - } - - jsonOpts := sortedOptions(jsonParts[1:]) - yamlOpts := sortedOptions(strings.Split(f.yamlTag, ",")[1:]) - - if !reflect.DeepEqual(jsonOpts, yamlOpts) { - t.Errorf("field %s.%s: yaml tag options %v do not match json tag options %v (json %q, yaml %q)", - f.structName, f.fieldName, yamlOpts, jsonOpts, f.jsonTag, f.yamlTag) - } + runTagParityCheck(t, f) }) } } @@ -474,3 +565,205 @@ protocolVersion: "1.0" t.Errorf("AsyncAPIServer.ProtocolVersion = %q, want %q", s.ProtocolVersion, "1.0") } } + +// TestOAuthFlowsClientCredentialsAndAuthorizationCodeFromYAML asserts that +// OAuthFlows.ClientCredentials and OAuthFlows.AuthorizationCode populate from +// YAML's camelCase "clientCredentials"/"authorizationCode" keys. Before the +// fix, OAuthFlows had no struct tags at all on any of its four fields, so +// yaml.v3's no-tag fallback looked them up as "clientcredentials" and +// "authorizationcode" (strings.ToLower of the Go field name) — keys that +// never appear in a real OpenAPI document, so these two flows silently +// decoded as nil for every YAML-sourced spec. (Implicit and Password +// happened to decode correctly by coincidence: their Go names are already a +// single lowercase word, so the no-tag fallback's lowercased key matches the +// real "implicit"/"password" keys.) +func TestOAuthFlowsClientCredentialsAndAuthorizationCodeFromYAML(t *testing.T) { + const doc = ` +implicit: + authorizationUrl: https://example.com/authorize +password: + tokenUrl: https://example.com/token +clientCredentials: + tokenUrl: https://example.com/client-credentials-token +authorizationCode: + authorizationUrl: https://example.com/authorize + tokenUrl: https://example.com/authorization-code-token +` + f := unmarshalYAML[OAuthFlows](t, doc) + + if f.ClientCredentials == nil { + t.Fatal("OAuthFlows.ClientCredentials = nil, want a populated *OAuthFlow (yaml key \"clientCredentials\")") + } + + if f.ClientCredentials.TokenURL != "https://example.com/client-credentials-token" { + t.Errorf("OAuthFlows.ClientCredentials.TokenURL = %q, want %q", f.ClientCredentials.TokenURL, "https://example.com/client-credentials-token") + } + + if f.AuthorizationCode == nil { + t.Fatal("OAuthFlows.AuthorizationCode = nil, want a populated *OAuthFlow (yaml key \"authorizationCode\")") + } + + if f.AuthorizationCode.TokenURL != "https://example.com/authorization-code-token" { + t.Errorf("OAuthFlows.AuthorizationCode.TokenURL = %q, want %q", f.AuthorizationCode.TokenURL, "https://example.com/authorization-code-token") + } +} + +// TestOAuthFlowsJSONMarshalUsesCamelCaseKeys asserts that marshalling +// OAuthFlows to JSON emits the OpenAPI-correct camelCase keys +// ("clientCredentials", "authorizationCode") rather than Go's default +// capitalized field names ("ClientCredentials", "AuthorizationCode"). Before +// the fix, OAuthFlows had no json tags, so encoding/json fell back to the +// literal Go field name on marshal — decode was accidentally lenient +// (case-insensitive field matching) but encode was not, so any OAuthFlows +// round-tripped through this codebase's own marshaller would emit spec- +// incompatible keys. +func TestOAuthFlowsJSONMarshalUsesCamelCaseKeys(t *testing.T) { + flows := OAuthFlows{ + Implicit: &OAuthFlow{AuthorizationURL: "https://example.com/authorize"}, + Password: &OAuthFlow{TokenURL: "https://example.com/token"}, + ClientCredentials: &OAuthFlow{TokenURL: "https://example.com/client-credentials-token"}, + AuthorizationCode: &OAuthFlow{TokenURL: "https://example.com/authorization-code-token"}, + } + + b, err := json.Marshal(flows) + if err != nil { + t.Fatalf("json.Marshal(OAuthFlows) failed: %v", err) + } + + s := string(b) + + for _, want := range []string{`"implicit"`, `"password"`, `"clientCredentials"`, `"authorizationCode"`} { + if !strings.Contains(s, want) { + t.Errorf("marshalled OAuthFlows missing expected camelCase key %s: %s", want, s) + } + } + + for _, unwanted := range []string{`"Implicit"`, `"Password"`, `"ClientCredentials"`, `"AuthorizationCode"`} { + if strings.Contains(s, unwanted) { + t.Errorf("marshalled OAuthFlows still emits capitalized Go field name %s: %s", unwanted, s) + } + } +} + +// parseStructFieldsFromSource is like parseStructFields but parses supplied +// source text directly instead of reading openapi.go/asyncapi.go off disk. +// This lets the guard's own field-discovery logic be exercised against +// synthetic structs, independent of whatever real structs currently exist in +// the shared package — used by TestTaglessFieldIsSurfacedByParser below to +// prove the parser's blind spot (skipping fields with no json tag entirely) +// without needing to temporarily vandalize openapi.go/asyncapi.go for a +// permanent test. +func parseStructFieldsFromSource(t *testing.T, filename, src string) []astField { + t.Helper() + + fset := token.NewFileSet() + + file, err := parser.ParseFile(fset, filename, src, 0) + if err != nil { + t.Fatalf("parse synthetic source %s: %v", filename, err) + } + + return extractFields(t, filename, file) +} + +// TestTaglessFieldIsSurfacedByParser proves parseStructFields no longer has +// a blind spot for exported fields with no json tag at all. Before the fix, +// parseStructFields did `jsonTag, hasJSON := tag.Lookup("json"); if !hasJSON +// { continue }`, which meant a field like OAuthFlows.ClientCredentials (no +// tag of any kind) never became an astField and therefore never became a +// subtest — TestJSONYAMLTagParity was structurally incapable of catching it, +// no matter how wrong the field was. This test parses a synthetic struct +// with one tagless field and asserts that field is present in the returned +// []astField with hasJSON == false, so the tag-parity test (and any +// allowlist logic layered on top of it) actually gets a chance to see it and +// fail. +func TestTaglessFieldIsSurfacedByParser(t *testing.T) { + const src = `package shared + +type SyntheticSpecStruct struct { + Foo string + Bar string ` + "`json:\"bar\"`" + ` +} +` + fields := parseStructFieldsFromSource(t, "synthetic.go", src) + + var found bool + + for _, f := range fields { + if f.structName == "SyntheticSpecStruct" && f.fieldName == "Foo" { + found = true + + if f.hasJSON { + t.Errorf("SyntheticSpecStruct.Foo: hasJSON = true, want false (field has no json tag in the synthetic source)") + } + } + } + + if !found { + t.Fatal("SyntheticSpecStruct.Foo was not returned by parseStructFieldsFromSource; the parser is still silently skipping fields with no json tag — this is the exact blind spot that let OAuthFlows.ClientCredentials/.AuthorizationCode hide from the guard") + } +} + +// TestTaglessFieldInSpecStructFailsTagParityGuard proves the extended guard +// rule end-to-end: an exported field with no json tag on a struct that is +// NOT in the go-only allowlist must produce a t.Errorf/t.Fatalf from +// runTagParityCheck (the extracted per-field check used by +// TestJSONYAMLTagParity). This exercises the actual assertion function +// rather than re-deriving its logic, so a future edit to the rule can't +// silently diverge between the real guard and this regression test. +func TestTaglessFieldInSpecStructFailsTagParityGuard(t *testing.T) { + f := astField{ + structName: "SyntheticSpecStruct", + fieldName: "Foo", + hasJSON: false, + } + + rt := &recordingT{} + runTagParityCheck(rt, f) + + if !rt.failed { + t.Fatal("runTagParityCheck did not fail for a tagless exported field on a non-allowlisted struct; the guard's blind spot is not closed") + } +} + +// TestTaglessFieldInGoOnlyStructIsExempt proves the false-positive side of +// the same rule: a tagless field on a struct named in goOnlyStructs (e.g. +// AsyncAPIConfig, which is only ever constructed in Go code via +// forge.WithAsyncAPI(...) and never unmarshalled from a spec file) must NOT +// fail, so the stricter rule doesn't turn every one of AsyncAPIConfig's +// intentionally-untagged fields into a spurious failure. +func TestTaglessFieldInGoOnlyStructIsExempt(t *testing.T) { + f := astField{ + structName: "AsyncAPIConfig", + fieldName: "Title", + hasJSON: false, + } + + rt := &recordingT{} + runTagParityCheck(rt, f) + + if rt.failed { + t.Fatalf("runTagParityCheck failed for AsyncAPIConfig.Title, want no failure: %v", rt.errors) + } +} + +// recordingT is a minimal stand-in for *testing.T that records +// Errorf/Fatalf calls instead of acting on them, so runTagParityCheck's +// pass/fail outcome can be asserted on directly without spawning a nested +// `go test -run` subprocess. +type recordingT struct { + failed bool + errors []string +} + +func (r *recordingT) Helper() {} + +func (r *recordingT) Errorf(format string, args ...any) { + r.failed = true + r.errors = append(r.errors, fmt.Sprintf(format, args...)) +} + +func (r *recordingT) Fatalf(format string, args ...any) { + r.failed = true + r.errors = append(r.errors, fmt.Sprintf(format, args...)) +} From 463b9464edb06f085c9d7d5f9187b0d3aa94e399 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 11:27:13 -0500 Subject: [PATCH 31/71] feat(client/typescript): emit polymorphic schemas as unions --- .../client/generators/typescript/gate_test.go | 42 +++ .../client/generators/typescript/generator.go | 34 ++ .../generators/typescript/polymorphic_test.go | 342 ++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 internal/client/generators/typescript/polymorphic_test.go diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 884173a3..2d3f5639 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -645,6 +645,48 @@ components: assert.Empty(t, errs, "a client generated from a real parsed OpenAPI YAML file with a schema-valued additionalProperties must type-check cleanly") } +// TestDiscriminatedUnion asserts that a polymorphic schema (oneOf, here with +// a discriminator) is emitted as a real TypeScript union instead of falling +// to schemaToTypeScript's default branch, which — before this fix — produced +// `export type Pet = any;` because a polymorphic schema has no Schema.Type to +// switch on. +func TestDiscriminatedUnion(t *testing.T) { + spec := baseSpec() + spec.Schemas["Cat"] = &client.Schema{ + Type: "object", + Required: []string{"kind", "meows"}, + Properties: map[string]*client.Schema{ + "kind": {Type: "string", Enum: []any{"cat"}}, + "meows": {Type: "boolean"}, + }, + } + spec.Schemas["Dog"] = &client.Schema{ + Type: "object", + Required: []string{"kind", "barks"}, + Properties: map[string]*client.Schema{ + "kind": {Type: "string", Enum: []any{"dog"}}, + "barks": {Type: "boolean"}, + }, + } + spec.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Cat"}, + {Ref: "#/components/schemas/Dog"}, + }, + Discriminator: &client.Discriminator{ + PropertyName: "kind", + Mapping: map[string]string{"cat": "#/components/schemas/Cat", "dog": "#/components/schemas/Dog"}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, "export type Pet = Cat | Dog;") +} + func TestGenerateTestUtilsGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index bb6018eb..f6dddc00 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -861,6 +861,31 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec // only" cleanly, so it's reused as-is. buf.WriteString(propertyJSDoc(schema, "")) + // Polymorphic schemas (oneOf/anyOf/allOf) are handled before the + // switch on schema.Type below, for two reasons: + // + // 1. A schema that is *purely* polymorphic (no sibling "type") has an + // empty Schema.Type, so it would fall to the switch's default + // branch anyway — which already delegates to schemaToTSType, so + // this first case is a no-op for that shape today. It is kept + // explicit rather than relying on the default branch because (2) + // below is a real, reachable divergence. + // 2. A schema that declares "type: object" *alongside* oneOf/anyOf/ + // allOf — a pattern real OpenAPI documents use for composition — + // would otherwise hit the "object" case, which reads + // schema.Properties (empty for a purely compositional schema) and + // silently emits an empty `export interface Pet {}`, discarding + // the polymorphism entirely. Checking OneOf/AnyOf/AllOf first + // avoids that regardless of what schema.Type says. + // + // schemaToTSType already joins OneOf/AnyOf with " | " and AllOf with + // " & " (and applies Nullable), so this delegates rather than + // reimplementing union logic here. + if len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 || len(schema.AllOf) > 0 { + buf.WriteString(fmt.Sprintf("export type %s = %s;\n", name, g.schemaToTSType(schema, spec))) + return buf.String() + } + switch schema.Type { case "object": valueSchema, allowed := additionalPropsSchema(schema.AdditionalProperties) @@ -1128,6 +1153,15 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) result = "Record" + case len(schema.Properties) > 0: + // additionalProperties absent/false but the schema still declares + // real properties: an inline object (most commonly a oneOf/anyOf + // member with no $ref of its own) renders as an object type + // literal via the same helper the named-schema "object" case + // uses, rather than collapsing to Record and losing + // every declared field. + result = g.objectPropsLiteral(schema, spec) + default: result = "Record" } diff --git a/internal/client/generators/typescript/polymorphic_test.go b/internal/client/generators/typescript/polymorphic_test.go new file mode 100644 index 00000000..a161daee --- /dev/null +++ b/internal/client/generators/typescript/polymorphic_test.go @@ -0,0 +1,342 @@ +package typescript + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// discriminatedPetSpec returns a spec with Cat/Dog/Pet shaped exactly like +// TestDiscriminatedUnion in gate_test.go, factored out so the narrowing +// proof below and any future test can build on the same fixture without +// duplicating it. +func discriminatedPetSpec() *client.APISpec { + spec := baseSpec() + spec.Schemas["Cat"] = &client.Schema{ + Type: "object", + Required: []string{"kind", "meows"}, + Properties: map[string]*client.Schema{ + "kind": {Type: "string", Enum: []any{"cat"}}, + "meows": {Type: "boolean"}, + }, + } + spec.Schemas["Dog"] = &client.Schema{ + Type: "object", + Required: []string{"kind", "barks"}, + Properties: map[string]*client.Schema{ + "kind": {Type: "string", Enum: []any{"dog"}}, + "barks": {Type: "boolean"}, + }, + } + spec.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Cat"}, + {Ref: "#/components/schemas/Dog"}, + }, + Discriminator: &client.Discriminator{ + PropertyName: "kind", + Mapping: map[string]string{"cat": "#/components/schemas/Cat", "dog": "#/components/schemas/Dog"}, + }, + } + + return spec +} + +// TestDiscriminatedUnionNarrows is the narrowing proof required by Task 6: +// a string assertion that "export type Pet = Cat | Dog;" appears is not +// sufficient to prove TypeScript can actually narrow the union, because a +// union of two structurally-unrelated interfaces still type-checks even +// without narrowing working (e.g. via `any`-shaped access). This test +// compiles two consumer snippets against the real generated output: +// +// - consumer.ts switches/if-narrows on the `kind` discriminant and then +// accesses the member-specific field (Cat.meows / Dog.barks). This only +// type-checks if TypeScript actually narrows Pet to Cat or Dog based on +// the `kind: "cat"` / `kind: "dog"` literal types emitted by enumTSType +// (Task 4) — which is exactly the mechanism Task 6 depends on. +// - unnarrowed.ts accesses pet.meows with NO narrowing at all. This must +// FAIL to type-check (TS2339, "Property 'meows' does not exist on type +// 'Dog'"), proving the union is a real discriminated union and not +// something permissive enough that any member access always compiles +// (e.g. if Cat/Dog were accidentally widened to Record). +func TestDiscriminatedUnionNarrows(t *testing.T) { + spec := discriminatedPetSpec() + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + assert.Contains(t, types, "export type Pet = Cat | Dog;") + assert.Contains(t, types, `kind: "cat";`) + assert.Contains(t, types, `kind: "dog";`) + + // Positive proof: narrowing via `if` and `switch` on the discriminant + // must type-check cleanly. + goodFiles := make(map[string]string, len(out.Files)+1) + for k, v := range out.Files { + goodFiles[k] = v + } + goodFiles["src/consumer.ts"] = `import { Pet } from './types'; + +export function describeIf(pet: Pet): string { + if (pet.kind === 'cat') { + return pet.meows ? 'purring' : 'sleeping'; + } else { + return pet.barks ? 'barking' : 'quiet'; + } +} + +export function describeSwitch(pet: Pet): string { + switch (pet.kind) { + case 'cat': + return pet.meows ? 'purring' : 'sleeping'; + case 'dog': + return pet.barks ? 'barking' : 'quiet'; + } +} +` + + goodDir := t.TempDir() + writeTree(t, goodDir, goodFiles) + + errs := typeCheck(t, goodDir) + assert.Empty(t, errs, "a consumer that narrows Pet on the `kind` discriminant must type-check cleanly:\n%s", strings.Join(errs, "\n")) + + // Negative proof: the SAME field access with NO narrowing must fail. + // This rules out the union having accidentally widened to something + // that makes every member field accessible unconditionally. + badFiles := make(map[string]string, len(out.Files)+1) + for k, v := range out.Files { + badFiles[k] = v + } + badFiles["src/unnarrowed.ts"] = `import { Pet } from './types'; + +export function bad(pet: Pet): boolean { + // No narrowing: 'meows' does not exist on the Dog branch of the union. + return pet.meows; +} +` + + badDir := t.TempDir() + writeTree(t, badDir, badFiles) + + badErrs := typeCheck(t, badDir) + require.NotEmpty(t, badErrs, "accessing a member-specific field on Pet without narrowing must fail to type-check") + + if bad := errorsMentioning(badErrs, "TS2339"); len(bad) == 0 { + t.Errorf("expected a TS2339 (property does not exist) error for the unnarrowed access, got:\n%s", strings.Join(badErrs, "\n")) + } +} + +// TestAllOfMixesRefAndInlineObject answers brief question 1: when allOf +// mixes a $ref with an inline object schema, does schemaToTSType render the +// inline part usefully, or does it collapse to Record? +// +// Before the schemaToTSType "object" case learned to fall back to +// objectPropsLiteral for a schema with declared Properties but no +// additionalProperties (the same fix that answers question 3), the inline +// branch had no Ref, no AdditionalProperties, and Type == "object", so it +// hit the "object" case's default branch and collapsed to +// Record — losing the "extra" field entirely and producing +// `export type Combined = Base & Record;`. This test pins the +// fixed behavior: the inline member renders as its own object type literal. +func TestAllOfMixesRefAndInlineObject(t *testing.T) { + spec := baseSpec() + spec.Schemas["Base"] = &client.Schema{ + Type: "object", + Required: []string{"id"}, + Properties: map[string]*client.Schema{"id": {Type: "string"}}, + } + spec.Schemas["Combined"] = &client.Schema{ + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/Base"}, + {Type: "object", Properties: map[string]*client.Schema{"extra": {Type: "string"}}}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, "export type Combined = Base & {\n extra?: string;\n};") + assert.NotContains(t, types, "Combined = Base & Record") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "an allOf mixing a $ref with an inline object schema must type-check cleanly:\n%s", strings.Join(errs, "\n")) +} + +// TestOneOfInlineObjectMembersRenderAsObjectLiterals answers brief question +// 3: a oneOf whose members are inline object schemas (no $ref) must render +// each member as an object type literal, not collapse to +// Record. Also proves the resulting union still narrows on a +// discriminant-shaped property, same as the $ref-member case. +func TestOneOfInlineObjectMembersRenderAsObjectLiterals(t *testing.T) { + spec := baseSpec() + spec.Schemas["Shape"] = &client.Schema{ + OneOf: []*client.Schema{ + {Type: "object", Required: []string{"kind", "radius"}, Properties: map[string]*client.Schema{ + "kind": {Type: "string", Enum: []any{"circle"}}, "radius": {Type: "number"}, + }}, + {Type: "object", Required: []string{"kind", "side"}, Properties: map[string]*client.Schema{ + "kind": {Type: "string", Enum: []any{"square"}}, "side": {Type: "number"}, + }}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + assert.Contains(t, types, `export type Shape = { + kind: "circle"; + radius: number; +} | { + kind: "square"; + side: number; +};`) + assert.NotContains(t, types, "Shape = Record | Record") + + files := make(map[string]string, len(out.Files)+1) + for k, v := range out.Files { + files[k] = v + } + files["src/consumer.ts"] = `import { Shape } from './types'; + +export function area(shape: Shape): number { + if (shape.kind === 'circle') { + return Math.PI * shape.radius * shape.radius; + } + return shape.side * shape.side; +} +` + + dir := t.TempDir() + writeTree(t, dir, files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "a oneOf of inline object schemas must render as narrowable object literals and type-check cleanly:\n%s", strings.Join(errs, "\n")) +} + +// generateWithTimeout runs Generate on a goroutine with a hard deadline so a +// test asserting "does not infinite-loop" fails fast instead of hanging CI +// if that invariant is ever broken. +func generateWithTimeout(t *testing.T, spec *client.APISpec, cfg client.GeneratorConfig, timeout time.Duration) map[string]string { + t.Helper() + + type genResult struct { + files map[string]string + err error + } + + done := make(chan genResult, 1) + + go func() { + res, genErr := NewGenerator().Generate(context.Background(), spec, cfg) + + var files map[string]string + if res != nil { + files = res.Files + } + + done <- genResult{files: files, err: genErr} + }() + + select { + case result := <-done: + require.NoError(t, result.err) + return result.files + case <-time.After(timeout): + t.Fatalf("Generate did not return within %s; suspected infinite loop", timeout) + return nil + } +} + +// TestOneOfSelfReferenceDoesNotInfiniteLoop answers brief question 4: a +// schema whose oneOf includes a $ref back to itself must not infinite-loop +// or stack-overflow during generation. +// +// It terminates by construction rather than by accident: schemaToTSType's +// Ref branch (`if schema.Ref != "" { ... return typeName }`) never +// dereferences the referenced schema's body — it only ever returns the +// schema's own name as a type reference — so a $ref-mediated cycle can never +// recurse into schemaToTSType a second time no matter how deep the cycle is. +// A real stack overflow would only be reachable via a literal Go pointer +// cycle inside a single *client.Schema (e.g. schema.OneOf[i] == schema +// itself, bypassing $ref entirely), which is not a shape either +// spec_parser.go or introspector.go can produce from a real OpenAPI +// document, so it is out of scope here. +// +// Generation succeeding and terminating is one thing; the emitted TypeScript +// being valid is another. `export type Node = string | Node;` — a directly +// self-referential union with no object/array indirection — is REJECTED by +// tsc with TS2456 ("Type alias 'Node' circularly references itself"). +// That is TypeScript's own recursive-type-alias rule, not a defect in this +// generator: a bare `type X = A | X` has no indirection for the compiler to +// defer resolution through, and no other OpenAPI-to-TypeScript generator +// can make that construct valid either. TestSelfReferenceWithIndirection +// below shows the realistic shape (self-reference via an object property, +// not via the top-level union itself) type-checks cleanly. +func TestOneOfSelfReferenceDoesNotInfiniteLoop(t *testing.T) { + spec := baseSpec() + spec.Schemas["Node"] = &client.Schema{ + OneOf: []*client.Schema{ + {Type: "string"}, + {Ref: "#/components/schemas/Node"}, + }, + } + + files := generateWithTimeout(t, spec, baseConfig(), 10*time.Second) + + types := files["src/types.ts"] + assert.Contains(t, types, "export type Node = string | Node;") + + dir := t.TempDir() + writeTree(t, dir, files) + + errs := typeCheck(t, dir) + if bad := errorsMentioning(errs, "TS2456"); len(bad) == 0 { + t.Errorf("expected tsc to reject the directly self-referential union with TS2456, got:\n%s", strings.Join(errs, "\n")) + } +} + +// TestSelfReferenceWithIndirection is the realistic counterpart to +// TestOneOfSelfReferenceDoesNotInfiniteLoop: self-reference through an +// object property (the shape a real recursive schema — e.g. an expression +// tree or a linked structure — actually takes) rather than directly at the +// top level of the union. This does type-check, confirming the TS2456 seen +// above is specific to the no-indirection case and not a general limitation +// of the emitted unions. +func TestSelfReferenceWithIndirection(t *testing.T) { + spec := baseSpec() + spec.Schemas["Expr"] = &client.Schema{ + OneOf: []*client.Schema{ + {Type: "object", Required: []string{"value"}, Properties: map[string]*client.Schema{ + "value": {Type: "number"}, + }}, + {Type: "object", Required: []string{"left", "right", "op"}, Properties: map[string]*client.Schema{ + "left": {Ref: "#/components/schemas/Expr"}, + "right": {Ref: "#/components/schemas/Expr"}, + "op": {Type: "string"}, + }}, + }, + } + + files := generateWithTimeout(t, spec, baseConfig(), 10*time.Second) + + dir := t.TempDir() + writeTree(t, dir, files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "a self-referencing oneOf via object-property indirection must type-check cleanly:\n%s", strings.Join(errs, "\n")) +} From 8c94d22878892d5cc532776f6bb67f918141e682 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 11:39:12 -0500 Subject: [PATCH 32/71] feat(client/typescript): type all 2xx responses including non-JSON bodies --- .../generators/typescript/fetch_client.go | 11 ++- internal/client/generators/typescript/rest.go | 73 +++++++++++++++--- .../client/generators/typescript/rest_test.go | 76 +++++++++++++++++++ 3 files changed, 149 insertions(+), 11 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index b6367742..bc2c56a5 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -247,7 +247,16 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" return {} as T;\n") buf.WriteString(" }\n\n") - buf.WriteString(" return await response.text() as any;\n") + buf.WriteString(" // A declared text return type (e.g. `string`) must be read with\n") + buf.WriteString(" // .text(); anything else (e.g. a declared `Blob` return type for a\n") + buf.WriteString(" // file download) falls through to .blob() so the runtime value\n") + buf.WriteString(" // matches what generateReturnType declared — otherwise the declared\n") + buf.WriteString(" // type is a lie tsc cannot catch.\n") + buf.WriteString(" if (contentType && contentType.startsWith('text/')) {\n") + buf.WriteString(" return await response.text() as any;\n") + buf.WriteString(" }\n\n") + + buf.WriteString(" return await response.blob() as any;\n") buf.WriteString(" } catch (error) {\n") buf.WriteString(" clearTimeout(timeoutId);\n") buf.WriteString(" combined.dispose();\n\n") diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index f578aea3..3d0bc19a 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -417,23 +417,76 @@ func (r *RESTGenerator) generateParameters(endpoint client.Endpoint, spec *clien return strings.Join(append(required, optional...), ", ") } -// generateReturnType generates the return type for an endpoint. +// generateReturnType generates the return type for an endpoint by unioning +// the body type of every declared 2xx response (not just 200/201 JSON). +// +// Responses is a map[int]*client.Response, so the status codes are collected +// and sorted before iterating — ranging the map directly would make the +// union's member order (and therefore the generated file's bytes) +// non-deterministic across runs. +// +// Non-2xx responses (including Endpoint.DefaultError) are deliberately +// excluded: they describe the shape of a thrown error, not a resolved +// value — handleErrorResponse in fetch_client.go throws before the "Parse +// response" step ever runs, so a 4xx/5xx/default body never flows through +// the Promise this function types. func (r *RESTGenerator) generateReturnType(endpoint client.Endpoint, spec *client.APISpec) string { - // Look for 200 or 201 response - for _, statusCode := range []int{200, 201} { - if resp, ok := endpoint.Responses[statusCode]; ok { - if media, ok := resp.Content["application/json"]; ok && media.Schema != nil { - return r.getSchemaTypeName(media.Schema, spec) - } + codes := make([]int, 0, len(endpoint.Responses)) + + for code := range endpoint.Responses { + if code >= 200 && code < 300 { + codes = append(codes, code) } } - // Check for 204 No Content - if _, ok := endpoint.Responses[204]; ok { + sort.Ints(codes) + + var types []string + + seen := make(map[string]bool, len(codes)) + + for _, code := range codes { + t := r.responseBodyType(endpoint.Responses[code], spec) + + if !seen[t] { + seen[t] = true + + types = append(types, t) + } + } + + if len(types) == 0 { + // No 2xx responses declared at all (e.g. only a default error). return "void" } - return "any" + return strings.Join(types, " | ") +} + +// responseBodyType maps a single response's declared content to a TypeScript +// type, honouring content-type precedence: application/json first (a schema +// resolves to a concrete type, or falls back through schemaToTSType — which +// is also where a "binary" format wins over a JSON content-type on a +// contradictory schema, matching the precedence formatTSType already +// establishes for object properties), then any text/* media type -> string, +// then any other media type -> Blob (the DOM type for an opaque body such as +// a file download). A response with no Content at all contributes "void". +func (r *RESTGenerator) responseBodyType(resp *client.Response, spec *client.APISpec) string { + if resp == nil || len(resp.Content) == 0 { + return "void" + } + + if media, ok := resp.Content["application/json"]; ok && media.Schema != nil { + return r.getSchemaTypeName(media.Schema, spec) + } + + for _, contentType := range sortedKeys(resp.Content) { + if strings.HasPrefix(contentType, "text/") { + return "string" + } + } + + return "Blob" } // generatePathExpression generates the path expression with parameters. diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index 764b9ccb..9c27e3e5 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -1,6 +1,7 @@ package typescript import ( + "context" "strings" "testing" @@ -443,6 +444,81 @@ func TestRESTGenerator_ReturnTypes(t *testing.T) { assert.Contains(t, code, "await this.request(config)") } +// TestReturnTypeCoversAll2xxAndNonJSON asserts that generateReturnType looks +// at every 2xx response (not just 200/201) and every content type (not just +// application/json): a 202 with a JSON body, a union across multiple success +// codes with different bodies, and non-JSON bodies (octet-stream -> Blob, +// text/plain -> string) must all produce a real type instead of degrading to +// `any`. It also proves the generated fetch.ts response parser agrees with a +// Blob-typed declaration by actually calling response.blob() at runtime, +// since a declared type tsc cannot check against the runtime parser would be +// a silent lie. +func TestReturnTypeCoversAll2xxAndNonJSON(t *testing.T) { + mk := func(responses map[int]*client.Response) string { + return NewRESTGenerator().Generate(&client.APISpec{ + Info: client.APIInfo{Title: "T", Version: "1"}, + Endpoints: []client.Endpoint{{ + Method: "GET", Path: "/x", OperationID: "x.get", Responses: responses, + }}, + Schemas: map[string]*client.Schema{"A": {Type: "object"}, "B": {Type: "object"}}, + }, client.DefaultConfig()) + } + + // 202 with a JSON body must not degrade to any. + code := mk(map[int]*client.Response{202: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/A"}}}}}) + assert.Contains(t, code, "Promise") + + // Two success codes with different bodies produce a union. + code = mk(map[int]*client.Response{ + 200: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/A"}}}}, + 201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/B"}}}}, + }) + assert.Contains(t, code, "Promise") + + // A non-JSON body is a Blob, not any. + code = mk(map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}}}}) + assert.Contains(t, code, "Promise") + + // text/plain is a string. + code = mk(map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "text/plain": {Schema: &client.Schema{Type: "string"}}}}}) + assert.Contains(t, code, "Promise") +} + +// TestFetchTsAgreesWithBlobReturnType is the runtime half of the above: a +// declared Promise return type is only honest if the generated +// fetch.ts actually calls response.blob() for a non-JSON, non-text body. +// Without this, tsc would happily accept the declared Blob type while the +// runtime handed back a string from response.text(), a mismatch tsc cannot +// catch because it never executes the generated code. +func TestFetchTsAgreesWithBlobReturnType(t *testing.T) { + spec := baseSpec() + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "GET", Path: "/download", OperationID: "download.get", + Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}}}}, + }) + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + assert.Contains(t, rest, "Promise") + + fetchTS := out.Files["src/fetch.ts"] + assert.Contains(t, fetchTS, ".blob()", "fetch.ts must call response.blob() so a declared Blob return type is not a lie tsc cannot catch") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "a client with a Blob-returning endpoint must still type-check cleanly") +} + func TestEndpointTreeKeepsBothOrders(t *testing.T) { // Reuses the same two operation IDs as TestRESTGenerator_ConflictingOperationIDs // ("users" and "users.active.list") but exercises both insertion orders. From a72cf07f1e20ea8071ba05a3beddf154a191cbc5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 11:59:07 -0500 Subject: [PATCH 33/71] fix(client/typescript): resolve empty response bodies to undefined, not {}/Blob A mixed union return type containing void (e.g. types.User | void, from a 200-with-body plus a 202-no-content) was a runtime lie: executeRequest resolved a body-less response to an always-truthy {} (the old 204 special case) or fell through to response.blob() for anything else, so 'if (result) { result.id }' always passed and .id silently evaluated to undefined despite tsc reporting zero errors. executeRequest now returns undefined for 204/205/304 (which RFC 9110 forbids from carrying a body) and, for everything else, reads the body once via response.blob() and returns undefined when blob.size === 0 -- catching an empty body regardless of whether Content-Length is present, since real empty responses are not guaranteed to send it. Also adds a mixed-union endpoint (200 + 202) to the shared gate-fixture base spec so the 8-fixture tsc gate and the 12-run determinism test both exercise this path going forward, not just a standalone unit test. --- .../generators/typescript/fetch_client.go | 53 ++++++--- .../generators/typescript/fixtures_test.go | 11 +- internal/client/generators/typescript/rest.go | 17 ++- .../client/generators/typescript/rest_test.go | 111 ++++++++++++++++++ .../generators/typescript/runtime_test.go | 90 ++++++++++++++ 5 files changed, 265 insertions(+), 17 deletions(-) create mode 100644 internal/client/generators/typescript/runtime_test.go diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index bc2c56a5..74f30b77 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -236,27 +236,52 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" await this.handleErrorResponse(response);\n") buf.WriteString(" }\n\n") - buf.WriteString(" // Parse response\n") - buf.WriteString(" const contentType = response.headers.get('content-type');\n") - buf.WriteString(" if (contentType && contentType.includes('application/json')) {\n") - buf.WriteString(" return await response.json();\n") + buf.WriteString(" // Parse response.\n") + buf.WriteString(" //\n") + buf.WriteString(" // Statuses that MUST NOT carry a body (RFC 9110 §15.3.5, §15.3.6,\n") + buf.WriteString(" // §15.4.5): reading them is both wasted work and, for a redirect\n") + buf.WriteString(" // status like 304, potentially incorrect. The declared return type\n") + buf.WriteString(" // for a no-content response is `void` (or includes it in a union),\n") + buf.WriteString(" // and the only honest runtime value for `void` is `undefined` — not\n") + buf.WriteString(" // `{}` (an always-truthy empty object) and not a zero-byte Blob (also\n") + buf.WriteString(" // always truthy), either of which would make `if (result)` pass when\n") + buf.WriteString(" // the caller has nothing.\n") + buf.WriteString(" const noBodyStatus = response.status === 204 || response.status === 205 || response.status === 304;\n") + buf.WriteString(" if (noBodyStatus) {\n") + buf.WriteString(" return undefined as T;\n") + buf.WriteString(" }\n\n") + + buf.WriteString(" // Every other response's body is read exactly once, as a Blob, and\n") + buf.WriteString(" // the declared shape is derived from that Blob rather than calling\n") + buf.WriteString(" // response.json()/.text()/.blob() directly — a Response body can only\n") + buf.WriteString(" // be consumed once, so those must not be called more than once.\n") + buf.WriteString(" //\n") + buf.WriteString(" // Checking the actual byte count (rather than trusting a\n") + buf.WriteString(" // Content-Length header) is deliberate: a real empty response — e.g.\n") + buf.WriteString(" // a 202 ack with nothing to say — is not guaranteed to carry\n") + buf.WriteString(" // Content-Length at all (chunked transfer encoding omits it, and\n") + buf.WriteString(" // some runtimes don't set it for a null body either), so trusting the\n") + buf.WriteString(" // header would silently miss exactly the case this exists to catch.\n") + buf.WriteString(" const blob = await response.blob();\n\n") + + buf.WriteString(" if (blob.size === 0) {\n") + buf.WriteString(" return undefined as T;\n") buf.WriteString(" }\n\n") - buf.WriteString(" // Return empty object for 204 No Content\n") - buf.WriteString(" if (response.status === 204) {\n") - buf.WriteString(" return {} as T;\n") + buf.WriteString(" const contentType = response.headers.get('content-type');\n") + buf.WriteString(" if (contentType && contentType.includes('application/json')) {\n") + buf.WriteString(" return JSON.parse(await blob.text());\n") buf.WriteString(" }\n\n") - buf.WriteString(" // A declared text return type (e.g. `string`) must be read with\n") - buf.WriteString(" // .text(); anything else (e.g. a declared `Blob` return type for a\n") - buf.WriteString(" // file download) falls through to .blob() so the runtime value\n") - buf.WriteString(" // matches what generateReturnType declared — otherwise the declared\n") - buf.WriteString(" // type is a lie tsc cannot catch.\n") + buf.WriteString(" // A declared text return type (e.g. `string`) must be read as text;\n") + buf.WriteString(" // anything else (e.g. a declared `Blob` return type for a file\n") + buf.WriteString(" // download) is returned as the Blob already read above — otherwise\n") + buf.WriteString(" // the declared type would be a lie tsc cannot catch.\n") buf.WriteString(" if (contentType && contentType.startsWith('text/')) {\n") - buf.WriteString(" return await response.text() as any;\n") + buf.WriteString(" return await blob.text() as any;\n") buf.WriteString(" }\n\n") - buf.WriteString(" return await response.blob() as any;\n") + buf.WriteString(" return blob as any;\n") buf.WriteString(" } catch (error) {\n") buf.WriteString(" clearTimeout(timeoutId);\n") buf.WriteString(" combined.dispose();\n\n") diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 25ec4da2..379f898e 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -58,8 +58,15 @@ func baseSpec() *client.APISpec { Method: "GET", Path: "/users/{id}", OperationID: "users.get", PathParams: []client.Parameter{{Name: "id", Schema: &client.Schema{Type: "string"}, Required: true}}, QueryParams: []client.Parameter{{Name: "include_deleted", Schema: &client.Schema{Type: "boolean"}}}, - Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ - "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}}, + // Two 2xx responses (200 with a body, 202 with none) so every gate + // fixture — and the 12-run determinism test — exercises + // generateReturnType's sorted-key, multi-status union path, not just + // the single standalone unit test that covers it directly. + Responses: map[int]*client.Response{ + 200: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}, + 202: {}, + }, }, { Method: "POST", Path: "/users", OperationID: "users.create", diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 3d0bc19a..18c0d22c 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -335,7 +335,22 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien fmt.Fprintf(buf, "%s retry: options?.retry,\n", indentStr) fmt.Fprintf(buf, "%s};\n\n", indentStr) - // Make request + // Make request. + // + // The comparison below is deliberately an exact match against the literal + // string "void", not a "contains void" check on a union. Discarding is + // correct only when NO caller could ever want the resolved value — true + // for the exact-void case (every 2xx response is content-less, so + // generateReturnType's dedupe collapses the whole union to "void"), but + // not for a mixed union such as "types.User | void" (a 200-with-body + // alongside a 202-with-none): there, a caller hitting the 200 path + // legitimately wants the User back, so the value must be forwarded via + // `return this.request(config)`, not thrown away. This is safe + // specifically because executeRequest's response parsing (fetch_client.go) + // now resolves an empty body to a real `undefined` rather than an + // always-truthy `{}` or `Blob` — so `types.User | void` callers who write + // `if (result) { result.id }` get a guard that is actually meaningful, + // not a compiling lie. returnType := r.generateReturnType(*endpoint, spec) if returnType != "void" { fmt.Fprintf(buf, "%sreturn this.request<%s>(config);\n", indentStr, returnType) diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index 9c27e3e5..c48ee7bc 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -2,6 +2,7 @@ package typescript import ( "context" + "encoding/json" "strings" "testing" @@ -519,6 +520,116 @@ func TestFetchTsAgreesWithBlobReturnType(t *testing.T) { assert.Empty(t, errs, "a client with a Blob-returning endpoint must still type-check cleanly") } +// TestEmptyBodyResponseResolvesToUndefined is the runtime proof for the +// critical fix-round-1 defect: an endpoint with a 200 (real body) and a 202 +// (no content) declares Promise (per generateReturnType's +// union logic), but before this fix the generated fetch.ts's executeRequest +// never actually produced `undefined` for a genuinely empty response body — +// a 204 hit the `{} as T` special case (an always-truthy empty object, not +// `void`/undefined), and anything else (including a bare 202 with no +// Content-Type, exactly what review reproduced) fell through content-type +// branching all the way to `response.blob()` (an always-truthy Blob). Either +// way, `if (result) { result.id }` — code the declared union type explicitly +// invites — would silently misbehave: the guard always passes, and `.id` is +// undefined at runtime despite compiling cleanly. +// +// tsc cannot catch this class of bug because it never executes anything, so +// this test actually bundles the generated client with esbuild and runs it +// under Node against a mocked global fetch, exactly as review did: once +// against a real 200 JSON body (the already-covered path, checked for +// regression) and once against a 202 with a null body and no headers at all +// (the exact shape from review's reproduction). +func TestEmptyBodyResponseResolvesToUndefined(t *testing.T) { + spec := baseSpec() + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "GET", Path: "/mixed", OperationID: "mixed.get", + Responses: map[int]*client.Response{ + 200: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}, + 202: {}, // no content + }, + }) + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + require.Contains(t, rest, "Promise", + "sanity check: the declared union must include void, or this test is not exercising the reported defect") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + + // 200 with a real JSON body: the already-covered path, checked here only + // to prove the fix didn't break it while making empty bodies honest. + (globalThis as any).fetch = async () => new Response(JSON.stringify({ id: 'abc' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + const withBody = await client.mixed.get(); + + // 202 with a null body and NO headers at all — no Content-Type, no + // Content-Length. This is the exact shape review reproduced: an empty ack + // with nothing to say about what it is. + (globalThis as any).fetch = async () => new Response(null, { status: 202 }); + const empty = await client.mixed.get(); + + let emptyDescription: string; + if (empty === undefined) { + emptyDescription = 'undefined'; + } else if (typeof Blob !== 'undefined' && empty instanceof Blob) { + emptyDescription = 'Blob'; + } else { + emptyDescription = 'other:' + JSON.stringify(empty); + } + + console.log(JSON.stringify({ + withBodyId: withBody ? withBody.id : null, + emptyDescription, + })); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +` + writeTree(t, dir, map[string]string{"src/__driver.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver.ts") + + var result struct { + WithBodyID string `json:"withBodyId"` + EmptyDescription string `json:"emptyDescription"` + } + + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(lastLine(stdout))), &result), "driver stdout:\n%s", stdout) + + assert.Equal(t, "abc", result.WithBodyID, "the ordinary 200-with-body path must still resolve to the real value") + assert.Equal(t, "undefined", result.EmptyDescription, + "an empty-bodied 2xx response must resolve to undefined, not {} (old 204 special case) or a Blob (old fallthrough) — either is an always-truthy value the declared void member did not promise") +} + +// lastLine returns the final non-empty line of s, so a driver script's +// diagnostic console.log/console.error noise (if any slips through despite +// the driver only emitting one JSON line) doesn't break json.Unmarshal. +func lastLine(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if strings.TrimSpace(lines[i]) != "" { + return lines[i] + } + } + + return "" +} + func TestEndpointTreeKeepsBothOrders(t *testing.T) { // Reuses the same two operation IDs as TestRESTGenerator_ConflictingOperationIDs // ("users" and "users.active.list") but exercises both insertion orders. diff --git a/internal/client/generators/typescript/runtime_test.go b/internal/client/generators/typescript/runtime_test.go new file mode 100644 index 00000000..4733c4b6 --- /dev/null +++ b/internal/client/generators/typescript/runtime_test.go @@ -0,0 +1,90 @@ +package typescript + +import ( + "context" + "os/exec" + "path/filepath" + "testing" +) + +// findESBuild returns the argv prefix used to bundle generated TypeScript +// into a single runnable ESM file, or skips the test when no bundler is +// available. Bundling (rather than running .ts files directly under Node's +// native TS support) is required because the generated tsconfig.json uses +// "moduleResolution": "bundler" and every generated import is intentionally +// extensionless (e.g. `from './fetch'`) — Node's own module resolver cannot +// resolve that without a bundler doing the same resolution tsc does. +func findESBuild(t *testing.T) []string { + t.Helper() + + if path, err := exec.LookPath("esbuild"); err == nil { + return []string{path} + } + + if path, err := exec.LookPath("npx"); err == nil { + return []string{path, "--no-install", "esbuild"} + } + + t.Skip("neither esbuild nor npx found on PATH; skipping generated-client runtime test") + + return nil +} + +// findNode returns the path to a Node.js binary, or skips the test when none +// is available. +func findNode(t *testing.T) string { + t.Helper() + + path, err := exec.LookPath("node") + if err != nil { + t.Skip("node not found on PATH; skipping generated-client runtime test") + } + + return path +} + +// runNodeDriver bundles entry (a path relative to dir, e.g. "src/__driver.ts") +// with esbuild into a single Node-runnable ESM file and executes it under +// Node, returning stdout. It fails the test outright on any bundling or +// runtime error — a thrown error or non-zero exit means the driver script +// itself is broken, not that the assertion it encodes failed, so that must +// not be mistaken for a passing (or even a meaningfully failing) assertion. +// +// This exists to verify actual runtime behavior of generated code — e.g. +// that a declared `Promise` return type is honored by what the +// generated fetch client actually resolves with for an empty-bodied +// response — which `tsc --noEmit` cannot check, since tsc never executes +// anything. +func runNodeDriver(t *testing.T, dir, entry string) string { + t.Helper() + + esbuildArgv := findESBuild(t) + nodePath := findNode(t) + + outFile := filepath.Join(dir, "__bundle.mjs") + + args := append(append([]string{}, esbuildArgv[1:]...), + entry, + "--bundle", + "--platform=node", + "--format=esm", + "--outfile="+outFile, + ) + + cmd := exec.CommandContext(context.Background(), esbuildArgv[0], args...) + cmd.Dir = dir + + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("esbuild failed to bundle %s: %v\n%s", entry, err, out) + } + + runCmd := exec.CommandContext(context.Background(), nodePath, outFile) + runCmd.Dir = dir + + out, err := runCmd.CombinedOutput() + if err != nil { + t.Fatalf("node execution of bundled %s failed: %v\n%s", entry, err, out) + } + + return string(out) +} From f2f961ebd437c625701b8b1731aaf86353575660 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 12:16:11 -0500 Subject: [PATCH 34/71] fix(client/typescript): gate empty-body-to-undefined conversion on the spec, not the bytes Round 1 made executeRequest convert ANY zero-byte response body to undefined unconditionally, which silently corrupted legitimate empty payloads for endpoints that never declared a no-content 2xx: an empty text/plain body (a valid empty string) and a zero-byte binary body (a valid empty file) both became undefined, and an empty JSON body -- which should throw a parse error -- silently 'succeeded' with undefined. generateReturnType now also reports whether its union includes a void member (i.e. whether the endpoint declared at least one no-content 2xx), and generateMethodBody threads that through a new RequestConfig. allowEmptyBody flag. executeRequest only treats a zero-byte body as undefined when that flag is set; otherwise it parses the body as its declared type normally would (empty string for text/plain, a zero-byte Blob for binary, a thrown SyntaxError for JSON). Status-based 204/205 handling stays unconditional, since those statuses cannot carry a body regardless of what the spec declares; the dead 304 branch (unreachable, since 304 fails response.ok and is caught by handleErrorResponse first) is dropped. Adds two gate-fixture endpoints (text/plain-only, binary-only, neither declaring a no-content 2xx) so the 8-fixture tsc gate and the 12-run determinism test cover this path going forward. --- .../generators/typescript/fetch_client.go | 61 +++++--- .../generators/typescript/fixtures_test.go | 17 +++ internal/client/generators/typescript/rest.go | 64 +++++++-- .../client/generators/typescript/rest_test.go | 132 ++++++++++++++++++ 4 files changed, 242 insertions(+), 32 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index 74f30b77..9b51197c 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -44,6 +44,13 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" body?: any;\n") buf.WriteString(" signal?: AbortSignal;\n") buf.WriteString(" retry?: RetryConfig;\n") + buf.WriteString(" // Set by the generated method when its declared return type has a\n") + buf.WriteString(" // no-content 2xx response (i.e. includes `void`). Only then does an\n") + buf.WriteString(" // empty response body mean \"there is legitimately nothing here\" —\n") + buf.WriteString(" // executeRequest cannot infer this from the bytes alone, since a\n") + buf.WriteString(" // genuinely empty text/plain or binary body is valid data for an\n") + buf.WriteString(" // endpoint that never declared a no-content response.\n") + buf.WriteString(" allowEmptyBody?: boolean;\n") buf.WriteString("}\n\n") // RetryConfig interface @@ -238,15 +245,13 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // Parse response.\n") buf.WriteString(" //\n") - buf.WriteString(" // Statuses that MUST NOT carry a body (RFC 9110 §15.3.5, §15.3.6,\n") - buf.WriteString(" // §15.4.5): reading them is both wasted work and, for a redirect\n") - buf.WriteString(" // status like 304, potentially incorrect. The declared return type\n") - buf.WriteString(" // for a no-content response is `void` (or includes it in a union),\n") - buf.WriteString(" // and the only honest runtime value for `void` is `undefined` — not\n") - buf.WriteString(" // `{}` (an always-truthy empty object) and not a zero-byte Blob (also\n") - buf.WriteString(" // always truthy), either of which would make `if (result)` pass when\n") - buf.WriteString(" // the caller has nothing.\n") - buf.WriteString(" const noBodyStatus = response.status === 204 || response.status === 205 || response.status === 304;\n") + buf.WriteString(" // Statuses that MUST NOT carry a body (RFC 9110 §15.3.5, §15.3.6):\n") + buf.WriteString(" // reading them would be wasted work regardless of what the spec\n") + buf.WriteString(" // declares, so `undefined` is returned unconditionally here — no\n") + buf.WriteString(" // `allowEmptyBody` gate. (304 is deliberately not included: it fails\n") + buf.WriteString(" // `response.ok`, so handleErrorResponse above already threw before\n") + buf.WriteString(" // this line is ever reached — including it here would be dead code.)\n") + buf.WriteString(" const noBodyStatus = response.status === 204 || response.status === 205;\n") buf.WriteString(" if (noBodyStatus) {\n") buf.WriteString(" return undefined as T;\n") buf.WriteString(" }\n\n") @@ -255,28 +260,42 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // the declared shape is derived from that Blob rather than calling\n") buf.WriteString(" // response.json()/.text()/.blob() directly — a Response body can only\n") buf.WriteString(" // be consumed once, so those must not be called more than once.\n") - buf.WriteString(" //\n") - buf.WriteString(" // Checking the actual byte count (rather than trusting a\n") - buf.WriteString(" // Content-Length header) is deliberate: a real empty response — e.g.\n") - buf.WriteString(" // a 202 ack with nothing to say — is not guaranteed to carry\n") - buf.WriteString(" // Content-Length at all (chunked transfer encoding omits it, and\n") - buf.WriteString(" // some runtimes don't set it for a null body either), so trusting the\n") - buf.WriteString(" // header would silently miss exactly the case this exists to catch.\n") buf.WriteString(" const blob = await response.blob();\n\n") - buf.WriteString(" if (blob.size === 0) {\n") + buf.WriteString(" // An empty body only means \"there is nothing here\" (-> undefined) when\n") + buf.WriteString(" // the spec actually declared a no-content 2xx for this call\n") + buf.WriteString(" // (requestConfig.allowEmptyBody, set by the generated method — see\n") + buf.WriteString(" // generateMethodBody in rest.go). executeRequest is one generic\n") + buf.WriteString(" // function shared by every endpoint, so it cannot otherwise tell an\n") + buf.WriteString(" // endpoint that legitimately never returns a body (e.g. a bare 202\n") + buf.WriteString(" // ack) apart from one that just happens to return an empty payload\n") + buf.WriteString(" // this time (e.g. an empty text/plain string, or a zero-byte file\n") + buf.WriteString(" // download) — collapsing both to `undefined` unconditionally would\n") + buf.WriteString(" // silently corrupt the latter for any endpoint that never declared a\n") + buf.WriteString(" // no-content response. Checking the actual byte count (rather than\n") + buf.WriteString(" // trusting a Content-Length header) is deliberate even when the flag\n") + buf.WriteString(" // is set: a real empty response is not guaranteed to carry\n") + buf.WriteString(" // Content-Length at all (chunked transfer encoding omits it, and some\n") + buf.WriteString(" // runtimes don't set it for a null body either).\n") + buf.WriteString(" if (requestConfig.allowEmptyBody && blob.size === 0) {\n") buf.WriteString(" return undefined as T;\n") buf.WriteString(" }\n\n") buf.WriteString(" const contentType = response.headers.get('content-type');\n") buf.WriteString(" if (contentType && contentType.includes('application/json')) {\n") + buf.WriteString(" // An empty body under a declared JSON type is a genuine error, not a\n") + buf.WriteString(" // legitimate value (unless allowEmptyBody already returned above) —\n") + buf.WriteString(" // JSON.parse('') throws, matching what response.json() would have\n") + buf.WriteString(" // thrown before this method read the body itself.\n") buf.WriteString(" return JSON.parse(await blob.text());\n") buf.WriteString(" }\n\n") - buf.WriteString(" // A declared text return type (e.g. `string`) must be read as text;\n") - buf.WriteString(" // anything else (e.g. a declared `Blob` return type for a file\n") - buf.WriteString(" // download) is returned as the Blob already read above — otherwise\n") - buf.WriteString(" // the declared type would be a lie tsc cannot catch.\n") + buf.WriteString(" // A declared text return type (e.g. `string`) must be read as text —\n") + buf.WriteString(" // an empty text/plain body is a legitimate empty string, not `void`,\n") + buf.WriteString(" // unless allowEmptyBody already returned above. Anything else (e.g. a\n") + buf.WriteString(" // declared `Blob` return type for a file download) is returned as the\n") + buf.WriteString(" // Blob already read above, zero-byte or not — otherwise the declared\n") + buf.WriteString(" // type would be a lie tsc cannot catch.\n") buf.WriteString(" if (contentType && contentType.startsWith('text/')) {\n") buf.WriteString(" return await blob.text() as any;\n") buf.WriteString(" }\n\n") diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 379f898e..76cc2068 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -75,6 +75,23 @@ func baseSpec() *client.APISpec { Responses: map[int]*client.Response{201: {Content: map[string]*client.MediaType{ "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}}, }, + // The next two endpoints declare exactly one 2xx response each, and it + // always has content — neither contributes a `void` member to + // generateReturnType's union. They exist so the 8-fixture tsc gate and + // the 12-run determinism test cover the "no allowEmptyBody" runtime path + // (round-1 fix regressed this: an empty text/plain or zero-byte binary + // body was collapsing to `undefined` even when the spec never declared a + // no-content response for that endpoint). + { + Method: "GET", Path: "/text", OperationID: "texts.get", + Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "text/plain": {Schema: &client.Schema{Type: "string"}}}}}, + }, + { + Method: "GET", Path: "/download", OperationID: "downloads.get", + Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}}}}, + }, }, Schemas: map[string]*client.Schema{"User": user}, } diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 18c0d22c..362c3a2c 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -250,7 +250,7 @@ func (r *RESTGenerator) generateArrowFunction(buf *strings.Builder, methodName s params += "options?: { signal?: AbortSignal; retry?: { maxAttempts?: number } }" - returnType := r.generateReturnType(*endpoint, spec) + returnType, _ := r.generateReturnType(*endpoint, spec) if isRoot { // Root level property @@ -278,6 +278,12 @@ func (r *RESTGenerator) generateArrowFunction(buf *strings.Builder, methodName s func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *client.Endpoint, spec *client.APISpec, indent int) { indentStr := strings.Repeat(" ", indent) + // Computed once up front: both the config object (which needs to tell + // executeRequest whether an empty body is a legitimate outcome for this + // specific endpoint) and the final discard-vs-forward decision below + // depend on it. + returnType, hasVoid := r.generateReturnType(*endpoint, spec) + // Build URL. The accumulator is prefixed with underscores so it never // collides with a request parameter that happens to be named "path". pathExpr := r.generatePathExpression(*endpoint) @@ -333,6 +339,20 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien fmt.Fprintf(buf, "%s signal: options?.signal,\n", indentStr) fmt.Fprintf(buf, "%s retry: options?.retry,\n", indentStr) + + // Tell executeRequest whether an empty body is a legitimate outcome for + // THIS endpoint specifically. executeRequest is one generic function + // shared by every method, so it cannot infer this from the bytes alone — + // an empty text/plain body and an empty octet-stream body are both + // perfectly valid non-void payloads, and collapsing them to `undefined` + // unconditionally would silently corrupt them for any endpoint that + // never declared a no-content 2xx. Only set the flag (never emit it as + // `false`) when the spec actually contributes a void member, i.e. when + // generateReturnType found at least one 2xx response with no content. + if hasVoid { + fmt.Fprintf(buf, "%s allowEmptyBody: true,\n", indentStr) + } + fmt.Fprintf(buf, "%s};\n\n", indentStr) // Make request. @@ -347,11 +367,10 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien // legitimately wants the User back, so the value must be forwarded via // `return this.request(config)`, not thrown away. This is safe // specifically because executeRequest's response parsing (fetch_client.go) - // now resolves an empty body to a real `undefined` rather than an - // always-truthy `{}` or `Blob` — so `types.User | void` callers who write - // `if (result) { result.id }` get a guard that is actually meaningful, - // not a compiling lie. - returnType := r.generateReturnType(*endpoint, spec) + // now resolves an empty body to a real `undefined` — but only when + // `allowEmptyBody` says the spec actually allows that for this call — so + // `types.User | void` callers who write `if (result) { result.id }` get a + // guard that is actually meaningful, not a compiling lie. if returnType != "void" { fmt.Fprintf(buf, "%sreturn this.request<%s>(config);\n", indentStr, returnType) } else { @@ -433,7 +452,22 @@ func (r *RESTGenerator) generateParameters(endpoint client.Endpoint, spec *clien } // generateReturnType generates the return type for an endpoint by unioning -// the body type of every declared 2xx response (not just 200/201 JSON). +// the body type of every declared 2xx response (not just 200/201 JSON), and +// reports whether that union includes a "void" member (i.e. whether the +// endpoint declares at least one no-content 2xx response, or none at all). +// +// The second return value exists so callers that need to know "can a +// legitimate response for this endpoint have no body" don't have to +// re-derive it by substring-searching the joined type string for "void" — +// a schema legitimately named something containing "void" (e.g. a type +// called "Avoidance") would false-positive a naive `strings.Contains`. This +// drives generateMethodBody's `allowEmptyBody` flag: fetch_client.go's +// executeRequest only converts an empty body to `undefined` when the spec +// actually declared a no-content 2xx for that call; otherwise an empty body +// is parsed as the type normally would (e.g. "" for text/plain, a +// zero-byte Blob for a binary body, a thrown SyntaxError for JSON) — see +// executeRequest's own comment for why unconditional empty-to-undefined +// conversion was wrong. // // Responses is a map[int]*client.Response, so the status codes are collected // and sorted before iterating — ranging the map directly would make the @@ -445,7 +479,7 @@ func (r *RESTGenerator) generateParameters(endpoint client.Endpoint, spec *clien // value — handleErrorResponse in fetch_client.go throws before the "Parse // response" step ever runs, so a 4xx/5xx/default body never flows through // the Promise this function types. -func (r *RESTGenerator) generateReturnType(endpoint client.Endpoint, spec *client.APISpec) string { +func (r *RESTGenerator) generateReturnType(endpoint client.Endpoint, spec *client.APISpec) (string, bool) { codes := make([]int, 0, len(endpoint.Responses)) for code := range endpoint.Responses { @@ -459,10 +493,15 @@ func (r *RESTGenerator) generateReturnType(endpoint client.Endpoint, spec *clien var types []string seen := make(map[string]bool, len(codes)) + hasVoid := false for _, code := range codes { t := r.responseBodyType(endpoint.Responses[code], spec) + if t == "void" { + hasVoid = true + } + if !seen[t] { seen[t] = true @@ -471,11 +510,14 @@ func (r *RESTGenerator) generateReturnType(endpoint client.Endpoint, spec *clien } if len(types) == 0 { - // No 2xx responses declared at all (e.g. only a default error). - return "void" + // No 2xx responses declared at all (e.g. only a default error). The + // success shape is entirely unspecified, so treat an empty body as + // legitimate rather than as something to parse against a type nobody + // declared. + return "void", true } - return strings.Join(types, " | ") + return strings.Join(types, " | "), hasVoid } // responseBodyType maps a single response's declared content to a TypeScript diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index c48ee7bc..befcc698 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -630,6 +630,138 @@ func lastLine(s string) string { return "" } +// TestEmptyBodyConversionIsGatedBySpec is the runtime proof for the +// fix-round-2 defect: round 1 made executeRequest convert ANY empty body to +// `undefined` unconditionally (`blob.size === 0`), regardless of whether the +// endpoint's declared return type had a `void` member at all. That silently +// corrupted legitimate empty payloads for endpoints that never declared a +// no-content 2xx: an empty `text/plain` body (a valid empty string) and a +// zero-byte binary body (a valid empty file) both became `undefined`, and an +// empty JSON body — which should throw a parse error — silently "succeeded" +// with `undefined` instead. +// +// The fix threads the spec's knowledge through a new `RequestConfig. +// allowEmptyBody` flag, set by generateMethodBody exactly when +// generateReturnType found a `void` member in the endpoint's union, and +// executeRequest only treats a zero-byte body as `undefined` when that flag +// is set. This test proves both directions, and the two status-only cases, +// by executing the actual generated client under Node with a mocked global +// fetch — the only way to see the runtime value, since tsc never executes +// anything: +// +// 1. WITH a no-content 2xx (users.get: 200 body + 202 none) -> an empty 202 +// still resolves to undefined (round-1 behavior preserved). +// 2. WITHOUT one, Promise (texts.get, text/plain only) -> an empty +// body resolves to "", not undefined. +// 3. WITHOUT one, Promise (downloads.get, octet-stream only) -> a +// zero-byte body resolves to a real zero-size Blob, not undefined. +// 4. WITHOUT one, Promise (users.create, JSON only) -> an empty +// body throws (JSON.parse on an empty string throws SyntaxError), not undefined. +// 5. Status-based 204/205 -> undefined unconditionally, flag or not. +func TestEmptyBodyConversionIsGatedBySpec(t *testing.T) { + spec := baseSpec() + spec.Endpoints = append(spec.Endpoints, + client.Endpoint{ + Method: "GET", Path: "/empty204", OperationID: "empty204.get", + Responses: map[int]*client.Response{204: {}}, + }, + client.Endpoint{ + Method: "GET", Path: "/empty205", OperationID: "empty205.get", + Responses: map[int]*client.Response{205: {}}, + }, + ) + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + // Sanity checks: confirm the declared types this test's conclusions rest + // on are actually what's generated, before trusting the runtime results. + require.Contains(t, rest, "Promise", "users.get must declare the mixed union for case 1 to be meaningful") + require.Contains(t, rest, "Promise", "texts.get must declare a bare string type (no void) for case 2 to be meaningful") + require.Contains(t, rest, "Promise", "downloads.get must declare a bare Blob type (no void) for case 3 to be meaningful") + + fetchTS := out.Files["src/fetch.ts"] + require.Contains(t, fetchTS, "allowEmptyBody", "fetch.ts must reference the allowEmptyBody gate for this test to be meaningful") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +function setFetch(status: number, body: string | null, headers: Record) { + (globalThis as any).fetch = async () => new Response(body, { status, headers }); +} + +function describe(v: unknown): string { + if (v === undefined) return 'undefined'; + if (typeof Blob !== 'undefined' && v instanceof Blob) return 'Blob(size=' + (v as Blob).size + ')'; + return JSON.stringify(v); +} + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + const results: Record = {}; + + // 1. WITH a no-content 2xx: an empty 202 must still resolve to undefined. + setFetch(202, null, {}); + results.withVoidEmpty = describe(await client.users.get('u1')); + + // 2. WITHOUT one, Promise: an empty text/plain body is legitimate + // data, not void. + setFetch(200, '', { 'content-type': 'text/plain' }); + results.textEmpty = describe(await client.texts.get()); + + // 3. WITHOUT one, Promise: a zero-byte binary body is a legitimate + // (empty) file, not void. + setFetch(200, '', { 'content-type': 'application/octet-stream' }); + results.binaryEmpty = describe(await client.downloads.get()); + + // 4. WITHOUT one, Promise: an empty JSON body is a genuine + // parse error, not a legitimate value. + setFetch(200, '', { 'content-type': 'application/json' }); + try { + await client.users.create({ id: 'x' }); + results.jsonEmpty = 'did-not-throw'; + } catch (err) { + results.jsonEmpty = 'threw:' + (err instanceof Error ? err.constructor.name : typeof err); + } + + // 5. Status-based no-body responses: undefined regardless of the flag. + setFetch(204, null, {}); + results.status204 = describe(await client.empty204.get()); + setFetch(205, null, {}); + results.status205 = describe(await client.empty205.get()); + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +` + writeTree(t, dir, map[string]string{"src/__driver2.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver2.ts") + + var results map[string]string + + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(lastLine(stdout))), &results), "driver stdout:\n%s", stdout) + + assert.Equal(t, "undefined", results["withVoidEmpty"], + "case 1: an endpoint that declares a no-content 2xx must still resolve an empty body to undefined") + assert.Equal(t, `""`, results["textEmpty"], + "case 2: an endpoint with no no-content 2xx must resolve an empty text/plain body to the empty string, not undefined") + assert.Equal(t, "Blob(size=0)", results["binaryEmpty"], + "case 3: an endpoint with no no-content 2xx must resolve a zero-byte binary body to a zero-byte Blob, not undefined") + assert.Contains(t, results["jsonEmpty"], "threw:", + "case 4: an endpoint with no no-content 2xx must let an empty JSON body throw, not silently resolve to undefined") + assert.Equal(t, "undefined", results["status204"], "case 5a: 204 must resolve to undefined regardless of allowEmptyBody") + assert.Equal(t, "undefined", results["status205"], "case 5b: 205 must resolve to undefined regardless of allowEmptyBody") +} + func TestEndpointTreeKeepsBothOrders(t *testing.T) { // Reuses the same two operation IDs as TestRESTGenerator_ConflictingOperationIDs // ("users" and "users.active.list") but exercises both insertion orders. From 3dd4863af4b01251dd4502bfc72aed5b3808691f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 12:45:54 -0500 Subject: [PATCH 35/71] fix(client/typescript): keep fetch timeout/abort live through the body read Four pre-existing defects in the generated TS client's HTTPClient: - 3a: clearTimeout/combined.dispose() fired the instant fetch() resolved (headers only), so a server that sent headers then stalled the body hung forever. Both now tear down in a single finally wrapping the fetch call and the whole body-parsing block, covering every exit path exactly once. - 3b: on the combineSignals manual fallback (runtimes without AbortSignal.any), the same early dispose() removed the forwarding listeners before a caller abort during the body read could reach the merged controller. Fixed by the same finally-based teardown. - 2: executeRequest's shallow `{ ...config }` aliased requestConfig.headers to the caller's config.headers, so a request interceptor mutating headers in place compounded across retries. Now copies headers and retry defensively (body intentionally left aliased; its serialization is out of scope here). - 3c: the backoff sleep between retry attempts ignored the caller's signal entirely. Now races the sleep against config.signal so an abort during backoff rejects promptly instead of waiting out the full delay. All four proven by executing the bundled generated client under Node against constructed Response objects, not by string assertions. Re-verified the 500-sequential-requests/0-listeners guarantee on the fallback path still holds. Updated TestFetchClientCombinesSignalsAndThrowsErrors, which had encoded the old (defective) two-call-site teardown shape as a requirement. All 8 gate fixtures still type-check with zero tsc errors; determinism unaffected. --- .../generators/typescript/fetch_client.go | 68 ++- .../typescript/fetch_client_test.go | 496 ++++++++++++++++++ .../client/generators/typescript/gate_test.go | 41 +- 3 files changed, 593 insertions(+), 12 deletions(-) create mode 100644 internal/client/generators/typescript/fetch_client_test.go diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index 9b51197c..bc90c505 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -176,8 +176,37 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" const delay = Math.min(\n") buf.WriteString(" (retryConfig.delay || 1000) * Math.pow(2, attempt),\n") buf.WriteString(" retryConfig.maxDelay || 30000\n") - buf.WriteString(" );\n") - buf.WriteString(" await new Promise(resolve => setTimeout(resolve, delay));\n") + buf.WriteString(" );\n\n") + buf.WriteString(" // Wait out the backoff, but let the caller's own abort interrupt it\n") + buf.WriteString(" // early instead of always sitting out the full delay — with\n") + buf.WriteString(" // production defaults (1s/2s/4s) an unabortable wait means a caller\n") + buf.WriteString(" // who aborted 20ms in still waits out the rest of the delay. This\n") + buf.WriteString(" // races against config.signal (the caller's own signal), not the\n") + buf.WriteString(" // per-attempt timeout controller created inside executeRequest: the\n") + buf.WriteString(" // timeout governs a single request attempt, not the gap between\n") + buf.WriteString(" // attempts.\n") + buf.WriteString(" await new Promise((resolve, reject) => {\n") + buf.WriteString(" const signal = config.signal;\n") + buf.WriteString(" let timer: ReturnType;\n") + buf.WriteString(" const onAbort = () => {\n") + buf.WriteString(" clearTimeout(timer);\n") + buf.WriteString(" reject((signal as any)?.reason ?? new DOMException('Aborted', 'AbortError'));\n") + buf.WriteString(" };\n") + buf.WriteString(" if (signal) {\n") + buf.WriteString(" if (signal.aborted) {\n") + buf.WriteString(" reject((signal as any).reason ?? new DOMException('Aborted', 'AbortError'));\n") + buf.WriteString(" return;\n") + buf.WriteString(" }\n") + buf.WriteString(" // Removed explicitly when the timer wins (below), not left to\n") + buf.WriteString(" // { once: true } alone, so it doesn't accumulate on a long-lived\n") + buf.WriteString(" // caller signal reused across many non-aborted retry sequences.\n") + buf.WriteString(" signal.addEventListener('abort', onAbort, { once: true });\n") + buf.WriteString(" }\n") + buf.WriteString(" timer = setTimeout(() => {\n") + buf.WriteString(" if (signal) signal.removeEventListener('abort', onAbort);\n") + buf.WriteString(" resolve();\n") + buf.WriteString(" }, delay);\n") + buf.WriteString(" });\n") buf.WriteString(" }\n") buf.WriteString(" }\n\n") @@ -186,7 +215,21 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c // Execute request method buf.WriteString(" private async executeRequest(config: RequestConfig, attempt: number): Promise {\n") - buf.WriteString(" let requestConfig = { ...config };\n\n") + buf.WriteString(" // A bare `{ ...config }` only copies the top-level fields: nested\n") + buf.WriteString(" // objects like headers and retry stay aliased to the caller's config\n") + buf.WriteString(" // (requestConfig.headers === config.headers). A request interceptor\n") + buf.WriteString(" // that mutates config.headers in place — a legitimate reading of\n") + buf.WriteString(" // onRequest(config): RequestConfig — would then compound its mutation\n") + buf.WriteString(" // on every retry attempt, since each attempt re-spreads the same\n") + buf.WriteString(" // already-mutated object. Copying these two nested objects keeps each\n") + buf.WriteString(" // attempt's interceptor mutations scoped to that attempt. (`body` is\n") + buf.WriteString(" // deliberately left aliased here — its serialization is out of scope\n") + buf.WriteString(" // for this change.)\n") + buf.WriteString(" let requestConfig: RequestConfig = {\n") + buf.WriteString(" ...config,\n") + buf.WriteString(" headers: config.headers ? { ...config.headers } : config.headers,\n") + buf.WriteString(" retry: config.retry ? { ...config.retry } : config.retry,\n") + buf.WriteString(" };\n\n") // Apply request interceptors if config.Interceptors { @@ -235,9 +278,6 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" }\n\n") } - buf.WriteString(" clearTimeout(timeoutId);\n") - buf.WriteString(" combined.dispose();\n\n") - buf.WriteString(" // Handle non-OK responses\n") buf.WriteString(" if (!response.ok) {\n") buf.WriteString(" await this.handleErrorResponse(response);\n") @@ -302,8 +342,6 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" return blob as any;\n") buf.WriteString(" } catch (error) {\n") - buf.WriteString(" clearTimeout(timeoutId);\n") - buf.WriteString(" combined.dispose();\n\n") // Apply error interceptors if config.Interceptors { @@ -319,6 +357,20 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" throw error;\n") } + buf.WriteString(" } finally {\n") + buf.WriteString(" // The timeout and the abort-signal forwarding must stay live until\n") + buf.WriteString(" // the response body has been fully read, not just until fetch()\n") + buf.WriteString(" // resolves (headers only) — otherwise a server that sends headers then\n") + buf.WriteString(" // stalls the body hangs forever, and (on the manual combineSignals\n") + buf.WriteString(" // fallback) a caller abort during the body read never reaches the\n") + buf.WriteString(" // merged controller once the forwarding listeners are gone. Tearing\n") + buf.WriteString(" // both down in a finally that wraps the fetch call AND the whole\n") + buf.WriteString(" // body-parsing block above covers every exit path — success, a\n") + buf.WriteString(" // thrown network/parse error, and handleErrorResponse's throw —\n") + buf.WriteString(" // exactly once. combined.dispose() is idempotent (an abort may already\n") + buf.WriteString(" // have triggered it), so calling it again here is always safe.\n") + buf.WriteString(" clearTimeout(timeoutId);\n") + buf.WriteString(" combined.dispose();\n") buf.WriteString(" }\n") buf.WriteString(" }\n\n") diff --git a/internal/client/generators/typescript/fetch_client_test.go b/internal/client/generators/typescript/fetch_client_test.go new file mode 100644 index 00000000..28c57389 --- /dev/null +++ b/internal/client/generators/typescript/fetch_client_test.go @@ -0,0 +1,496 @@ +package typescript + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeFetchOnly generates just src/fetch.ts (self-contained: HTTPClient has +// no imports of its own) into a fresh temp dir, for tests that only exercise +// HTTPClient's timeout/abort/retry machinery and don't need the rest of the +// generated tree (rest.ts, types.ts, etc). +func writeFetchOnly(t *testing.T) string { + t.Helper() + + code := NewFetchClientGenerator().GenerateBaseClient(baseSpec(), baseConfig()) + dir := t.TempDir() + writeTree(t, dir, map[string]string{"src/fetch.ts": code}) + + return dir +} + +// decodeLastLine unmarshals the last non-empty line of driver stdout into v. +func decodeLastLine(t *testing.T, stdout string, v any) { + t.Helper() + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(lastLine(stdout))), v), "driver stdout:\n%s", stdout) +} + +// TestFetchTimeoutCoversResponseBodyRead is the runtime proof for defect 3a: +// clearTimeout(timeoutId) used to fire the moment fetch() resolved (headers +// only), before the body was read. A server that sends headers then stalls +// the body hung forever, because nothing was left to abort the stalled +// response.blob() call. +// +// Measured BEFORE the fix (100ms timeout, body a ReadableStream that never +// closes): the request never rejected; the driver's own 1200ms watchdog fired +// and printed {"outcome":"STILL-HANGING","elapsedMs":~1200} because +// client.request() was still pending. +// +// Also carries a positive control: an ordinary request whose body closes +// normally must still resolve with the real value, well inside the timeout. +func TestFetchTimeoutCoversResponseBodyRead(t *testing.T) { + dir := writeFetchOnly(t) + + cases := []struct { + name string + fetchImpl string + wantResult string + }{ + { + name: "stalled-body-must-timeout", + // A realistic mock: headers arrive immediately and the body never + // closes on its own, but — matching what a real fetch() + // implementation does internally — the response body stream is + // wired to the request's abort signal, so aborting mid-read + // actually terminates the pending body read. Without this wiring + // the mock wouldn't exercise the fix at all: a signal nothing is + // listening to can't reject anything no matter how the client + // code is written. + fetchImpl: `(globalThis as any).fetch = async (_url: string, init: any) => { + let ctrl: ReadableStreamDefaultController; + const stream = new ReadableStream({ start(c) { ctrl = c; } }); + if (init && init.signal) { + if (init.signal.aborted) { + ctrl!.error(init.signal.reason); + } else { + init.signal.addEventListener('abort', () => ctrl.error(init.signal.reason), { once: true }); + } + } + return new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }); + };`, + wantResult: "rejected", + }, + { + name: "normal-body-succeeds-within-timeout", + fetchImpl: `(globalThis as any).fetch = async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + });`, + wantResult: "resolved", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 100); // 100ms timeout + + ` + tc.fetchImpl + ` + + const start = Date.now(); + const watchdog = setTimeout(() => { + console.log(JSON.stringify({ outcome: 'STILL-HANGING', elapsedMs: Date.now() - start })); + process.exit(0); + }, 1200); + + try { + const value = await client.request({ method: 'GET', url: '/slow-body' }); + clearTimeout(watchdog); + console.log(JSON.stringify({ outcome: 'resolved', value, elapsedMs: Date.now() - start })); + } catch (err) { + clearTimeout(watchdog); + console.log(JSON.stringify({ + outcome: 'rejected', + name: err instanceof Error ? err.name : typeof err, + elapsedMs: Date.now() - start, + })); + } +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + driverFile := "src/__driver_3a_" + tc.name + ".ts" + writeTree(t, dir, map[string]string{driverFile: driver}) + + stdout := runNodeDriver(t, dir, driverFile) + + var result struct { + Outcome string `json:"outcome"` + Name string `json:"name"` + ElapsedMs int `json:"elapsedMs"` + } + decodeLastLine(t, stdout, &result) + + assert.NotEqual(t, "STILL-HANGING", result.Outcome, + "measured before the fix: STILL-HANGING-after-1200ms (clearTimeout fires when headers arrive, not when the body finishes) — elapsedMs=%d", result.ElapsedMs) + assert.Equal(t, tc.wantResult, result.Outcome) + assert.Less(t, result.ElapsedMs, 1000, + "a stalled body must be aborted by the 100ms timeout well before the 1200ms watchdog; a normal body must resolve fast") + }) + } +} + +// TestFetchCallerAbortReachesManualFallbackDuringBodyRead is the runtime +// proof for defect 3b: on the combineSignals manual fallback (used on +// runtimes without AbortSignal.any, e.g. Node < 20.3, Safari < 17.4), +// combined.dispose() removed the forwarding listeners at the same point +// clearTimeout fired — i.e. the instant headers arrived — so a caller abort +// during the body read never reached the merged controller. +// +// Measured BEFORE the fix: with native AbortSignal.any, aborting mid-body +// threw AbortError in ~51ms. With the fallback forced (AbortSignal.any +// deleted), the same abort left the request STILL-HANGING past 1200ms — the +// exact runtimes the fallback exists for were both untimed AND +// uncancellable after headers arrived. +func TestFetchCallerAbortReachesManualFallbackDuringBodyRead(t *testing.T) { + dir := writeFetchOnly(t) + + cases := []struct { + name string + forceFallback bool + abort bool + wantResult string + }{ + {name: "native-AbortSignal-any", forceFallback: false, abort: true, wantResult: "rejected"}, + {name: "manual-fallback-forced", forceFallback: true, abort: true, wantResult: "rejected"}, + {name: "manual-fallback-no-abort-succeeds", forceFallback: true, abort: false, wantResult: "resolved"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + forceLine := "" + if tc.forceFallback { + forceLine = "delete (AbortSignal as any).any;" + } + + abortSetup := "// no caller abort" + if tc.abort { + abortSetup = "setTimeout(() => controller.abort(), 50);" + } + + // A realistic mock fetch: the response body never closes on its + // own, but — matching what a real fetch() implementation does + // internally — the body stream is wired to whatever signal was + // actually passed to fetch() (HTTPClient's merged/combined + // signal), so an abort that reaches that signal terminates the + // pending body read. In the no-abort case the stream instead + // closes on its own shortly after the request starts, so the + // positive control resolves instead of hanging. + streamSetup := `let ctrl: ReadableStreamDefaultController; + const stream = new ReadableStream({ start(c) { ctrl = c; } }); + if (init && init.signal) { + if (init.signal.aborted) { + ctrl!.error(init.signal.reason); + } else { + init.signal.addEventListener('abort', () => ctrl.error(init.signal.reason), { once: true }); + } + }` + if !tc.abort { + streamSetup += ` + setTimeout(() => { ctrl.enqueue(new TextEncoder().encode('{"ok":true}')); ctrl.close(); }, 20);` + } + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + ` + forceLine + ` + + const client = new HTTPClient('http://example.invalid', 5000); // long per-attempt timeout: isolate the caller abort as the cause + + (globalThis as any).fetch = async (_url: string, init: any) => { + ` + streamSetup + ` + return new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }); + }; + + const controller = new AbortController(); + ` + abortSetup + ` + + const start = Date.now(); + const watchdog = setTimeout(() => { + console.log(JSON.stringify({ outcome: 'STILL-HANGING', elapsedMs: Date.now() - start })); + process.exit(0); + }, 1200); + + try { + await client.request({ method: 'GET', url: '/slow-body', signal: controller.signal }); + clearTimeout(watchdog); + console.log(JSON.stringify({ outcome: 'resolved', elapsedMs: Date.now() - start })); + } catch (err) { + clearTimeout(watchdog); + console.log(JSON.stringify({ + outcome: 'rejected', + name: err instanceof Error ? err.name : typeof err, + elapsedMs: Date.now() - start, + })); + } +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + driverFile := "src/__driver_3b_" + tc.name + ".ts" + writeTree(t, dir, map[string]string{driverFile: driver}) + + stdout := runNodeDriver(t, dir, driverFile) + + var result struct { + Outcome string `json:"outcome"` + Name string `json:"name"` + ElapsedMs int `json:"elapsedMs"` + } + decodeLastLine(t, stdout, &result) + + assert.NotEqual(t, "STILL-HANGING", result.Outcome, + "measured before the fix (manual fallback): STILL-HANGING-after-1200ms (combined.dispose() removes the forwarding listeners as soon as headers arrive, so a caller abort during the body read never reaches the merged controller) — elapsedMs=%d", result.ElapsedMs) + assert.Equal(t, tc.wantResult, result.Outcome) + + if tc.abort { + assert.Less(t, result.ElapsedMs, 500, "a caller abort 50ms into the body read must reject promptly, not hang") + } + }) + } +} + +// TestInterceptorHeaderMutationDoesNotCompoundAcrossRetries is the runtime +// proof for defect 2: executeRequest's `let requestConfig = { ...config }` +// is a shallow copy, so requestConfig.headers === config.headers. A request +// interceptor that mutates config.headers in place — a legitimate reading of +// onRequest(config): RequestConfig — compounded its mutation on every retry +// attempt, since each attempt re-spread the same already-mutated headers +// object. +// +// Measured BEFORE the fix, across a 503 -> 503 -> 200 retry, an +// interceptor appending '>hop' to x-trace on each attempt produced +// "t>hop", "t>hop>hop", "t>hop>hop>hop" on the three outgoing requests. +// After the fix each attempt must independently see "t>hop". +func TestInterceptorHeaderMutationDoesNotCompoundAcrossRetries(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + + client.addRequestInterceptor({ + onRequest(config) { + config.headers = config.headers || {}; + config.headers['x-trace'] = (config.headers['x-trace'] || 't') + '>hop'; + return config; + }, + }); + + let calls = 0; + const seenTraces: string[] = []; + (globalThis as any).fetch = async (_url: string, init: any) => { + calls++; + seenTraces.push(init.headers['x-trace']); + if (calls < 3) { + return new Response(null, { status: 503 }); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await client.request({ + method: 'GET', + url: '/x', + headers: { 'x-trace': 't' }, + retry: { maxAttempts: 3, delay: 5, maxDelay: 20, retryableStatusCodes: [503] }, + }); + + console.log(JSON.stringify({ seenTraces, calls, result })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_2.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_2.ts") + + var result struct { + SeenTraces []string `json:"seenTraces"` + Calls int `json:"calls"` + Result map[string]any `json:"result"` + } + decodeLastLine(t, stdout, &result) + + require.Len(t, result.SeenTraces, 3, "driver stdout:\n%s", stdout) + assert.Equal(t, []string{"t>hop", "t>hop", "t>hop"}, result.SeenTraces, + "measured before the fix: [\"t>hop\", \"t>hop>hop\", \"t>hop>hop>hop\"] — a shallow config copy let the interceptor's in-place header mutation compound across retries") + + // Positive control: the retry flow itself must still work. + assert.Equal(t, 3, result.Calls) + assert.Equal(t, true, result.Result["ok"]) +} + +// TestBackoffSleepAbortsPromptly is the runtime proof for defect 3c: the +// backoff sleep (`await new Promise(resolve => setTimeout(resolve, delay))`) +// ignored the caller's signal entirely, so aborting partway into a backoff +// still waited out the full delay. With production defaults (1s/2s/4s) a +// caller could wait seconds past their own abort. +// +// Measured BEFORE the fix: aborting 20ms into a 300ms backoff still took +// ~300ms to reject. After the fix it must reject in roughly 20ms. +func TestBackoffSleepAbortsPromptly(t *testing.T) { + dir := writeFetchOnly(t) + + t.Run("abort-mid-backoff", func(t *testing.T) { + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + + (globalThis as any).fetch = async () => new Response(null, { status: 503 }); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 20); + + const start = Date.now(); + try { + await client.request({ + method: 'GET', + url: '/x', + signal: controller.signal, + retry: { maxAttempts: 5, delay: 300, maxDelay: 300, retryableStatusCodes: [503] }, + }); + console.log(JSON.stringify({ outcome: 'resolved', elapsedMs: Date.now() - start })); + } catch (err) { + console.log(JSON.stringify({ + outcome: 'rejected', + name: err instanceof Error ? err.name : typeof err, + elapsedMs: Date.now() - start, + })); + } +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_3c_abort.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_3c_abort.ts") + + var result struct { + Outcome string `json:"outcome"` + Name string `json:"name"` + ElapsedMs int `json:"elapsedMs"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "rejected", result.Outcome) + assert.Less(t, result.ElapsedMs, 150, + "measured before the fix: aborting 20ms into a 300ms backoff still took ~300ms to reject; elapsedMs=%d", result.ElapsedMs) + }) + + // Positive control: without an abort, the retry/backoff flow itself must + // still work end to end. + t.Run("no-abort-retry-still-succeeds", func(t *testing.T) { + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + + let calls = 0; + (globalThis as any).fetch = async () => { + calls++; + if (calls < 3) return new Response(null, { status: 503 }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await client.request({ + method: 'GET', + url: '/x', + retry: { maxAttempts: 3, delay: 10, maxDelay: 20, retryableStatusCodes: [503] }, + }); + + console.log(JSON.stringify({ outcome: 'resolved', calls, result })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_3c_control.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_3c_control.ts") + + var result struct { + Outcome string `json:"outcome"` + Calls int `json:"calls"` + Result map[string]any `json:"result"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "resolved", result.Outcome) + assert.Equal(t, 3, result.Calls) + assert.Equal(t, true, result.Result["ok"]) + }) +} + +// TestFallbackAbortListenersDoNotLeakAcrossManyRequests re-verifies, after +// this task's teardown restructuring, a guarantee an earlier task proved: on +// the combineSignals manual fallback, 500 sequential requests reusing one +// long-lived caller AbortController leave 0 'abort' listeners on that +// controller's signal once they've all settled. Moving the teardown later +// (to cover the body read, per defects 3a/3b) must not reintroduce the +// listener leak that earlier fix closed. +func TestFallbackAbortListenersDoNotLeakAcrossManyRequests(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; +import { getEventListeners } from 'node:events'; + +async function main() { + delete (AbortSignal as any).any; // force the manual combineSignals fallback + + const client = new HTTPClient('http://example.invalid', 5000); + (globalThis as any).fetch = async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + // One long-lived controller reused by every request, never aborted — + // exactly the shape that leaks if dispose() isn't called on every + // non-aborted request too. + const controller = new AbortController(); + + const before = getEventListeners(controller.signal, 'abort').length; + + for (let i = 0; i < 500; i++) { + await client.request({ method: 'GET', url: '/x', signal: controller.signal }); + } + + const after = getEventListeners(controller.signal, 'abort').length; + + console.log(JSON.stringify({ before, after })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_leak.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_leak.ts") + + var result struct { + Before int `json:"before"` + After int `json:"after"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, 0, result.Before) + assert.Equal(t, 0, result.After, + "500 sequential requests reusing one caller AbortController must leave 0 'abort' listeners on the manual fallback path") +} diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 2d3f5639..c080e383 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -174,12 +174,45 @@ func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { t.Error("expected the caller to dispose of the combined signal wherever the timeout is cleared") } - if got := strings.Count(code, "combined.dispose()"); got < 2 { - t.Errorf("expected combined.dispose() on both the success and error exit paths, found %d occurrence(s)", got) + // The timeout and the combined signal must stay live until the response + // body has been fully read, not just until fetch() resolves (headers + // only) — otherwise a server that sends headers then stalls the body + // hangs forever, and (on the manual combineSignals fallback) a caller + // abort during the body read never reaches the merged controller once + // the forwarding listeners are gone (task 7b). A single `finally` block + // wrapping the fetch call and the whole body-parsing section is what + // covers every exit path — success, a thrown error, and + // handleErrorResponse's throw — exactly once, so this asserts that + // shape directly instead of counting call sites, which the old + // (defective) two-call-site shape would also have satisfied. + if !strings.Contains(code, "} finally {\n") { + t.Error("expected the timeout/signal teardown to live in a finally block wrapping the fetch call and body read, so it runs on every exit path exactly once, after the body has been consumed") } - if strings.Contains(code, "{ once: true }") { - t.Error("fallback abort listeners must be removed explicitly via dispose, not left to { once: true } which leaks on non-abort exits") + finallyIdx := strings.Index(code, "} finally {\n") + if finallyIdx == -1 { + t.Fatal("no finally block found in executeRequest") + } + + tail := code[finallyIdx:] + if !strings.Contains(tail, "clearTimeout(timeoutId);") || !strings.Contains(tail, "combined.dispose();") { + t.Error("expected the finally block to clear the timeout and dispose the combined signal") + } + + // { once: true } is fine elsewhere (e.g. the backoff-sleep abort + // listener, which removes itself explicitly when the timer wins) — this + // only guards the combineSignals fallback specifically, which must keep + // removing its listeners via explicit dispose() rather than relying on + // { once: true } alone, which leaks on a non-abort exit. + combineStart := strings.Index(code, "const combineSignals") + combineEnd := strings.Index(code, "DEFAULT_RETRY_CONFIG") + + if combineStart == -1 || combineEnd == -1 || combineEnd <= combineStart { + t.Fatal("could not locate the combineSignals block") + } + + if strings.Contains(code[combineStart:combineEnd], "{ once: true }") { + t.Error("fallback abort listeners inside combineSignals must be removed explicitly via dispose, not left to { once: true } which leaks on non-abort exits") } } From 4d9b41df84aebfb7b5a3bfe82bc10d096b7729d1 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 25 Jul 2026 13:10:15 -0500 Subject: [PATCH 36/71] feat(client/typescript): support multipart, binary and text request bodies hasBodyParam accepted only application/json, so a multipart, binary, or plain-text request body generated a method with no body parameter at all - the request was silently sent empty. requestBodyContentType/ requestBodyParamType generalise the selection using the same json > text/* > other precedence responseBodyType already established for responses, mapping to FormData/string/Blob respectively. fetch.ts's executeRequest no longer unconditionally JSON.stringify's the body or forces Content-Type: application/json - it now serialises by the body's runtime type (FormData/Blob/string/ReadableStream pass through untouched; anything else is JSON-stringified) and only sets a default Content-Type when nothing already set one. A ReadableStream body is one-shot, so request() now caps retries to a single attempt when the body is a stream, failing loudly on the first attempt instead of silently retrying with a disturbed or empty stream. --- .../generators/typescript/fetch_client.go | 86 ++++- .../typescript/fetch_client_test.go | 298 ++++++++++++++++++ .../generators/typescript/fixtures_test.go | 20 ++ .../client/generators/typescript/gate_test.go | 36 +++ internal/client/generators/typescript/rest.go | 77 ++++- .../client/generators/typescript/rest_test.go | 97 ++++++ 6 files changed, 596 insertions(+), 18 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index bc90c505..b1b802e8 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -134,9 +134,14 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" constructor(baseURL: string, timeout: number = 30000) {\n") buf.WriteString(" this.baseURL = baseURL;\n") buf.WriteString(" this.timeout = timeout;\n") - buf.WriteString(" this.defaultHeaders = {\n") - buf.WriteString(" 'Content-Type': 'application/json',\n") - buf.WriteString(" };\n") + buf.WriteString(" // No default Content-Type here: unconditionally forcing\n") + buf.WriteString(" // 'application/json' on every request — including ones with a\n") + buf.WriteString(" // FormData/Blob/string body, or no body at all — is wrong. A JSON\n") + buf.WriteString(" // body gets 'application/json' dynamically in executeRequest, only\n") + buf.WriteString(" // when nothing has already set a Content-Type. A caller that wants a\n") + buf.WriteString(" // different default for every request can still call\n") + buf.WriteString(" // setDefaultHeader('Content-Type', ...) explicitly.\n") + buf.WriteString(" this.defaultHeaders = {};\n") buf.WriteString(" }\n\n") // Add interceptor methods @@ -157,7 +162,24 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c // Main request method with retry logic buf.WriteString(" async request(config: RequestConfig): Promise {\n") - buf.WriteString(" const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config.retry };\n") + buf.WriteString(" const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config.retry };\n\n") + + buf.WriteString(" // A ReadableStream request body is one-shot: once it has been read by\n") + buf.WriteString(" // one fetch() attempt, it is disturbed and cannot be re-sent by a\n") + buf.WriteString(" // second attempt (unlike FormData/Blob/string, which are all\n") + buf.WriteString(" // re-readable). Retrying it anyway would either throw a confusing\n") + buf.WriteString(" // 'body stream already read' TypeError from fetch() itself — which\n") + buf.WriteString(" // shouldRetry would then classify as a retryable network error and\n") + buf.WriteString(" // retry again, burning every remaining attempt on that same error —\n") + buf.WriteString(" // or, on runtimes tolerant of a disturbed stream, silently send an\n") + buf.WriteString(" // empty body. Capping maxAttempts to 1 up front reuses the existing\n") + buf.WriteString(" // attempt-loop and shouldRetry logic unchanged: attempt 0 is the only\n") + buf.WriteString(" // attempt, so any failure surfaces immediately and loudly instead of\n") + buf.WriteString(" // being retried at all.\n") + buf.WriteString(" if (typeof ReadableStream !== 'undefined' && config.body instanceof ReadableStream) {\n") + buf.WriteString(" retryConfig.maxAttempts = 1;\n") + buf.WriteString(" }\n\n") + buf.WriteString(" let lastError: Error | null = null;\n\n") buf.WriteString(" for (let attempt = 0; attempt < (retryConfig.maxAttempts || 1); attempt++) {\n") @@ -223,8 +245,10 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // on every retry attempt, since each attempt re-spreads the same\n") buf.WriteString(" // already-mutated object. Copying these two nested objects keeps each\n") buf.WriteString(" // attempt's interceptor mutations scoped to that attempt. (`body` is\n") - buf.WriteString(" // deliberately left aliased here — its serialization is out of scope\n") - buf.WriteString(" // for this change.)\n") + buf.WriteString(" // deliberately left aliased here too — a FormData/Blob/ReadableStream\n") + buf.WriteString(" // value must stay the exact same object across attempts for a retry to\n") + buf.WriteString(" // resend it at all; see the body-serialization block below for how\n") + buf.WriteString(" // that aliased value is turned into an actual fetch() body.)\n") buf.WriteString(" let requestConfig: RequestConfig = {\n") buf.WriteString(" ...config,\n") buf.WriteString(" headers: config.headers ? { ...config.headers } : config.headers,\n") @@ -244,11 +268,53 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" ? requestConfig.url \n") buf.WriteString(" : this.baseURL + requestConfig.url;\n\n") - buf.WriteString(" // Merge headers\n") - buf.WriteString(" const headers = {\n") + buf.WriteString(" // Serialize the body based on its RUNTIME type, not any declared\n") + buf.WriteString(" // TypeScript type (which is erased by the time this code runs).\n") + buf.WriteString(" // FormData and Blob are native BodyInit values fetch already knows how\n") + buf.WriteString(" // to send — passing them through untouched (rather than\n") + buf.WriteString(" // JSON.stringify-ing them, which flattens either to the useless\n") + buf.WriteString(" // literal string \"{}\") is required for a multipart upload or a raw\n") + buf.WriteString(" // binary body to reach the server as anything but empty. A string\n") + buf.WriteString(" // body (e.g. a text/plain endpoint) also passes through as-is. A\n") + buf.WriteString(" // ReadableStream is included here for the same reason, even though\n") + buf.WriteString(" // request() above already refuses to retry one — it must still be\n") + buf.WriteString(" // sent untouched on its one and only attempt. Anything else is\n") + buf.WriteString(" // assumed to be a JSON-serializable value.\n") + buf.WriteString(" let body: BodyInit | undefined;\n") + buf.WriteString(" let isJSONBody = false;\n") + buf.WriteString(" if (requestConfig.body === undefined || requestConfig.body === null) {\n") + buf.WriteString(" body = undefined;\n") + buf.WriteString(" } else if (typeof FormData !== 'undefined' && requestConfig.body instanceof FormData) {\n") + buf.WriteString(" body = requestConfig.body;\n") + buf.WriteString(" } else if (typeof Blob !== 'undefined' && requestConfig.body instanceof Blob) {\n") + buf.WriteString(" body = requestConfig.body;\n") + buf.WriteString(" } else if (typeof requestConfig.body === 'string') {\n") + buf.WriteString(" body = requestConfig.body;\n") + buf.WriteString(" } else if (typeof ReadableStream !== 'undefined' && requestConfig.body instanceof ReadableStream) {\n") + buf.WriteString(" body = requestConfig.body;\n") + buf.WriteString(" } else {\n") + buf.WriteString(" body = JSON.stringify(requestConfig.body);\n") + buf.WriteString(" isJSONBody = true;\n") + buf.WriteString(" }\n\n") + + buf.WriteString(" // Merge headers. A JSON-serialized body gets a default\n") + buf.WriteString(" // 'Content-Type: application/json' — but only when nothing has\n") + buf.WriteString(" // already set a Content-Type (checked case-insensitively, since HTTP\n") + buf.WriteString(" // header names are case-insensitive): an endpoint that declares an\n") + buf.WriteString(" // explicit Content-Type header parameter, or a caller/interceptor\n") + buf.WriteString(" // that sets one directly, must win. FormData, Blob, string, and\n") + buf.WriteString(" // stream bodies never get this default at all — forcing a\n") + buf.WriteString(" // Content-Type onto FormData specifically breaks the request, because\n") + buf.WriteString(" // the runtime computes the multipart boundary only when it sets the\n") + buf.WriteString(" // header itself; a manually-set 'multipart/form-data' has no boundary\n") + buf.WriteString(" // and the server cannot parse the body.\n") + buf.WriteString(" const headers: Record = {\n") buf.WriteString(" ...this.defaultHeaders,\n") buf.WriteString(" ...requestConfig.headers,\n") - buf.WriteString(" };\n\n") + buf.WriteString(" };\n") + buf.WriteString(" if (isJSONBody && !Object.keys(headers).some((k) => k.toLowerCase() === 'content-type')) {\n") + buf.WriteString(" headers['Content-Type'] = 'application/json';\n") + buf.WriteString(" }\n\n") buf.WriteString(" // Create abort controller with timeout\n") buf.WriteString(" const controller = new AbortController();\n") @@ -266,7 +332,7 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" let response = await fetch(url, {\n") buf.WriteString(" method: requestConfig.method,\n") buf.WriteString(" headers,\n") - buf.WriteString(" body: requestConfig.body ? JSON.stringify(requestConfig.body) : undefined,\n") + buf.WriteString(" body,\n") buf.WriteString(" signal,\n") buf.WriteString(" });\n\n") diff --git a/internal/client/generators/typescript/fetch_client_test.go b/internal/client/generators/typescript/fetch_client_test.go index 28c57389..147bc48a 100644 --- a/internal/client/generators/typescript/fetch_client_test.go +++ b/internal/client/generators/typescript/fetch_client_test.go @@ -494,3 +494,301 @@ main().catch((err) => { console.error(err); process.exit(1); }); assert.Equal(t, 0, result.After, "500 sequential requests reusing one caller AbortController must leave 0 'abort' listeners on the manual fallback path") } + +// TestRequestBodySerializationByRuntimeType is task 8's runtime proof that +// executeRequest stopped unconditionally JSON.stringify-ing every body and +// stopped forcing 'Content-Type: application/json' on every request. Before +// this fix, `body: requestConfig.body ? JSON.stringify(requestConfig.body) : +// undefined` flattened a FormData/Blob body to the literal string "{}" (both +// have no own enumerable properties), silently sending an empty payload to a +// server expecting a multipart upload or raw bytes — exactly the class of +// defect this task exists to fix, one level below rest.go's generated +// signatures. This drives HTTPClient.request() directly (bypassing rest.ts) +// so it can exercise runtime body types no single generated method's +// declared parameter type could ever mix into one call. +func TestRequestBodySerializationByRuntimeType(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +function hasContentTypeHeader(headers: any): boolean { + if (!headers) return false; + return Object.keys(headers).some((k) => k.toLowerCase() === 'content-type'); +} + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + const results: Record = {}; + + // 1. A plain object body is JSON.stringify'd and gets the JSON Content-Type. + { + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + }; + await client.request({ method: 'POST', url: '/json', body: { a: 1 } }); + results.json = { + bodyIsString: typeof captured.body === 'string', + bodyValue: captured.body, + contentType: captured.headers['Content-Type'], + }; + } + + // 2. A FormData body passes through by identity, with NO explicit + // Content-Type — the runtime computes the multipart boundary only when + // it sets the header itself; a caller- or default-supplied + // 'multipart/form-data' (or JSON) Content-Type has no boundary and + // breaks the request server-side. + { + let captured: any; + const fd = new FormData(); + fd.append('file', 'contents'); + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + await client.request({ method: 'POST', url: '/upload', body: fd, allowEmptyBody: true }); + results.formData = { + sameReference: captured.body === fd, + hasContentType: hasContentTypeHeader(captured.headers), + }; + } + + // 3. A Blob body passes through by identity, with no forced JSON Content-Type. + { + let captured: any; + const blob = new Blob(['raw bytes'], { type: 'application/octet-stream' }); + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + await client.request({ method: 'POST', url: '/raw', body: blob, allowEmptyBody: true }); + results.blob = { + sameReference: captured.body === blob, + hasContentType: hasContentTypeHeader(captured.headers), + }; + } + + // 4. A string body (e.g. a text/plain endpoint) passes through as-is, not + // JSON.stringify'd (which would have wrapped it in quotes). + { + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + await client.request({ method: 'POST', url: '/note', body: 'hello world', allowEmptyBody: true }); + results.string = { + bodyValue: captured.body, + hasContentType: hasContentTypeHeader(captured.headers), + }; + } + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_body_types.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_body_types.ts") + + var result struct { + JSON struct { + BodyIsString bool `json:"bodyIsString"` + BodyValue string `json:"bodyValue"` + ContentType string `json:"contentType"` + } `json:"json"` + FormData struct { + SameReference bool `json:"sameReference"` + HasContentType bool `json:"hasContentType"` + } `json:"formData"` + Blob struct { + SameReference bool `json:"sameReference"` + HasContentType bool `json:"hasContentType"` + } `json:"blob"` + String struct { + BodyValue string `json:"bodyValue"` + HasContentType bool `json:"hasContentType"` + } `json:"string"` + } + decodeLastLine(t, stdout, &result) + + assert.True(t, result.JSON.BodyIsString, "driver stdout:\n%s", stdout) + assert.Equal(t, `{"a":1}`, result.JSON.BodyValue) + assert.Equal(t, "application/json", result.JSON.ContentType) + + assert.True(t, result.FormData.SameReference, "FormData body must pass through by identity, not be re-wrapped or JSON-flattened") + assert.False(t, result.FormData.HasContentType, "FormData body must not get an explicit Content-Type header — the runtime computes the multipart boundary only when it sets the header itself") + + assert.True(t, result.Blob.SameReference, "Blob body must pass through by identity") + assert.False(t, result.Blob.HasContentType, "Blob body must not be forced to a JSON Content-Type") + + assert.Equal(t, "hello world", result.String.BodyValue, "a string body must pass through as-is, not be JSON.stringify'd (which would add surrounding quotes)") + assert.False(t, result.String.HasContentType, "a bare string body must not be forced to a JSON Content-Type") +} + +// TestExplicitContentTypeHeaderIsNotOverridden asserts that a caller-supplied +// (or endpoint-declared-header-parameter-supplied) Content-Type always wins +// over executeRequest's own JSON-body default. An endpoint can declare an +// explicit Content-Type header parameter alongside a JSON-shaped body (e.g. +// 'application/vnd.custom+json'); the runtime default must not clobber it. +func TestExplicitContentTypeHeaderIsNotOverridden(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + + await client.request({ + method: 'POST', + url: '/custom', + body: { a: 1 }, + headers: { 'Content-Type': 'application/vnd.custom+json' }, + allowEmptyBody: true, + }); + + console.log(JSON.stringify({ contentType: captured.headers['Content-Type'] })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_ct_override.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_ct_override.ts") + + var result struct { + ContentType string `json:"contentType"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "application/vnd.custom+json", result.ContentType, + "an explicit Content-Type header (e.g. from a declared header parameter) must win over the JSON-body default; driver stdout:\n%s", stdout) +} + +// TestRetryResendsSameFormDataBodyByIdentity is the retry-safety half of task +// 8's KNOWN HAZARD: FormData is re-readable (unlike a ReadableStream), so a +// retried request must resend the exact same FormData object on every +// attempt — not re-serialize, clone, or drop it. +func TestRetryResendsSameFormDataBodyByIdentity(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + const fd = new FormData(); + fd.append('file', 'contents'); + + let calls = 0; + const seenBodies: boolean[] = []; + (globalThis as any).fetch = async (_url: string, init: any) => { + calls++; + seenBodies.push(init.body === fd); + if (calls < 2) { + return new Response(null, { status: 503 }); + } + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + }; + + const result = await client.request({ + method: 'POST', + url: '/upload', + body: fd, + retry: { maxAttempts: 3, delay: 5, maxDelay: 20, retryableStatusCodes: [503] }, + }); + + console.log(JSON.stringify({ calls, seenBodies, result })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_retry_formdata.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_retry_formdata.ts") + + var result struct { + Calls int `json:"calls"` + SeenBodies []bool `json:"seenBodies"` + Result map[string]any `json:"result"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, 2, result.Calls, "driver stdout:\n%s", stdout) + assert.Equal(t, []bool{true, true}, result.SeenBodies, + "a retried FormData body must be the exact same object on every attempt — FormData is re-readable, so no re-serialization or cloning should occur") + assert.Equal(t, true, result.Result["ok"]) +} + +// TestStreamBodyDisablesRetry is the stream-safety half of task 8's KNOWN +// HAZARD: once native bodies pass through unmodified, a ReadableStream body +// is one-shot — it cannot be re-sent on a second attempt. The chosen policy +// is to refuse to retry at all when the body is a stream: the retry loop's +// effective maxAttempts is capped to 1, so a failure is thrown immediately +// and loudly on the first attempt rather than being silently retried with a +// disturbed (or, in some runtimes, empty) stream. +func TestStreamBodyDisablesRetry(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('chunk')); + controller.close(); + }, + }); + + let calls = 0; + (globalThis as any).fetch = async (_url: string, _init: any) => { + calls++; + return new Response(null, { status: 503 }); + }; + + let outcome: string; + try { + await client.request({ + method: 'POST', + url: '/stream', + body: stream, + retry: { maxAttempts: 5, delay: 5, maxDelay: 20, retryableStatusCodes: [503] }, + }); + outcome = 'resolved'; + } catch (err) { + outcome = 'rejected:' + (err instanceof Error ? err.name : typeof err); + } + + console.log(JSON.stringify({ calls, outcome })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_stream_no_retry.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_stream_no_retry.ts") + + var result struct { + Calls int `json:"calls"` + Outcome string `json:"outcome"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, 1, result.Calls, + "a ReadableStream body must never be retried — it is one-shot and a second fetch() attempt would send a disturbed/empty stream, not the original data; driver stdout:\n%s", stdout) + assert.Contains(t, result.Outcome, "rejected", + "a failed request with a stream body must fail loudly on the first attempt, not hang or silently succeed") +} diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 76cc2068..10569480 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -92,6 +92,26 @@ func baseSpec() *client.APISpec { Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}}}}, }, + // Task 8 gate coverage: a request body beyond application/json. These + // two endpoints (a multipart upload and a raw binary upload) exercise + // hasBodyParam's generalisation and requestBodyParamType's FormData/Blob + // mapping across every fixture in the corpus — the 8-fixture tsc gate + // (TestGeneratedClientsTypeCheck) and the 12-run determinism test both + // derive from baseSpec(), so adding them here is what makes those two + // tests actually cover the defect this task fixed. + { + Method: "POST", Path: "/uploads", OperationID: "uploads.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "multipart/form-data": {Schema: &client.Schema{Type: "object"}}}}, + Responses: map[int]*client.Response{201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}}, + }, + { + Method: "POST", Path: "/raw", OperationID: "raw.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}}}, + Responses: map[int]*client.Response{204: {Description: "ok"}}, + }, }, Schemas: map[string]*client.Schema{"User": user}, } diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index c080e383..7ece17eb 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -216,6 +216,42 @@ func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { } } +// TestFetchClientSerializesBodyByRuntimeType is the string-level companion to +// the runtime proof in fetch_client_test.go's +// TestRequestBodySerializationByRuntimeType: it asserts the generated source +// actually contains the runtime-type checks the fix depends on, and that the +// old unconditional-JSON-stringify / unconditional-JSON-Content-Type code is +// gone. A string-only assertion can't prove runtime correctness by itself +// (hence the separate execution test), but it does pin the shape and catches +// an accidental revert immediately, without a Node/esbuild round trip. +func TestFetchClientSerializesBodyByRuntimeType(t *testing.T) { + code := NewFetchClientGenerator().GenerateBaseClient(baseSpec(), baseConfig()) + + if !strings.Contains(code, "instanceof FormData") { + t.Error("expected executeRequest to check for a FormData body so it can pass it through untouched") + } + + if !strings.Contains(code, "instanceof Blob") { + t.Error("expected executeRequest to check for a Blob body so it can pass it through untouched") + } + + if !strings.Contains(code, "instanceof ReadableStream") { + t.Error("expected a ReadableStream body to be recognised (both for pass-through serialization and the no-retry guard)") + } + + if strings.Contains(code, "requestConfig.body ? JSON.stringify(requestConfig.body) : undefined") { + t.Error("the old unconditional JSON.stringify of every body must be gone") + } + + if strings.Contains(code, "'Content-Type': 'application/json',\n };\n }") { + t.Error("the constructor must no longer force a default 'Content-Type: application/json' on every request") + } + + if !strings.Contains(code, "isJSONBody") { + t.Error("expected a flag distinguishing a JSON-serialized body from a pass-through one, so the Content-Type default is applied conditionally") + } +} + func TestGenerateExampleTestGatesAuthWhenIncludeAuthFalse(t *testing.T) { tg := NewTestingGenerator() diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 362c3a2c..6a6e1c9a 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -378,16 +378,78 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien } } -// hasBodyParam reports whether the endpoint carries a JSON request body, which -// is the sole condition under which a `body` parameter is generated. +// hasBodyParam reports whether the endpoint carries a usable request body — +// any content type requestBodyContentType can resolve to, not just +// "application/json" — which is the condition under which a `body` parameter +// is generated. func (r *RESTGenerator) hasBodyParam(endpoint *client.Endpoint) bool { - if endpoint.RequestBody == nil { - return false + return r.requestBodyContentType(endpoint) != "" +} + +// requestBodyContentType selects the single content type an endpoint's +// request body is generated for, using the same precedence responseBodyType +// already established for responses: application/json first (only when it +// actually carries a schema — an empty MediaType contributes nothing), +// then any text/* media type, then the remaining content types in +// deterministic (sorted) order. RequestBody.Content is a map, so an endpoint +// COULD declare more than one content type (e.g. a spec offering both JSON +// and multipart upload for the same operation); a generated TypeScript +// method can only accept one shape for its `body` parameter, so exactly one +// content type must win, and it must win the same way every generation run +// picks it — hence sorting rather than ranging the map directly. Returns "" +// when there is no usable request body at all. +func (r *RESTGenerator) requestBodyContentType(endpoint *client.Endpoint) string { + if endpoint.RequestBody == nil || len(endpoint.RequestBody.Content) == 0 { + return "" + } + + if media, ok := endpoint.RequestBody.Content["application/json"]; ok && media.Schema != nil { + return "application/json" } - media, ok := endpoint.RequestBody.Content["application/json"] + for _, contentType := range sortedKeys(endpoint.RequestBody.Content) { + if strings.HasPrefix(contentType, "text/") { + return contentType + } + } - return ok && media.Schema != nil + keys := sortedKeys(endpoint.RequestBody.Content) + if len(keys) == 0 { + return "" + } + + return keys[0] +} + +// requestBodyParamType maps the content type requestBodyContentType selected +// to the TypeScript type of the generated `body` parameter: +// application/json -> the declared schema's type (a real interface/type +// name, or a primitive, so callers get full type safety); multipart/form-data +// -> FormData, the DOM type a caller assembles an upload with (setting a +// Content-Type header for it manually — including a JSON default — breaks +// the request, since the browser/runtime computes the multipart boundary +// only when it sets the header itself; see fetch.ts's executeRequest); +// text/* -> string; anything else (e.g. application/octet-stream, an image +// type, or any other opaque binary content type) -> Blob, the DOM type for an +// opaque byte payload, mirroring responseBodyType's fallback for the same +// reason. Returns "" when hasBodyParam(endpoint) is false; callers must check +// that first. +func (r *RESTGenerator) requestBodyParamType(endpoint *client.Endpoint, spec *client.APISpec) string { + contentType := r.requestBodyContentType(endpoint) + + switch { + case contentType == "": + return "" + case contentType == "application/json": + media := endpoint.RequestBody.Content[contentType] + return r.getSchemaTypeName(media.Schema, spec) + case contentType == "multipart/form-data": + return "FormData" + case strings.HasPrefix(contentType, "text/"): + return "string" + default: + return "Blob" + } } // generateParameters generates method parameters. Query and header parameters @@ -410,8 +472,7 @@ func (r *RESTGenerator) generateParameters(endpoint client.Endpoint, spec *clien var optionalBody string if r.hasBodyParam(&endpoint) { - media := endpoint.RequestBody.Content["application/json"] - typeName := r.getSchemaTypeName(media.Schema, spec) + typeName := r.requestBodyParamType(&endpoint, spec) if endpoint.RequestBody.Required { required = append(required, "body: "+typeName) diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index befcc698..b463be13 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -827,3 +827,100 @@ func TestPathParamsAreURLEncoded(t *testing.T) { assert.Contains(t, code, "${encodeURIComponent(String(path))}") } + +// TestNonJSONRequestBodies is task 8's failing-test-first proof for the +// defect hasBodyParam had: it accepted only "application/json", so a +// multipart, binary, or plain-text request body generated a method with NO +// body parameter at all — the request was silently sent empty, with no +// compile error and no runtime error, because the shorthand `body,` in +// generateMethodBody's config object was never emitted either (see +// generateMethodBody's `if r.hasBodyParam(endpoint)` guard). +// +// Mirrors responseBodyType's content-type precedence (application/json +// first, then text/*, then anything else) so a request body picks the same +// bucket a response would for the same declared content types. The +// TypeScript parameter type per bucket: application/json -> the schema type, +// multipart/form-data -> FormData (the DOM type a caller builds an upload +// with), text/* -> string, anything else (e.g. application/octet-stream) -> +// Blob (the DOM type for an opaque byte payload). +func TestNonJSONRequestBodies(t *testing.T) { + mk := func(contentType string, schema *client.Schema) string { + return NewRESTGenerator().Generate(&client.APISpec{ + Info: client.APIInfo{Title: "T", Version: "1"}, + Endpoints: []client.Endpoint{{ + Method: "POST", Path: "/up", OperationID: "up.post", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + contentType: {Schema: schema}}}, + Responses: map[int]*client.Response{204: {Description: "ok"}}, + }}, + Schemas: map[string]*client.Schema{}, + }, client.DefaultConfig()) + } + + code := mk("multipart/form-data", &client.Schema{Type: "object"}) + assert.Contains(t, code, "body: FormData", "multipart/form-data body must be typed as FormData") + assert.Contains(t, code, "body,", "the body parameter must actually be forwarded into the request config") + + code = mk("application/octet-stream", &client.Schema{Type: "string", Format: "binary"}) + assert.Contains(t, code, "body: Blob", "an unrecognised binary content type must fall back to Blob") + + code = mk("text/plain", &client.Schema{Type: "string"}) + assert.Contains(t, code, "body: string", "text/* must be typed as string") +} + +// TestRequestBodyContentTypePrecedenceMatchesResponse asserts that when an +// endpoint declares multiple request-body content types (unusual, but the IR +// allows it — RequestBody.Content is a map), the SAME endpoint always picks +// exactly one, using the same precedence responseBodyType already +// established for responses: application/json wins over everything, then any +// text/* media type, then the remainder. This keeps the two "which content +// type wins" decisions in the generator consistent instead of diverging. +func TestRequestBodyContentTypePrecedenceMatchesResponse(t *testing.T) { + mk := func(content map[string]*client.MediaType) string { + return NewRESTGenerator().Generate(&client.APISpec{ + Info: client.APIInfo{Title: "T", Version: "1"}, + Endpoints: []client.Endpoint{{ + Method: "POST", Path: "/up", OperationID: "up.post", + RequestBody: &client.RequestBody{Required: true, Content: content}, + Responses: map[int]*client.Response{204: {Description: "ok"}}, + }}, + Schemas: map[string]*client.Schema{"User": {Type: "object"}}, + }, client.DefaultConfig()) + } + + // application/json beats both text/plain and multipart/form-data when all + // three are declared on the same request body. + code := mk(map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}, + "text/plain": {Schema: &client.Schema{Type: "string"}}, + "multipart/form-data": {Schema: &client.Schema{Type: "object"}}, + }) + assert.Contains(t, code, "body: types.User") + assert.NotContains(t, code, "body: FormData") + assert.NotContains(t, code, "body?: string") + + // Without JSON, text/* beats the remaining generic bucket. + code = mk(map[string]*client.MediaType{ + "text/plain": {Schema: &client.Schema{Type: "string"}}, + "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}, + }) + assert.Contains(t, code, "body: string") +} + +// TestNoRequestBodyStillGeneratesNoBodyParam is the negative control for +// hasBodyParam's generalisation: an endpoint with no RequestBody at all must +// still generate no `body` parameter and no `body,` forwarding line, exactly +// as before this task. +func TestNoRequestBodyStillGeneratesNoBodyParam(t *testing.T) { + code := NewRESTGenerator().Generate(&client.APISpec{ + Info: client.APIInfo{Title: "T", Version: "1"}, + Endpoints: []client.Endpoint{{ + Method: "GET", Path: "/noop", OperationID: "noop.get", + Responses: map[int]*client.Response{204: {Description: "ok"}}, + }}, + }, client.DefaultConfig()) + + assert.NotContains(t, code, "body:") + assert.NotContains(t, code, "body,") + assert.NotContains(t, code, "body?") +} From aa24043a38aac7728c41b4ba7125754fc81ecd1c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 05:20:10 -0500 Subject: [PATCH 37/71] fix(client/typescript): complete the request-body type enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects review found in the first cut of non-JSON request body support, all in new code. Selection: a schemaless `application/json` entry (legal OpenAPI) was skipped by the first branch but re-won the sorted `keys[0]` fallback, since "application/json" sorts before "multipart/form-data" and "application/octet-stream". It then mapped to `any` via getSchemaTypeName(nil), silently erasing a usable sibling body. The fallback now skips the JSON key unless it is the only option. The doc comment claimed this mirrored responseBodyType's precedence exactly; it does not, and now says where and why it differs. Runtime enumeration: URLSearchParams and ArrayBuffer went out as "{}" and a Uint8Array as {"0":1,"1":2,...}, each with a wrong application/json Content-Type. All three are native BodyInit values. application/x-www-form-urlencoded now maps to URLSearchParams rather than the generic Blob bucket. Realm binding: dispatch used `instanceof`, which is realm-bound, so a FormData or ReadableStream from an iframe, a polyfill, or a bundler-substituted global fell through to JSON.stringify — sending an empty body AND, for a stream, defeating the no-retry guard. Dispatch is now on Object.prototype.toString tags, which are per-object and hold across realms, plus ArrayBuffer.isView for TypedArray/DataView. Stream retry cap: capping a caller's explicit maxAttempts to 1 was silent. It now warns. Reviewers suggested gating on `.locked` to avoid capping when the body was never touched; that is wrong at this point — fetch locks the stream during the read, so `.locked` is false before the first attempt and the cap would never apply. The comment records why the pessimistic form stands and what refining it would need. The gate test's `instanceof` assertions encoded the realm-bound shape as a requirement; they now assert each type is recognised without pinning the mechanism, and forbid instanceof for body dispatch. Also fixes a 2-line pre-existing gofmt misalignment in TestPathParamsAreURLEncoded. --- .../generators/typescript/fetch_client.go | 58 +++++- .../typescript/fetch_client_test.go | 182 ++++++++++++++++++ .../client/generators/typescript/gate_test.go | 34 +++- .../generators/typescript/naming_test.go | 20 +- internal/client/generators/typescript/rest.go | 36 +++- .../client/generators/typescript/rest_test.go | 36 +++- 6 files changed, 336 insertions(+), 30 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index b1b802e8..d285d22d 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -176,7 +176,32 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // attempt-loop and shouldRetry logic unchanged: attempt 0 is the only\n") buf.WriteString(" // attempt, so any failure surfaces immediately and loudly instead of\n") buf.WriteString(" // being retried at all.\n") - buf.WriteString(" if (typeof ReadableStream !== 'undefined' && config.body instanceof ReadableStream) {\n") + buf.WriteString(" // isStreamBody uses Object.prototype.toString rather than `instanceof`\n") + buf.WriteString(" // because instanceof is realm-bound: a stream created in an iframe, by\n") + buf.WriteString(" // a polyfill, or by a bundler-substituted global is NOT an instance of\n") + buf.WriteString(" // this realm's ReadableStream, so instanceof would miss it — sending an\n") + buf.WriteString(" // empty body AND retrying it. Symbol.toStringTag is per-object, so this\n") + buf.WriteString(" // check holds across realms.\n") + buf.WriteString(" const isStreamBody = Object.prototype.toString.call(config.body) === '[object ReadableStream]';\n\n") + + buf.WriteString(" // The cap tests 'is a stream at all', not `.locked`. A stream is NOT\n") + buf.WriteString(" // locked before the first attempt — fetch() locks it while reading — so\n") + buf.WriteString(" // testing `.locked` here would be false every time and the cap would\n") + buf.WriteString(" // never apply. That does leave the cap pessimistic: a failure that\n") + buf.WriteString(" // never touched the body (DNS failure, connection refused, TLS\n") + buf.WriteString(" // handshake) leaves the stream intact and would have been safely\n") + buf.WriteString(" // retryable. Refining it needs a per-attempt disturbed check, and the\n") + buf.WriteString(" // stream API exposes no `disturbed` getter to write one against.\n") + buf.WriteString(" // Warn when the caller explicitly asked for retries, so silently\n") + buf.WriteString(" // getting a single attempt is at least discoverable.\n") + buf.WriteString(" if (isStreamBody) {\n") + buf.WriteString(" if ((config.retry?.maxAttempts ?? 0) > 1) {\n") + buf.WriteString(" console.warn(\n") + buf.WriteString(" '[client] request body is a ReadableStream, which cannot be re-sent; ' +\n") + buf.WriteString(" 'retries are disabled for this request (requested maxAttempts: ' +\n") + buf.WriteString(" String(config.retry?.maxAttempts) + ').'\n") + buf.WriteString(" );\n") + buf.WriteString(" }\n") buf.WriteString(" retryConfig.maxAttempts = 1;\n") buf.WriteString(" }\n\n") @@ -280,18 +305,37 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // request() above already refuses to retry one — it must still be\n") buf.WriteString(" // sent untouched on its one and only attempt. Anything else is\n") buf.WriteString(" // assumed to be a JSON-serializable value.\n") + buf.WriteString(" // Dispatch on Object.prototype.toString rather than `instanceof`:\n") + buf.WriteString(" // instanceof is realm-bound, so a FormData/Blob/stream created in an\n") + buf.WriteString(" // iframe, by a polyfill, or against a bundler-substituted global is not\n") + buf.WriteString(" // an instance of THIS realm's constructor and would fall through to\n") + buf.WriteString(" // JSON.stringify — sending \"{}\" with a JSON Content-Type. The\n") + buf.WriteString(" // Symbol.toStringTag each of these types carries is per-object, so the\n") + buf.WriteString(" // tag check holds across realms and needs no `typeof X !== 'undefined'`\n") + buf.WriteString(" // guard for environments where the global is absent.\n") buf.WriteString(" let body: BodyInit | undefined;\n") buf.WriteString(" let isJSONBody = false;\n") + buf.WriteString(" const bodyTag = Object.prototype.toString.call(requestConfig.body);\n") buf.WriteString(" if (requestConfig.body === undefined || requestConfig.body === null) {\n") buf.WriteString(" body = undefined;\n") - buf.WriteString(" } else if (typeof FormData !== 'undefined' && requestConfig.body instanceof FormData) {\n") - buf.WriteString(" body = requestConfig.body;\n") - buf.WriteString(" } else if (typeof Blob !== 'undefined' && requestConfig.body instanceof Blob) {\n") - buf.WriteString(" body = requestConfig.body;\n") buf.WriteString(" } else if (typeof requestConfig.body === 'string') {\n") buf.WriteString(" body = requestConfig.body;\n") - buf.WriteString(" } else if (typeof ReadableStream !== 'undefined' && requestConfig.body instanceof ReadableStream) {\n") - buf.WriteString(" body = requestConfig.body;\n") + buf.WriteString(" } else if (bodyTag === '[object FormData]') {\n") + buf.WriteString(" body = requestConfig.body as FormData;\n") + buf.WriteString(" } else if (bodyTag === '[object Blob]' || bodyTag === '[object File]') {\n") + buf.WriteString(" body = requestConfig.body as Blob;\n") + buf.WriteString(" } else if (bodyTag === '[object URLSearchParams]') {\n") + buf.WriteString(" // Native BodyInit: fetch form-urlencodes it and sets the matching\n") + buf.WriteString(" // Content-Type itself. JSON.stringify would have flattened it to\n") + buf.WriteString(" // \"{}\" — URLSearchParams has no own enumerable properties.\n") + buf.WriteString(" body = requestConfig.body as URLSearchParams;\n") + buf.WriteString(" } else if (bodyTag === '[object ReadableStream]') {\n") + buf.WriteString(" body = requestConfig.body as ReadableStream;\n") + buf.WriteString(" } else if (bodyTag === '[object ArrayBuffer]' || ArrayBuffer.isView(requestConfig.body)) {\n") + buf.WriteString(" // ArrayBuffer.isView covers every TypedArray and DataView, and is\n") + buf.WriteString(" // realm-independent. Without this a Uint8Array would be stringified\n") + buf.WriteString(" // index-by-index into {\\\"0\\\":1,\\\"1\\\":2,...} and an ArrayBuffer into \"{}\".\n") + buf.WriteString(" body = requestConfig.body as ArrayBuffer | ArrayBufferView;\n") buf.WriteString(" } else {\n") buf.WriteString(" body = JSON.stringify(requestConfig.body);\n") buf.WriteString(" isJSONBody = true;\n") diff --git a/internal/client/generators/typescript/fetch_client_test.go b/internal/client/generators/typescript/fetch_client_test.go index 147bc48a..18dfe7a3 100644 --- a/internal/client/generators/typescript/fetch_client_test.go +++ b/internal/client/generators/typescript/fetch_client_test.go @@ -792,3 +792,185 @@ main().catch((err) => { console.error(err); process.exit(1); }); assert.Contains(t, result.Outcome, "rejected", "a failed request with a stream body must fail loudly on the first attempt, not hang or silently succeed") } + +// TestNativeBodyInitTypesPassThroughAcrossRealms covers the two gaps review +// found in task 8's first cut: the runtime's BodyInit enumeration was +// incomplete (URLSearchParams, ArrayBuffer and TypedArray fell through to +// JSON.stringify and went out as "{}" or an index-keyed object, with a wrong +// application/json Content-Type), and it dispatched with `instanceof`, which +// is realm-bound — a FormData or ReadableStream from another realm (an +// iframe, a polyfill, a bundler-substituted global) is not an instance of +// THIS realm's constructor and was silently JSON.stringify-ed too. +// +// The cross-realm cases are simulated the way they actually bite: the value +// is real and fully functional, but globalThis's constructor is a different +// object, so `x instanceof globalThis.FormData` is false. +func TestNativeBodyInitTypesPassThroughAcrossRealms(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +function contentTypeOf(headers: any): string | null { + if (!headers) return null; + const k = Object.keys(headers).find((h) => h.toLowerCase() === 'content-type'); + return k ? headers[k] : null; +} + +async function send(client: any, body: any) { + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + await client.request({ method: 'POST', url: '/x', body, allowEmptyBody: true }); + return captured; +} + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + const results: Record = {}; + + // URLSearchParams: a native BodyInit fetch form-urlencodes itself. It has + // no own enumerable properties, so JSON.stringify flattens it to "{}". + { + const usp = new URLSearchParams({ a: '1', b: '2' }); + const c = await send(client, usp); + results.urlSearchParams = { sameReference: c.body === usp, contentType: contentTypeOf(c.headers) }; + } + + // Uint8Array: JSON.stringify turns it into {"0":1,"1":2,...}. + { + const bytes = new Uint8Array([1, 2, 3, 255]); + const c = await send(client, bytes); + results.typedArray = { sameReference: c.body === bytes, contentType: contentTypeOf(c.headers) }; + } + + // ArrayBuffer: JSON.stringify flattens it to "{}". + { + const buf = new ArrayBuffer(8); + const c = await send(client, buf); + results.arrayBuffer = { sameReference: c.body === buf, contentType: contentTypeOf(c.headers) }; + } + + // Cross-realm FormData: real and functional, but globalThis.FormData is a + // different constructor, so instanceof is false. + { + const fd = new FormData(); + fd.append('file', 'contents'); + const RealFormData = globalThis.FormData; + (globalThis as any).FormData = class Decoy {}; + try { + const c = await send(client, fd); + results.crossRealmFormData = { + sameReference: c.body === fd, + contentType: contentTypeOf(c.headers), + instanceofWouldHaveMissed: !(fd instanceof (globalThis as any).FormData), + }; + } finally { + (globalThis as any).FormData = RealFormData; + } + } + + // Cross-realm ReadableStream: must pass through AND still suppress retries. + { + const stream = new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode('x')); c.close(); } }); + const RealRS = globalThis.ReadableStream; + (globalThis as any).ReadableStream = class Decoy {}; + let calls = 0; + const warnings: string[] = []; + const realWarn = console.warn; + console.warn = (...args: any[]) => { warnings.push(args.join(' ')); }; + try { + (globalThis as any).fetch = async (_url: string, init: any) => { + calls++; + return new Response('nope', { status: 503 }); + }; + let threw = ''; + try { + await client.request({ method: 'POST', url: '/stream', body: stream, retry: { maxAttempts: 5 } }); + } catch (err: any) { + threw = err?.constructor?.name ?? 'unknown'; + } + results.crossRealmStream = { calls, threw, warned: warnings.length > 0 }; + } finally { + (globalThis as any).ReadableStream = RealRS; + console.warn = realWarn; + } + } + + // Positive control: a plain object still becomes JSON with a JSON type. + { + const c = await send(client, { a: 1 }); + results.plainObject = { body: c.body, contentType: contentTypeOf(c.headers) }; + } + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_bodyinit.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_bodyinit.ts") + + var result struct { + URLSearchParams struct { + SameReference bool `json:"sameReference"` + ContentType *string `json:"contentType"` + } `json:"urlSearchParams"` + TypedArray struct { + SameReference bool `json:"sameReference"` + ContentType *string `json:"contentType"` + } `json:"typedArray"` + ArrayBuffer struct { + SameReference bool `json:"sameReference"` + ContentType *string `json:"contentType"` + } `json:"arrayBuffer"` + CrossRealmFormData struct { + SameReference bool `json:"sameReference"` + ContentType *string `json:"contentType"` + InstanceofWouldHaveMissed bool `json:"instanceofWouldHaveMissed"` + } `json:"crossRealmFormData"` + CrossRealmStream struct { + Calls int `json:"calls"` + Threw string `json:"threw"` + Warned bool `json:"warned"` + } `json:"crossRealmStream"` + PlainObject struct { + Body string `json:"body"` + ContentType *string `json:"contentType"` + } `json:"plainObject"` + } + decodeLastLine(t, stdout, &result) + + assert.True(t, result.URLSearchParams.SameReference, + "a URLSearchParams body must pass through by identity, not be JSON.stringify-ed to \"{}\"; driver stdout:\n%s", stdout) + assert.Nil(t, result.URLSearchParams.ContentType, + "fetch sets application/x-www-form-urlencoded for URLSearchParams itself; the client must not force a JSON Content-Type") + + assert.True(t, result.TypedArray.SameReference, + "a Uint8Array body must pass through by identity, not be stringified index-by-index; driver stdout:\n%s", stdout) + assert.Nil(t, result.TypedArray.ContentType, "a binary body must not get a JSON Content-Type") + + assert.True(t, result.ArrayBuffer.SameReference, + "an ArrayBuffer body must pass through by identity, not be flattened to \"{}\"; driver stdout:\n%s", stdout) + assert.Nil(t, result.ArrayBuffer.ContentType, "a binary body must not get a JSON Content-Type") + + assert.True(t, result.CrossRealmFormData.InstanceofWouldHaveMissed, + "test setup is wrong: the decoy constructor should make instanceof false") + assert.True(t, result.CrossRealmFormData.SameReference, + "a cross-realm FormData must still pass through by identity — instanceof is realm-bound; driver stdout:\n%s", stdout) + assert.Nil(t, result.CrossRealmFormData.ContentType, + "a cross-realm FormData must still get no Content-Type, so the runtime supplies the multipart boundary") + + assert.Equal(t, 1, result.CrossRealmStream.Calls, + "a cross-realm ReadableStream body must still suppress retries — it is just as one-shot; driver stdout:\n%s", stdout) + assert.True(t, result.CrossRealmStream.Warned, + "capping a caller's explicit maxAttempts:5 down to 1 must warn, or the caller has no way to discover it") + + assert.Equal(t, `{"a":1}`, result.PlainObject.Body, "positive control: a plain object must still be JSON-serialized") + if assert.NotNil(t, result.PlainObject.ContentType) { + assert.Equal(t, "application/json", *result.PlainObject.ContentType) + } +} diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 7ece17eb..7ff57d53 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -227,16 +227,38 @@ func TestFetchClientCombinesSignalsAndThrowsErrors(t *testing.T) { func TestFetchClientSerializesBodyByRuntimeType(t *testing.T) { code := NewFetchClientGenerator().GenerateBaseClient(baseSpec(), baseConfig()) - if !strings.Contains(code, "instanceof FormData") { - t.Error("expected executeRequest to check for a FormData body so it can pass it through untouched") + // Assert each native BodyInit type is RECOGNISED, without pinning the + // mechanism — these were once `instanceof` checks and are now + // Object.prototype.toString tag comparisons, because instanceof is + // realm-bound and silently missed cross-realm values. What must hold is + // that each type is detected at all; the execution test proves it is + // detected correctly. + for _, tag := range []string{ + "[object FormData]", + "[object Blob]", + "[object File]", + "[object URLSearchParams]", + "[object ReadableStream]", + "[object ArrayBuffer]", + } { + if !strings.Contains(code, tag) { + t.Errorf("expected executeRequest to recognise a %s body so it passes through untouched instead of being JSON.stringify-ed", tag) + } } - if !strings.Contains(code, "instanceof Blob") { - t.Error("expected executeRequest to check for a Blob body so it can pass it through untouched") + if !strings.Contains(code, "ArrayBuffer.isView(") { + t.Error("expected ArrayBuffer.isView to cover TypedArray/DataView bodies, which have no single toStringTag") } - if !strings.Contains(code, "instanceof ReadableStream") { - t.Error("expected a ReadableStream body to be recognised (both for pass-through serialization and the no-retry guard)") + // instanceof is realm-bound; reintroducing it for body dispatch would + // silently misroute cross-realm values back into JSON.stringify. + for _, forbidden := range []string{ + "requestConfig.body instanceof", + "config.body instanceof", + } { + if strings.Contains(code, forbidden) { + t.Errorf("body dispatch must not use %q — instanceof is realm-bound and misses cross-realm FormData/Blob/streams", forbidden) + } } if strings.Contains(code, "requestConfig.body ? JSON.stringify(requestConfig.body) : undefined") { diff --git a/internal/client/generators/typescript/naming_test.go b/internal/client/generators/typescript/naming_test.go index bf5295fb..ed572197 100644 --- a/internal/client/generators/typescript/naming_test.go +++ b/internal/client/generators/typescript/naming_test.go @@ -6,17 +6,17 @@ func TestToCamel(t *testing.T) { cases := []struct{ in, want string }{ {"user_id", "userId"}, {"user-id", "userId"}, - {"userId", "userId"}, // already camel: must be preserved, not lowercased - {"UserID", "userID"}, // leading cap dropped, interior caps kept + {"userId", "userId"}, // already camel: must be preserved, not lowercased + {"UserID", "userID"}, // leading cap dropped, interior caps kept {"id", "id"}, {"", ""}, - {"_", "_"}, // separators only: no words found, name returned unchanged, no panic - {"--", "--"}, // separators only: no words found, name returned unchanged, no panic - {"...", "..."}, // separators only, no panic + {"_", "_"}, // separators only: no words found, name returned unchanged, no panic + {"--", "--"}, // separators only: no words found, name returned unchanged, no panic + {"...", "..."}, // separators only, no panic {"_a_", "a"}, - {"123abc", "123abc"}, // leading digit: no case boundary, no panic - {"Ábc", "ábc"}, // multi-byte leading rune must be lowercased by rune, not by byte - {"ábc", "ábc"}, // already-lowercase multi-byte leading rune: unchanged + {"123abc", "123abc"}, // leading digit: no case boundary, no panic + {"Ábc", "ábc"}, // multi-byte leading rune must be lowercased by rune, not by byte + {"ábc", "ábc"}, // already-lowercase multi-byte leading rune: unchanged {"café_id", "caféId"}, // multi-byte rune mid-word must survive untouched } @@ -37,8 +37,8 @@ func TestToPascal(t *testing.T) { {"--", ""}, {"...", ""}, {"_a_", "A"}, - {"123abc", "123abc"}, // leading digit: ToUpper is a no-op, no panic - {"ábc_id", "ÁbcId"}, // multi-byte leading rune must be uppercased by rune, not by byte + {"123abc", "123abc"}, // leading digit: ToUpper is a no-op, no panic + {"ábc_id", "ÁbcId"}, // multi-byte leading rune must be uppercased by rune, not by byte } for _, c := range cases { diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 6a6e1c9a..416c66cc 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -387,11 +387,15 @@ func (r *RESTGenerator) hasBodyParam(endpoint *client.Endpoint) bool { } // requestBodyContentType selects the single content type an endpoint's -// request body is generated for, using the same precedence responseBodyType -// already established for responses: application/json first (only when it -// actually carries a schema — an empty MediaType contributes nothing), -// then any text/* media type, then the remaining content types in -// deterministic (sorted) order. RequestBody.Content is a map, so an endpoint +// request body is generated for, following the precedence responseBodyType +// established for responses — application/json, then text/*, then anything +// else — with one deliberate difference. Here application/json only wins +// when it actually carries a schema; a schemaless entry is skipped, INCLUDING +// in the final fallback, because mapping it back to `any` would erase a +// sibling multipart/form-data or octet-stream body that the caller can +// actually use. responseBodyType has no equivalent hazard: its fallback +// returns a hard "Blob" and can never re-enter the JSON branch. +// RequestBody.Content is a map, so an endpoint // COULD declare more than one content type (e.g. a spec offering both JSON // and multipart upload for the same operation); a generated TypeScript // method can only accept one shape for its `body` parameter, so exactly one @@ -413,7 +417,22 @@ func (r *RESTGenerator) requestBodyContentType(endpoint *client.Endpoint) string } } + // Fall back to the first remaining content type, skipping + // "application/json" — reaching here means the JSON entry exists but + // carries no schema (`content: {application/json: {}}` is legal + // OpenAPI), so selecting it would map back to `any` via + // requestBodyParamType and silently erase a perfectly good + // multipart/form-data or application/octet-stream sibling. "a" sorts + // first, so without this skip the schemaless JSON entry wins every + // mixed-content body. keys := sortedKeys(endpoint.RequestBody.Content) + for _, contentType := range keys { + if contentType != "application/json" { + return contentType + } + } + + // Schemaless application/json really was the only option. if len(keys) == 0 { return "" } @@ -445,6 +464,13 @@ func (r *RESTGenerator) requestBodyParamType(endpoint *client.Endpoint, spec *cl return r.getSchemaTypeName(media.Schema, spec) case contentType == "multipart/form-data": return "FormData" + case contentType == "application/x-www-form-urlencoded": + // URLSearchParams, not Blob: this is the idiomatic DOM type for + // form-urlencoded, it is a native BodyInit fetch serialises itself + // (and sets the matching Content-Type for), and it is the most + // common non-JSON request body in practice. Handing the caller a + // Blob here would make them hand-encode the pairs themselves. + return "URLSearchParams" case strings.HasPrefix(contentType, "text/"): return "string" default: diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index b463be13..530518c5 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -818,8 +818,8 @@ func TestPathParamsAreURLEncoded(t *testing.T) { Method: "GET", Path: "/files/{path}", OperationID: "files.get", - PathParams: []client.Parameter{{Name: "path", Schema: &client.Schema{Type: "string"}, Required: true}}, - Responses: map[int]*client.Response{204: {Description: "ok"}}, + PathParams: []client.Parameter{{Name: "path", Schema: &client.Schema{Type: "string"}, Required: true}}, + Responses: map[int]*client.Response{204: {Description: "ok"}}, }}, } @@ -905,6 +905,38 @@ func TestRequestBodyContentTypePrecedenceMatchesResponse(t *testing.T) { "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}, }) assert.Contains(t, code, "body: string") + + // A SCHEMALESS application/json entry must not win, in the first branch + // or in the sorted fallback. "application/json" sorts before both + // "multipart/form-data" and "application/octet-stream", so a naive + // `return keys[0]` fallback re-selects it and maps it to `any` via + // getSchemaTypeName(nil) — silently erasing a usable sibling body. + // `content: {application/json: {}}` is legal OpenAPI. + code = mk(map[string]*client.MediaType{ + "application/json": {}, + "multipart/form-data": {Schema: &client.Schema{Type: "object"}}, + }) + assert.Contains(t, code, "body: FormData") + assert.NotContains(t, code, "body: any") + + code = mk(map[string]*client.MediaType{ + "application/json": {}, + "application/octet-stream": {Schema: &client.Schema{Type: "string", Format: "binary"}}, + }) + assert.Contains(t, code, "body: Blob") + assert.NotContains(t, code, "body: any") + + // Schemaless JSON as the ONLY content type still yields a body param — + // there is nothing better to fall back to. + code = mk(map[string]*client.MediaType{"application/json": {}}) + assert.Contains(t, code, "body: any") + + // application/x-www-form-urlencoded maps to URLSearchParams, the native + // BodyInit for it, not to the generic Blob bucket. + code = mk(map[string]*client.MediaType{ + "application/x-www-form-urlencoded": {Schema: &client.Schema{Type: "object"}}, + }) + assert.Contains(t, code, "body: URLSearchParams") } // TestNoRequestBodyStillGeneratesNoBodyParam is the negative control for From 86b262caff7e655710f6a8dff4aeee7b8f622410 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 05:27:15 -0500 Subject: [PATCH 38/71] feat(client/typescript): generate the per-schema codec table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emits src/codecs.ts: a Codec union type, a CODECS table describing how each schema's wire payload maps onto its TypeScript shape, and the encode/decode runtime that walks it. Exported from src/index.ts. Every field's `ts` name equals its wire name today, so encode/decode are effectively identity. That is deliberate — renaming lands in a later phase, and the point of emitting the table now is that the shape and the walk are in place and gate-tested, so turning renaming on is a change of names rather than a change of machinery. Composite kinds: object (field map), array (items), record (additionalProperties), union (discriminator + member map), and passthrough for everything else. Inline non-$ref nested schemas get synthetic ids derived from the parent id and property name ("Nested.items") rather than a counter, so an id stays stable for as long as that property exists instead of shifting when an unrelated property is added. The three runtime rules are proven by executing the emitted code under Node, not by string assertions: unknown keys pass through verbatim so an older client does not silently drop a field a newer server added; a record renames its values but never its keys, which are user-chosen ids rather than schema-defined names; and a union with no discriminator falls back to passthrough rather than guessing a member by structural shape. Dropping the unknown-key branch was verified to fail the test. Self-referential schemas terminate: an id is reserved before recursing, so a cycle resolves to a pointer back at the named codec. The tsc gate was confirmed to actually cover the new file by injecting a deliberate type error and watching all 8 fixtures fail on src/codecs.ts. --- .../client/generators/typescript/codecs.go | 407 ++++++++++++++++++ .../generators/typescript/codecs_test.go | 285 ++++++++++++ .../client/generators/typescript/generator.go | 6 + 3 files changed, 698 insertions(+) create mode 100644 internal/client/generators/typescript/codecs.go create mode 100644 internal/client/generators/typescript/codecs_test.go diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go new file mode 100644 index 00000000..9e60d437 --- /dev/null +++ b/internal/client/generators/typescript/codecs.go @@ -0,0 +1,407 @@ +package typescript + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/xraph/forge/internal/client" +) + +// CodecGenerator emits src/codecs.ts: a per-schema description of how a wire +// payload maps onto its TypeScript shape, plus the encode/decode runtime that +// walks it. +// +// The table is the deliverable Phase 3 depends on. Today every field's `ts` +// name equals its wire name, so encode/decode are effectively identity — the +// point of emitting it now is that the SHAPE is in place and gate-tested, so +// Phase 3 only has to change what the names are, not build the machinery +// that renames them. +type CodecGenerator struct{} + +// NewCodecGenerator mirrors the other generators' constructor shape. +func NewCodecGenerator() *CodecGenerator { + return &CodecGenerator{} +} + +// codecEntry is the Go-side model of one emitted CODECS entry. It is +// marshalled to JSON rather than hand-written as TypeScript so that string +// escaping is correct for any schema or property name (see enumTSType, which +// takes the same approach for the same reason). +type codecEntry struct { + Kind string `json:"kind"` + + // object + Fields map[string]codecField `json:"fields,omitempty"` + + // array + Items string `json:"items,omitempty"` + + // record + Values string `json:"values,omitempty"` + + // union + Discriminator *codecDiscriminator `json:"discriminator,omitempty"` + Members []string `json:"members,omitempty"` +} + +type codecField struct { + TS string `json:"ts"` + Codec string `json:"codec,omitempty"` +} + +type codecDiscriminator struct { + Wire string `json:"wire"` + Map map[string]string `json:"map"` +} + +// codecTable accumulates entries while walking the spec. Inline (non-$ref) +// nested schemas have no name of their own, so they get a synthetic id +// derived from the parent name and property path ("Nested.items"). Deriving +// it from the path rather than a counter is what keeps the table +// byte-identical across runs. +type codecTable struct { + entries map[string]codecEntry +} + +// refName extracts the schema name from a "#/components/schemas/X" pointer. +// A ref that does not follow that shape yields "", which callers treat as +// "no codec" rather than emitting a dangling id. +func refName(ref string) string { + const prefix = "#/components/schemas/" + if !strings.HasPrefix(ref, prefix) { + return "" + } + + return strings.TrimPrefix(ref, prefix) +} + +// codecIDFor returns the codec id a property's value should be decoded with, +// registering a synthetic entry first when the property is an inline +// composite. Primitives get "" — there is nothing to rename inside a string +// or a number, and emitting a passthrough entry for every scalar would +// triple the table for no behavioural gain. +func (t *codecTable) codecIDFor(parentID, prop string, schema *client.Schema, spec *client.APISpec) string { + if schema == nil { + return "" + } + + if name := refName(schema.Ref); name != "" { + return name + } + + synthetic := parentID + "." + prop + + switch { + case schema.Type == "array" && schema.Items != nil: + t.add(synthetic, spec, schema) + return synthetic + case len(schema.Properties) > 0: + t.add(synthetic, spec, schema) + return synthetic + case len(schema.OneOf) > 0 || len(schema.AnyOf) > 0: + t.add(synthetic, spec, schema) + return synthetic + } + + if _, ok := additionalPropsSchema(schema.AdditionalProperties); ok { + t.add(synthetic, spec, schema) + return synthetic + } + + return "" +} + +// add builds the entry for one schema under the given id. It is called for +// both named schemas and synthetic inline ones; the only difference is where +// the id came from. +func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) { + if schema == nil { + return + } + + if _, seen := t.entries[id]; seen { + // Already built. Guards against a schema that references itself, + // directly or through a cycle, which would otherwise recurse forever. + return + } + + // Reserve the id before recursing, so a self-reference hits the guard + // above rather than re-entering. + t.entries[id] = codecEntry{Kind: "passthrough"} + + switch { + case len(schema.OneOf) > 0 || len(schema.AnyOf) > 0: + t.entries[id] = t.unionEntry(schema) + return + + case schema.Type == "array" && schema.Items != nil: + t.entries[id] = codecEntry{ + Kind: "array", + Items: t.codecIDFor(id, "items", schema.Items, spec), + } + + return + + case len(schema.Properties) > 0: + fields := make(map[string]codecField, len(schema.Properties)) + for _, prop := range sortedKeys(schema.Properties) { + fields[prop] = codecField{ + // ts == wire for now: renaming lands in Phase 3. The field + // map has to exist today regardless, because it is what + // Phase 3 rewrites. + TS: prop, + Codec: t.codecIDFor(id, prop, schema.Properties[prop], spec), + } + } + + t.entries[id] = codecEntry{Kind: "object", Fields: fields} + + return + } + + if values, ok := additionalPropsSchema(schema.AdditionalProperties); ok { + t.entries[id] = codecEntry{ + Kind: "record", + // A nil values schema means `additionalProperties: true` — the + // values are unconstrained, so there is nothing to descend into. + Values: t.codecIDFor(id, "values", values, spec), + } + + return + } + + // Anything else (scalars, empty objects, unresolvable refs) stays the + // passthrough reserved above. +} + +// unionEntry builds a union entry. A union WITHOUT a discriminator degrades +// to passthrough: with no tag to switch on there is no way to know which +// member's field map applies, and guessing by structural shape would rename +// fields based on a match that may be wrong. +func (t *codecTable) unionEntry(schema *client.Schema) codecEntry { + members := schema.OneOf + if len(members) == 0 { + members = schema.AnyOf + } + + memberIDs := make([]string, 0, len(members)) + + for _, member := range members { + if name := refName(member.Ref); name != "" { + memberIDs = append(memberIDs, name) + } + } + + if schema.Discriminator == nil || schema.Discriminator.PropertyName == "" { + return codecEntry{Kind: "passthrough"} + } + + mapping := make(map[string]string, len(schema.Discriminator.Mapping)) + for _, tag := range sortedKeys(schema.Discriminator.Mapping) { + if name := refName(schema.Discriminator.Mapping[tag]); name != "" { + mapping[tag] = name + } + } + + return codecEntry{ + Kind: "union", + Discriminator: &codecDiscriminator{ + Wire: schema.Discriminator.PropertyName, + Map: mapping, + }, + Members: memberIDs, + } +} + +// Generate emits src/codecs.ts. +func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) string { + table := &codecTable{entries: map[string]codecEntry{}} + + for _, name := range sortedKeys(spec.Schemas) { + table.add(name, spec, spec.Schemas[name]) + } + + var buf strings.Builder + + buf.WriteString("// Generated codec table\n") + buf.WriteString("//\n") + buf.WriteString("// Describes, per schema, how a wire payload maps onto its TypeScript\n") + buf.WriteString("// shape. `ts` currently equals the wire name for every field — property\n") + buf.WriteString("// renaming lands in a later phase — so encode/decode are effectively\n") + buf.WriteString("// identity today. The table and the walk exist now so that turning\n") + buf.WriteString("// renaming on is a change of names, not a change of machinery.\n\n") + + buf.WriteString("export type Codec =\n") + buf.WriteString(" | { kind: 'object'; fields: Record }\n") + buf.WriteString(" | { kind: 'array'; items?: string }\n") + buf.WriteString(" | { kind: 'record'; values?: string }\n") + buf.WriteString(" | { kind: 'union'; discriminator: { wire: string; map: Record }; members: string[] }\n") + buf.WriteString(" | { kind: 'passthrough' };\n\n") + + buf.WriteString("export const CODECS: Record = {\n") + + for _, id := range sortedCodecIDs(table.entries) { + encoded, err := json.Marshal(table.entries[id]) + if err != nil { + // codecEntry contains only strings, maps and slices of strings, + // none of which can fail to marshal. Emitting a passthrough is + // the safe degradation if that ever changes. + encoded = []byte(`{"kind":"passthrough"}`) + } + + key, err := json.Marshal(id) + if err != nil { + continue + } + + fmt.Fprintf(&buf, " %s: %s,\n", key, spacedJSON(string(encoded))) + } + + buf.WriteString("};\n\n") + + buf.WriteString(codecRuntime) + + return buf.String() +} + +// sortedCodecIDs orders table keys deterministically. Synthetic ids contain +// a dot and named ones do not, but they share one namespace and one sort — +// splitting them would only make the emitted order harder to predict. +func sortedCodecIDs(entries map[string]codecEntry) []string { + ids := make([]string, 0, len(entries)) + for id := range entries { + ids = append(ids, id) + } + + sort.Strings(ids) + + return ids +} + +// spacedJSON widens encoding/json's compact output into the `{"kind": "object"}` +// spacing the rest of the generated TypeScript uses. It only touches the +// separator between a key and its value, so string CONTENTS are untouched — +// a property name containing `":` is not a real risk here because +// json.Marshal has already escaped it, but the substitution is deliberately +// narrow for that reason. +func spacedJSON(s string) string { + // `,"` cannot occur inside a marshalled string: json.Marshal escapes an + // embedded quote to `\"`, so the byte before the quote is a backslash, + // not a comma. The key/value separator substitution is safe for the same + // reason. + s = strings.ReplaceAll(s, `":`, `": `) + + return strings.ReplaceAll(s, `,"`, `, "`) +} + +// codecRuntime is the emitted encode/decode implementation. The three rules +// it enforces are load-bearing: +// +// - unknown keys pass through verbatim, so a server that adds a field does +// not have it silently dropped by an older client; +// - `record` renames its VALUES but never its KEYS, because a record's keys +// are data (user-chosen ids), not schema-defined field names; +// - a union with no discriminator falls back to passthrough rather than +// guessing a member by structural shape. +const codecRuntime = `function codecFor(id?: string): Codec | undefined { + return id ? CODECS[id] : undefined; +} + +function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { + const codec = codecFor(id); + if (!codec || value === null || value === undefined) { + return value; + } + + switch (codec.kind) { + case 'object': { + if (typeof value !== 'object' || Array.isArray(value)) { + return value; + } + + const src = value as Record; + const out: Record = {}; + + // Build the rename map in the requested direction. Decoding maps a + // wire key to its ts name; encoding maps back. + const rename = new Map(); + for (const [wire, field] of Object.entries(codec.fields)) { + if (toTS) { + rename.set(wire, { to: field.ts, codec: field.codec }); + } else { + rename.set(field.ts, { to: wire, codec: field.codec }); + } + } + + for (const [key, val] of Object.entries(src)) { + const mapped = rename.get(key); + if (mapped) { + out[mapped.to] = walk(val, mapped.codec, toTS); + } else { + // Unknown key: pass through verbatim, name and value untouched. + out[key] = val; + } + } + + return out; + } + + case 'array': { + if (!Array.isArray(value)) { + return value; + } + + return value.map((item) => walk(item, codec.items, toTS)); + } + + case 'record': { + if (typeof value !== 'object' || Array.isArray(value)) { + return value; + } + + const src = value as Record; + const out: Record = {}; + + // Keys are data here, not field names — they are never renamed. + for (const [key, val] of Object.entries(src)) { + out[key] = walk(val, codec.values, toTS); + } + + return out; + } + + case 'union': { + if (typeof value !== 'object' || Array.isArray(value)) { + return value; + } + + const tag = (value as Record)[codec.discriminator.wire]; + if (typeof tag !== 'string') { + return value; + } + + const memberID = codec.discriminator.map[tag]; + if (!memberID) { + return value; + } + + return walk(value, memberID, toTS); + } + + default: + return value; + } +} + +/** Convert a wire payload into its TypeScript shape. */ +export function decode(value: unknown, codecID?: string): T { + return walk(value, codecID, true) as T; +} + +/** Convert a TypeScript value into its wire shape. */ +export function encode(value: unknown, codecID?: string): unknown { + return walk(value, codecID, false); +} +` diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go new file mode 100644 index 00000000..8cef19bd --- /dev/null +++ b/internal/client/generators/typescript/codecs_test.go @@ -0,0 +1,285 @@ +package typescript + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// nestedSpec returns baseSpec() plus a schema exercising every composite +// codec kind: a $ref property, an array of $refs, and a record. +func nestedSpec() *client.APISpec { + spec := baseSpec() + spec.Schemas["Nested"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "user": {Ref: "#/components/schemas/User"}, + "items": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + "tags": {Type: "object", AdditionalProperties: &client.Schema{Type: "string"}}, + }, + } + + return spec +} + +func TestCodecTableShape(t *testing.T) { + code := NewCodecGenerator().Generate(nestedSpec(), baseConfig()) + + assert.Contains(t, code, "export const CODECS:") + assert.Contains(t, code, `"User":`) + assert.Contains(t, code, `"kind": "object"`) + assert.Contains(t, code, `"kind": "array"`) + assert.Contains(t, code, `"kind": "record"`) + assert.Contains(t, code, "export function decode") + assert.Contains(t, code, "export function encode") +} + +// TestCodecTableSyntheticIDsAreDerivedFromPath pins the naming rule for +// inline (non-$ref) nested schemas. A counter would also be deterministic +// within one run but would shift every downstream id when an unrelated +// property is added; deriving from the parent id and property name keeps +// each id stable for as long as that property exists. +func TestCodecTableSyntheticIDsAreDerivedFromPath(t *testing.T) { + code := NewCodecGenerator().Generate(nestedSpec(), baseConfig()) + + assert.Contains(t, code, `"Nested.items":`, "an inline array property needs its own entry, keyed by parent and property name") + assert.Contains(t, code, `"Nested.tags":`, "an inline record property needs its own entry") + + // The $ref property must reuse the named codec, not mint a synthetic one. + assert.NotContains(t, code, `"Nested.user":`) + assert.Contains(t, code, `"codec": "User"`) +} + +// TestCodecTableUnionRequiresDiscriminator covers the runtime rule that a +// union with no discriminator degrades to passthrough. Guessing a member by +// structural shape could rename fields based on a wrong match, which is +// worse than leaving the payload alone. +func TestCodecTableUnionRequiresDiscriminator(t *testing.T) { + withDisc := baseSpec() + withDisc.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Cat"}, + {Ref: "#/components/schemas/Dog"}, + }, + Discriminator: &client.Discriminator{ + PropertyName: "kind", + Mapping: map[string]string{ + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog", + }, + }, + } + withDisc.Schemas["Cat"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"meows": {Type: "boolean"}}} + withDisc.Schemas["Dog"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"barks": {Type: "boolean"}}} + + code := NewCodecGenerator().Generate(withDisc, baseConfig()) + assert.Contains(t, code, `"kind": "union"`) + assert.Contains(t, code, `"wire": "kind"`) + + // Same union, no discriminator. + noDisc := baseSpec() + noDisc.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Cat"}, + {Ref: "#/components/schemas/Dog"}, + }, + } + noDisc.Schemas["Cat"] = withDisc.Schemas["Cat"] + noDisc.Schemas["Dog"] = withDisc.Schemas["Dog"] + + code = NewCodecGenerator().Generate(noDisc, baseConfig()) + assert.NotContains(t, code, `"kind": "union"`) + assert.Contains(t, code, `"Pet": {"kind": "passthrough"}`) +} + +// TestCodecTableHandlesSelfReference is the non-termination guard. A schema +// that references itself (a tree node, a linked list) must not recurse +// forever while the table is built. +func TestCodecTableHandlesSelfReference(t *testing.T) { + spec := baseSpec() + spec.Schemas["Node"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "value": {Type: "string"}, + "children": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/Node"}}, + }, + } + + done := make(chan string, 1) + go func() { done <- NewCodecGenerator().Generate(spec, baseConfig()) }() + + select { + case code := <-done: + assert.Contains(t, code, `"Node":`) + // The cycle resolves through the synthetic array entry's `items`, + // which points back at the named codec rather than expanding it + // again — that pointer is exactly what makes the walk terminate. + assert.Contains(t, code, `"Node.children"`) + assert.Contains(t, code, `"items": "Node"`, "the self-reference must resolve to the named codec, not re-expand it") + case <-time.After(10 * time.Second): + t.Fatal("codec table generation did not terminate on a self-referential schema") + } +} + +func TestCodecTableIsDeterministic(t *testing.T) { + spec := nestedSpec() + first := NewCodecGenerator().Generate(spec, baseConfig()) + + for i := range 12 { + if got := NewCodecGenerator().Generate(spec, baseConfig()); got != first { + t.Fatalf("run %d differs", i) + } + } +} + +func TestCodecsEmittedAndExported(t *testing.T) { + out, err := NewGenerator().Generate(context.Background(), baseSpec(), baseConfig()) + require.NoError(t, err) + + assert.Contains(t, out.Files, "src/codecs.ts") + assert.Contains(t, out.Files["src/index.ts"], "export * from './codecs';") +} + +// TestCodecRuntimeRulesArePresent pins the three non-negotiable runtime +// behaviours in the emitted walk. The behavioural proof is the execution +// test; this catches an accidental removal without a Node round trip. +func TestCodecRuntimeRulesArePresent(t *testing.T) { + code := NewCodecGenerator().Generate(baseSpec(), baseConfig()) + + assert.Contains(t, code, "out[key] = val;", "unknown keys must pass through verbatim") + assert.Contains(t, code, "Keys are data here", "a record must rename values but never keys") + assert.True(t, strings.Contains(code, "if (typeof tag !== 'string')"), + "a union whose discriminator value is missing or non-string must fall back to passthrough") +} + +// TestCodecRuntimeBehaviour is the execution proof for the three +// non-negotiable rules. String assertions can show the branches exist; only +// running them shows they do the right thing. The table is asserted through +// encode/decode round trips rather than by inspecting CODECS, so the test +// stays valid once a later phase makes `ts` differ from the wire name. +func TestCodecRuntimeBehaviour(t *testing.T) { + spec := nestedSpec() + spec.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Cat"}, + {Ref: "#/components/schemas/Dog"}, + }, + Discriminator: &client.Discriminator{ + PropertyName: "kind", + Mapping: map[string]string{ + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog", + }, + }, + } + spec.Schemas["Cat"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "kind": {Type: "string"}, "meows": {Type: "boolean"}, + }} + spec.Schemas["Dog"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "kind": {Type: "string"}, "barks": {Type: "boolean"}, + }} + spec.Schemas["Untagged"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Cat"}, + {Ref: "#/components/schemas/Dog"}, + }, + } + + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "src/codecs.ts": NewCodecGenerator().Generate(spec, baseConfig()), + }) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +// Rule 1: unknown keys pass through verbatim — a server that adds a field +// must not have it silently dropped by an older client. +results.unknownKeys = decode({ id: 'u1', surprise: { nested: 1 } }, 'User'); + +// Rule 2: a record renames its VALUES but never its KEYS. The keys of +// Nested.tags are user-chosen ids, not schema-defined field names. +results.recordKeys = decode({ tags: { 'Weird-Key': 'v', 'another key': 'w' } }, 'Nested'); + +// Rule 3: a union WITHOUT a discriminator falls back to passthrough rather +// than guessing a member by structural shape. +results.untagged = decode({ kind: 'cat', meows: true, extra: 1 }, 'Untagged'); + +// A union WITH a discriminator resolves to the tagged member. +results.tagged = decode({ kind: 'cat', meows: true, extra: 1 }, 'Pet'); + +// A discriminator value with no mapping entry passes through untouched. +results.unknownTag = decode({ kind: 'fish', swims: true }, 'Pet'); + +// Nested walks: array of $refs, and a $ref property. +results.nested = decode({ user: { id: 'u' }, items: [{ id: 'a' }, { id: 'b' }] }, 'Nested'); + +// encode is the inverse of decode. With ts === wire today this is identity, +// but the round trip is what a later phase's rename has to preserve. +const original = { user: { id: 'u' }, items: [{ id: 'a' }], tags: { k: 'v' } }; +results.roundTrip = encode(decode(original, 'Nested'), 'Nested'); + +// A null or undefined value is returned as-is, never walked. +results.nullish = { a: decode(null, 'User'), b: decode(undefined, 'User') }; + +// An unknown codec id leaves the value alone rather than throwing. +results.unknownCodec = decode({ a: 1 }, 'NoSuchCodec'); + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_codecs.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_codecs.ts") + + var got struct { + UnknownKeys map[string]any `json:"unknownKeys"` + RecordKeys map[string]any `json:"recordKeys"` + Untagged map[string]any `json:"untagged"` + Tagged map[string]any `json:"tagged"` + UnknownTag map[string]any `json:"unknownTag"` + Nested map[string]any `json:"nested"` + RoundTrip map[string]any `json:"roundTrip"` + Nullish map[string]any `json:"nullish"` + UnknownCodec map[string]any `json:"unknownCodec"` + } + decodeLastLine(t, stdout, &got) + + assert.Equal(t, map[string]any{"nested": float64(1)}, got.UnknownKeys["surprise"], + "an unknown key must survive decode with its name and value intact; driver stdout:\n%s", stdout) + + tags, _ := got.RecordKeys["tags"].(map[string]any) + assert.Contains(t, tags, "Weird-Key", "record keys are data and must never be renamed") + assert.Contains(t, tags, "another key", "record keys are data and must never be renamed") + + assert.Equal(t, map[string]any{"kind": "cat", "meows": true, "extra": float64(1)}, got.Untagged, + "a union with no discriminator must pass through untouched rather than guess a member") + + assert.Equal(t, "cat", got.Tagged["kind"]) + assert.Equal(t, float64(1), got.Tagged["extra"], "unknown keys survive inside a resolved union member too") + + assert.Equal(t, map[string]any{"kind": "fish", "swims": true}, got.UnknownTag, + "a discriminator value absent from the mapping must pass through, not throw") + + items, _ := got.Nested["items"].([]any) + assert.Len(t, items, 2, "an array of $refs must be walked element by element") + + assert.Equal(t, map[string]any{ + "user": map[string]any{"id": "u"}, + "items": []any{map[string]any{"id": "a"}}, + "tags": map[string]any{"k": "v"}, + }, got.RoundTrip, "encode(decode(x)) must round-trip; driver stdout:\n%s", stdout) + + assert.Nil(t, got.Nullish["a"], "null must pass through untouched") + assert.Nil(t, got.Nullish["b"], "undefined must pass through untouched") + + assert.Equal(t, map[string]any{"a": float64(1)}, got.UnknownCodec, + "an unknown codec id must leave the value alone rather than throw") +} diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index f6dddc00..6cfa152c 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -290,6 +290,11 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, typesCode := g.generateTypes(spec, config) genClient.Files["src/types.ts"] = typesCode + // Generate the codec table. Always emitted for now: the config field + // that will let a caller opt out (preserving wire casing, making the + // table pointless) does not exist yet. + genClient.Files["src/codecs.ts"] = NewCodecGenerator().Generate(spec, config) + // Generate WebSocket clients if len(spec.WebSockets) > 0 && config.IncludeStreaming { wsGen := NewWebSocketGenerator() @@ -1263,6 +1268,7 @@ func (g *Generator) generateIndex(spec *client.APISpec, config client.GeneratorC } buf.WriteString("export * from './types';\n") + buf.WriteString("export * from './codecs';\n") if !isAsyncAPIOnly { buf.WriteString("export * from './client';\n\n") From da836c392a133be965abedb55aeb8eef84e9e382 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 14:29:39 -0500 Subject: [PATCH 39/71] fix(client/typescript): split runs of capitals when converting case splitWords did not break a run of capitals before a following lowercase letter, so HTTPStatus was read as one word instead of HTTP + Status. lowerFirst/upperFirst also only ever touched the first rune, so an all-caps word like USER or ID was left shouting (uSERID) instead of being normalised as a whole acronym. Both are fixed: splitWords now splits at the trailing edge of a capital run when followed by a lowercase letter, and lowerFirst/upperFirst normalise an entirely-uppercase word as a whole. toCamel and toPascal now agree on all-caps words wherever they appear in an identifier (leading or trailing), which is why toCamel("userID") now yields "userId" instead of the old "userID" - a deliberate, documented decision, not a regression. This is the prerequisite for Phase 3's client-side property renaming: SCREAMING_SNAKE is a plausible wire casing, and a wrong rename (uSERID) is worse than no rename at all. All 8 gate fixtures still type-check at zero tsc errors and generation stays deterministic - none of them contain an all-caps identifier today, so none of their generated output changes. --- .../client/generators/typescript/naming.go | 64 ++++++++++++++++--- .../generators/typescript/naming_test.go | 47 +++++++++++++- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/internal/client/generators/typescript/naming.go b/internal/client/generators/typescript/naming.go index 3f98a228..a0cd190a 100644 --- a/internal/client/generators/typescript/naming.go +++ b/internal/client/generators/typescript/naming.go @@ -5,10 +5,36 @@ import ( "unicode" ) -// lowerFirst returns w with its first rune lowercased, leaving the rest -// untouched. Operates on runes, not bytes, so multi-byte leading characters -// are not corrupted. +// isAllUpper reports whether w contains at least one uppercase letter and no +// lowercase letters. Such a word is an acronym (USER, HTTP, ID) rather than a +// normally-cased word (User, Http, Id), and must be normalised as a whole — +// touching only its first rune, as lowerFirst/upperFirst otherwise do, would +// leave the rest of the acronym shouting (uSERID, hTTPStatus). +func isAllUpper(w string) bool { + hasUpper := false + + for _, r := range w { + if unicode.IsLower(r) { + return false + } + + if unicode.IsUpper(r) { + hasUpper = true + } + } + + return hasUpper +} + +// lowerFirst returns w lowercased. If w is an all-caps acronym (isAllUpper), +// the whole word is lowercased ("USER" -> "user"); otherwise only the first +// rune is, leaving the rest untouched ("User" -> "user"). Operates on runes, +// not bytes, so multi-byte leading characters are not corrupted. func lowerFirst(w string) string { + if isAllUpper(w) { + return strings.ToLower(w) + } + r := []rune(w) if len(r) == 0 { return w @@ -19,10 +45,16 @@ func lowerFirst(w string) string { return string(r) } -// upperFirst returns w with its first rune uppercased, leaving the rest -// untouched. Operates on runes, not bytes, so multi-byte leading characters -// are not corrupted. +// upperFirst returns w title-cased: first rune uppercased. If w is an +// all-caps acronym (isAllUpper), the rest of the word is lowercased first +// ("ID" -> "Id"); otherwise only the first rune is touched, leaving the rest +// as-is ("user" -> "User", "userId" -> "UserId"). Operates on runes, not +// bytes, so multi-byte leading characters are not corrupted. func upperFirst(w string) string { + if isAllUpper(w) { + w = strings.ToLower(w) + } + r := []rune(w) if len(r) == 0 { return w @@ -33,9 +65,12 @@ func upperFirst(w string) string { return string(r) } -// splitWords breaks name on separators and on lower-to-upper boundaries, so an -// already-camelCase name round-trips instead of being flattened. Only non-empty -// words are ever appended, so callers may safely pass words[i] to lowerFirst / +// splitWords breaks name on separators, on lower-to-upper boundaries (so an +// already-camelCase name round-trips instead of being flattened), and on the +// trailing edge of a run of capitals that is followed by a lowercase letter +// (so an acronym-then-word name like "HTTPStatus" splits into "HTTP" + +// "Status" rather than being read as one word). Only non-empty words are +// ever appended, so callers may safely pass words[i] to lowerFirst / // upperFirst without an emptiness check. func splitWords(name string) []string { var ( @@ -50,12 +85,21 @@ func splitWords(name string) []string { } } + isUpper := func(r rune) bool { return r >= 'A' && r <= 'Z' } + isLower := func(r rune) bool { return r >= 'a' && r <= 'z' } + runes := []rune(name) for i, r := range runes { switch { case r == '_' || r == '-' || r == ' ' || r == '.': flush() - case i > 0 && r >= 'A' && r <= 'Z' && runes[i-1] >= 'a' && runes[i-1] <= 'z': + case i > 0 && isUpper(r) && isLower(runes[i-1]): + // lower-to-upper boundary: e.g. "user|Id". + flush() + cur.WriteRune(r) + case i > 0 && isUpper(r) && isUpper(runes[i-1]) && i+1 < len(runes) && isLower(runes[i+1]): + // trailing edge of a capital run, followed by a lowercase letter: + // e.g. "HTTP|Status" splits before the "S", not before the "P". flush() cur.WriteRune(r) default: diff --git a/internal/client/generators/typescript/naming_test.go b/internal/client/generators/typescript/naming_test.go index ed572197..fec4ee55 100644 --- a/internal/client/generators/typescript/naming_test.go +++ b/internal/client/generators/typescript/naming_test.go @@ -2,13 +2,30 @@ package typescript import "testing" +// TestToCamel is table-driven and covers, in one place: +// - the defect this file fixes: a run of capitals (an acronym) not being +// split from a following capitalised word (HTTPStatus), and an all-caps +// word being mangled to one-lower-rune-plus-caps (uSERID) instead of +// being normalised as a whole acronym (userId). +// - the pre-existing regression guards that must keep passing. +// +// Decision (see naming.go's isAllUpper/lowerFirst/upperFirst doc comments for +// the full rationale): a word that is entirely uppercase is treated as an +// acronym and normalised as a whole — lowercased entirely as the leading +// word, title-cased (first rune up, rest down) as a trailing word. This is +// why toCamel("userID") now yields "userId" rather than the old "userID": +// once HTTPStatus -> httpStatus normalises "HTTP" down to "http", leaving +// "ID" as "ID" in userID would be the one inconsistent case. toCamel and +// toPascal agree on this: both flatten an all-caps word instead of leaving +// interior acronyms shouting. func TestToCamel(t *testing.T) { cases := []struct{ in, want string }{ + // Regression guards: already-correct behaviour that must not change. {"user_id", "userId"}, {"user-id", "userId"}, {"userId", "userId"}, // already camel: must be preserved, not lowercased - {"UserID", "userID"}, // leading cap dropped, interior caps kept {"id", "id"}, + {"a", "a"}, {"", ""}, {"_", "_"}, // separators only: no words found, name returned unchanged, no panic {"--", "--"}, // separators only: no words found, name returned unchanged, no panic @@ -18,6 +35,20 @@ func TestToCamel(t *testing.T) { {"Ábc", "ábc"}, // multi-byte leading rune must be lowercased by rune, not by byte {"ábc", "ábc"}, // already-lowercase multi-byte leading rune: unchanged {"café_id", "caféId"}, // multi-byte rune mid-word must survive untouched + + // The defect: a run of capitals must split before a following + // capitalised word, and an all-caps word must be normalised as a + // whole rather than having only its first rune changed. + {"USER_ID", "userId"}, + {"HTTPStatus", "httpStatus"}, + {"HTTP_STATUS_CODE", "httpStatusCode"}, + {"ID", "id"}, + {"A", "a"}, + + // Judgment call: an all-caps trailing acronym now normalises like any + // other all-caps word, so this changes from the old "userID". + {"userID", "userId"}, + {"UserID", "userId"}, } for _, c := range cases { @@ -27,8 +58,14 @@ func TestToCamel(t *testing.T) { } } +// TestToPascal mirrors TestToCamel's cases in PascalCase, plus its own +// pre-existing regression guards. toPascal("HTTPStatus") -> "HttpStatus" (not +// "HTTPStatus") for the same reason toCamel flattens "userID" -> "userId": +// an all-caps word is an acronym to be normalised as a whole, consistently, +// wherever it appears in the identifier. func TestToPascal(t *testing.T) { cases := []struct{ in, want string }{ + // Regression guards. {"user_id", "UserId"}, {"message.created", "MessageCreated"}, {"userId", "UserId"}, @@ -39,6 +76,14 @@ func TestToPascal(t *testing.T) { {"_a_", "A"}, {"123abc", "123abc"}, // leading digit: ToUpper is a no-op, no panic {"ábc_id", "ÁbcId"}, // multi-byte leading rune must be uppercased by rune, not by byte + + // The defect, mirrored in PascalCase. + {"USER_ID", "UserId"}, + {"HTTPStatus", "HttpStatus"}, + {"HTTP_STATUS_CODE", "HttpStatusCode"}, + {"ID", "Id"}, + {"A", "A"}, + {"userID", "UserId"}, } for _, c := range cases { From 1afb487deae2899699281039206969663d0d8d2e Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 14:38:41 -0500 Subject: [PATCH 40/71] feat(client): add FieldNaming and FieldOverrides configuration Adds NamingStrategy (camel/pascal/snake/preserve), GeneratorConfig.FieldNaming, and GeneratorConfig.FieldOverrides, plus toSnake in the TypeScript generator's naming.go and a new tsFieldName resolver that applies the override precedence (schema-scoped > global > strategy) and resolves FieldNaming's zero value at read time. Configuration only: nothing calls tsFieldName yet, so generated output is unchanged. --- internal/client/config.go | 23 +++ .../client/generators/typescript/fieldname.go | 92 ++++++++++++ .../generators/typescript/fieldname_test.go | 139 ++++++++++++++++++ .../client/generators/typescript/naming.go | 21 +++ .../generators/typescript/naming_test.go | 28 ++++ 5 files changed, 303 insertions(+) create mode 100644 internal/client/generators/typescript/fieldname.go create mode 100644 internal/client/generators/typescript/fieldname_test.go diff --git a/internal/client/config.go b/internal/client/config.go index 14a40fc1..5410e577 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -54,8 +54,31 @@ type GeneratorConfig struct { // Output control ClientOnly bool // Generate only client source files (no package.json, tsconfig, etc.) + + // FieldNaming selects the client-side identifier style for schema properties. + // The wire name always comes from the spec. Only the TypeScript generator reads + // this field in this change; other language generators ignore it and are + // unaffected. Defaults to NamingCamel when Language is "typescript", and to + // NamingPreserve otherwise, so no existing generator changes behaviour. + FieldNaming NamingStrategy + + // FieldOverrides maps a wire name to an explicit client-side name. A key of + // "Schema.wire_name" applies to that schema only; a bare "wire_name" applies + // globally. A schema-scoped entry wins over a global one for the same wire + // name. Overrides bypass FieldNaming entirely and are used verbatim. + FieldOverrides map[string]string } +// NamingStrategy selects a target identifier style. +type NamingStrategy string + +const ( + NamingCamel NamingStrategy = "camel" + NamingPascal NamingStrategy = "pascal" + NamingSnake NamingStrategy = "snake" + NamingPreserve NamingStrategy = "preserve" +) + // StreamingConfig configures streaming client generation features. type StreamingConfig struct { // EnableRooms generates room management client (join/leave/broadcast) diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go new file mode 100644 index 00000000..eb6b3a38 --- /dev/null +++ b/internal/client/generators/typescript/fieldname.go @@ -0,0 +1,92 @@ +package typescript + +import "github.com/xraph/forge/internal/client" + +// tsFieldName resolves the TypeScript-side identifier for a schema property. +// +// Resolution order: +// 1. A schema-scoped override ("SchemaName.wire_name") wins. +// 2. A bare global override ("wire_name") wins next. +// 3. Otherwise the effective naming strategy (see effectiveFieldNaming) is +// applied to wireName. +// +// An override is used verbatim -- it bypasses the strategy entirely and is +// never case-converted, even if it happens to look like a wire name. +// +// A schema name or wire name that itself contains a "." makes the +// concatenated override key ambiguous: schemaName="User.Detail" wireName="id" +// and schemaName="User" wireName="Detail.id" both produce the key +// "User.Detail.id". This is a known, unresolved ambiguity -- OpenAPI schema +// names and JSON property names are conventionally dot-free, and the design +// doc specifies the key format literally with no escaping scheme. If this +// becomes a real collision, it needs an explicit escape/delimiter change to +// FieldOverrides' key format, not a fix here. +func tsFieldName(schemaName, wireName string, config client.GeneratorConfig) string { + if override, ok := config.FieldOverrides[schemaName+"."+wireName]; ok { + return override + } + + if override, ok := config.FieldOverrides[wireName]; ok { + return override + } + + switch effectiveFieldNaming(config) { + case client.NamingCamel: + return toCamel(wireName) + case client.NamingPascal: + return toPascal(wireName) + case client.NamingSnake: + return toSnake(wireName) + default: + // client.NamingPreserve, and any unrecognised value (e.g. a typo'd + // "kebab"), fall back to returning wireName unchanged. tsFieldName + // has no error return -- the generator currently has no config + // validation error path for this kind of field, only + // GeneratorConfig.Validate()'s language/output-dir/package-name + // checks -- so silently falling back is the only option that + // doesn't mean adding a new error path this task isn't scoped to + // wire in. Falling back to preserve, specifically, is the only + // choice that cannot silently corrupt a name into something + // plausible-but-wrong the way guessing camel/pascal/snake for an + // unrecognised value could. + return wireName + } +} + +// effectiveFieldNaming resolves config.FieldNaming's zero value (""), which +// is not one of the four NamingStrategy constants. +// +// This resolution deliberately happens here, at read time, rather than being +// baked into a config value once (e.g. inside DefaultConfig). Two things +// rule out defaulting at construction time alone: +// +// 1. Hand-built configs bypass DefaultConfig entirely. E.g. +// cmd/forge/plugins/client.go builds `client.GeneratorConfig{Language: ...}` +// directly, never calling DefaultConfig or NewConfig. A default only +// written into DefaultConfig's return value would never reach that +// caller, so its FieldNaming would stay "" -- exactly the case this task +// requires to still behave correctly. +// 2. Even callers that do use the functional-options constructor can defeat +// a construction-time default: NewConfig(WithLanguage("typescript")) runs +// DefaultConfig() first (Language: "go") and applies WithLanguage after. +// If DefaultConfig computed FieldNaming from Language at that point, it +// would freeze in the "go" answer (preserve) before WithLanguage ever +// runs, silently giving the wrong default for a config that ends up +// targeting TypeScript. +// +// Resolving at read time avoids both failure modes uniformly, for every +// construction path, at the cost of config.FieldNaming (the stored zero +// value) and the effective strategy sometimes differing. Callers that need +// the effective strategy -- not just the raw field -- must call this +// function rather than reading config.FieldNaming directly. +func effectiveFieldNaming(config client.GeneratorConfig) client.NamingStrategy { + if config.FieldNaming != "" { + return config.FieldNaming + } + + if config.Language == "typescript" { + return client.NamingCamel + } + + return client.NamingPreserve +} diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go new file mode 100644 index 00000000..f2a22c97 --- /dev/null +++ b/internal/client/generators/typescript/fieldname_test.go @@ -0,0 +1,139 @@ +package typescript + +import ( + "testing" + + "github.com/xraph/forge/internal/client" +) + +// TestTsFieldName covers tsFieldName's full resolution order: strategy +// selection, override precedence (schema-scoped over global), overrides +// being used verbatim (never case-converted), NamingPreserve, the +// zero-value default (both for typescript and for other/unset languages), +// and an unrecognised strategy value. +func TestTsFieldName(t *testing.T) { + cases := []struct { + name string + schemaName string + wireName string + config client.GeneratorConfig + want string + }{ + { + name: "camel strategy", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{FieldNaming: client.NamingCamel}, + want: "userId", + }, + { + name: "pascal strategy", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{FieldNaming: client.NamingPascal}, + want: "UserId", + }, + { + name: "snake strategy", + schemaName: "User", + wireName: "userId", + config: client.GeneratorConfig{FieldNaming: client.NamingSnake}, + want: "user_id", + }, + { + name: "snake strategy is consistent with the acronym fix", + schemaName: "User", + wireName: "HTTPStatus", + config: client.GeneratorConfig{FieldNaming: client.NamingSnake}, + want: "http_status", + }, + { + name: "preserve strategy returns the wire name unchanged", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{FieldNaming: client.NamingPreserve}, + want: "user_id", + }, + { + name: "preserve strategy does not case-convert an already-camel wire name", + schemaName: "User", + wireName: "userId", + config: client.GeneratorConfig{FieldNaming: client.NamingPreserve}, + want: "userId", + }, + { + name: "schema-scoped override beats global override", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{ + FieldNaming: client.NamingCamel, + FieldOverrides: map[string]string{ + "User.user_id": "uid", + "user_id": "userIdentifier", + }, + }, + want: "uid", + }, + { + name: "global override applies when no schema-scoped entry matches", + schemaName: "Other", + wireName: "user_id", + config: client.GeneratorConfig{ + FieldNaming: client.NamingCamel, + FieldOverrides: map[string]string{ + "User.user_id": "uid", + "user_id": "userIdentifier", + }, + }, + want: "userIdentifier", + }, + { + name: "override is used verbatim, bypassing the strategy entirely", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{ + FieldNaming: client.NamingCamel, + FieldOverrides: map[string]string{ + "User.user_id": "USER_ID_RAW", + }, + }, + want: "USER_ID_RAW", + }, + { + name: "zero-value config defaults to camel for typescript", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{Language: "typescript"}, + want: "userId", + }, + { + name: "zero-value config defaults to preserve for a non-typescript language", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{Language: "go"}, + want: "user_id", + }, + { + name: "fully zero-value config (hand-built, Language unset) defaults to preserve", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{}, + want: "user_id", + }, + { + name: "unrecognised strategy value falls back to preserve rather than panicking", + schemaName: "User", + wireName: "user_id", + config: client.GeneratorConfig{FieldNaming: client.NamingStrategy("kebab")}, + want: "user_id", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := tsFieldName(c.schemaName, c.wireName, c.config); got != c.want { + t.Errorf("tsFieldName(%q, %q, %+v) = %q, want %q", c.schemaName, c.wireName, c.config, got, c.want) + } + }) + } +} diff --git a/internal/client/generators/typescript/naming.go b/internal/client/generators/typescript/naming.go index a0cd190a..8d069d19 100644 --- a/internal/client/generators/typescript/naming.go +++ b/internal/client/generators/typescript/naming.go @@ -142,3 +142,24 @@ func toPascal(name string) string { return out.String() } + +// toSnake converts name to snake_case, reusing splitWords so all three +// converters agree on where word boundaries fall -- including the +// acronym-then-word split splitWords applies (e.g. "HTTPStatus" splits into +// "HTTP" + "Status" for toCamel, toPascal, and toSnake alike). Unlike +// lowerFirst/upperFirst, snake_case has no "first word" special case: every +// word is simply lowercased as a whole, since case only distinguishes +// acronyms in camel/Pascal, not in snake_case. +func toSnake(name string) string { + words := splitWords(name) + if len(words) == 0 { + return name + } + + lowered := make([]string, len(words)) + for i, w := range words { + lowered[i] = strings.ToLower(w) + } + + return strings.Join(lowered, "_") +} diff --git a/internal/client/generators/typescript/naming_test.go b/internal/client/generators/typescript/naming_test.go index fec4ee55..883a2ecd 100644 --- a/internal/client/generators/typescript/naming_test.go +++ b/internal/client/generators/typescript/naming_test.go @@ -92,3 +92,31 @@ func TestToPascal(t *testing.T) { } } } + +// TestToSnake covers snake_case conversion, in particular that it stays +// consistent with toCamel/toPascal's splitWords-driven acronym handling: +// "HTTPStatus" must split into "HTTP" + "Status" here too, giving +// "http_status" rather than "h_t_t_p_status" or "httpstatus". +func TestToSnake(t *testing.T) { + cases := []struct{ in, want string }{ + {"user_id", "user_id"}, + {"userId", "user_id"}, + {"UserId", "user_id"}, + {"user-id", "user_id"}, + {"message.created", "message_created"}, + {"id", "id"}, + {"", ""}, + {"_", "_"}, + {"USER_ID", "user_id"}, + {"HTTPStatus", "http_status"}, + {"HTTP_STATUS_CODE", "http_status_code"}, + {"ID", "id"}, + {"userID", "user_id"}, + } + + for _, c := range cases { + if got := toSnake(c.in); got != c.want { + t.Errorf("toSnake(%q) = %q, want %q", c.in, got, c.want) + } + } +} From c0ecef100185e85072628794db9b473a3cc9f080 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 14:52:16 -0500 Subject: [PATCH 41/71] feat(client/typescript): fail generation on field-name collisions Schema property renaming is about to turn on, and a silent rename that maps two different wire names (e.g. user_id and userId under camel) to the same client field would drop one of them with no trace in the generated output. Add checkFieldNameCollisions, which walks every schema's properties in sortedKeys order, derives each client name via tsFieldName (so FieldOverrides is honored), and reports every wire-name pair within a schema that collides. Cross-schema collisions (User.id vs Post.id) are fine and not reported; NamingPreserve can never collide and is skipped entirely. Generator.Generate now runs this check immediately after the existing schema-name-collision check, before any file is assembled, so a collision aborts generation with zero files produced. --- .../client/generators/typescript/fieldname.go | 87 ++++++++- .../generators/typescript/fieldname_test.go | 168 ++++++++++++++++++ .../client/generators/typescript/generator.go | 4 + 3 files changed, 258 insertions(+), 1 deletion(-) diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index eb6b3a38..b907d90b 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -1,6 +1,11 @@ package typescript -import "github.com/xraph/forge/internal/client" +import ( + "fmt" + "strings" + + "github.com/xraph/forge/internal/client" +) // tsFieldName resolves the TypeScript-side identifier for a schema property. // @@ -90,3 +95,83 @@ func effectiveFieldNaming(config client.GeneratorConfig) client.NamingStrategy { return client.NamingPreserve } + +// checkFieldNameCollisions reports every case where two distinct wire-name +// properties of the same schema resolve, via tsFieldName, to the same +// client-side field name. This must run -- and must abort generation -- +// before schema property renaming is wired into the actual output (a later +// task): two wire names landing on one identifier means whichever is +// rendered second silently overwrites the first in the generated interface, +// and nothing in the output signals that a field went missing. +// +// tsFieldName is called for every property exactly as the renaming code +// path will, so FieldOverrides is consulted before any comparison happens: +// - A schema-scoped or global override that gives one of the two wire +// names in a would-be collision a different client name resolves it -- +// no error, because that is exactly how a caller is meant to fix this. +// - An override's chosen *value* can just as easily collide with another +// property's derived name (e.g. wire "a" overridden to "x", and wire +// "x_" deriving to "x" under camel). Because tsFieldName is used +// uniformly regardless of whether a name came from an override or from +// the naming strategy, this case is caught by the same comparison -- +// there is no separate "override values" pass to add. +// +// Collisions across different schemas are never reported: User.id and +// Post.id both deriving to "id" is normal, since each schema becomes its own +// TypeScript interface with its own, independent set of property names. +// Property names are also the only collision surface this check needs to +// consider -- schemaToTypeScript's object case emits exactly the properties +// in schema.Properties and nothing else keyed by name (the +// additionalProperties case adds an unnamed `Record` intersection +// member, which cannot collide with a specific property key); a schema name +// colliding with a reserved streaming type name is a distinct namespace +// already covered by checkSchemaNameCollisions. +// +// Under NamingPreserve, tsFieldName returns wireName unchanged for every +// property that has no override, so two distinct wire names can never +// derive to the same name -- the walk is skipped entirely rather than +// running a pass that can only ever come back empty. +// +// All collisions found across the whole spec are reported at once, not just +// the first, so a caller does not have to fix them one regeneration at a +// time. Schemas are walked in sortedKeys order and, within each schema, +// properties are walked in sortedKeys order, so the report is deterministic +// across runs. +func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfig) error { + if effectiveFieldNaming(config) == client.NamingPreserve { + return nil + } + + var messages []string + + for _, schemaName := range sortedKeys(spec.Schemas) { + schema := spec.Schemas[schemaName] + if schema == nil { + continue + } + + owner := make(map[string]string, len(schema.Properties)) // client name -> first wire name that claimed it + + for _, wireName := range sortedKeys(schema.Properties) { + clientName := tsFieldName(schemaName, wireName, config) + + first, claimed := owner[clientName] + if !claimed { + owner[clientName] = wireName + continue + } + + messages = append(messages, fmt.Sprintf( + "schema %q: wire names %q and %q both resolve to client field %q; add FieldOverrides[%q] to disambiguate", + schemaName, first, wireName, clientName, schemaName+"."+wireName)) + } + } + + if len(messages) == 0 { + return nil + } + + return fmt.Errorf( + "field-name collision(s) detected, generation aborted (no files were produced):\n%s", + strings.Join(messages, "\n")) +} diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go index f2a22c97..4bbe38ed 100644 --- a/internal/client/generators/typescript/fieldname_test.go +++ b/internal/client/generators/typescript/fieldname_test.go @@ -1,6 +1,8 @@ package typescript import ( + "context" + "strings" "testing" "github.com/xraph/forge/internal/client" @@ -137,3 +139,169 @@ func TestTsFieldName(t *testing.T) { }) } } + +// collisionSpec returns a minimal spec with one schema ("User") whose two +// properties, "user_id" and "userId", both derive to the client field +// "userId" under camel naming -- the canonical collision this task must +// catch. +func collisionSpec() *client.APISpec { + return &client.APISpec{ + Info: client.APIInfo{Title: "Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "User": { + Type: "object", + Properties: map[string]*client.Schema{ + "user_id": {Type: "string"}, + "userId": {Type: "string"}, + }, + }, + }, + } +} + +func collisionConfig() client.GeneratorConfig { + config := client.DefaultConfig() + config.Language = "typescript" + config.PackageName = "probe" + config.FieldNaming = client.NamingCamel + + return config +} + +// TestGenerateFailsOnFieldNameCollision is the primary case this task adds: +// two wire names in one schema deriving to the same client field must make +// Generate error out, name both wire names and the schema, point at the +// FieldOverrides key that resolves it, and produce no files at all. +func TestGenerateFailsOnFieldNameCollision(t *testing.T) { + out, err := NewGenerator().Generate(context.Background(), collisionSpec(), collisionConfig()) + + if err == nil { + t.Fatal("expected an error for colliding field names, got nil") + } + + if out != nil { + t.Errorf("expected no generated client on collision, got %+v", out) + } + + for _, want := range []string{"User", "user_id", "userId", `FieldOverrides["User.user_id"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestGenerateAllowsCollisionResolvedByOverride is the negative case: once a +// FieldOverrides entry disambiguates the pair, generation must succeed. +func TestGenerateAllowsCollisionResolvedByOverride(t *testing.T) { + config := collisionConfig() + config.FieldOverrides = map[string]string{"User.user_id": "userIdentifier"} + + out, err := NewGenerator().Generate(context.Background(), collisionSpec(), config) + if err != nil { + t.Fatalf("expected no error once the collision is resolved by an override, got: %v", err) + } + + if out == nil { + t.Fatal("expected a generated client once the collision is resolved") + } +} + +// TestGenerateAllowsSameCollisionAcrossDifferentSchemas asserts that the same +// pair of wire names landing on the same client name is fine when they +// belong to different schemas -- each schema gets its own interface, so +// there is no shared namespace to collide in. +func TestGenerateAllowsSameCollisionAcrossDifferentSchemas(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "User": {Type: "object", Properties: map[string]*client.Schema{"user_id": {Type: "string"}}}, + "Post": {Type: "object", Properties: map[string]*client.Schema{"userId": {Type: "string"}}}, + }, + } + + if _, err := NewGenerator().Generate(context.Background(), spec, collisionConfig()); err != nil { + t.Fatalf("expected no error for a collision across different schemas, got: %v", err) + } +} + +// TestGenerateAllowsCollisionUnderPreserve asserts that NamingPreserve never +// reports a collision: with no case conversion, "user_id" and "userId" +// remain distinct client names. +func TestGenerateAllowsCollisionUnderPreserve(t *testing.T) { + config := collisionConfig() + config.FieldNaming = client.NamingPreserve + + if _, err := NewGenerator().Generate(context.Background(), collisionSpec(), config); err != nil { + t.Fatalf("expected no error under NamingPreserve, got: %v", err) + } +} + +// TestCheckFieldNameCollisionsReportsAll asserts that every collision in the +// spec is reported in one pass -- not just the first -- so a caller does not +// have to fix them one regeneration at a time. +func TestCheckFieldNameCollisionsReportsAll(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "User": { + Type: "object", + Properties: map[string]*client.Schema{ + "user_id": {Type: "string"}, + "userId": {Type: "string"}, + }, + }, + "Post": { + Type: "object", + Properties: map[string]*client.Schema{ + "author_id": {Type: "string"}, + "authorId": {Type: "string"}, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error reporting both schemas' collisions") + } + + for _, want := range []string{"User", "Post", `FieldOverrides["User.user_id"]`, `FieldOverrides["Post.author_id"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestCheckFieldNameCollisionsDetectsOverrideValueCollision covers the +// corollary raised in review: a FieldOverrides *value* can coincide with +// another property's derived name just as easily as two derived names can +// coincide with each other, and tsFieldName is used uniformly for every +// property, so this must be caught too. Wire "a" is overridden to "x"; wire +// "x_" derives to "x" under camel (a lone trailing separator contributes no +// extra word), so both land on client name "x". +func TestCheckFieldNameCollisionsDetectsOverrideValueCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Thing": { + Type: "object", + Properties: map[string]*client.Schema{ + "a": {Type: "string"}, + "x_": {Type: "string"}, + }, + }, + }, + } + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Thing.a": "x"} + + err := checkFieldNameCollisions(spec, config) + if err == nil { + t.Fatal("expected a collision between an override value and a derived name") + } + + if !strings.Contains(err.Error(), "Thing") { + t.Errorf("error message missing schema name; got: %s", err.Error()) + } +} diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 6cfa152c..fb68b91f 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -234,6 +234,10 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, return nil, err } + if err := checkFieldNameCollisions(spec, config); err != nil { + return nil, err + } + genClient := &generators.GeneratedClient{ Files: make(map[string]string), Language: "typescript", From 1d91bd2c23063cad07bc37c7f00bdeedce943b98 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 15:04:46 -0500 Subject: [PATCH 42/71] fix(client/typescript): detect field-name collisions inside nested composites checkFieldNameCollisions only walked a schema's direct Properties, missing that schemaToTSType's "object" case renders an inline (non-$ref) nested object, array-items object, additionalProperties-value object, or oneOf/ anyOf/allOf inline member through the exact same objectPropsLiteral path a top-level interface uses. Each of those is just as real a collision surface, and was reaching output unchecked. Recurse checkSchemaFieldCollisions into all of them, reusing codecIDFor's synthetic-id scheme (parentID + "." + token) for the three shapes it already covers (nested property, array items, additionalProperties value) so the printed FieldOverrides key agrees with the codec table's own ids, and adding ".oneOf"/"anyOf"/"allOf" for inline union members, which the codec table does not register at all today. A visited set reserves each id before recursing, mirroring codecTable.add's cycle guard. --- .../client/generators/typescript/fieldname.go | 165 +++++++-- .../generators/typescript/fieldname_test.go | 328 ++++++++++++++++++ 2 files changed, 461 insertions(+), 32 deletions(-) diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index b907d90b..7842f09e 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -97,12 +97,22 @@ func effectiveFieldNaming(config client.GeneratorConfig) client.NamingStrategy { } // checkFieldNameCollisions reports every case where two distinct wire-name -// properties of the same schema resolve, via tsFieldName, to the same -// client-side field name. This must run -- and must abort generation -- -// before schema property renaming is wired into the actual output (a later -// task): two wire names landing on one identifier means whichever is -// rendered second silently overwrites the first in the generated interface, -// and nothing in the output signals that a field went missing. +// properties resolve, via tsFieldName, to the same client-side field name +// within the same object namespace. This must run -- and must abort +// generation -- before schema property renaming is wired into the actual +// output (a later task): two wire names landing on one identifier means +// whichever is rendered second silently overwrites the first in the +// generated interface, and nothing in the output signals that a field went +// missing. +// +// "Object namespace" is not only a top-level named schema. schemaToTSType's +// "object" case renders an inline (non-$ref) nested schema with declared +// Properties through the exact same objectPropsLiteral helper a top-level +// `export interface` uses, so an inline object nested inside a property, an +// array's items, an additionalProperties value schema, or a oneOf/anyOf/ +// allOf member is just as real a collision surface as a named schema's own +// direct properties -- see checkSchemaFieldCollisions, which the walk below +// recurses into for exactly those shapes. // // tsFieldName is called for every property exactly as the renaming code // path will, so FieldOverrides is consulted before any comparison happens: @@ -116,14 +126,11 @@ func effectiveFieldNaming(config client.GeneratorConfig) client.NamingStrategy { // the naming strategy, this case is caught by the same comparison -- // there is no separate "override values" pass to add. // -// Collisions across different schemas are never reported: User.id and +// Collisions across different namespaces are never reported: User.id and // Post.id both deriving to "id" is normal, since each schema becomes its own -// TypeScript interface with its own, independent set of property names. -// Property names are also the only collision surface this check needs to -// consider -- schemaToTypeScript's object case emits exactly the properties -// in schema.Properties and nothing else keyed by name (the -// additionalProperties case adds an unnamed `Record` intersection -// member, which cannot collide with a specific property key); a schema name +// TypeScript interface with its own, independent set of property names -- +// and the same holds for two different inline namespaces, e.g. +// Order.shipping.street_name and Order.billing.street_name. A schema name // colliding with a reserved streaming type name is a distinct namespace // already covered by checkSchemaNameCollisions. // @@ -134,9 +141,9 @@ func effectiveFieldNaming(config client.GeneratorConfig) client.NamingStrategy { // // All collisions found across the whole spec are reported at once, not just // the first, so a caller does not have to fix them one regeneration at a -// time. Schemas are walked in sortedKeys order and, within each schema, -// properties are walked in sortedKeys order, so the report is deterministic -// across runs. +// time. Top-level schemas are walked in sortedKeys order and every nested +// namespace is walked in the same deterministic, sorted, depth-first order +// checkSchemaFieldCollisions defines, so the report is stable across runs. func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfig) error { if effectiveFieldNaming(config) == client.NamingPreserve { return nil @@ -144,34 +151,128 @@ func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfi var messages []string + visited := make(map[string]bool, len(spec.Schemas)) + for _, schemaName := range sortedKeys(spec.Schemas) { - schema := spec.Schemas[schemaName] - if schema == nil { - continue - } + messages = append(messages, checkSchemaFieldCollisions(schemaName, spec.Schemas[schemaName], config, visited)...) + } + + if len(messages) == 0 { + return nil + } + + return fmt.Errorf( + "field-name collision(s) detected, generation aborted (no files were produced):\n%s", + strings.Join(messages, "\n")) +} + +// checkSchemaFieldCollisions checks one object namespace -- identified by +// id -- for property-name collisions, then recurses into every inline +// composite reachable from it: a property's own inline object, an array's +// inline-object items, an additionalProperties inline-object value schema, +// and inline (non-$ref) oneOf/anyOf/allOf members. +// +// id doubles as the "schema name" half of tsFieldName's lookup and as the +// prefix of the FieldOverrides key printed in a collision message. For a +// top-level schema, id is simply its name (e.g. "User"), matching existing +// behavior exactly. For a nested namespace, id is a synthetic dotted path +// built the same way codecTable's codecIDFor already builds codec-table ids +// for the same shapes ("Order.shipping" for a nested property, +// "Order.line_items.items" for array items, "Order.extras.values" for an +// additionalProperties value) -- reusing that scheme, rather than inventing +// a second one, is what makes the FieldOverrides key this function prints +// actually work: tsFieldName builds its schema-scoped lookup key as +// id + "." + wireName, so calling it with id="Order.shipping" and +// wireName="street_name" checks exactly the key +// FieldOverrides["Order.shipping.street_name"] -- the same literal string +// printed in the error and the same one a caller pastes back in. This does +// inherit the pre-existing dot-concatenation ambiguity documented on +// tsFieldName (a component of the path itself containing a literal "." +// could make two different nestings print the same key); that risk already +// existed for top-level schema/wire names and is not resolved here, per the +// same reasoning tsFieldName's doc comment gives. +// +// codecIDFor has no synthetic-id scheme for an inline oneOf/anyOf/allOf +// member -- codecTable.unionEntry only ever extracts $ref members for its +// discriminator mapping and silently skips inline ones, so there is no +// existing id to reuse there. This function invents ".oneOf" / +// ".anyOf" / ".allOf" for that one shape, following +// the same "." shape as every id codecIDFor does define, so +// it is at least self-consistent with the rest of the scheme even though it +// is not literally shared with codecs.go today. +// +// A property whose own schema is a $ref is never recursed into: that named +// schema is already checked independently by the top-level walk in +// checkFieldNameCollisions, under its own name. Recursing into it here too +// would check the same properties twice, under two different ids, and (via +// a $ref cycle, e.g. a self-referential linked-list-shaped schema) could +// recurse forever -- codecIDFor makes exactly the same "$ref means reuse +// the target's name and stop" choice for the same reason. +// +// visited guards against the one remaining cycle shape: a hand-built +// *client.Schema graph containing a literal Go pointer cycle with no $ref +// involved at all (e.g. schema.Properties["self"] pointing back to schema +// itself). This is not a shape spec_parser.go or introspector.go can +// produce from a real OpenAPI document (see +// TestOneOfSelfReferenceDoesNotInfiniteLoop's doc comment for the same +// observation about schemaToTSType), but the guard is cheap and +// codecTable.add already sets the same precedent: reserve the id before +// recursing, so re-entering the same id hits the guard instead of looping. +func checkSchemaFieldCollisions(id string, schema *client.Schema, config client.GeneratorConfig, visited map[string]bool) []string { + if schema == nil || visited[id] { + return nil + } + visited[id] = true + + var messages []string + + if len(schema.Properties) > 0 { owner := make(map[string]string, len(schema.Properties)) // client name -> first wire name that claimed it for _, wireName := range sortedKeys(schema.Properties) { - clientName := tsFieldName(schemaName, wireName, config) + prop := schema.Properties[wireName] + clientName := tsFieldName(id, wireName, config) - first, claimed := owner[clientName] - if !claimed { + if first, claimed := owner[clientName]; claimed { + messages = append(messages, fmt.Sprintf( + "schema %q: wire names %q and %q both resolve to client field %q; add FieldOverrides[%q] to disambiguate", + id, first, wireName, clientName, id+"."+wireName)) + } else { owner[clientName] = wireName - continue } - messages = append(messages, fmt.Sprintf( - "schema %q: wire names %q and %q both resolve to client field %q; add FieldOverrides[%q] to disambiguate", - schemaName, first, wireName, clientName, schemaName+"."+wireName)) + if prop != nil && prop.Ref == "" { + messages = append(messages, checkSchemaFieldCollisions(id+"."+wireName, prop, config, visited)...) + } } } - if len(messages) == 0 { - return nil + if schema.Type == "array" && schema.Items != nil && schema.Items.Ref == "" { + messages = append(messages, checkSchemaFieldCollisions(id+".items", schema.Items, config, visited)...) } - return fmt.Errorf( - "field-name collision(s) detected, generation aborted (no files were produced):\n%s", - strings.Join(messages, "\n")) + if values, ok := additionalPropsSchema(schema.AdditionalProperties); ok && values != nil && values.Ref == "" { + messages = append(messages, checkSchemaFieldCollisions(id+".values", values, config, visited)...) + } + + for i, member := range schema.OneOf { + if member != nil && member.Ref == "" { + messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.oneOf%d", id, i), member, config, visited)...) + } + } + + for i, member := range schema.AnyOf { + if member != nil && member.Ref == "" { + messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.anyOf%d", id, i), member, config, visited)...) + } + } + + for i, member := range schema.AllOf { + if member != nil && member.Ref == "" { + messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.allOf%d", id, i), member, config, visited)...) + } + } + + return messages } diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go index 4bbe38ed..e0e4dcca 100644 --- a/internal/client/generators/typescript/fieldname_test.go +++ b/internal/client/generators/typescript/fieldname_test.go @@ -305,3 +305,331 @@ func TestCheckFieldNameCollisionsDetectsOverrideValueCollision(t *testing.T) { t.Errorf("error message missing schema name; got: %s", err.Error()) } } + +// --- Round 2: inline composite namespaces ----------------------------- +// +// Review round 1 found that checkFieldNameCollisions only ever looked at a +// schema's own direct Properties, never descending into an inline (non-$ref) +// nested object -- even though schemaToTSType's "object" case renders such a +// nested schema via the exact same objectPropsLiteral helper used for +// top-level named schemas (generator.go's schemaToTypeScript "object" case), +// making it just as real a collision surface. The tests below cover each +// inline-composite shape the reviewer asked about, after first confirming +// (by reading schemaToTSType and codecIDFor) that the shape actually reaches +// rendered output. + +// nestedCollisionConfig is collisionConfig but with a distinct schema/field +// vocabulary per test isn't needed -- collisionConfig itself is naming-scheme +// only (camel, no overrides by default), so it is reused as-is by every test +// below; each test sets its own FieldOverrides when it needs one. + +// TestGenerateFailsOnNestedInlineObjectFieldCollision: an inline object +// nested one level inside a named schema (Order.shipping) with two wire +// names that collide under camel. schemaToTSType's "object" case renders +// Order.shipping via objectPropsLiteral exactly like a top-level interface, +// and codecIDFor already gives inline objects with declared Properties a +// synthetic id of "." -- here "Order.shipping" -- so the +// FieldOverrides key this test expects reuses that exact scheme. +func TestGenerateFailsOnNestedInlineObjectFieldCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "shipping": { + Type: "object", + Properties: map[string]*client.Schema{ + "street_name": {Type: "string"}, + "streetName": {Type: "string"}, + }, + }, + }, + }, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, collisionConfig()) + + if err == nil { + t.Fatal("expected an error for a field-name collision inside a nested inline object") + } + + if out != nil { + t.Errorf("expected no generated client on collision, got %+v", out) + } + + for _, want := range []string{"street_name", "streetName", `FieldOverrides["Order.shipping.street_name"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestNestedFieldOverrideKeyActuallyResolvesCollision proves the key printed +// above is not just cosmetically plausible: pasting it into FieldOverrides +// verbatim must make the collision go away, because tsFieldName builds its +// schema-scoped lookup key by concatenating whatever "schema name" it is +// called with and the wire name with a ".", and the nested walk calls it +// with the synthetic id ("Order.shipping") as that schema name -- so +// "Order.shipping" + "." + "street_name" reconstructs the exact same string +// a caller would paste in. +func TestNestedFieldOverrideKeyActuallyResolvesCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "shipping": { + Type: "object", + Properties: map[string]*client.Schema{ + "street_name": {Type: "string"}, + "streetName": {Type: "string"}, + }, + }, + }, + }, + }, + } + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Order.shipping.street_name": "streetNameAlt"} + + if _, err := NewGenerator().Generate(context.Background(), spec, config); err != nil { + t.Fatalf("expected the printed FieldOverrides key to resolve the nested collision, got: %v", err) + } +} + +// TestGenerateFailsOnTwoLevelsDeepNestedCollision: the same inline-object +// surface, one level deeper (Order.shipping.geo), proving the walk recurses +// rather than stopping after one level of nesting. +func TestGenerateFailsOnTwoLevelsDeepNestedCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "shipping": { + Type: "object", + Properties: map[string]*client.Schema{ + "geo": { + Type: "object", + Properties: map[string]*client.Schema{ + "lat_long": {Type: "string"}, + "latLong": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for a field-name collision two levels of inline nesting deep") + } + + for _, want := range []string{"lat_long", "latLong", `FieldOverrides["Order.shipping.geo.lat_long"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestGenerateFailsOnArrayItemsInlineObjectCollision: an inline object used +// as an array's `items` schema (Order.line_items, array of inline objects). +// schemaToTSType's "array" case renders `itemType[]` where itemType comes +// from schemaToTSType on schema.Items -- an inline object with Properties +// hits the same objectPropsLiteral path. codecIDFor already registers such +// an items schema under ".items", reused here. +func TestGenerateFailsOnArrayItemsInlineObjectCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "line_items": { + Type: "array", + Items: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "unit_price": {Type: "string"}, + "unitPrice": {Type: "string"}, + }, + }, + }, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for a field-name collision inside array items") + } + + for _, want := range []string{"unit_price", "unitPrice", `FieldOverrides["Order.line_items.items.unit_price"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestGenerateFailsOnAdditionalPropertiesValueInlineObjectCollision: an +// inline object used as an `additionalProperties` VALUE schema +// (Order.extras, a map whose values are inline objects). schemaToTSType's +// "object" case's `allowed` branches call schemaToTSType on the value +// schema, which hits objectPropsLiteral the same way. codecIDFor registers +// such a value schema under ".values", reused here. +func TestGenerateFailsOnAdditionalPropertiesValueInlineObjectCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "extras": { + Type: "object", + AdditionalProperties: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "display_name": {Type: "string"}, + "displayName": {Type: "string"}, + }, + }, + }, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for a field-name collision inside an additionalProperties value schema") + } + + for _, want := range []string{"display_name", "displayName", `FieldOverrides["Order.extras.values.display_name"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestGenerateFailsOnOneOfInlineMemberFieldCollision: an inline (non-$ref) +// object as a member of a oneOf (Order.payment). schemaToTSType's OneOf +// handling calls schemaToTSType on every member regardless of whether it has +// a $ref, so an inline member with Properties renders via objectPropsLiteral +// exactly like the other shapes above. Unlike the three shapes above, +// codecIDFor/unionEntry does NOT register non-$ref union members under any +// id at all today (it only extracts $ref members for the discriminator +// mapping) -- there is no existing scheme to reuse here, so this test pins +// the "oneOf" token the walk invents for this one shape. +func TestGenerateFailsOnOneOfInlineMemberFieldCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "payment": { + OneOf: []*client.Schema{ + { + Type: "object", + Properties: map[string]*client.Schema{ + "card_number": {Type: "string"}, + "cardNumber": {Type: "string"}, + }, + }, + {Type: "string"}, + }, + }, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for a field-name collision inside a oneOf inline member") + } + + for _, want := range []string{"card_number", "cardNumber", `FieldOverrides["Order.payment.oneOf0.card_number"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestGenerateAllowsNestedCollisionAcrossDifferentParents: the same wire +// names colliding under two different inline namespaces (Order.shipping and +// Order.billing) must not error against each other -- each inline object is +// its own namespace, exactly like two different top-level schemas. +func TestGenerateAllowsNestedCollisionAcrossDifferentParents(t *testing.T) { + addr := func() *client.Schema { + return &client.Schema{Type: "string"} + } + + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "shipping": {Type: "object", Properties: map[string]*client.Schema{"street_name": addr()}}, + "billing": {Type: "object", Properties: map[string]*client.Schema{"streetName": addr()}}, + }, + }, + }, + } + + if err := checkFieldNameCollisions(spec, collisionConfig()); err != nil { + t.Fatalf("expected no error for a collision across two different inline namespaces, got: %v", err) + } +} + +// TestGenerateFailsOnAllOfInlineMemberFieldCollision: an inline (non-$ref) +// object as an allOf member (Order.combined). Not explicitly requested by +// the review, but added for the same reason as oneOf/anyOf: schemaToTSType's +// AllOf handling (generator.go's schemaToTSType, the "&"-joined branch) +// calls schemaToTSType on every member exactly like OneOf/AnyOf do, so an +// inline allOf member is an identical collision surface and the walk above +// treats it the same way (".allOf"). +func TestGenerateFailsOnAllOfInlineMemberFieldCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "combined": { + AllOf: []*client.Schema{ + { + Type: "object", + Properties: map[string]*client.Schema{ + "full_name": {Type: "string"}, + "fullName": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for a field-name collision inside an allOf inline member") + } + + for _, want := range []string{"full_name", "fullName", `FieldOverrides["Order.combined.allOf0.full_name"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} From 30db1c37ed4cb439fb2516907b0b7d638be1f8de Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 15:35:22 -0500 Subject: [PATCH 43/71] feat(client/typescript): resolve undiscriminated unions structurally An undiscriminated union (oneOf/anyOf with no discriminator) no longer degrades to {kind:"passthrough"}. It now emits {kind:"union", members:[...]} with no discriminator key, and the runtime tries each member id in declared order, picking the first whose required wire fields are all present on the value. No match still falls back to passthrough -- never a best-effort guess. Generation records one warning per undiscriminated union, naming the schema. Required-field data now reaches the table via a new codecEntry.Required (sorted, filtered to keys the schema actually declares), computed for every object entry so union members can be tested structurally. Also closes the "inline union member gets no codec id" gap: unionEntry previously only registered $ref members, silently skipping any inline (non-$ref) oneOf/anyOf member -- it could never be selected no matter how well a payload matched it. Inline members now get a synthetic id (".oneOf"/".anyOf"), reusing the exact scheme checkSchemaFieldCollisions (fieldname.go) already defines for this namespace. This fix lives in the same function rewritten for structural matching and could not be meaningfully separated from it. CodecGenerator.Generate now returns (string, []string) -- code plus sorted warnings -- rather than adding a logger dependency or a package-level global. Generator.Generate forwards them onto a new GeneratedClient.Warnings field, and OutputManager.WriteClient prints them to stderr, the one real sink that already exists for a generation run's out-of-band output. Execution-tested via the existing esbuild+Node harness (TestCodecRuntimeUndiscriminatedUnionStructuralMatch): a payload matching only one member's required fields decodes as that member: a payload matching neither passes through verbatim (proven by reference identity); a payload matching both picks the first declared, deterministically; and the inline member fix is proven by its own subtree actually being walked (a new array reference) rather than skipped. --- internal/client/generators/interface.go | 7 + .../client/generators/typescript/codecs.go | 156 +++++++++-- .../generators/typescript/codecs_test.go | 255 ++++++++++++++++-- .../client/generators/typescript/generator.go | 4 +- internal/client/output.go | 10 + 5 files changed, 388 insertions(+), 44 deletions(-) diff --git a/internal/client/generators/interface.go b/internal/client/generators/interface.go index a2c3e52f..cf111263 100644 --- a/internal/client/generators/interface.go +++ b/internal/client/generators/interface.go @@ -43,6 +43,13 @@ type GeneratedClient struct { // Version is the generated client version Version string + + // Warnings lists generation-time warnings that do not abort generation + // but are worth surfacing to whoever runs it -- e.g. an undiscriminated + // union resolved via structural matching rather than a tag, which is + // ambiguity a caller may want to know about. Ordered deterministically + // by whichever generator produced them. + Warnings []string } // Dependency represents a required dependency. diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 9e60d437..d469f69f 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -32,16 +32,26 @@ func NewCodecGenerator() *CodecGenerator { type codecEntry struct { Kind string `json:"kind"` - // object + // object (and allOf, which codecs as an object -- see allOfEntry) Fields map[string]codecField `json:"fields,omitempty"` + // Required lists which of Fields' WIRE names must be present on the + // value -- the data an undiscriminated union match tests against. Kept + // sorted so the table (and the union match order it feeds) is + // deterministic regardless of what order Required appeared in the + // source schema, or what order multiple allOf members contributed it. + Required []string `json:"required,omitempty"` + // array Items string `json:"items,omitempty"` // record Values string `json:"values,omitempty"` - // union + // union. Discriminator is absent for an undiscriminated union: Members + // is still populated, and the runtime falls back to trying each one + // structurally in order (see codecRuntime's 'union' case) rather than + // having nothing to decode against at all. Discriminator *codecDiscriminator `json:"discriminator,omitempty"` Members []string `json:"members,omitempty"` } @@ -63,6 +73,13 @@ type codecDiscriminator struct { // byte-identical across runs. type codecTable struct { entries map[string]codecEntry + + // warnings accumulates generation-time messages that don't abort + // generation but are worth surfacing -- currently just "this union has + // no discriminator". Sorted before being handed back to the caller (see + // CodecGenerator.Generate) so callers get a stable order regardless of + // the recursion shape that produced them. + warnings []string } // refName extracts the schema name from a "#/components/schemas/X" pointer. @@ -133,7 +150,7 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) switch { case len(schema.OneOf) > 0 || len(schema.AnyOf) > 0: - t.entries[id] = t.unionEntry(schema) + t.entries[id] = t.unionEntry(id, schema, spec) return case schema.Type == "array" && schema.Items != nil: @@ -156,7 +173,7 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) } } - t.entries[id] = codecEntry{Kind: "object", Fields: fields} + t.entries[id] = codecEntry{Kind: "object", Fields: fields, Required: requiredWireFields(schema.Properties, schema.Required)} return } @@ -176,26 +193,51 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) // passthrough reserved above. } -// unionEntry builds a union entry. A union WITHOUT a discriminator degrades -// to passthrough: with no tag to switch on there is no way to know which -// member's field map applies, and guessing by structural shape would rename -// fields based on a match that may be wrong. -func (t *codecTable) unionEntry(schema *client.Schema) codecEntry { +// unionEntry builds a union entry. WITH a discriminator, decode can switch +// directly on its wire value. WITHOUT one, there is no tag to switch on, so +// the runtime instead tries each member in declared order and picks the +// first whose required wire fields are all present on the value (see +// codecRuntime's 'union' case) -- never a best-effort guess: no match falls +// back to passthrough. Because that ambiguity is real (a payload could +// structurally satisfy more than one member, or none), the caller records a +// warning naming this schema so it isn't silently invisible. +func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.APISpec) codecEntry { members := schema.OneOf + token := "oneOf" if len(members) == 0 { members = schema.AnyOf + token = "anyOf" } memberIDs := make([]string, 0, len(members)) - for _, member := range members { + for i, member := range members { + if member == nil { + continue + } + if name := refName(member.Ref); name != "" { memberIDs = append(memberIDs, name) + continue } + + // Gap 1: an inline (non-$ref) member previously got no id at all here + // and was silently skipped -- it could never be selected, structurally + // or otherwise, no matter how well a payload matched it. The synthetic + // id reuses the exact ".oneOf"/".anyOf" scheme + // checkSchemaFieldCollisions (fieldname.go) already defines for this + // namespace, so the two agree on what an inline union member is called. + synthetic := fmt.Sprintf("%s.%s%d", id, token, i) + t.add(synthetic, spec, member) + memberIDs = append(memberIDs, synthetic) } if schema.Discriminator == nil || schema.Discriminator.PropertyName == "" { - return codecEntry{Kind: "passthrough"} + t.warnings = append(t.warnings, fmt.Sprintf( + "schema %q: union has no discriminator; members will be tried in declared order and matched by required wire fields (no match falls back to passthrough) -- add a discriminator to remove the ambiguity", + id)) + + return codecEntry{Kind: "union", Members: memberIDs} } mapping := make(map[string]string, len(schema.Discriminator.Mapping)) @@ -215,14 +257,50 @@ func (t *codecTable) unionEntry(schema *client.Schema) codecEntry { } } -// Generate emits src/codecs.ts. -func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) string { +// requiredWireFields returns schema.Required filtered to names that are +// actually keys of props and sorted for determinism. Filtering guards +// against a malformed schema listing a required name that isn't one of its +// own properties; sorting means the emitted `required` array -- and +// therefore the order a structural union match tests fields in -- never +// depends on the order Required happened to be declared in. +func requiredWireFields(props map[string]*client.Schema, required []string) []string { + if len(required) == 0 { + return nil + } + + out := make([]string, 0, len(required)) + + for _, r := range required { + if _, ok := props[r]; ok { + out = append(out, r) + } + } + + sort.Strings(out) + + return out +} + +// Generate emits src/codecs.ts. The second return value lists +// generation-time warnings (currently: an undiscriminated union was found +// and will be resolved structurally at runtime rather than by a +// discriminator) -- returning them on this existing return path, rather +// than adding a logger dependency or a package-level global, is what keeps +// CodecGenerator a pure function callers (and tests) can call directly with +// no setup. The top-level Generator.Generate (generator.go) forwards these +// onto GeneratedClient.Warnings, which is the one place a caller already +// looks for out-of-band information about a generation run. Warnings are +// sorted before being returned, so their order is deterministic regardless +// of the schema walk's recursion shape. +func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) (string, []string) { table := &codecTable{entries: map[string]codecEntry{}} for _, name := range sortedKeys(spec.Schemas) { table.add(name, spec, spec.Schemas[name]) } + sort.Strings(table.warnings) + var buf strings.Builder buf.WriteString("// Generated codec table\n") @@ -234,10 +312,10 @@ func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorC buf.WriteString("// renaming on is a change of names, not a change of machinery.\n\n") buf.WriteString("export type Codec =\n") - buf.WriteString(" | { kind: 'object'; fields: Record }\n") + buf.WriteString(" | { kind: 'object'; fields: Record; required?: string[] }\n") buf.WriteString(" | { kind: 'array'; items?: string }\n") buf.WriteString(" | { kind: 'record'; values?: string }\n") - buf.WriteString(" | { kind: 'union'; discriminator: { wire: string; map: Record }; members: string[] }\n") + buf.WriteString(" | { kind: 'union'; discriminator?: { wire: string; map: Record }; members: string[] }\n") buf.WriteString(" | { kind: 'passthrough' };\n\n") buf.WriteString("export const CODECS: Record = {\n") @@ -263,7 +341,7 @@ func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorC buf.WriteString(codecRuntime) - return buf.String() + return buf.String(), table.warnings } // sortedCodecIDs orders table keys deterministically. Synthetic ids contain @@ -296,15 +374,18 @@ func spacedJSON(s string) string { return strings.ReplaceAll(s, `,"`, `, "`) } -// codecRuntime is the emitted encode/decode implementation. The three rules -// it enforces are load-bearing: +// codecRuntime is the emitted encode/decode implementation. The rules it +// enforces are load-bearing: // // - unknown keys pass through verbatim, so a server that adds a field does // not have it silently dropped by an older client; // - `record` renames its VALUES but never its KEYS, because a record's keys // are data (user-chosen ids), not schema-defined field names; -// - a union with no discriminator falls back to passthrough rather than -// guessing a member by structural shape. +// - a union WITH a discriminator resolves by its tag, exactly as before; +// - a union WITHOUT one tries each declared member in order, structurally: +// the first whose required wire fields are all present on the value +// wins. No match falls back to passthrough -- never a best-effort guess, +// because guessing could rename fields based on a match that is wrong. const codecRuntime = `function codecFor(id?: string): Codec | undefined { return id ? CODECS[id] : undefined; } @@ -377,17 +458,38 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { return value; } - const tag = (value as Record)[codec.discriminator.wire]; - if (typeof tag !== 'string') { - return value; + const src = value as Record; + + if (codec.discriminator) { + const tag = src[codec.discriminator.wire]; + if (typeof tag !== 'string') { + return value; + } + + const memberID = codec.discriminator.map[tag]; + if (!memberID) { + return value; + } + + return walk(value, memberID, toTS); } - const memberID = codec.discriminator.map[tag]; - if (!memberID) { - return value; + // No discriminator: try each member in the order it was declared, + // taking the first whose required wire fields are ALL present on the + // value. A member with no required fields matches unconditionally, + // which is why it winning as the FIRST declared member -- rather than + // being tried last as a catch-all -- is exactly the deterministic, + // declared-order behaviour this is meant to provide. + for (const memberID of codec.members ?? []) { + const member = codecFor(memberID); + const required = member && member.kind === 'object' ? (member.required ?? []) : []; + if (required.every((field) => Object.prototype.hasOwnProperty.call(src, field))) { + return walk(value, memberID, toTS); + } } - return walk(value, memberID, toTS); + // No member matched: pass through verbatim rather than guess. + return value; } default: diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index 8cef19bd..ca065dc2 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -2,6 +2,7 @@ package typescript import ( "context" + "slices" "strings" "testing" "time" @@ -29,7 +30,7 @@ func nestedSpec() *client.APISpec { } func TestCodecTableShape(t *testing.T) { - code := NewCodecGenerator().Generate(nestedSpec(), baseConfig()) + code, _ := NewCodecGenerator().Generate(nestedSpec(), baseConfig()) assert.Contains(t, code, "export const CODECS:") assert.Contains(t, code, `"User":`) @@ -46,7 +47,7 @@ func TestCodecTableShape(t *testing.T) { // property is added; deriving from the parent id and property name keeps // each id stable for as long as that property exists. func TestCodecTableSyntheticIDsAreDerivedFromPath(t *testing.T) { - code := NewCodecGenerator().Generate(nestedSpec(), baseConfig()) + code, _ := NewCodecGenerator().Generate(nestedSpec(), baseConfig()) assert.Contains(t, code, `"Nested.items":`, "an inline array property needs its own entry, keyed by parent and property name") assert.Contains(t, code, `"Nested.tags":`, "an inline record property needs its own entry") @@ -56,10 +57,12 @@ func TestCodecTableSyntheticIDsAreDerivedFromPath(t *testing.T) { assert.Contains(t, code, `"codec": "User"`) } -// TestCodecTableUnionRequiresDiscriminator covers the runtime rule that a -// union with no discriminator degrades to passthrough. Guessing a member by -// structural shape could rename fields based on a wrong match, which is -// worse than leaving the payload alone. +// TestCodecTableUnionRequiresDiscriminator covers the table shape for both +// union forms: WITH a discriminator, the entry carries the discriminator +// map; WITHOUT one, the entry still emits `members` (for structural +// matching at runtime -- see TestCodecRuntimeUndiscriminatedUnion) but omits +// `discriminator` entirely, and generation emits a warning naming the +// schema so the ambiguity is visible. func TestCodecTableUnionRequiresDiscriminator(t *testing.T) { withDisc := baseSpec() withDisc.Schemas["Pet"] = &client.Schema{ @@ -78,9 +81,10 @@ func TestCodecTableUnionRequiresDiscriminator(t *testing.T) { withDisc.Schemas["Cat"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"meows": {Type: "boolean"}}} withDisc.Schemas["Dog"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"barks": {Type: "boolean"}}} - code := NewCodecGenerator().Generate(withDisc, baseConfig()) + code, warnings := NewCodecGenerator().Generate(withDisc, baseConfig()) assert.Contains(t, code, `"kind": "union"`) assert.Contains(t, code, `"wire": "kind"`) + assert.Empty(t, warnings, "a discriminated union must not warn") // Same union, no discriminator. noDisc := baseSpec() @@ -93,9 +97,17 @@ func TestCodecTableUnionRequiresDiscriminator(t *testing.T) { noDisc.Schemas["Cat"] = withDisc.Schemas["Cat"] noDisc.Schemas["Dog"] = withDisc.Schemas["Dog"] - code = NewCodecGenerator().Generate(noDisc, baseConfig()) - assert.NotContains(t, code, `"kind": "union"`) - assert.Contains(t, code, `"Pet": {"kind": "passthrough"}`) + code, warnings = NewCodecGenerator().Generate(noDisc, baseConfig()) + + // members present, discriminator absent -- exact string match on the + // emitted entry pins the field ORDER too (Kind then Members, nothing + // between them), which is only true when Discriminator marshals to + // nothing (omitempty on a nil pointer). + assert.Contains(t, code, `"Pet": {"kind": "union", "members": ["Cat", "Dog"]}`) + + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], `"Pet"`, "the warning must name the schema") + assert.Contains(t, warnings[0], "discriminator") } // TestCodecTableHandlesSelfReference is the non-termination guard. A schema @@ -112,7 +124,10 @@ func TestCodecTableHandlesSelfReference(t *testing.T) { } done := make(chan string, 1) - go func() { done <- NewCodecGenerator().Generate(spec, baseConfig()) }() + go func() { + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + done <- code + }() select { case code := <-done: @@ -129,11 +144,45 @@ func TestCodecTableHandlesSelfReference(t *testing.T) { func TestCodecTableIsDeterministic(t *testing.T) { spec := nestedSpec() - first := NewCodecGenerator().Generate(spec, baseConfig()) + first, firstWarnings := NewCodecGenerator().Generate(spec, baseConfig()) for i := range 12 { - if got := NewCodecGenerator().Generate(spec, baseConfig()); got != first { - t.Fatalf("run %d differs", i) + got, gotWarnings := NewCodecGenerator().Generate(spec, baseConfig()) + if got != first { + t.Fatalf("run %d code differs", i) + } + + if !slices.Equal(gotWarnings, firstWarnings) { + t.Fatalf("run %d warnings differ: %v vs %v", i, gotWarnings, firstWarnings) + } + } +} + +// TestCodecTableWarningOrderIsDeterministic pins determinism for the case +// TestCodecTableIsDeterministic's spec doesn't exercise: MULTIPLE +// undiscriminated unions in one spec. Warnings are collected across a +// recursive, sorted-key walk, not a single flat map, so this is the test +// that would catch an accidental map-iteration dependency in how they're +// gathered or ordered. +func TestCodecTableWarningOrderIsDeterministic(t *testing.T) { + spec := baseSpec() + spec.Schemas["Zebra"] = &client.Schema{ + OneOf: []*client.Schema{{Ref: "#/components/schemas/User"}}, + } + spec.Schemas["Alpha"] = &client.Schema{ + OneOf: []*client.Schema{{Ref: "#/components/schemas/User"}}, + } + spec.Schemas["Mid"] = &client.Schema{ + OneOf: []*client.Schema{{Ref: "#/components/schemas/User"}}, + } + + _, first := NewCodecGenerator().Generate(spec, baseConfig()) + require.Len(t, first, 3, "one warning per undiscriminated union") + + for i := range 12 { + _, got := NewCodecGenerator().Generate(spec, baseConfig()) + if !slices.Equal(got, first) { + t.Fatalf("run %d warning order differs: %v vs %v", i, got, first) } } } @@ -146,16 +195,40 @@ func TestCodecsEmittedAndExported(t *testing.T) { assert.Contains(t, out.Files["src/index.ts"], "export * from './codecs';") } +// TestCodecWarningsSurfaceOnGeneratedClient pins the chosen warnings +// mechanism end to end: CodecGenerator.Generate returns warnings on its +// existing return path (a second value, alongside the emitted code) rather +// than a logger or a package-level global, and the top-level +// Generator.Generate forwards them onto GeneratedClient.Warnings so a +// caller (e.g. the CLI) can act on them without reaching into the +// typescript package's internals. +func TestCodecWarningsSurfaceOnGeneratedClient(t *testing.T) { + spec := baseSpec() + spec.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/User"}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + require.Len(t, out.Warnings, 1) + assert.Contains(t, out.Warnings[0], `"Pet"`) +} + // TestCodecRuntimeRulesArePresent pins the three non-negotiable runtime // behaviours in the emitted walk. The behavioural proof is the execution // test; this catches an accidental removal without a Node round trip. func TestCodecRuntimeRulesArePresent(t *testing.T) { - code := NewCodecGenerator().Generate(baseSpec(), baseConfig()) + code, _ := NewCodecGenerator().Generate(baseSpec(), baseConfig()) assert.Contains(t, code, "out[key] = val;", "unknown keys must pass through verbatim") assert.Contains(t, code, "Keys are data here", "a record must rename values but never keys") assert.True(t, strings.Contains(code, "if (typeof tag !== 'string')"), "a union whose discriminator value is missing or non-string must fall back to passthrough") + assert.Contains(t, code, "hasOwnProperty", + "an undiscriminated union must test each member's required wire fields structurally") } // TestCodecRuntimeBehaviour is the execution proof for the three @@ -192,8 +265,9 @@ func TestCodecRuntimeBehaviour(t *testing.T) { } dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) writeTree(t, dir, map[string]string{ - "src/codecs.ts": NewCodecGenerator().Generate(spec, baseConfig()), + "src/codecs.ts": code, }) driver := ` @@ -283,3 +357,152 @@ console.log(JSON.stringify(results)); assert.Equal(t, map[string]any{"a": float64(1)}, got.UnknownCodec, "an unknown codec id must leave the value alone rather than throw") } + +// structuralUnionSpec builds Shape and MaybeLabeled, which +// TestCodecRuntimeUndiscriminatedUnionStructuralMatch exercises: +// +// - Shape: OneOf[Square, Circle], no discriminator, disjoint required +// fields ("side" vs "radius") -- covers matching B-not-A, matching +// neither, and matching both (first declared wins). +// - MaybeLabeled: OneOf[Labeled ($ref), inline{required: special}] -- Gap +// 1, an inline (non-$ref) union member. +// +// Every schema that needs to prove "its fields were actually walked" carries +// an array property with $ref items: the array codec case always rebuilds +// the array via `.map`, so a fresh reference on decode is only possible if +// the walk actually reached that subtree. An untouched (still-identical) +// reference means the value passed through without being recognised at all +// -- exactly the failure mode both "no match" and Gap 1 produce. +func structuralUnionSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["Square"] = &client.Schema{ + Type: "object", + Required: []string{"side"}, + Properties: map[string]*client.Schema{ + "side": {Type: "number"}, + "tags": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + } + spec.Schemas["Circle"] = &client.Schema{ + Type: "object", + Required: []string{"radius"}, + Properties: map[string]*client.Schema{"radius": {Type: "number"}}, + } + spec.Schemas["Shape"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Square"}, + {Ref: "#/components/schemas/Circle"}, + }, + } + + spec.Schemas["Labeled"] = &client.Schema{ + Type: "object", + Required: []string{"label"}, + Properties: map[string]*client.Schema{"label": {Type: "string"}}, + } + spec.Schemas["MaybeLabeled"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Labeled"}, + { + Type: "object", + Required: []string{"special"}, + Properties: map[string]*client.Schema{ + "special": {Type: "boolean"}, + "list": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + }, + }, + } + + return spec +} + +// TestCodecRuntimeUndiscriminatedUnionStructuralMatch is the execution proof +// for structural matching (including Gap 1, the inline union member) -- +// string assertions on the emitted table can show `members` is populated, +// but only running the walk shows it picks the right member, falls back +// correctly, and reaches the subtree Gap 1 previously skipped. +// +// The positive control -- a DISCRIMINATED union still resolving by tag, not +// falling into structural matching -- is covered by the existing, unchanged +// TestCodecRuntimeBehaviour ("tagged"/"Pet"); it is not repeated here. +func TestCodecRuntimeUndiscriminatedUnionStructuralMatch(t *testing.T) { + spec := structuralUnionSpec() + + dir := t.TempDir() + code, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + // Two undiscriminated unions in this spec: Shape and MaybeLabeled. + require.Len(t, warnings, 2) + + driver := ` +import { decode } from './codecs'; + +const results: Record = {}; + +// Matches Circle's required field ("radius") but not Square's ("side"). +const onlyCircle = { radius: 3 }; +const decodedCircle = decode(onlyCircle, 'Shape'); +results.onlyCircleIsCopy = decodedCircle !== onlyCircle; +results.onlyCircleValue = decodedCircle; + +// Matches neither Square nor Circle: falls back to passthrough verbatim, +// proven by reference identity -- a genuinely matched member always +// constructs a new object via the 'object' codec case. +const neither = { weight: 9 }; +const decodedNeither = decode(neither, 'Shape'); +results.neitherIsIdentity = decodedNeither === neither; +results.neitherValue = decodedNeither; + +// Matches BOTH members. Square is declared first and must win. Proven by +// 'tags' (declared only on Square, with its own array codec) coming back as +// a NEW array -- if Circle had won instead, 'tags' would be an unrecognised +// key on Circle and would pass through by the same reference. +const both = { side: 1, radius: 2, tags: [{ id: 'x' }] }; +const decodedBoth = decode(both, 'Shape'); +results.bothPicksFirstDeclared = decodedBoth.tags !== both.tags; + +// Gap 1: the inline (non-$ref) union member. This payload satisfies ONLY +// the inline member's required field ("special"), not the $ref member's +// ("label"). Before the fix the inline member had no codec id at all, so +// it could never be selected regardless of the payload -- this would fall +// through to passthrough (identity) instead of being walked. +const inlinePayload = { special: true, list: [{ id: 'y' }] }; +const decodedInline = decode(inlinePayload, 'MaybeLabeled'); +results.inlineMemberWalked = decodedInline.list !== inlinePayload.list; +results.inlineMemberIsCopy = decodedInline !== inlinePayload; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_structural_union.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_structural_union.ts") + + var got struct { + OnlyCircleIsCopy bool `json:"onlyCircleIsCopy"` + OnlyCircleValue map[string]any `json:"onlyCircleValue"` + NeitherIsIdentity bool `json:"neitherIsIdentity"` + NeitherValue map[string]any `json:"neitherValue"` + BothPicksFirstDeclared bool `json:"bothPicksFirstDeclared"` + InlineMemberWalked bool `json:"inlineMemberWalked"` + InlineMemberIsCopy bool `json:"inlineMemberIsCopy"` + } + decodeLastLine(t, stdout, &got) + + assert.True(t, got.OnlyCircleIsCopy, + "a payload matching only Circle's required fields must be walked (decoded as a member), not passed through; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"radius": float64(3)}, got.OnlyCircleValue) + + assert.True(t, got.NeitherIsIdentity, + "a payload matching no member must pass through verbatim, never a best-effort guess; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"weight": float64(9)}, got.NeitherValue) + + assert.True(t, got.BothPicksFirstDeclared, + "when a payload matches every member, the first declared in the union must win, deterministically; driver stdout:\n%s", stdout) + + assert.True(t, got.InlineMemberWalked, + "Gap 1: an inline (non-$ref) union member must get a codec id and actually be walked; driver stdout:\n%s", stdout) + assert.True(t, got.InlineMemberIsCopy) +} diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index fb68b91f..46997918 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -297,7 +297,9 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, // Generate the codec table. Always emitted for now: the config field // that will let a caller opt out (preserving wire casing, making the // table pointless) does not exist yet. - genClient.Files["src/codecs.ts"] = NewCodecGenerator().Generate(spec, config) + codecCode, codecWarnings := NewCodecGenerator().Generate(spec, config) + genClient.Files["src/codecs.ts"] = codecCode + genClient.Warnings = append(genClient.Warnings, codecWarnings...) // Generate WebSocket clients if len(spec.WebSockets) > 0 && config.IncludeStreaming { diff --git a/internal/client/output.go b/internal/client/output.go index c45ecbea..dd0803d2 100644 --- a/internal/client/output.go +++ b/internal/client/output.go @@ -19,6 +19,16 @@ func NewOutputManager() *OutputManager { // WriteClient writes the generated client to disk. func (m *OutputManager) WriteClient(client *generators.GeneratedClient, outputDir string) error { + // Surface generation-time warnings (e.g. an undiscriminated union + // resolved structurally rather than by a discriminator) to whoever ran + // the generator. Neither CodecGenerator nor GeneratedClient has a + // logger dependency of its own, so stderr -- here, at the one place + // that already writes the generated output somewhere the caller will + // see it -- is the sink, not a package-level global. + for _, w := range client.Warnings { + fmt.Fprintf(os.Stderr, "warning: %s\n", w) + } + // Create output directory with restrictive permissions if err := os.MkdirAll(outputDir, 0750); err != nil { return fmt.Errorf("create output directory: %w", err) From dd2ec34a87d3c63469e8702064b556a2efe17198 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 15:38:15 -0500 Subject: [PATCH 44/71] fix(client/typescript): recognize allOf as compositional in the codec table codecIDFor's switch tested array, len(Properties) > 0, and len(OneOf) > 0 || len(AnyOf) > 0, with no AllOf case -- a property whose schema is a pure allOf composition (no Properties of its own, just AllOf) fell through every case and got "" (no codec), leaving it unwalked even though it renders as a real intersection type on the TypeScript side. Once property renaming lands, that's a silent data-shape mismatch: the type says camelCase, the runtime value stays wire-cased, and the compiler has no way to catch it. allOf now codecs as the existing "object" kind: a merged fields map (plus a merged, deduplicated required list) built from every member's Properties/Required -- a $ref member's fields are read directly from the named schema (which is independently registered and walked in its own right), an inline member's fields are merged straight in. A field declared by more than one member resolves last-declared-wins, with the schema's own Properties (which allOf permits alongside AllOf) applied last as the most specific layer. This reuses "object" rather than adding a new `kind` that would need its own, functionally identical, runtime case: the TypeScript side already renders allOf as an intersection (A & B & ...), and a JSON value satisfying an intersection type is one flat object with no wrapper or tag distinguishing which member contributed which field. Execution-tested via the existing esbuild+Node harness (TestCodecRuntimeAllOfPropertyIsWalked): a property whose schema is a pure allOf now gets a codec id and is actually walked (proven by a nested array coming back as a new reference, not the original), and the $ref member's contributed field still survives decode. --- .../client/generators/typescript/codecs.go | 96 ++++++++++++++++++- .../generators/typescript/codecs_test.go | 67 +++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index d469f69f..46ef5ce9 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -117,7 +117,12 @@ func (t *codecTable) codecIDFor(parentID, prop string, schema *client.Schema, sp case len(schema.Properties) > 0: t.add(synthetic, spec, schema) return synthetic - case len(schema.OneOf) > 0 || len(schema.AnyOf) > 0: + case len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 || len(schema.AllOf) > 0: + // AllOf included alongside OneOf/AnyOf (Gap 2): a property whose + // schema is a pure allOf composition -- no Properties of its own, + // only AllOf -- would otherwise fall through every case above and + // return "" (no codec), leaving it unwalked even though it renders + // as a real object (an intersection type) on the TypeScript side. t.add(synthetic, spec, schema) return synthetic } @@ -153,6 +158,10 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) t.entries[id] = t.unionEntry(id, schema, spec) return + case len(schema.AllOf) > 0: + t.entries[id] = t.allOfEntry(id, schema, spec) + return + case schema.Type == "array" && schema.Items != nil: t.entries[id] = codecEntry{ Kind: "array", @@ -257,6 +266,91 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A } } +// allOfEntry builds an object entry for an allOf composition. The +// TypeScript side renders allOf as an intersection (schemaToTSType joins +// members with " & "), and a JSON value satisfying an intersection type is +// one flat object carrying every member's fields at once -- there is no +// wrapper or tag distinguishing which member contributed which field. That +// is exactly what the 'object' kind already models (a flat field map plus +// which of them are required), so this reuses it rather than adding a new +// `kind` that would need its own, functionally identical, runtime case. +// +// A field declared by more than one member -- or by the schema's own +// Properties, which allOf permits alongside its members even though it's +// unusual -- resolves last-declared-wins: allOf is conventionally read as +// "base type, then extension", so a field the extension redeclares is meant +// to override the base's version of it. The schema's own Properties are +// treated as the final, most-specific layer and applied after every member. +// +// Required is the UNION of every member's required list (plus the schema's +// own), not an intersection: satisfying allOf means satisfying every member +// simultaneously, so a field required by any one of them is required on the +// composed value. +// +// A member that resolves to another schema which is itself a further +// composition (its own oneOf/anyOf/allOf with no direct Properties) is not +// flattened recursively -- only its immediate Properties/Required are +// merged in. This is a known scope boundary, not a silent bug: such a +// member contributes no properties, which for an otherwise plain allOf is +// the same outcome codecIDFor already accepts for an empty-Properties +// schema (passthrough-equivalent, since walking an empty field map still +// preserves every wire key via the "unknown key" path). +func (t *codecTable) allOfEntry(id string, schema *client.Schema, spec *client.APISpec) codecEntry { + fields := map[string]codecField{} + required := map[string]bool{} + + merge := func(props map[string]*client.Schema, req []string) { + for _, prop := range sortedKeys(props) { + fields[prop] = codecField{ + TS: prop, + Codec: t.codecIDFor(id, prop, props[prop], spec), + } + } + + for _, r := range req { + if _, ok := props[r]; ok { + required[r] = true + } + } + } + + for _, member := range schema.AllOf { + if member == nil { + continue + } + + if name := refName(member.Ref); name != "" { + // A $ref member: merge the NAMED schema's own fields. The named + // schema is also, separately, registered and walked in its own + // right by the top-level sortedKeys(spec.Schemas) loop -- this + // merge only borrows its shape, it does not re-add it. + if resolved := spec.Schemas[name]; resolved != nil { + merge(resolved.Properties, resolved.Required) + } + + continue + } + + // An inline member: merge its own fields directly. There is nothing + // further to register here -- unlike a union member, an allOf + // member's fields are flattened straight into this entry rather than + // walked through a separate delegated codec, so no synthetic id for + // the member itself would ever be referenced. + merge(member.Properties, member.Required) + } + + merge(schema.Properties, schema.Required) + + sortedRequired := make([]string, 0, len(required)) + for r := range required { + sortedRequired = append(sortedRequired, r) + } + + sort.Strings(sortedRequired) + + return codecEntry{Kind: "object", Fields: fields, Required: sortedRequired} +} + // requiredWireFields returns schema.Required filtered to names that are // actually keys of props and sorted for determinism. Filtering guards // against a malformed schema listing a required name that isn't one of its diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index ca065dc2..2acfde9c 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -506,3 +506,70 @@ console.log(JSON.stringify(results)); "Gap 1: an inline (non-$ref) union member must get a codec id and actually be walked; driver stdout:\n%s", stdout) assert.True(t, got.InlineMemberIsCopy) } + +// TestCodecRuntimeAllOfPropertyIsWalked is the execution proof for Gap 2: a +// property whose schema is a pure allOf (no schema.Properties of its own) +// must get a codec id and actually be walked, not silently skipped. +// +// Container.composed carries an array property ($ref items) so a successful +// walk is provable by reference identity: the array codec case always +// rebuilds the array via `.map`, so a fresh reference after decode is only +// possible if the walk actually reached that subtree. +func TestCodecRuntimeAllOfPropertyIsWalked(t *testing.T) { + spec := baseSpec() + spec.Schemas["Base"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"baseField": {Type: "string"}}, + } + spec.Schemas["Container"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "composed": { + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/Base"}, + { + Type: "object", + Properties: map[string]*client.Schema{ + "list": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + }, + }, + }, + }, + } + + dir := t.TempDir() + code, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + assert.Empty(t, warnings, "a pure allOf composition is not ambiguous the way an undiscriminated union is; it must not warn") + + driver := ` +import { decode } from './codecs'; + +const results: Record = {}; + +// 'composed' has no schema.Properties of its own -- only AllOf -- so before +// the fix codecIDFor returned "" for it and it was never walked at all. +const containerPayload = { composed: { baseField: 'x', list: [{ id: 'z' }] } }; +const decodedContainer = decode(containerPayload, 'Container'); +results.allOfPropertyWalked = decodedContainer.composed.list !== containerPayload.composed.list; +results.baseFieldSurvived = decodedContainer.composed.baseField; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_allof.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_allof.ts") + + var got struct { + AllOfPropertyWalked bool `json:"allOfPropertyWalked"` + BaseFieldSurvived string `json:"baseFieldSurvived"` + } + decodeLastLine(t, stdout, &got) + + assert.True(t, got.AllOfPropertyWalked, + "Gap 2: a property whose schema is a pure allOf must get a codec id and actually be walked; driver stdout:\n%s", stdout) + assert.Equal(t, "x", got.BaseFieldSurvived, + "a field contributed by the $ref member of the allOf must still be present after decode") +} From 2c718c5def7f2f955ad5229ec0e28f7375db3933 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 16:12:23 -0500 Subject: [PATCH 45/71] fix(client/typescript): close two codec-table safety gaps found in review Review of the undiscriminated-union/allOf work found two Criticals and two Importants, all fixed here: - allOfEntry could emit {"kind":"object"} with NO fields key: a second-level allOf ($ref A -> allOf[$ref B], where B has no Properties of its own), the same shape reached via an inline nested allOf, or a dangling $ref member all produced an empty fields map. Since Fields is `json:"fields,omitempty"` but the emitted Codec type declares it required for kind:'object', this broke tsc ("not assignable to type 'Codec'") and threw at runtime (Object.entries(undefined)). Fixed via a new flattenAllOfLayers helper that recursively resolves a composition (through $ref hops and nested allOf, cycle-guarded on schema pointers) down to the schemas that actually own properties; if flattening still yields nothing, the entry now explicitly degrades to passthrough instead of an empty object. This also properly flattens nested allOf rather than merely not crashing on it. - Two allOf members declaring the same wire field with different nested shapes merged with silent last-write-wins -- no record that one member's shape (and everything nested under it) was discarded, which is real data loss once renaming lands, not just a lying type. flattenAllOfLayers now returns every contributing layer (not a pre-merged map) so allOfEntry can detect when two layers resolve a field to different codec ids and emit a warning naming the schema and field. Last-declared-wins is kept (documented, not silently accidental); merging the two nested codecs was rejected since two conflicting shapes have no general well-defined merge. - codecRuntime's union loop treated an evidence-free member (no required fields declared -- the OpenAPI default when `required` is omitted, or not an 'object' kind at all) as a vacuous match: [].every(...) is trivially true, so the FIRST such member silently became an unconditional catch-all, regardless of payload shape. Fixed by skipping members with no required fields to test, falling through to passthrough if none offer evidence. unionEntry also now eagerly registers a $ref member's table entry before checking it (idempotent), so the evidence-free warning is correct even when a union's own schema name sorts alphabetically before its member's. Added a "allof" gate fixture (three-level $ref inheritance chain, nested inline allOf, conflicting members) so this class of defect can't regress unnoticed the way it did the first time -- none of the original 8 fixtures touched allOf at all. A genuinely dangling $ref is deliberately not in this fixture: schemaToTSType has no ref-existence validation of its own (a pre-existing, orthogonal gap, not allOf-specific), so a fixture containing one could never satisfy zero tsc errors regardless of this fix; that case is covered directly against the codec table and bundled runtime instead. Also updates TestCodecRuntimeBehaviour's Untagged assertion to prove real passthrough via reference identity rather than a JSON-value comparison that couldn't distinguish a genuine passthrough from a coincidental vacuous match while ts === wire. --- .../client/generators/typescript/codecs.go | 268 +++++++--- .../generators/typescript/codecs_test.go | 459 +++++++++++++++++- .../generators/typescript/fixtures_test.go | 64 +++ 3 files changed, 705 insertions(+), 86 deletions(-) diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 46ef5ce9..9fa58df8 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -182,7 +182,7 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) } } - t.entries[id] = codecEntry{Kind: "object", Fields: fields, Required: requiredWireFields(schema.Properties, schema.Required)} + t.entries[id] = codecEntry{Kind: "object", Fields: fields, Required: requiredWireFields(fields, schema.Required)} return } @@ -226,6 +226,16 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A } if name := refName(member.Ref); name != "" { + // Force this member's entry to exist NOW rather than trusting the + // top-level sortedKeys(spec.Schemas) loop to reach it eventually. + // The evidence-free check just below inspects t.entries[name] -- + // if this union sorts alphabetically before its own member (e.g. + // schema "Alpha" referencing "Zebra"), that entry would not have + // been built yet, and would be misread as "no entry" (which the + // check below also treats as evidence-free, but for the wrong + // reason). t.add is idempotent -- a no-op if already built now + // or later -- so calling it eagerly here is always safe. + t.add(name, spec, spec.Schemas[name]) memberIDs = append(memberIDs, name) continue } @@ -246,6 +256,31 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A "schema %q: union has no discriminator; members will be tried in declared order and matched by required wire fields (no match falls back to passthrough) -- add a discriminator to remove the ambiguity", id)) + // A member that is not an 'object' kind, or is an object with no + // required fields, offers no evidence a structural match can test: + // codecRuntime's union case would otherwise treat an empty required + // list as vacuously satisfied by ANY payload, turning that member + // into an unconditional catch-all rather than a real test -- exactly + // the "best-effort guess" the whole feature exists to rule out. Such + // a member is skipped entirely at runtime (see codecRuntime), so a + // union whose first (or only) member is evidence-free degrades to + // permanent passthrough -- degenerate, and worth calling out by name + // rather than leaving the caller to notice via silent non-matching. + for _, memberID := range memberIDs { + entry, ok := t.entries[memberID] + + kind := "undefined" + if ok { + kind = entry.Kind + } + + if !ok || entry.Kind != "object" || len(entry.Required) == 0 { + t.warnings = append(t.warnings, fmt.Sprintf( + "schema %q: union member %q offers no required wire fields to match on (kind %q) and can never be selected by structural matching -- give it required fields or add a discriminator", + id, memberID, kind)) + } + } + return codecEntry{Kind: "union", Members: memberIDs} } @@ -266,6 +301,66 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A } } +// flattenAllOfLayers recursively resolves schema into an ordered list of the +// schemas that directly own the properties composing it: each AllOf member +// resolved through however many further $ref hops and nested AllOf +// compositions it takes to reach something with its own Properties, in the +// order those properties should be applied (earliest member first, the +// schema's own Properties -- which allOf permits alongside its members, +// unusual but legal -- last). This is what lets a three-level allOf +// inheritance chain (Outer.allOf[$ref Mid], where Mid.allOf[$ref Leaf], an +// ordinary OpenAPI pattern) resolve down to Leaf's actual fields, instead of +// stopping at Mid -- which has none of its own -- and silently producing an +// entry with no fields at all. +// +// Returning every contributing layer, rather than a single pre-merged map, +// is what lets allOfEntry notice when two layers declare the SAME wire +// field name with two DIFFERENT effective codecs, instead of silently +// letting the later layer win with no record that an earlier one's shape +// was discarded. +// +// A member that cannot be resolved at all -- a dangling $ref (the target +// name isn't in spec.Schemas), or a $ref in a shape refName doesn't +// recognise (e.g. a cross-file "./common.yaml#/Base") -- contributes no +// layers rather than panicking: the nil checks below turn "nothing found" +// into "no fields from this member", which allOfEntry's empty-result +// fallback (passthrough) then degrades safely instead of emitting a lying +// empty object. +// +// visited guards a schema-graph cycle -- through a $ref cycle (A allOf B, +// B allOf A) or a hand-built Go pointer cycle -- by tracking schema +// pointers already on the current resolution path; re-reaching one +// contributes no further layers rather than recursing forever. Because a +// named $ref always resolves to the SAME *client.Schema pointer from +// spec.Schemas, tracking pointers alone catches both cycle shapes with one +// mechanism. +func flattenAllOfLayers(schema *client.Schema, spec *client.APISpec, visited map[*client.Schema]bool) []*client.Schema { + if schema == nil || visited[schema] { + return nil + } + + visited[schema] = true + defer delete(visited, schema) + + if name := refName(schema.Ref); name != "" { + // spec.Schemas[name] is nil for a dangling ref; the nil check above + // turns that into "no layers" on the next call, not a crash. + return flattenAllOfLayers(spec.Schemas[name], spec, visited) + } + + var layers []*client.Schema + + for _, member := range schema.AllOf { + layers = append(layers, flattenAllOfLayers(member, spec, visited)...) + } + + if len(schema.Properties) > 0 { + layers = append(layers, schema) + } + + return layers +} + // allOfEntry builds an object entry for an allOf composition. The // TypeScript side renders allOf as an intersection (schemaToTSType joins // members with " & "), and a JSON value satisfying an intersection type is @@ -275,99 +370,114 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A // which of them are required), so this reuses it rather than adding a new // `kind` that would need its own, functionally identical, runtime case. // -// A field declared by more than one member -- or by the schema's own -// Properties, which allOf permits alongside its members even though it's -// unusual -- resolves last-declared-wins: allOf is conventionally read as -// "base type, then extension", so a field the extension redeclares is meant -// to override the base's version of it. The schema's own Properties are -// treated as the final, most-specific layer and applied after every member. +// A field declared by more than one layer resolves last-declared-wins: +// allOf is conventionally read as "base type, then extension", so a field +// the extension redeclares is meant to override the base's version of it. +// When two layers declare the SAME wire name with DIFFERENT effective +// codecs -- a real shape conflict, not just harmless duplication -- a +// warning names the schema and field: the TypeScript type is the +// intersection of both members, so a conforming value can carry both +// members' nested field sets, and silently keeping only the last member's +// codec would leave the discarded member's nested fields unrenamed under a +// type that claims otherwise. This is deliberately a warning, not an +// attempt to merge the two nested codecs: two conflicting shapes for one +// field name have no single well-defined merged codec in general (they may +// not even be structurally compatible), so surfacing the ambiguity is the +// honest choice, the same one an undiscriminated union's ambiguity gets. // -// Required is the UNION of every member's required list (plus the schema's -// own), not an intersection: satisfying allOf means satisfying every member +// Required is the UNION of every layer's required list, not an +// intersection: satisfying allOf means satisfying every member // simultaneously, so a field required by any one of them is required on the // composed value. // -// A member that resolves to another schema which is itself a further -// composition (its own oneOf/anyOf/allOf with no direct Properties) is not -// flattened recursively -- only its immediate Properties/Required are -// merged in. This is a known scope boundary, not a silent bug: such a -// member contributes no properties, which for an otherwise plain allOf is -// the same outcome codecIDFor already accepts for an empty-Properties -// schema (passthrough-equivalent, since walking an empty field map still -// preserves every wire key via the "unknown key" path). +// Known residual limitation: conflict detection compares the STRING +// codecIDFor returns for each layer's declaration of a field, which +// correctly distinguishes two different $ref codecs (the common case, and +// the one this warns about) but cannot distinguish two different INLINE +// sub-schemas for the same field name -- codecIDFor synthesizes an inline +// property's id purely from "." (parentID and property name), +// with no dependence on which layer or which schema shape produced it, so +// two conflicting inline schemas at the same field name produce the SAME +// id string and are silently indistinguishable here. Closing this fully +// would mean giving codecIDFor a per-layer-aware synthetic id scheme for +// this one call site, which is not needed by any case this task's review +// actually measured ($ref-vs-$ref conflicts). +// +// If NO layer contributes any fields at all -- every member is an +// unresolvable $ref, or the composition is genuinely empty -- the entry +// degrades to passthrough rather than an `object` with no `fields`. An +// empty `fields` map marshals to no `fields` key at all (it's +// `omitempty`), but the emitted `Codec` type declares `fields` required for +// `kind: 'object'`; tsc would reject the generated file, and at runtime +// `Object.entries(codec.fields)` would throw on `undefined`. Passthrough is +// the safe, honest degradation: an unresolvable composition can't be +// walked, so the table must not claim it can. func (t *codecTable) allOfEntry(id string, schema *client.Schema, spec *client.APISpec) codecEntry { + layers := flattenAllOfLayers(schema, spec, map[*client.Schema]bool{}) + fields := map[string]codecField{} - required := map[string]bool{} + fieldCodec := map[string]string{} + conflicts := map[string]bool{} + var required []string - merge := func(props map[string]*client.Schema, req []string) { - for _, prop := range sortedKeys(props) { - fields[prop] = codecField{ - TS: prop, - Codec: t.codecIDFor(id, prop, props[prop], spec), - } - } + for _, layer := range layers { + for _, prop := range sortedKeys(layer.Properties) { + codecID := t.codecIDFor(id, prop, layer.Properties[prop], spec) - for _, r := range req { - if _, ok := props[r]; ok { - required[r] = true + if prev, ok := fieldCodec[prop]; ok && prev != codecID { + conflicts[prop] = true } - } - } - for _, member := range schema.AllOf { - if member == nil { - continue + fieldCodec[prop] = codecID + fields[prop] = codecField{TS: prop, Codec: codecID} } - if name := refName(member.Ref); name != "" { - // A $ref member: merge the NAMED schema's own fields. The named - // schema is also, separately, registered and walked in its own - // right by the top-level sortedKeys(spec.Schemas) loop -- this - // merge only borrows its shape, it does not re-add it. - if resolved := spec.Schemas[name]; resolved != nil { - merge(resolved.Properties, resolved.Required) - } + required = append(required, layer.Required...) + } - continue + if len(conflicts) > 0 { + names := make([]string, 0, len(conflicts)) + for name := range conflicts { + names = append(names, name) } - // An inline member: merge its own fields directly. There is nothing - // further to register here -- unlike a union member, an allOf - // member's fields are flattened straight into this entry rather than - // walked through a separate delegated codec, so no synthetic id for - // the member itself would ever be referenced. - merge(member.Properties, member.Required) - } - - merge(schema.Properties, schema.Required) + sort.Strings(names) - sortedRequired := make([]string, 0, len(required)) - for r := range required { - sortedRequired = append(sortedRequired, r) + t.warnings = append(t.warnings, fmt.Sprintf( + "schema %q: allOf members declare field(s) %s with different shapes; the last declared member's shape wins and earlier members' nested fields for that name will not be renamed", + id, strings.Join(names, ", "))) } - sort.Strings(sortedRequired) + if len(fields) == 0 { + return codecEntry{Kind: "passthrough"} + } - return codecEntry{Kind: "object", Fields: fields, Required: sortedRequired} + return codecEntry{Kind: "object", Fields: fields, Required: requiredWireFields(fields, required)} } -// requiredWireFields returns schema.Required filtered to names that are -// actually keys of props and sorted for determinism. Filtering guards -// against a malformed schema listing a required name that isn't one of its -// own properties; sorting means the emitted `required` array -- and -// therefore the order a structural union match tests fields in -- never -// depends on the order Required happened to be declared in. -func requiredWireFields(props map[string]*client.Schema, required []string) []string { +// requiredWireFields returns required filtered to names present in fields, +// deduplicated and sorted for determinism. Filtering guards against a +// malformed schema listing a required name that isn't one of its own +// properties; deduplication matters once a name can be required by more +// than one source (an allOf's members can each separately require the same +// field); sorting means the emitted `required` array -- and therefore the +// order a structural union match tests fields in -- never depends on the +// order `required` happened to be built in. +func requiredWireFields(fields map[string]codecField, required []string) []string { if len(required) == 0 { return nil } + seen := make(map[string]bool, len(required)) out := make([]string, 0, len(required)) for _, r := range required { - if _, ok := props[r]; ok { - out = append(out, r) + if _, ok := fields[r]; !ok || seen[r] { + continue } + + seen[r] = true + out = append(out, r) } sort.Strings(out) @@ -376,9 +486,11 @@ func requiredWireFields(props map[string]*client.Schema, required []string) []st } // Generate emits src/codecs.ts. The second return value lists -// generation-time warnings (currently: an undiscriminated union was found -// and will be resolved structurally at runtime rather than by a -// discriminator) -- returning them on this existing return path, rather +// generation-time warnings -- an undiscriminated union was found and will +// be resolved structurally rather than by a discriminator; one of that +// union's members offers no evidence a structural match can ever use; or +// an allOf composition has two members declaring the same wire field with +// different shapes -- returning them on this existing return path, rather // than adding a logger dependency or a package-level global, is what keeps // CodecGenerator a pure function callers (and tests) can call directly with // no setup. The top-level Generator.Generate (generator.go) forwards these @@ -570,13 +682,23 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { // No discriminator: try each member in the order it was declared, // taking the first whose required wire fields are ALL present on the - // value. A member with no required fields matches unconditionally, - // which is why it winning as the FIRST declared member -- rather than - // being tried last as a catch-all -- is exactly the deterministic, - // declared-order behaviour this is meant to provide. + // value. A member that is evidence-free -- not an 'object' at all, or + // an object with no required fields -- is SKIPPED, not treated as a + // vacuous match: an empty required list is satisfied by every object + // trivially, which would turn that member into an unconditional + // catch-all rather than a real structural test, defeating "never a + // best-effort guess" for the common case where a schema simply has no + // required fields declared (the OpenAPI default). If every member is + // skipped, fall through to the same passthrough a genuine mismatch + // gets -- generation-time warns when a member has no evidence to + // offer (see unionEntry), so this is never a silent surprise. for (const memberID of codec.members ?? []) { const member = codecFor(memberID); - const required = member && member.kind === 'object' ? (member.required ?? []) : []; + const required = member && member.kind === 'object' ? member.required : undefined; + if (!required || required.length === 0) { + continue; + } + if (required.every((field) => Object.prototype.hasOwnProperty.call(src, field))) { return walk(value, memberID, toTS); } diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index 2acfde9c..0813764a 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -78,8 +78,8 @@ func TestCodecTableUnionRequiresDiscriminator(t *testing.T) { }, }, } - withDisc.Schemas["Cat"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"meows": {Type: "boolean"}}} - withDisc.Schemas["Dog"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"barks": {Type: "boolean"}}} + withDisc.Schemas["Cat"] = &client.Schema{Type: "object", Required: []string{"meows"}, Properties: map[string]*client.Schema{"meows": {Type: "boolean"}}} + withDisc.Schemas["Dog"] = &client.Schema{Type: "object", Required: []string{"barks"}, Properties: map[string]*client.Schema{"barks": {Type: "boolean"}}} code, warnings := NewCodecGenerator().Generate(withDisc, baseConfig()) assert.Contains(t, code, `"kind": "union"`) @@ -284,8 +284,14 @@ results.unknownKeys = decode({ id: 'u1', surprise: { nested: 1 } }, 'User'); results.recordKeys = decode({ tags: { 'Weird-Key': 'v', 'another key': 'w' } }, 'Nested'); // Rule 3: a union WITHOUT a discriminator falls back to passthrough rather -// than guessing a member by structural shape. -results.untagged = decode({ kind: 'cat', meows: true, extra: 1 }, 'Untagged'); +// than guessing a member by structural shape. Cat/Dog declare no required +// fields, so both are evidence-free and neither can ever be structurally +// matched -- this must be TRUE passthrough (the identical reference), not a +// decode that merely happens to look the same because ts === wire today. +const untaggedInput = { kind: 'cat', meows: true, extra: 1 }; +const untaggedResult = decode(untaggedInput, 'Untagged'); +results.untagged = untaggedResult; +results.untaggedIsIdentity = untaggedResult === untaggedInput; // A union WITH a discriminator resolves to the tagged member. results.tagged = decode({ kind: 'cat', meows: true, extra: 1 }, 'Pet'); @@ -314,15 +320,16 @@ console.log(JSON.stringify(results)); stdout := runNodeDriver(t, dir, "src/__driver_codecs.ts") var got struct { - UnknownKeys map[string]any `json:"unknownKeys"` - RecordKeys map[string]any `json:"recordKeys"` - Untagged map[string]any `json:"untagged"` - Tagged map[string]any `json:"tagged"` - UnknownTag map[string]any `json:"unknownTag"` - Nested map[string]any `json:"nested"` - RoundTrip map[string]any `json:"roundTrip"` - Nullish map[string]any `json:"nullish"` - UnknownCodec map[string]any `json:"unknownCodec"` + UnknownKeys map[string]any `json:"unknownKeys"` + RecordKeys map[string]any `json:"recordKeys"` + Untagged map[string]any `json:"untagged"` + UntaggedIsIdentity bool `json:"untaggedIsIdentity"` + Tagged map[string]any `json:"tagged"` + UnknownTag map[string]any `json:"unknownTag"` + Nested map[string]any `json:"nested"` + RoundTrip map[string]any `json:"roundTrip"` + Nullish map[string]any `json:"nullish"` + UnknownCodec map[string]any `json:"unknownCodec"` } decodeLastLine(t, stdout, &got) @@ -335,6 +342,8 @@ console.log(JSON.stringify(results)); assert.Equal(t, map[string]any{"kind": "cat", "meows": true, "extra": float64(1)}, got.Untagged, "a union with no discriminator must pass through untouched rather than guess a member") + assert.True(t, got.UntaggedIsIdentity, + "a union whose members are all evidence-free (no required fields) must be TRUE passthrough (identical reference), not a decode that coincidentally looks the same; driver stdout:\n%s", stdout) assert.Equal(t, "cat", got.Tagged["kind"]) assert.Equal(t, float64(1), got.Tagged["extra"], "unknown keys survive inside a resolved union member too") @@ -573,3 +582,427 @@ console.log(JSON.stringify(results)); assert.Equal(t, "x", got.BaseFieldSurvived, "a field contributed by the $ref member of the allOf must still be present after decode") } + +// ========== Fix round 1: allOf empty-composition, conflicts, evidence-free union members ========== + +// allOfChainSpec builds a three-level $ref allOf inheritance chain +// (Outer -> Mid -> Leaf, an ordinary OpenAPI pattern per the review), the +// same shape reached through an INLINE (non-$ref) intermediate composition +// (OuterInline), and a member whose $ref cannot be resolved at all +// (Dangling). Mid and the inline intermediate in OuterInline each have NO +// Properties of their own -- only AllOf -- so Outer/OuterInline cannot get +// their fields without recursively flattening through them down to Leaf. +func allOfChainSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["Leaf"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "name": {Type: "string"}, + "tags": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + } + spec.Schemas["Mid"] = &client.Schema{AllOf: []*client.Schema{{Ref: "#/components/schemas/Leaf"}}} + spec.Schemas["Outer"] = &client.Schema{AllOf: []*client.Schema{{Ref: "#/components/schemas/Mid"}}} + spec.Schemas["OuterInline"] = &client.Schema{ + AllOf: []*client.Schema{ + {AllOf: []*client.Schema{{Ref: "#/components/schemas/Leaf"}}}, + }, + } + spec.Schemas["Dangling"] = &client.Schema{ + AllOf: []*client.Schema{{Ref: "#/components/schemas/Ghost"}}, + } + + return spec +} + +// TestCodecTableAllOfEmptyCompositionDegradesToPassthrough is the CRITICAL 1 +// regression guard: allOfEntry must never emit {"kind":"object"} with no +// `fields` key. Before the fix, Outer, OuterInline, and Dangling all +// produced exactly that -- tsc rejects it (the emitted Codec type declares +// `fields` required for kind:'object') and decode() throws +// (Object.entries(undefined)). +func TestCodecTableAllOfEmptyCompositionDegradesToPassthrough(t *testing.T) { + spec := allOfChainSpec() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + + // Mid resolves one $ref hop (Leaf) and already worked before this fix -- + // pinned here as the control case the broken ones are compared against. + assert.Contains(t, code, `"Mid": {"kind": "object", "fields": {"name": {"ts": "name"}, "tags"`) + + // Outer is a SECOND level ($ref Mid, which has no Properties of its + // own): before the fix this was `{"kind": "object"}` with no fields key + // at all. It must now flatten all the way down to Leaf's fields. + assert.NotContains(t, code, `"Outer": {"kind": "object"}`, "an allOf entry must never have an empty fields map") + assert.Contains(t, code, `"Outer": {"kind": "object", "fields": {"name": {"ts": "name"}, "tags"`) + + // OuterInline reaches the identical empty-composition shape through an + // INLINE (non-$ref) intermediate rather than a $ref hop. + assert.NotContains(t, code, `"OuterInline": {"kind": "object"}`) + assert.Contains(t, code, `"OuterInline": {"kind": "object", "fields": {"name": {"ts": "name"}, "tags"`) + + // Dangling's only member resolves to nothing at all (spec.Schemas["Ghost"] + // doesn't exist) -- this MUST degrade to passthrough, never a lying + // empty object. + assert.Contains(t, code, `"Dangling": {"kind": "passthrough"}`) + assert.NotContains(t, code, `"Dangling": {"kind": "object"}`) +} + +// TestCodecRuntimeAllOfChainDoesNotThrowAndIsWalked is the execution proof +// for CRITICAL 1 (no throw) and MINOR 5 (nested allOf actually flattens, +// not just "known scope boundary"). String assertions on the table can show +// the right JSON shape; only running it proves decode() doesn't throw on a +// dangling member and that the resolved chains are actually walked, not +// just correctly *shaped*. +func TestCodecRuntimeAllOfChainDoesNotThrowAndIsWalked(t *testing.T) { + spec := allOfChainSpec() + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode } from './codecs'; + +const results: Record = {}; + +// Outer flattens through Mid down to Leaf -- 'tags' must actually be +// walked (a new array reference), proving the chain resolves all the way +// down rather than stopping at Mid, which has no fields of its own. +const outerPayload = { name: 'x', tags: [{ id: 'a' }] }; +const decodedOuter = decode(outerPayload, 'Outer'); +results.outerWalked = decodedOuter.tags !== outerPayload.tags; +results.outerName = decodedOuter.name; + +const outerInlinePayload = { name: 'y', tags: [{ id: 'b' }] }; +const decodedOuterInline = decode(outerInlinePayload, 'OuterInline'); +results.outerInlineWalked = decodedOuterInline.tags !== outerInlinePayload.tags; + +// A dangling $ref member must not throw -- it must pass through verbatim, +// exactly like any other passthrough entry. +let danglingThrew = false; +let decodedDangling; +try { + decodedDangling = decode({ anything: 1 }, 'Dangling'); +} catch (e) { + danglingThrew = true; +} +results.danglingThrew = danglingThrew; +results.decodedDangling = decodedDangling; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_allof_chain.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_allof_chain.ts") + + var got struct { + OuterWalked bool `json:"outerWalked"` + OuterName string `json:"outerName"` + OuterInlineWalked bool `json:"outerInlineWalked"` + DanglingThrew bool `json:"danglingThrew"` + DecodedDangling map[string]any `json:"decodedDangling"` + } + decodeLastLine(t, stdout, &got) + + assert.True(t, got.OuterWalked, + "a three-level allOf $ref chain must flatten all the way down to the leaf's fields; driver stdout:\n%s", stdout) + assert.Equal(t, "x", got.OuterName) + + assert.True(t, got.OuterInlineWalked, + "an inline (non-$ref) nested allOf composition must flatten the same way; driver stdout:\n%s", stdout) + + assert.False(t, got.DanglingThrew, + "a dangling $ref allOf member must degrade to passthrough, never throw; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"anything": float64(1)}, got.DecodedDangling) +} + +// allOfConflictSpec builds Dup = allOf[MemberA, MemberB], where both members +// require a "payload" field but point it at DIFFERENT nested schemas +// (PayloadA vs PayloadB). PayloadA/PayloadB each carry their own +// array-of-$ref field (listA/listB) so that which one actually drove the +// decode is provable by reference identity, not just by inspecting the +// table. +func allOfConflictSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["PayloadA"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "x": {Type: "string"}, + "listA": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + } + spec.Schemas["PayloadB"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "y": {Type: "string"}, + "listB": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + } + spec.Schemas["MemberA"] = &client.Schema{ + Type: "object", Required: []string{"payload"}, + Properties: map[string]*client.Schema{"payload": {Ref: "#/components/schemas/PayloadA"}}, + } + spec.Schemas["MemberB"] = &client.Schema{ + Type: "object", Required: []string{"payload"}, + Properties: map[string]*client.Schema{"payload": {Ref: "#/components/schemas/PayloadB"}}, + } + spec.Schemas["Dup"] = &client.Schema{ + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/MemberA"}, + {Ref: "#/components/schemas/MemberB"}, + }, + } + + return spec +} + +// TestCodecTableAllOfConflictingMembersWarnAndLastWins is CRITICAL 2 / +// IMPORTANT 4's regression guard: two allOf members declaring the same wire +// field with different nested shapes must (a) warn, naming the schema and +// field, and (b) resolve deterministically to the LAST declared member's +// shape, not silently drop one with no record of the conflict. +func TestCodecTableAllOfConflictingMembersWarnAndLastWins(t *testing.T) { + spec := allOfConflictSpec() + code, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + + // Last member (MemberB / PayloadB) wins the field entry. + assert.Contains(t, code, `"Dup": {"kind": "object", "fields": {"payload": {"ts": "payload", "codec": "PayloadB"}}, "required": ["payload"]}`) + + var conflictWarning string + + for _, w := range warnings { + if strings.Contains(w, `"Dup"`) { + conflictWarning = w + } + } + + require.NotEmpty(t, conflictWarning, "a cross-member field conflict must be named in a warning, not silently dropped") + assert.Contains(t, conflictWarning, "payload") + assert.Contains(t, conflictWarning, "different shapes") +} + +// TestCodecTableAllOfConflictIsDeterministic pins that the last-wins +// resolution (and the warning it produces) is stable across repeated runs, +// not merely stable-by-luck once. +func TestCodecTableAllOfConflictIsDeterministic(t *testing.T) { + spec := allOfConflictSpec() + firstCode, firstWarnings := NewCodecGenerator().Generate(spec, baseConfig()) + + for i := range 20 { + gotCode, gotWarnings := NewCodecGenerator().Generate(spec, baseConfig()) + if gotCode != firstCode { + t.Fatalf("run %d code differs", i) + } + + if !slices.Equal(gotWarnings, firstWarnings) { + t.Fatalf("run %d warnings differ: %v vs %v", i, gotWarnings, firstWarnings) + } + } +} + +// TestCodecRuntimeAllOfConflictLastMemberDrivesTheWalk is the execution +// proof that the LAST declared member's codec is what actually runs, not +// just what the table claims: PayloadA's "listA" and PayloadB's "listB" are +// each declared with their own array codec, so if PayloadB (last) truly +// drives the walk, only "listB" gets a new array reference -- "listA" +// becomes an unrecognised key relative to PayloadB and passes through by +// the same reference, exactly as if PayloadA's declaration for "payload" +// had never existed. +func TestCodecRuntimeAllOfConflictLastMemberDrivesTheWalk(t *testing.T) { + spec := allOfConflictSpec() + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode } from './codecs'; + +const results: Record = {}; + +const payload = { payload: { x: 'a', y: 'b', listA: [{ id: '1' }], listB: [{ id: '2' }] } }; +const decoded = decode(payload, 'Dup'); + +results.listBWalked = decoded.payload.listB !== payload.payload.listB; +results.listAUntouched = decoded.payload.listA === payload.payload.listA; +results.value = decoded; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_allof_conflict.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_allof_conflict.ts") + + var got struct { + ListBWalked bool `json:"listBWalked"` + ListAUntouched bool `json:"listAUntouched"` + Value map[string]any `json:"value"` + } + decodeLastLine(t, stdout, &got) + + assert.True(t, got.ListBWalked, + "the last declared member (PayloadB) must actually drive the walk, proven by 'listB' getting a fresh array reference; driver stdout:\n%s", stdout) + assert.True(t, got.ListAUntouched, + "PayloadA's codec must NOT run once PayloadB has won the field -- 'listA' must stay the original reference, an unrecognised key relative to PayloadB; driver stdout:\n%s", stdout) +} + +// evidenceFreeUnionSpec builds three scenarios for IMPORTANT 3: +// +// - UndeterminedPet: oneOf[CatNR, DogNR], no discriminator, NEITHER member +// declares any required field -- mirrors the review's own measured +// regression exactly (catIdentity/dogIdentity/alienIdentity all false). +// - Mixed: oneOf[ArrLeaf, RealMember] -- ArrLeaf is not an 'object' kind +// at all (it's an array), declared FIRST; RealMember is a real object +// with a required field, declared second. +// - Alpha: oneOf[Zebra], where Zebra sorts AFTER Alpha alphabetically -- +// the eager-registration regression guard: the top-level +// sortedKeys(spec.Schemas) walk reaches "Alpha" before "Zebra", so +// unionEntry must force Zebra's entry to exist before checking whether +// it offers evidence, not rely on Zebra having been built already. +func evidenceFreeUnionSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["CatNR"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "kind": {Type: "string"}, "meows": {Type: "boolean"}, + }} + spec.Schemas["DogNR"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "kind": {Type: "string"}, "barks": {Type: "boolean"}, + }} + spec.Schemas["UndeterminedPet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/CatNR"}, + {Ref: "#/components/schemas/DogNR"}, + }, + } + + spec.Schemas["ArrLeaf"] = &client.Schema{Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}} + spec.Schemas["RealMember"] = &client.Schema{ + Type: "object", Required: []string{"tag"}, + Properties: map[string]*client.Schema{ + "tag": {Type: "string"}, + "list": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + } + spec.Schemas["Mixed"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/ArrLeaf"}, + {Ref: "#/components/schemas/RealMember"}, + }, + } + + spec.Schemas["Zebra"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "z": {Type: "string"}, + }} + spec.Schemas["Alpha"] = &client.Schema{ + OneOf: []*client.Schema{{Ref: "#/components/schemas/Zebra"}}, + } + + return spec +} + +// TestCodecTableWarnsOnEvidenceFreeUnionMember is IMPORTANT 3's +// generation-time half: a member that can never be structurally matched +// (no required fields, or not an object at all) must be named in a +// warning -- a union whose first (or only) member is evidence-free is +// degenerate, and the person regenerating the client needs to know, not +// discover it via silent non-matching in production. +func TestCodecTableWarnsOnEvidenceFreeUnionMember(t *testing.T) { + spec := evidenceFreeUnionSpec() + _, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + + var catWarning, dogWarning, arrWarning, alphaWarning string + + for _, w := range warnings { + switch { + case strings.Contains(w, `"UndeterminedPet"`) && strings.Contains(w, `"CatNR"`): + catWarning = w + case strings.Contains(w, `"UndeterminedPet"`) && strings.Contains(w, `"DogNR"`): + dogWarning = w + case strings.Contains(w, `"Mixed"`) && strings.Contains(w, `"ArrLeaf"`): + arrWarning = w + case strings.Contains(w, `"Alpha"`) && strings.Contains(w, `"Zebra"`): + alphaWarning = w + } + } + + require.NotEmpty(t, catWarning, "CatNR offers no required fields and must be named as evidence-free") + require.NotEmpty(t, dogWarning, "DogNR offers no required fields and must be named as evidence-free") + require.NotEmpty(t, arrWarning, "ArrLeaf is not an object kind at all and must be named as evidence-free") + require.NotEmpty(t, alphaWarning, + "Zebra (sorting AFTER Alpha alphabetically) must still be correctly detected as evidence-free -- "+ + "proves unionEntry force-registers a $ref member before checking it, rather than relying on processing order") + + for _, w := range warnings { + assert.NotContains(t, w, `"RealMember"`, "a member that DOES declare required fields must not be reported as evidence-free") + } +} + +// TestCodecRuntimeUnionSkipsEvidenceFreeMembers is IMPORTANT 3's execution +// proof. Before the fix, codec.required defaulted to [] for an evidence-free +// member, and [].every(...) is vacuously true -- the FIRST declared member +// (even one that can't structurally represent the value at all, like an +// array) would "match" every payload, exactly the "best-effort guess" the +// feature exists to rule out. +func TestCodecRuntimeUnionSkipsEvidenceFreeMembers(t *testing.T) { + spec := evidenceFreeUnionSpec() + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode } from './codecs'; + +const results: Record = {}; + +// Neither CatNR nor DogNR declares required fields -- BOTH are +// evidence-free, so NO payload can ever be structurally matched to either +// one. This must be TRUE passthrough (identity) for every shape below, +// mirroring exactly what an undiscriminated client actually receives. +const dogShaped = { kind: 'dog', barks: true }; +results.dogIdentity = decode(dogShaped, 'UndeterminedPet') === dogShaped; + +const catShaped = { kind: 'cat', meows: true }; +results.catIdentity = decode(catShaped, 'UndeterminedPet') === catShaped; + +const alienShaped = { totally: 'unrelated' }; +results.alienIdentity = decode(alienShaped, 'UndeterminedPet') === alienShaped; + +// Mixed: ArrLeaf (array kind, evidence-free, declared FIRST) must be +// skipped outright, letting RealMember (object, required: tag, declared +// second) resolve the payload -- proven by 'list' (declared only on +// RealMember, with its own array codec) coming back as a NEW array +// reference. +const mixedPayload = { tag: 'x', list: [{ id: 'a' }] }; +const decodedMixed = decode(mixedPayload, 'Mixed'); +results.mixedWalkedViaRealMember = decodedMixed.list !== mixedPayload.list; +results.mixedIsCopy = decodedMixed !== mixedPayload; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_evidence_free.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_evidence_free.ts") + + var got struct { + DogIdentity bool `json:"dogIdentity"` + CatIdentity bool `json:"catIdentity"` + AlienIdentity bool `json:"alienIdentity"` + MixedWalkedViaRealMember bool `json:"mixedWalkedViaRealMember"` + MixedIsCopy bool `json:"mixedIsCopy"` + } + decodeLastLine(t, stdout, &got) + + assert.True(t, got.DogIdentity, + "a dog-shaped payload must pass through verbatim when every union member is evidence-free; driver stdout:\n%s", stdout) + assert.True(t, got.CatIdentity, + "a cat-shaped payload must pass through verbatim when every union member is evidence-free; driver stdout:\n%s", stdout) + assert.True(t, got.AlienIdentity, + "an unrelated payload must pass through verbatim when every union member is evidence-free; driver stdout:\n%s", stdout) + + assert.True(t, got.MixedWalkedViaRealMember, + "a non-object (array) member must be skipped outright, letting the real object member resolve the payload; driver stdout:\n%s", stdout) + assert.True(t, got.MixedIsCopy) +} diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 10569480..5236b88f 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -161,9 +161,73 @@ func gateFixtures() []gateFixture { // nor "ws-sse" alone exercises AuthConfig gating in websocket.go/sse.go, // which is exactly the gap that let AuthConfig ship ungated there. {Name: "no-auth-ws-sse", Spec: wsSSESpec(), Config: noAuthWsSSE}, + // Exercises allOf, which none of the fixtures above touch at all -- + // that gap is exactly what let a pure-allOf property compile to a + // codec table entry with no `fields` (a tsc-breaking, decode()- + // throwing shape) ship unnoticed. Covers a three-level $ref + // inheritance chain (Outer -> Mid -> Leaf, an ordinary OpenAPI + // pattern) and an inline (non-$ref) nested allOf member + // (OuterInline), plus two members that declare the SAME wire field + // with different nested shapes (Dup). A genuinely dangling $ref + // member is deliberately NOT included here -- see + // TestCodecTableAllOfEmptyCompositionDegradesToPassthrough and + // TestCodecRuntimeAllOfChainDoesNotThrowAndIsWalked in + // codecs_test.go, which cover it directly against the codec table + // and the bundled runtime; schemaToTSType has no ref-existence + // validation of its own (a pre-existing, orthogonal gap: the exact + // same "Cannot find name" tsc error occurs for ANY dangling $ref, + // allOf or not), so a fixture containing one could never pass this + // gate's zero-tsc-errors bar regardless of the allOf fix. + {Name: "allof", Spec: allOfSpec(), Config: baseConfig()}, } } +// allOfSpec returns baseSpec() plus the allOf shapes gateFixtures' "allof" +// fixture type-checks and TestCodecTableAllOfEmptyCompositionDegradesToPassthrough +// / TestCodecTableAllOfConflictingMembersWarnAndLastWins exercise directly. +func allOfSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["Leaf"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "name": {Type: "string"}, + "tags": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + } + // Mid has no Properties of its own -- only AllOf -- so Outer, one level + // down, cannot get its fields by looking at Mid's own Properties; it + // must flatten through Mid to Leaf. + spec.Schemas["Mid"] = &client.Schema{AllOf: []*client.Schema{{Ref: "#/components/schemas/Leaf"}}} + spec.Schemas["Outer"] = &client.Schema{AllOf: []*client.Schema{{Ref: "#/components/schemas/Mid"}}} + // Same shape as Mid->Outer, but the intermediate composition is INLINE + // (no $ref) rather than a named schema. + spec.Schemas["OuterInline"] = &client.Schema{ + AllOf: []*client.Schema{ + {AllOf: []*client.Schema{{Ref: "#/components/schemas/Leaf"}}}, + }, + } + + spec.Schemas["PayloadA"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"x": {Type: "string"}}} + spec.Schemas["PayloadB"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"y": {Type: "string"}}} + spec.Schemas["MemberA"] = &client.Schema{ + Type: "object", Required: []string{"payload"}, + Properties: map[string]*client.Schema{"payload": {Ref: "#/components/schemas/PayloadA"}}, + } + spec.Schemas["MemberB"] = &client.Schema{ + Type: "object", Required: []string{"payload"}, + Properties: map[string]*client.Schema{"payload": {Ref: "#/components/schemas/PayloadB"}}, + } + spec.Schemas["Dup"] = &client.Schema{ + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/MemberA"}, + {Ref: "#/components/schemas/MemberB"}, + }, + } + + return spec +} + // wsSSESpec returns a fresh spec with a WebSocket and an SSE endpoint, built // from baseSpec(). Called once per fixture that needs it so fixtures never // share (and risk mutating) the same *client.APISpec. From 7df1d3b52127032e735a0dfcbfaf5430134b6871 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 16:12:37 -0500 Subject: [PATCH 46/71] fix(client): print generation warnings after the CLI spinner stops OutputManager.WriteClient printed GeneratedClient.Warnings via a raw fmt.Fprintf(os.Stderr, ...) as its first action, but its one real caller (cmd/forge/plugins/client.go) wraps that call in a live terminal spinner -- on a TTY the spinner's own repaint (~80ms) could overwrite those lines before a human ever read them. Piped/CI output was unaffected; only the interactive path could lose them silently. WriteClient no longer prints warnings itself -- it only writes files, and GeneratedClient.Warnings is still there for any caller to inspect. The CLI plugin now prints each warning via ctx.Warning, using the same text-output mechanism the rest of the command already uses, right after spinner.Stop() returns rather than while it's still live. --- cmd/forge/plugins/client.go | 9 +++++++++ internal/client/output.go | 22 ++++++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/cmd/forge/plugins/client.go b/cmd/forge/plugins/client.go index 568fc39a..a89d147b 100644 --- a/cmd/forge/plugins/client.go +++ b/cmd/forge/plugins/client.go @@ -435,6 +435,15 @@ func (p *ClientPlugin) generateClient(ctx cli.CommandContext) error { spinner.Stop(cli.Green("✓ Client generated in " + outputDir)) + // Surface generation-time warnings (e.g. an undiscriminated union + // resolved structurally rather than by a discriminator, or a conflicting + // allOf composition) now that the spinner has stopped -- printing them + // while the spinner is still active would have them overwritten by its + // next repaint on a TTY before anyone could read them. + for _, w := range generatedClient.Warnings { + ctx.Warning(w) + } + // Show summary ctx.Println("") ctx.Success("Client generation complete!") diff --git a/internal/client/output.go b/internal/client/output.go index dd0803d2..3095900b 100644 --- a/internal/client/output.go +++ b/internal/client/output.go @@ -18,17 +18,19 @@ func NewOutputManager() *OutputManager { } // WriteClient writes the generated client to disk. +// +// It deliberately does NOT print client.Warnings itself. That used to +// happen here via a raw os.Stderr write, but the one real caller +// (cmd/forge/plugins/client.go) starts a terminal spinner immediately +// around this call -- on a TTY the spinner's own repaint (every ~80ms) +// overwrites whatever this function had just written to stderr, so a human +// on the interactive path could go the whole run without ever seeing a +// warning (piped/CI output was fine; only the live-spinner case lost them). +// The caller now prints client.Warnings itself, through its own text-output +// mechanism, AFTER the spinner has stopped. Warnings are still on +// GeneratedClient for any caller that wants them; this function just isn't +// the one that decides how or when to surface them. func (m *OutputManager) WriteClient(client *generators.GeneratedClient, outputDir string) error { - // Surface generation-time warnings (e.g. an undiscriminated union - // resolved structurally rather than by a discriminator) to whoever ran - // the generator. Neither CodecGenerator nor GeneratedClient has a - // logger dependency of its own, so stderr -- here, at the one place - // that already writes the generated output somewhere the caller will - // see it -- is the sink, not a package-level global. - for _, w := range client.Warnings { - fmt.Fprintf(os.Stderr, "warning: %s\n", w) - } - // Create output directory with restrictive permissions if err := os.MkdirAll(outputDir, 0750); err != nil { return fmt.Errorf("create output directory: %w", err) From d01dd11616f4b63340cfaf8a602f03c840ee532b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 16:39:28 -0500 Subject: [PATCH 47/71] fix(client/typescript): detect field collisions across the flattened allOf namespace The previous fix round closed the codec-table SYMPTOM of allOf-driven data loss but never taught fieldname.go's collision guard to check the same namespace allOfEntry actually builds -- checkSchemaFieldCollisions still namespaced each allOf member independently ("id.allOf"), which catches a collision WITHIN one member's own properties but cannot see that allOfEntry merges every member's properties into ONE table entry keyed by the allOf schema's own id. Two different wire names on two different members resolving to the same client name (e.g. street_name vs streetName) was therefore invisible to the guard entirely: checkFieldNameCollisions returned nil, Generate returned nil, zero warnings, for a schema whose emitted codec entry carried both wire names unrenamed in one merged namespace -- nothing prevented the exact rename-time data destruction a manual simulation confirmed (decode losing a value outright; encode round-tripping to the wrong wire key). Fixed by threading spec through checkSchemaFieldCollisions (a signature-only change; no caller exists outside fieldname.go) and adding checkFlattenedAllOfCollisions, which reuses flattenAllOfLayers -- the same function allOfEntry calls, not a second independently-maintained notion of what an allOf's fields are -- and compares tsFieldName across every layer's properties combined. This runs ALONGSIDE the existing per-member loop, not instead of it: a collision within one member is still a real collision. Verified the printed FieldOverrides key (".", the allOf schema's own id, never a per-layer id) actually resolves the collision when pasted back in. Also closes a related gap found while fixing this: an allOf member that is itself a oneOf/anyOf (no Properties of its own) contributed no fields and no warning -- the same lying-type failure as the empty-composition case, just non-empty (since other members still contribute real fields) and therefore invisible to that case's passthrough safety net. flattenAllOfLayers now detects this and reports it; allOfEntry turns it into a warning naming the schema and the polymorphic member, while still building the entry from whatever other layers genuinely contribute. The member's own fields still pass through safely (unrenamed, never dropped) via the ordinary unknown-key path -- warn and degrade, not guess which alternative applies. Also corrects allOfEntry's doc comment, which claimed "last-declared-wins" for a field conflict unconditionally: that holds when two layers resolve to different effective codecs ($ref vs $ref, or $ref vs inline), but two INLINE conflicting sub-schemas at the same field name synthesize the identical path-derived id regardless of layer, so the FIRST layer to register wins instead -- documented precisely rather than restructured, since fully unifying the direction would mean giving codecIDFor a per-layer-aware id scheme that would also change every existing non-conflicting allOf property's id. Pinned with a regression test rather than left as an unverified claim. Cosmetic: a union $ref cycle (UA: oneOf[$ref UB], UB: oneOf[$ref UA]) misreported the evidence-free-member warning's kind as "passthrough" for a member still mid-construction one call frame up, since add()'s reserve-before-recursing placeholder is indistinguishable from a genuinely resolved passthrough entry without tracking which ids are still being built. codecTable now tracks that (building map[string]bool), and the warning reports the cycle honestly instead. --- .../client/generators/typescript/codecs.go | 162 ++++++++--- .../generators/typescript/codecs_test.go | 263 ++++++++++++++++++ .../client/generators/typescript/fieldname.go | 95 ++++++- .../generators/typescript/fieldname_test.go | 157 +++++++++++ 4 files changed, 635 insertions(+), 42 deletions(-) diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 9fa58df8..5114db5b 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -80,6 +80,19 @@ type codecTable struct { // CodecGenerator.Generate) so callers get a stable order regardless of // the recursion shape that produced them. warnings []string + + // building tracks ids currently on the call stack inside add(), i.e. + // reserved (see add's "reserve before recursing" comment) but not yet + // assigned their real entry. This exists purely so unionEntry's + // evidence-free-member warning can tell "this member is still being + // built, one call frame up, because of a reference cycle" apart from + // "this member really is passthrough" -- both look identical if you + // only look at t.entries[id].Kind, since add() reserves a placeholder + // {Kind: "passthrough"} before it knows what the schema actually is. + // Without this, e.g. UA: oneOf[$ref UB], UB: oneOf[$ref UA] reports + // UB's warning (checked from inside building UA) as if UB resolved to + // kind "passthrough", when it is actually mid-construction as a union. + building map[string]bool } // refName extracts the schema name from a "#/components/schemas/X" pointer. @@ -153,6 +166,13 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) // above rather than re-entering. t.entries[id] = codecEntry{Kind: "passthrough"} + if t.building == nil { + t.building = map[string]bool{} + } + + t.building[id] = true + defer delete(t.building, id) + switch { case len(schema.OneOf) > 0 || len(schema.AnyOf) > 0: t.entries[id] = t.unionEntry(id, schema, spec) @@ -269,8 +289,21 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A for _, memberID := range memberIDs { entry, ok := t.entries[memberID] + // t.building[memberID] means memberID is reserved but not yet + // assigned its real entry -- a call frame further up this same + // stack is still building it (a reference cycle, e.g. + // UA: oneOf[$ref UB], UB: oneOf[$ref UA]). t.entries[memberID] + // would report the RESERVED placeholder {Kind: "passthrough"} + // in that case, which looks identical to a genuinely + // evidence-free passthrough member -- naming it accurately + // here avoids a misleading "kind \"passthrough\"" for a member + // that is actually mid-construction as something else entirely. kind := "undefined" - if ok { + + switch { + case t.building[memberID]: + kind = "unknown (cyclic reference back to a schema still being built)" + case ok: kind = entry.Kind } @@ -327,6 +360,22 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A // fallback (passthrough) then degrades safely instead of emitting a lying // empty object. // +// A member that is ITSELF a union (oneOf/anyOf, with no Properties of its +// own) is a different failure shape from a dangling ref: it resolves to +// something real, just something with no single fixed set of properties -- +// which alternative applies depends on the runtime value, not the schema +// alone, so there is genuinely nothing here to merge in without guessing. +// This is reported via the second return value (a label per such member -- +// the $ref name if there is one, "an inline member" otherwise) rather than +// silently contributing zero layers the way a dangling ref does: unlike a +// dangling ref, this member's fields DO appear in the rendered TypeScript +// intersection type (schemaToTSType has no trouble rendering a union member +// of an allOf), so silently dropping it from the codec table would be the +// exact same lying-type failure Critical 1 was about, just non-empty and +// therefore invisible to the empty-fields safety net. allOfEntry turns +// these labels into a warning rather than guessing which alternative's +// shape to merge in. +// // visited guards a schema-graph cycle -- through a $ref cycle (A allOf B, // B allOf A) or a hand-built Go pointer cycle -- by tracking schema // pointers already on the current resolution path; re-reaching one @@ -334,9 +383,17 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A // named $ref always resolves to the SAME *client.Schema pointer from // spec.Schemas, tracking pointers alone catches both cycle shapes with one // mechanism. -func flattenAllOfLayers(schema *client.Schema, spec *client.APISpec, visited map[*client.Schema]bool) []*client.Schema { +// +// label carries "how did we get here" purely for the union-member warning +// above -- the $ref name that led to this schema, or "" for an inline +// schema reached directly from an AllOf slice (rendered as "an inline +// member" in the warning). It is reset to "" for every AllOf member +// recursed into below, then set to the resolved name whenever a $ref hop +// is followed, so a union found ANY number of hops deep is still +// attributed to the $ref (if any) that most immediately led to it. +func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpec, visited map[*client.Schema]bool) (layers []*client.Schema, polymorphicMembers []string) { if schema == nil || visited[schema] { - return nil + return nil, nil } visited[schema] = true @@ -345,20 +402,29 @@ func flattenAllOfLayers(schema *client.Schema, spec *client.APISpec, visited map if name := refName(schema.Ref); name != "" { // spec.Schemas[name] is nil for a dangling ref; the nil check above // turns that into "no layers" on the next call, not a crash. - return flattenAllOfLayers(spec.Schemas[name], spec, visited) + return flattenAllOfLayers(spec.Schemas[name], name, spec, visited) } - var layers []*client.Schema + if len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { + desc := label + if desc == "" { + desc = "an inline member" + } + + polymorphicMembers = append(polymorphicMembers, desc) + } for _, member := range schema.AllOf { - layers = append(layers, flattenAllOfLayers(member, spec, visited)...) + subLayers, subPolymorphic := flattenAllOfLayers(member, "", spec, visited) + layers = append(layers, subLayers...) + polymorphicMembers = append(polymorphicMembers, subPolymorphic...) } if len(schema.Properties) > 0 { layers = append(layers, schema) } - return layers + return layers, polymorphicMembers } // allOfEntry builds an object entry for an allOf composition. The @@ -370,12 +436,12 @@ func flattenAllOfLayers(schema *client.Schema, spec *client.APISpec, visited map // which of them are required), so this reuses it rather than adding a new // `kind` that would need its own, functionally identical, runtime case. // -// A field declared by more than one layer resolves last-declared-wins: -// allOf is conventionally read as "base type, then extension", so a field -// the extension redeclares is meant to override the base's version of it. -// When two layers declare the SAME wire name with DIFFERENT effective -// codecs -- a real shape conflict, not just harmless duplication -- a -// warning names the schema and field: the TypeScript type is the +// A field declared by more than one layer resolves last-declared-wins WHEN +// the two layers' declarations resolve to DIFFERENT effective codecs -- the +// common case, and the only one conflict detection below can see. allOf is +// conventionally read as "base type, then extension", so a field the +// extension redeclares is meant to override the base's version of it, and +// a warning names the schema and field: the TypeScript type is the // intersection of both members, so a conforming value can carry both // members' nested field sets, and silently keeping only the last member's // codec would leave the discarded member's nested fields unrenamed under a @@ -390,30 +456,58 @@ func flattenAllOfLayers(schema *client.Schema, spec *client.APISpec, visited map // simultaneously, so a field required by any one of them is required on the // composed value. // -// Known residual limitation: conflict detection compares the STRING -// codecIDFor returns for each layer's declaration of a field, which -// correctly distinguishes two different $ref codecs (the common case, and -// the one this warns about) but cannot distinguish two different INLINE -// sub-schemas for the same field name -- codecIDFor synthesizes an inline -// property's id purely from "." (parentID and property name), -// with no dependence on which layer or which schema shape produced it, so -// two conflicting inline schemas at the same field name produce the SAME -// id string and are silently indistinguishable here. Closing this fully -// would mean giving codecIDFor a per-layer-aware synthetic id scheme for -// this one call site, which is not needed by any case this task's review -// actually measured ($ref-vs-$ref conflicts). +// Known residual limitation, and where "last-declared-wins" above is +// actually WRONG: conflict detection compares the STRING codecIDFor +// returns for each layer's declaration of a field. For two $ref layers (or +// one $ref, one inline) declaring different shapes, that string genuinely +// differs per layer, so both the conflict warning AND the final winner are +// correct and last-declared-wins holds. For two INLINE sub-schemas at the +// SAME field name, codecIDFor synthesizes the id purely from "." +// (parentID and property name), with NO dependence on which layer or which +// schema shape produced it -- so both layers compute the identical id +// string, the conflict goes UNDETECTED (no warning), and because t.add +// no-ops once that id is already registered, the FIRST layer to register it +// wins, not the last. Fully unifying the two directions would mean giving +// codecIDFor a per-layer-aware synthetic id scheme for this one call site, +// which is a larger change than the case actually measured ($ref-vs-$ref +// conflicts, where the existing behavior is already correct) justifies. +// +// A member that is itself a union (oneOf/anyOf) contributes no layer at +// all -- flattenAllOfLayers has nothing to merge in from it, by design (see +// its own doc comment) -- and is reported via a SEPARATE warning naming the +// schema and the union member, since its fields still appear in the +// rendered TypeScript intersection type even though the codec table cannot +// represent them: silently dropping them would be Critical 1's lying-type +// failure again, just non-empty and therefore invisible to the +// empty-fields safety net below. // // If NO layer contributes any fields at all -- every member is an -// unresolvable $ref, or the composition is genuinely empty -- the entry -// degrades to passthrough rather than an `object` with no `fields`. An -// empty `fields` map marshals to no `fields` key at all (it's -// `omitempty`), but the emitted `Codec` type declares `fields` required for -// `kind: 'object'`; tsc would reject the generated file, and at runtime -// `Object.entries(codec.fields)` would throw on `undefined`. Passthrough is -// the safe, honest degradation: an unresolvable composition can't be -// walked, so the table must not claim it can. +// unresolvable $ref, every member is itself a union, or the composition is +// genuinely empty -- the entry degrades to passthrough rather than an +// `object` with no `fields`. An empty `fields` map marshals to no `fields` +// key at all (it's `omitempty`), but the emitted `Codec` type declares +// `fields` required for `kind: 'object'`; tsc would reject the generated +// file, and at runtime `Object.entries(codec.fields)` would throw on +// `undefined`. Passthrough is the safe, honest degradation: an +// unresolvable composition can't be walked, so the table must not claim it +// can. func (t *codecTable) allOfEntry(id string, schema *client.Schema, spec *client.APISpec) codecEntry { - layers := flattenAllOfLayers(schema, spec, map[*client.Schema]bool{}) + layers, polymorphicMembers := flattenAllOfLayers(schema, "", spec, map[*client.Schema]bool{}) + + if len(polymorphicMembers) > 0 { + names := make([]string, len(polymorphicMembers)) + copy(names, polymorphicMembers) + sort.Strings(names) + + quoted := make([]string, len(names)) + for i, name := range names { + quoted[i] = fmt.Sprintf("%q", name) + } + + t.warnings = append(t.warnings, fmt.Sprintf( + "schema %q: allOf member(s) %s are themselves unions (oneOf/anyOf) and cannot be statically flattened; their fields will not appear in this composition's codec and will not be renamed", + id, strings.Join(quoted, ", "))) + } fields := map[string]codecField{} fieldCodec := map[string]string{} diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index 0813764a..e63dacc2 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -1006,3 +1006,266 @@ console.log(JSON.stringify(results)); "a non-object (array) member must be skipped outright, letting the real object member resolve the payload; driver stdout:\n%s", stdout) assert.True(t, got.MixedIsCopy) } + +// ========== Fix round 2: allOf containing a oneOf/anyOf member ========== + +// allOfWithPolymorphicMemberSpec builds AllOfWithOneOf = allOf[$ref OwnBase, +// $ref UnionMember], where UnionMember is itself a union (oneOf) with no +// Properties of its own. flattenAllOfLayers has nothing to merge in from +// UnionMember -- there is no single fixed set of properties a union +// member has, by definition -- but OwnBase DOES contribute real fields, so +// the entry is non-empty and Critical 1's empty-fields safety net never +// engages. Before this fix, UnionMember's contribution silently vanished +// from the table with no warning, even though schemaToTSType renders its +// members into the intersection type -- the same lying-type failure as +// Critical 1, just non-empty and therefore invisible. +func allOfWithPolymorphicMemberSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["Alt1"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"a": {Type: "string"}}} + spec.Schemas["Alt2"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"b": {Type: "string"}}} + spec.Schemas["UnionMember"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Alt1"}, + {Ref: "#/components/schemas/Alt2"}, + }, + } + spec.Schemas["OwnBase"] = &client.Schema{ + Type: "object", Required: []string{"fromBase"}, + Properties: map[string]*client.Schema{ + "fromBase": {Type: "string"}, + "list": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + } + spec.Schemas["AllOfWithOneOf"] = &client.Schema{ + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/OwnBase"}, + {Ref: "#/components/schemas/UnionMember"}, + }, + } + + return spec +} + +// TestCodecTableAllOfWithPolymorphicMemberWarnsWithoutEmptyingFields is the +// table-shape half: OwnBase's fields ARE present (this is NOT Critical 1's +// empty-fields case -- the entry is genuinely non-empty), and a warning +// names the schema and the union member that couldn't be flattened in. +func TestCodecTableAllOfWithPolymorphicMemberWarnsWithoutEmptyingFields(t *testing.T) { + spec := allOfWithPolymorphicMemberSpec() + code, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + + assert.Contains(t, code, `"AllOfWithOneOf": {"kind": "object", "fields": {"fromBase"`, + "OwnBase's fields must still be present -- this is a warn-and-degrade case, not an empty-fields one") + + var polyWarning string + for _, w := range warnings { + if strings.Contains(w, `"AllOfWithOneOf"`) && strings.Contains(w, `"UnionMember"`) { + polyWarning = w + } + } + + require.NotEmpty(t, polyWarning, "an allOf member that is itself a union (oneOf/anyOf) must be named in a warning") +} + +// TestCodecRuntimeAllOfWithPolymorphicMemberDegradesFieldsSafely is the +// execution proof: OwnBase's own field is genuinely walked (proven by +// reference identity on its array field), while the union member's +// contribution -- which the codec table cannot represent -- passes through +// UNRENAMED via the "unknown key" path rather than being corrupted or +// dropped. Safe (no data loss), but a lying type until renaming respects +// the warning; that tradeoff is exactly what the warning documents. +func TestCodecRuntimeAllOfWithPolymorphicMemberDegradesFieldsSafely(t *testing.T) { + spec := allOfWithPolymorphicMemberSpec() + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode } from './codecs'; + +const results: Record = {}; + +// 'a' is a field ONLY UnionMember's alternatives could ever declare -- it +// cannot be in the merged fields map at all, so it must survive as an +// unrecognised (unrenamed, but NOT dropped) key. +const payload = { fromBase: 'x', list: [{ id: 'z' }], a: 'union-field-value' }; +const decoded = decode(payload, 'AllOfWithOneOf'); + +results.listWalked = decoded.list !== payload.list; +results.decoded = decoded; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_allof_polymorphic.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_allof_polymorphic.ts") + + var got struct { + ListWalked bool `json:"listWalked"` + Decoded map[string]any `json:"decoded"` + } + decodeLastLine(t, stdout, &got) + + assert.True(t, got.ListWalked, + "OwnBase's own field must still be genuinely walked; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"fromBase": "x", "list": []any{map[string]any{"id": "z"}}, "a": "union-field-value"}, got.Decoded, + "the union member's field ('a') must survive UNRENAMED (safe passthrough via the unknown-key path), not be dropped or corrupted; driver stdout:\n%s", stdout) +} + +// ========== Fix round 2: known-limitation pin -- inline allOf conflicts resolve FIRST-wins ========== + +// inlineAllOfConflictSpec builds InlineDup = allOf[inline{payload: {p1, +// list1}}, inline{payload: {p2, list2}}] -- two INLINE (non-$ref) +// sub-schemas conflicting at the SAME field name ("payload"). Unlike the +// $ref-vs-$ref case (TestCodecRuntimeAllOfConflictLastMemberDrivesTheWalk, +// which correctly resolves last-declared-wins and warns), codecIDFor +// synthesizes an inline property's id purely from "." -- +// identical regardless of which layer produced it -- so t.add's +// already-registered guard makes the FIRST layer win, silently, with no +// conflict warning (allOfEntry's doc comment documents this precisely; see +// its "Known residual limitation" paragraph). This test pins the documented +// behavior so it cannot drift without the documentation being updated +// alongside it. +func inlineAllOfConflictSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["InlineDup"] = &client.Schema{ + AllOf: []*client.Schema{ + { + Type: "object", Required: []string{"payload"}, + Properties: map[string]*client.Schema{ + "payload": { + Type: "object", + Properties: map[string]*client.Schema{ + "p1": {Type: "string"}, + "list1": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + }, + }, + }, + { + Type: "object", Required: []string{"payload"}, + Properties: map[string]*client.Schema{ + "payload": { + Type: "object", + Properties: map[string]*client.Schema{ + "p2": {Type: "string"}, + "list2": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + }, + }, + }, + }, + } + + return spec +} + +// TestCodecTableAllOfInlineConflictResolvesFirstDeclaredWinsNoWarning pins +// the table-shape half of the known limitation: no conflict warning fires +// (both layers compute the SAME path-derived synthetic id, so the conflict +// check never sees them differ), and the winning entry reflects the FIRST +// declared layer. +func TestCodecTableAllOfInlineConflictResolvesFirstDeclaredWinsNoWarning(t *testing.T) { + spec := inlineAllOfConflictSpec() + code, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + + for _, w := range warnings { + assert.NotContains(t, w, `"InlineDup"`, + "this is the documented residual limitation: two inline sub-schemas at the same field name are NOT detected as a conflict") + } + + assert.Contains(t, code, `"InlineDup.payload": {"kind": "object", "fields": {"list1"`, + "the FIRST declared layer's inline structure must be what's registered") + assert.NotContains(t, code, `"list2"`, "the second layer's inline structure must never be registered at all") +} + +// TestCodecRuntimeAllOfInlineConflictResolvesFirstDeclaredWins is the +// execution proof: the FIRST layer's structure drives the walk (list1 gets +// a fresh array reference), while the SECOND layer's structure was never +// registered at all, so list2 passes through as an unrecognised key +// (unchanged reference) -- the opposite direction from the $ref-vs-$ref +// case, which is exactly why the doc comment now says "last-declared-wins +// WHEN the two layers resolve to different effective codecs" rather than +// unconditionally. +func TestCodecRuntimeAllOfInlineConflictResolvesFirstDeclaredWins(t *testing.T) { + spec := inlineAllOfConflictSpec() + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode } from './codecs'; + +const results: Record = {}; + +const payload = { payload: { p1: 'a', p2: 'b', list1: [{ id: '1' }], list2: [{ id: '2' }] } }; +const decoded = decode(payload, 'InlineDup'); + +results.list1Walked = decoded.payload.list1 !== payload.payload.list1; +results.list2Walked = decoded.payload.list2 !== payload.payload.list2; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_inline_allof_conflict.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_inline_allof_conflict.ts") + + var got struct { + List1Walked bool `json:"list1Walked"` + List2Walked bool `json:"list2Walked"` + } + decodeLastLine(t, stdout, &got) + + assert.True(t, got.List1Walked, + "the FIRST declared inline layer must drive the walk; driver stdout:\n%s", stdout) + assert.False(t, got.List2Walked, + "the SECOND declared inline layer's structure must never have been registered, so its field passes through untouched; driver stdout:\n%s", stdout) +} + +// ========== Fix round 2: cosmetic -- union $ref cycle warning wording ========== + +// TestCodecTableUnionCycleEvidenceFreeWarningNamesCycleNotPassthrough covers +// UA: oneOf[$ref UB], UB: oneOf[$ref UA] -- a genuine reference cycle. +// add()'s cycle guard (reserve the id before recursing) means that, from +// deep inside building UB, checking UA's entry sees the RESERVED +// placeholder {Kind: "passthrough"} reserved by the call frame further up +// the stack that is still building UA -- indistinguishable from a +// genuinely-resolved passthrough entry unless something tracks "still +// being built". Before this fix, the evidence-free warning would report +// that placeholder's kind literally, misleadingly implying UA had already +// resolved to passthrough when it is actually mid-construction as a union. +func TestCodecTableUnionCycleEvidenceFreeWarningNamesCycleNotPassthrough(t *testing.T) { + spec := baseSpec() + spec.Schemas["UA"] = &client.Schema{OneOf: []*client.Schema{{Ref: "#/components/schemas/UB"}}} + spec.Schemas["UB"] = &client.Schema{OneOf: []*client.Schema{{Ref: "#/components/schemas/UA"}}} + + done := make(chan []string, 1) + go func() { + _, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + done <- warnings + }() + + var warnings []string + select { + case warnings = <-done: + case <-time.After(10 * time.Second): + t.Fatal("generation did not terminate on a union $ref cycle") + } + + var cyclicWarning string + for _, w := range warnings { + if strings.Contains(w, "cyclic reference back to a schema still being built") { + cyclicWarning = w + } + + assert.NotContains(t, w, `member "UA" offers no required wire fields to match on (kind "passthrough")`, + "UA is never genuinely passthrough -- a member still mid-construction (a cycle) must not be mislabeled as if it had already resolved to passthrough") + assert.NotContains(t, w, `member "UB" offers no required wire fields to match on (kind "passthrough")`) + } + + require.NotEmpty(t, cyclicWarning, "the cyclic member's warning must name the cycle, not misreport the placeholder kind") +} diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index 7842f09e..baeca7d9 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -154,7 +154,7 @@ func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfi visited := make(map[string]bool, len(spec.Schemas)) for _, schemaName := range sortedKeys(spec.Schemas) { - messages = append(messages, checkSchemaFieldCollisions(schemaName, spec.Schemas[schemaName], config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(schemaName, spec.Schemas[schemaName], spec, config, visited)...) } if len(messages) == 0 { @@ -218,7 +218,27 @@ func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfi // observation about schemaToTSType), but the guard is cheap and // codecTable.add already sets the same precedent: reserve the id before // recursing, so re-entering the same id hits the guard instead of looping. -func checkSchemaFieldCollisions(id string, schema *client.Schema, config client.GeneratorConfig, visited map[string]bool) []string { +// +// allOf gets TWO passes, not one, because it is checked from two genuinely +// different namespaces: +// +// - The per-member loop below (unchanged from before this comment) treats +// each AllOf member as its OWN isolated namespace ("id.allOf"), +// which is correct for catching a collision WITHIN a single member's own +// properties. +// - checkFlattenedAllOfCollisions (below) additionally checks the +// FLATTENED namespace -- reusing flattenAllOfLayers, codecs.go's own +// allOf-resolution logic, rather than a second, independently +// maintained notion of what an allOf's fields even are. allOfEntry +// merges every member's properties into ONE table entry keyed by the +// allOf schema's OWN id (not per-member ids), so two DIFFERENT wire +// names on two DIFFERENT members that resolve to the same client name +// are a real collision in that single merged namespace -- one the +// per-member loop can never see, since it only ever compares a +// member's properties against ITSELF. Missing this half is exactly how +// a review found allOf-driven data loss survived a full task: the +// guard and the table disagreed about what the allOf namespace was. +func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig, visited map[string]bool) []string { if schema == nil || visited[id] { return nil } @@ -243,34 +263,93 @@ func checkSchemaFieldCollisions(id string, schema *client.Schema, config client. } if prop != nil && prop.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(id+"."+wireName, prop, config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(id+"."+wireName, prop, spec, config, visited)...) } } } if schema.Type == "array" && schema.Items != nil && schema.Items.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(id+".items", schema.Items, config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(id+".items", schema.Items, spec, config, visited)...) } if values, ok := additionalPropsSchema(schema.AdditionalProperties); ok && values != nil && values.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(id+".values", values, config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(id+".values", values, spec, config, visited)...) } for i, member := range schema.OneOf { if member != nil && member.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.oneOf%d", id, i), member, config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.oneOf%d", id, i), member, spec, config, visited)...) } } for i, member := range schema.AnyOf { if member != nil && member.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.anyOf%d", id, i), member, config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.anyOf%d", id, i), member, spec, config, visited)...) } } for i, member := range schema.AllOf { if member != nil && member.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.allOf%d", id, i), member, config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.allOf%d", id, i), member, spec, config, visited)...) + } + } + + if len(schema.AllOf) > 0 { + messages = append(messages, checkFlattenedAllOfCollisions(id, schema, spec, config)...) + } + + return messages +} + +// checkFlattenedAllOfCollisions checks the namespace allOfEntry +// (codecs.go) actually builds for an allOf composition: every contributing +// member flattened into ONE merged set of properties keyed by id, exactly +// as flattenAllOfLayers resolves it (the SAME function codecs.go's +// allOfEntry calls -- this deliberately does not reimplement allOf +// resolution a second time, since having the guard and the table disagree +// about what the namespace even is is exactly how a real collision +// survived a full review round undetected). +// +// tsFieldName is called with id as the schema name for every layer's +// property, matching how allOfEntry's own field derivation will work once +// renaming is wired into it (parentID stays the allOf schema's own id for +// every layer, never a per-layer id) -- so the FieldOverrides key this +// prints, "id.wireName", is the exact key that will actually take effect. +// +// This intentionally reports across ALL layers combined, not just pairs +// from DIFFERENT layers: a collision wholly within one layer's own +// properties would also be reported by the per-member loop in +// checkSchemaFieldCollisions (for that member's isolated "id.allOf" +// namespace), so the same underlying issue can surface twice, once from +// each pass. Both messages are independently true and actionable; avoiding +// that overlap would require tracking which layer each already-claimed +// name came from and suppressing same-layer repeats, which is more +// bookkeeping than a rare double-reported message currently justifies. +func checkFlattenedAllOfCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig) []string { + layers, _ := flattenAllOfLayers(schema, "", spec, map[*client.Schema]bool{}) + + var messages []string + + owner := make(map[string]string) // client name -> first wire name that claimed it + + for _, layer := range layers { + for _, wireName := range sortedKeys(layer.Properties) { + clientName := tsFieldName(id, wireName, config) + + if first, claimed := owner[clientName]; claimed { + if first == wireName { + // The identical wire name reachable through two + // different paths to the same layer (e.g. a diamond + // allOf graph) is not a real collision. + continue + } + + messages = append(messages, fmt.Sprintf( + "schema %q: wire names %q and %q both resolve to client field %q; add FieldOverrides[%q] to disambiguate", + id, first, wireName, clientName, id+"."+wireName)) + } else { + owner[clientName] = wireName + } } } diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go index e0e4dcca..7272b875 100644 --- a/internal/client/generators/typescript/fieldname_test.go +++ b/internal/client/generators/typescript/fieldname_test.go @@ -633,3 +633,160 @@ func TestGenerateFailsOnAllOfInlineMemberFieldCollision(t *testing.T) { } } } + +// --- Fix round 2, CRITICAL 2: collisions ACROSS allOf members ----------- +// +// TestGenerateFailsOnAllOfInlineMemberFieldCollision (above) checks each +// AllOf member as its OWN isolated namespace ("id.allOf") -- correct +// for a collision WITHIN one member's own properties, but codecs.go's +// allOfEntry does not keep members isolated: it FLATTENS every member's +// properties into ONE merged table entry keyed by the allOf schema's own +// id. Two DIFFERENT wire names on two DIFFERENT members that resolve to the +// same client name is therefore a real collision in that single merged +// namespace, invisible to the per-member check because it never compares +// one member's properties against another's. A prior fix round found and +// fixed the codec-table SYMPTOM of the resulting data loss (silently +// dropping one member's shape once renaming lands) but never taught THIS +// guard to check the same flattened namespace -- so the guard and the +// table disagreed about what the allOf namespace even was, which is how it +// went undetected through a full review round. + +// allOfFlattenedCollisionSpec returns Base (declaring street_name) and Addr +// = allOf[$ref Base, inline{streetName}] -- a $ref member colliding with an +// inline one, which the review measured directly. Callers that need the +// pure-inline-vs-inline variant build Addr without Base themselves (see +// TestGenerateFailsOnAllOfFlattenedInlineInlineCollision). +func allOfFlattenedCollisionSpec() *client.APISpec { + return &client.APISpec{ + Info: client.APIInfo{Title: "Flattened Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Base": {Type: "object", Properties: map[string]*client.Schema{"street_name": {Type: "string"}}}, + "Addr": { + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/Base"}, + {Type: "object", Properties: map[string]*client.Schema{"streetName": {Type: "string"}}}, + }, + }, + }, + } +} + +// TestGenerateFailsOnAllOfFlattenedInlineInlineCollision: BOTH members +// inline (no $ref at all) -- the review's first measured variant. Before +// this fix: checkFieldNameCollisions returned nil, Generate returned nil, +// zero warnings, for an allOf whose emitted codec entry is +// `{"fields": {"streetName": ..., "street_name": ...}}` -- both wire names +// present, unrenamed, in the SAME merged entry. +func TestGenerateFailsOnAllOfFlattenedInlineInlineCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Flattened Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Addr": { + AllOf: []*client.Schema{ + {Type: "object", Properties: map[string]*client.Schema{"street_name": {Type: "string"}}}, + {Type: "object", Properties: map[string]*client.Schema{"streetName": {Type: "string"}}}, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for wire names colliding across two INLINE allOf members") + } + + for _, want := range []string{"street_name", "streetName", `FieldOverrides["Addr.streetName"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestGenerateFailsOnAllOfFlattenedRefInlineCollision: one $ref member +// (Base, declaring street_name) and one inline member (declaring +// streetName) -- the review's second measured variant. +func TestGenerateFailsOnAllOfFlattenedRefInlineCollision(t *testing.T) { + spec := allOfFlattenedCollisionSpec() + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for wire names colliding between a $ref allOf member and an inline one") + } + + for _, want := range []string{"street_name", "streetName", `FieldOverrides["Addr.streetName"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + +// TestGenerateAllowsAllOfFlattenedNonCollidingFields is the false-positive +// guard the review explicitly asked for: a $ref member and an inline member +// declaring genuinely DIFFERENT fields (street_name vs zip_code) must not +// be reported as a collision just because they are both allOf members. +func TestGenerateAllowsAllOfFlattenedNonCollidingFields(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Flattened Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Base": {Type: "object", Properties: map[string]*client.Schema{"street_name": {Type: "string"}}}, + "Addr": { + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/Base"}, + {Type: "object", Properties: map[string]*client.Schema{"zip_code": {Type: "string"}}}, + }, + }, + }, + } + + if err := checkFieldNameCollisions(spec, collisionConfig()); err != nil { + t.Fatalf("expected no collision for genuinely non-colliding allOf members, got: %v", err) + } +} + +// TestAllOfFlattenedFieldOverrideKeyActuallyResolvesCollision proves the +// key printed above is not just cosmetically plausible: pasting it into +// FieldOverrides verbatim must make the collision go away. checkFlattenedAllOfCollisions +// calls tsFieldName with the allOf schema's OWN id (never a per-layer id), +// matching exactly how allOfEntry keys the merged table entry -- so +// "Addr" + "." + "streetName" is the same key a caller would paste in. +func TestAllOfFlattenedFieldOverrideKeyActuallyResolvesCollision(t *testing.T) { + spec := allOfFlattenedCollisionSpec() + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Addr.streetName": "streetNameAlt"} + + if _, err := NewGenerator().Generate(context.Background(), spec, config); err != nil { + t.Fatalf("expected the printed FieldOverrides key to resolve the flattened allOf collision, got: %v", err) + } +} + +// TestGenerateFailsOnBothFieldNamesInOneObjectControl is the control case +// the review asked to confirm still works: two colliding wire names +// declared directly on ONE object (no allOf at all) must still error -- +// proving the flattened-allOf addition didn't accidentally change the +// ordinary, non-allOf collision path. +func TestGenerateFailsOnBothFieldNamesInOneObjectControl(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Flattened Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Addr": { + Type: "object", + Properties: map[string]*client.Schema{ + "street_name": {Type: "string"}, + "streetName": {Type: "string"}, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for two colliding wire names declared directly on one object") + } + + // sortedKeys visits "streetName" before "street_name" ('N' < '_'), so it + // is the first claimer; "street_name" is the one flagged. + if !strings.Contains(err.Error(), `FieldOverrides["Addr.street_name"]`) { + t.Errorf("error message missing FieldOverrides key; got: %s", err.Error()) + } +} From db2d1edc5294b7c767ee021b4f2c5c1f6df3ad01 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 17:03:41 -0500 Subject: [PATCH 48/71] fix(client/typescript): stop printing phantom FieldOverrides keys for nested allOf collisions The allOf field-name-collision guard kept a per-member loop that checked each AllOf member under its own isolated "id.allOf" namespace, on the reasoning that it would catch a collision within a single member's own properties. That reasoning was wrong: codecs.go's allOfEntry never builds any table entry keyed "id.allOf" for any allOf shape -- every member's properties, at every depth, merge straight into entries keyed by the allOf schema's own id ("id" for top-level properties, "id." for a nested inline composite one level down, exactly like codecIDFor computes for any other object property). The per-member loop's printed key was phantom even for the simplest case an earlier fix round tested with a flat, one-level collision -- it just didn't matter there, since nothing exercised the difference. A collision nested one level deeper inside an allOf member's own property (allOf[{payload: {full_name, fullName}}], which the table keys "id.payload") exposed it: the only message printed named "id.allOf0.payload", a namespace absent from the emitted table entirely. Applying that key made Generate report success while the collision -- and the rename-time data loss it exists to prevent -- was still fully live, which is worse than not catching it at all. Fixed by removing the per-member allOf loop and making checkFlattenedAllOfCollisions the single source of truth for the allOf namespace: it now recurses into a nested inline composite within any layer's property using the same "id." derivation codecIDFor uses everywhere else, via checkSchemaFieldCollisions itself (no second recursion scheme), sharing the same visited set threaded through the whole walk rather than a fresh one per allOf composition. OneOf/AnyOf's per-member loops are untouched -- unionEntry DOES register a real "id.oneOf"/ "id.anyOf" table entry for an inline union member, so that scheme was never phantom. Eliminates the "double message with a bogus second key" failure as a side effect: there is now only one allOf pass, so only ever one message per genuine collision. A narrower, separate overlap remains -- a schema declaring both its own direct Properties and an AllOf gets its own-properties collision reported once by each of two independent, legitimate checks, verbatim -- closed by a new dedupeMessages pass over the final message list (exact-match, deterministic order preserved). Also updates the polymorphic-member warning (an allOf member that is itself a oneOf/anyOf) to explicitly state that the field-name-collision guard cannot see through a union member's own alternatives, rather than staying silent about that residual gap. Extending detection into a union's alternatives was considered and rejected: doing it soundly requires printing a FieldOverrides key scoped to the alternative's own eventual codec id (not the allOf's), and getting that scoping wrong would repeat the exact phantom-key failure this commit exists to close, in the one area of the codebase already under the most scrutiny for it. An honestly-scoped, documented limitation is safer than a partially-verified extension. --- .../client/generators/typescript/codecs.go | 23 +- .../generators/typescript/codecs_test.go | 58 ++++++ .../client/generators/typescript/fieldname.go | 177 +++++++++++----- .../generators/typescript/fieldname_test.go | 196 +++++++++++++++++- 4 files changed, 396 insertions(+), 58 deletions(-) diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 5114db5b..016a4411 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -481,6 +481,26 @@ func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpe // failure again, just non-empty and therefore invisible to the // empty-fields safety net below. // +// That warning also states, explicitly, a scope limitation rather than +// attempting to close it: checkFlattenedAllOfCollisions (fieldname.go) +// cannot see through a union member's own alternatives to detect a +// collision between one alternative's wire name and a sibling allOf +// member's renamed field (e.g. allOf[{street_name}, $ref Poly] where +// Poly = oneOf[{streetName}] -- decoding could write the renamed +// "streetName" from street_name, then pass the wire key "streetName" +// (Poly's own alternative, present on the same value) through unrenamed +// into the SAME target key, one clobbering the other). Extending detection +// into a union's alternatives was considered and rejected for now: doing +// it soundly requires evaluating each alternative under ITS OWN eventual +// codec id (e.g. "Poly.oneOf0", not this allOf's id) to print a +// FieldOverrides key that would actually resolve anything, and avoiding +// false positives between two alternatives of the SAME union that can +// never be present simultaneously (they are mutually exclusive, not +// additive, unlike allOf's own members) -- getting that wrong would repeat +// the exact "prints a key that doesn't work" failure this whole area of +// the guard exists to eliminate. An explicit, named limitation is safer +// than a partially-correct extension. +// // If NO layer contributes any fields at all -- every member is an // unresolvable $ref, every member is itself a union, or the composition is // genuinely empty -- the entry degrades to passthrough rather than an @@ -505,7 +525,8 @@ func (t *codecTable) allOfEntry(id string, schema *client.Schema, spec *client.A } t.warnings = append(t.warnings, fmt.Sprintf( - "schema %q: allOf member(s) %s are themselves unions (oneOf/anyOf) and cannot be statically flattened; their fields will not appear in this composition's codec and will not be renamed", + "schema %q: allOf member(s) %s are themselves unions (oneOf/anyOf) and cannot be statically flattened; their fields will not appear in this composition's codec and will not be renamed. "+ + "The field-name-collision guard cannot see through a union member's own alternatives either: a wire name declared by one of these alternatives that would collide with another member's renamed field once renaming lands is NOT detected by that guard -- review this composition manually before enabling renaming", id, strings.Join(quoted, ", "))) } diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index e63dacc2..a28aa2f6 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -1269,3 +1269,61 @@ func TestCodecTableUnionCycleEvidenceFreeWarningNamesCycleNotPassthrough(t *test require.NotEmpty(t, cyclicWarning, "the cyclic member's warning must name the cycle, not misreport the placeholder kind") } + +// ========== Fix round 3, FINDING 2: allOf member that is itself a oneOf is invisible to the collision guard ========== + +// TestCodecTableAllOfWithPolymorphicMemberWarningStatesCollisionLimitation +// covers Addr = allOf[{street_name}, $ref Poly], where Poly = oneOf[{streetName}]. +// Once renaming lands, decode could write the renamed "streetName" (from +// street_name) and then pass the wire key "streetName" -- Poly's own +// alternative, legitimately present on the same value since allOf requires +// every member simultaneously -- through unrenamed into the SAME target +// key, one clobbering the other. checkFlattenedAllOfCollisions cannot see +// this: Poly's alternative's properties are never part of any flattened +// layer (flattenAllOfLayers deliberately does not merge a union member's +// alternatives in -- see its own doc comment), so there is nothing for the +// collision comparison to compare "streetName" against. +// +// Decision: extending detection into a union member's alternatives was +// considered and rejected (see allOfEntry's doc comment for the full +// reasoning -- soundly doing so requires evaluating each alternative under +// its OWN eventual codec id, and avoiding false positives between two +// alternatives of the SAME union that can never be present +// simultaneously). Instead, the existing polymorphic-member warning states +// this limitation explicitly, so the gap is documented and visible rather +// than silently unmentioned. +func TestCodecTableAllOfWithPolymorphicMemberWarningStatesCollisionLimitation(t *testing.T) { + spec := baseSpec() + spec.Schemas["Poly"] = &client.Schema{ + OneOf: []*client.Schema{ + {Type: "object", Properties: map[string]*client.Schema{"streetName": {Type: "string"}}}, + }, + } + spec.Schemas["Addr"] = &client.Schema{ + AllOf: []*client.Schema{ + {Type: "object", Properties: map[string]*client.Schema{"street_name": {Type: "string"}}}, + {Ref: "#/components/schemas/Poly"}, + }, + } + + _, warnings := NewCodecGenerator().Generate(spec, baseConfig()) + + var polyWarning string + for _, w := range warnings { + if strings.Contains(w, `"Addr"`) && strings.Contains(w, `"Poly"`) { + polyWarning = w + } + } + + require.NotEmpty(t, polyWarning, "the polymorphic-member warning must fire for Addr/Poly") + assert.Contains(t, polyWarning, "field-name-collision guard cannot see through", + "the warning must explicitly state that this shape's collisions are not detected, not stay silent about the gap") + + // The guard itself must not claim success silently: it genuinely cannot + // see this collision shape (that's the documented, accepted gap), so + // checkFieldNameCollisions returns no error here -- the warning above + // is what carries the "review this manually" signal instead. + err := checkFieldNameCollisions(spec, collisionConfig()) + assert.NoError(t, err, + "this composition's guard-invisible collision shape is expected to pass the guard silently; the warning (not an error) is what documents the residual risk") +} diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index baeca7d9..c93e5144 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -144,6 +144,17 @@ func effectiveFieldNaming(config client.GeneratorConfig) client.NamingStrategy { // time. Top-level schemas are walked in sortedKeys order and every nested // namespace is walked in the same deterministic, sorted, depth-first order // checkSchemaFieldCollisions defines, so the report is stable across runs. +// +// dedupeMessages runs over the final list before formatting the error. A +// schema that declares its own direct Properties AND an AllOf (legal, +// unusual) can have the SAME underlying collision reported twice, verbatim +// -- once by the top-level own-Properties check, once by +// checkFlattenedAllOfCollisions, which includes that schema's own +// Properties as the allOf's last layer (see checkFlattenedAllOfCollisions' +// doc comment). Both messages are correct and identical strings, so +// dropping the repeat is safe and unambiguous -- unlike a mismatch between +// two DIFFERENT messages describing the same collision (which would be a +// sign the two passes disagree about something, not merely overlap). func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfig) error { if effectiveFieldNaming(config) == client.NamingPreserve { return nil @@ -157,6 +168,8 @@ func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfi messages = append(messages, checkSchemaFieldCollisions(schemaName, spec.Schemas[schemaName], spec, config, visited)...) } + messages = dedupeMessages(messages) + if len(messages) == 0 { return nil } @@ -166,6 +179,30 @@ func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfi strings.Join(messages, "\n")) } +// dedupeMessages removes exact-duplicate strings from messages, preserving +// first-occurrence order (itself already deterministic, since messages is +// built by a deterministic, sorted walk) so the error text stays stable +// across runs. +func dedupeMessages(messages []string) []string { + if len(messages) == 0 { + return messages + } + + seen := make(map[string]bool, len(messages)) + out := make([]string, 0, len(messages)) + + for _, m := range messages { + if seen[m] { + continue + } + + seen[m] = true + out = append(out, m) + } + + return out +} + // checkSchemaFieldCollisions checks one object namespace -- identified by // id -- for property-name collisions, then recurses into every inline // composite reachable from it: a property's own inline object, an array's @@ -219,25 +256,31 @@ func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfi // codecTable.add already sets the same precedent: reserve the id before // recursing, so re-entering the same id hits the guard instead of looping. // -// allOf gets TWO passes, not one, because it is checked from two genuinely -// different namespaces: -// -// - The per-member loop below (unchanged from before this comment) treats -// each AllOf member as its OWN isolated namespace ("id.allOf"), -// which is correct for catching a collision WITHIN a single member's own -// properties. -// - checkFlattenedAllOfCollisions (below) additionally checks the -// FLATTENED namespace -- reusing flattenAllOfLayers, codecs.go's own -// allOf-resolution logic, rather than a second, independently -// maintained notion of what an allOf's fields even are. allOfEntry -// merges every member's properties into ONE table entry keyed by the -// allOf schema's OWN id (not per-member ids), so two DIFFERENT wire -// names on two DIFFERENT members that resolve to the same client name -// are a real collision in that single merged namespace -- one the -// per-member loop can never see, since it only ever compares a -// member's properties against ITSELF. Missing this half is exactly how -// a review found allOf-driven data loss survived a full task: the -// guard and the table disagreed about what the allOf namespace was. +// allOf is handled by ONE pass -- checkFlattenedAllOfCollisions, below -- +// not a per-member loop, and this is a deliberate correction of an earlier +// design. A per-member loop that checked each AllOf member as its OWN +// isolated namespace ("id.allOf") existed here previously, on the +// reasoning that it would catch a collision WITHIN a single member's own +// properties. That reasoning was wrong: allOfEntry (codecs.go) never +// builds any table entry keyed "id.allOf" for ANY allOf shape -- +// every member's properties, at every depth, get merged straight into +// entries keyed by the allOf schema's own id ("id" for top-level +// properties, "id." for a nested inline composite one level down, +// exactly like codecIDFor computes for a property of a plain object). A +// per-member-namespaced check therefore printed a FieldOverrides key that +// named a namespace the table never uses -- for a single inline member +// with an internal collision, let alone a NESTED inline object inside a +// member (e.g. allOf[{payload: {full_name, fullName}}], where the table +// keys the collision "id.payload", not "id.allOf0.payload"). Applying that +// phantom key made checkFieldNameCollisions report success while the +// collision it was invented to describe was still live -- converting a +// caught error into a silently wrong one, which is worse than not catching +// it at all. checkFlattenedAllOfCollisions is the single source of truth +// for the allOf namespace now, because it reuses flattenAllOfLayers -- +// codecs.go's own allOf-resolution logic -- rather than a second, +// independently maintained notion of what an allOf's fields (and their +// nested structure) even are; the guard and the table cannot disagree +// about the namespace if there is only one function that computes it. func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig, visited map[string]bool) []string { if schema == nil || visited[id] { return nil @@ -288,14 +331,15 @@ func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.A } } - for i, member := range schema.AllOf { - if member != nil && member.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(fmt.Sprintf("%s.allOf%d", id, i), member, spec, config, visited)...) - } - } - + // AllOf inline members are NOT recursed into with their own + // "id.allOf" namespace here, unlike OneOf/AnyOf just above -- + // see this function's doc comment for why: codecs.go's allOfEntry + // never builds a table entry under that namespace for any allOf shape, + // so doing so here would reintroduce the exact phantom-key defect this + // comment describes. checkFlattenedAllOfCollisions (below) is the only + // allOf check, and it is unconditional on schema.AllOf being non-empty. if len(schema.AllOf) > 0 { - messages = append(messages, checkFlattenedAllOfCollisions(id, schema, spec, config)...) + messages = append(messages, checkFlattenedAllOfCollisions(id, schema, spec, config, visited)...) } return messages @@ -303,29 +347,48 @@ func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.A // checkFlattenedAllOfCollisions checks the namespace allOfEntry // (codecs.go) actually builds for an allOf composition: every contributing -// member flattened into ONE merged set of properties keyed by id, exactly +// member's properties, flattened into ONE merged set keyed by id, exactly // as flattenAllOfLayers resolves it (the SAME function codecs.go's -// allOfEntry calls -- this deliberately does not reimplement allOf -// resolution a second time, since having the guard and the table disagree -// about what the namespace even is is exactly how a real collision -// survived a full review round undetected). +// allOfEntry calls -- see checkSchemaFieldCollisions's doc comment for why +// reusing it, rather than reimplementing allOf resolution a second time, +// is the whole point of this function existing). // // tsFieldName is called with id as the schema name for every layer's -// property, matching how allOfEntry's own field derivation will work once -// renaming is wired into it (parentID stays the allOf schema's own id for -// every layer, never a per-layer id) -- so the FieldOverrides key this -// prints, "id.wireName", is the exact key that will actually take effect. +// TOP-level property, matching how allOfEntry's own field derivation works +// (parentID stays the allOf schema's own id for every layer, never a +// per-layer id) -- so the FieldOverrides key this prints for a top-level +// collision, "id.wireName", is the exact key that will actually take +// effect. // -// This intentionally reports across ALL layers combined, not just pairs -// from DIFFERENT layers: a collision wholly within one layer's own -// properties would also be reported by the per-member loop in -// checkSchemaFieldCollisions (for that member's isolated "id.allOf" -// namespace), so the same underlying issue can surface twice, once from -// each pass. Both messages are independently true and actionable; avoiding -// that overlap would require tracking which layer each already-claimed -// name came from and suppressing same-layer repeats, which is more -// bookkeeping than a rare double-reported message currently justifies. -func checkFlattenedAllOfCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig) []string { +// A property that is itself an inline composite (a nested object, an +// array's inline-object items, an inline additionalProperties value +// schema, or a further oneOf/anyOf/allOf) is recursed into via +// checkSchemaFieldCollisions using id+"."+wireName as the child's own +// namespace -- codecIDFor derives that SAME "." id for any +// property, allOf-contributed or not, so this reuses the identical +// resolution codecs.go performs rather than inventing a different scheme +// for allOf specifically. Without this recursion, a collision nested +// inside an allOf-contributed property (e.g. +// allOf[{payload: {full_name, fullName}}], which the table keys +// "id.payload") would be invisible to every pass: the flattened check only +// ever looked at each layer's OWN top-level properties, one level too +// shallow to see it. +// +// visited is the SAME set threaded through the whole +// checkSchemaFieldCollisions walk (not a fresh one per allOf composition): +// if schema also has its own direct Properties (allOf permits this +// alongside AllOf, unusual but legal), flattenAllOfLayers includes schema +// itself as the LAST layer, so this function's per-property recursion into +// e.g. id+"."+wireName for one of schema's own properties would otherwise +// re-enter a namespace the OWN-Properties block above already recursed +// into -- reusing visited makes that a no-op instead of a duplicate walk, +// which is also why the schema-own-Properties-collision case can still +// surface a duplicate MESSAGE (the top-level owner-map check above and +// this function's owner-map check both run independently over the same +// properties) -- checkFieldNameCollisions dedupes exact-duplicate message +// strings for that reason, deliberately, rather than this function trying +// to detect and suppress the overlap itself. +func checkFlattenedAllOfCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig, visited map[string]bool) []string { layers, _ := flattenAllOfLayers(schema, "", spec, map[*client.Schema]bool{}) var messages []string @@ -334,22 +397,28 @@ func checkFlattenedAllOfCollisions(id string, schema *client.Schema, spec *clien for _, layer := range layers { for _, wireName := range sortedKeys(layer.Properties) { + prop := layer.Properties[wireName] clientName := tsFieldName(id, wireName, config) if first, claimed := owner[clientName]; claimed { - if first == wireName { - // The identical wire name reachable through two - // different paths to the same layer (e.g. a diamond - // allOf graph) is not a real collision. - continue + if first != wireName { + messages = append(messages, fmt.Sprintf( + "schema %q: wire names %q and %q both resolve to client field %q; add FieldOverrides[%q] to disambiguate", + id, first, wireName, clientName, id+"."+wireName)) } - - messages = append(messages, fmt.Sprintf( - "schema %q: wire names %q and %q both resolve to client field %q; add FieldOverrides[%q] to disambiguate", - id, first, wireName, clientName, id+"."+wireName)) + // first == wireName: the identical wire name reachable + // through two different paths to the same layer (e.g. a + // diamond allOf graph) -- not a real collision. The + // recursion below still runs for this occurrence, but + // visited makes a repeat walk of the same namespace a + // no-op rather than a duplicate. } else { owner[clientName] = wireName } + + if prop != nil && prop.Ref == "" { + messages = append(messages, checkSchemaFieldCollisions(id+"."+wireName, prop, spec, config, visited)...) + } } } diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go index 7272b875..78684d1c 100644 --- a/internal/client/generators/typescript/fieldname_test.go +++ b/internal/client/generators/typescript/fieldname_test.go @@ -597,8 +597,18 @@ func TestGenerateAllowsNestedCollisionAcrossDifferentParents(t *testing.T) { // the review, but added for the same reason as oneOf/anyOf: schemaToTSType's // AllOf handling (generator.go's schemaToTSType, the "&"-joined branch) // calls schemaToTSType on every member exactly like OneOf/AnyOf do, so an -// inline allOf member is an identical collision surface and the walk above -// treats it the same way (".allOf"). +// inline allOf member is an identical collision surface. +// +// The expected key here was originally "Order.combined.allOf0.full_name" -- +// a fix round 3 review found that this was itself a phantom key: the codec +// table never builds an entry under an "id.allOf" namespace for ANY +// allOf shape (allOfEntry flattens every member's top-level properties +// straight into "id" -- "Order.combined" here, since these are the +// member's own DIRECT properties, not nested one level deeper). The +// correct, now-fixed key is "Order.combined.full_name", matching exactly +// what checkFlattenedAllOfCollisions (fieldname.go) and allOfEntry +// (codecs.go) both compute, since they now share flattenAllOfLayers as one +// source of truth for the namespace. func TestGenerateFailsOnAllOfInlineMemberFieldCollision(t *testing.T) { spec := &client.APISpec{ Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, @@ -627,11 +637,20 @@ func TestGenerateFailsOnAllOfInlineMemberFieldCollision(t *testing.T) { t.Fatal("expected an error for a field-name collision inside an allOf inline member") } - for _, want := range []string{"full_name", "fullName", `FieldOverrides["Order.combined.allOf0.full_name"]`} { + for _, want := range []string{"full_name", "fullName", `FieldOverrides["Order.combined.full_name"]`} { if !strings.Contains(err.Error(), want) { t.Errorf("error message missing %q; got: %s", want, err.Error()) } } + + // Prove the key actually resolves it -- exactly the property Finding 1 + // found was violated when the printed key named a phantom namespace. + config := collisionConfig() + config.FieldOverrides = map[string]string{"Order.combined.full_name": "fullNameAlt"} + + if _, err := NewGenerator().Generate(context.Background(), spec, config); err != nil { + t.Fatalf("expected the printed FieldOverrides key to resolve the collision, got: %v", err) + } } // --- Fix round 2, CRITICAL 2: collisions ACROSS allOf members ----------- @@ -790,3 +809,174 @@ func TestGenerateFailsOnBothFieldNamesInOneObjectControl(t *testing.T) { t.Errorf("error message missing FieldOverrides key; got: %s", err.Error()) } } + +// --- Fix round 3, FINDING 1: nested inline composite inside an allOf member --- + +// extractFieldOverrideKey pulls the literal string out of the first +// `FieldOverrides["..."]` in msg, so a test can both assert on it AND use +// it as an actual map key -- proving the key is not just plausible-looking +// but genuinely the one tsFieldName will look up. +func extractFieldOverrideKey(t *testing.T, msg string) string { + t.Helper() + + const marker = `FieldOverrides["` + + idx := strings.Index(msg, marker) + if idx == -1 { + t.Fatalf("no FieldOverrides key found in message: %s", msg) + } + + rest := msg[idx+len(marker):] + + end := strings.Index(rest, `"]`) + if end == -1 { + t.Fatalf("malformed FieldOverrides key in message: %s", msg) + } + + return rest[:end] +} + +// TestGenerateFailsOnAllOfNestedInlineObjectCollision is FINDING 1's +// regression guard: Addr = allOf[{payload: {full_name, fullName}}] -- a +// collision nested ONE LEVEL INSIDE an inline allOf member's own property, +// not at the member's top level. Before this fix, the only message printed +// named "Addr.allOf0.payload.full_name" -- a namespace the codec table +// never builds (the table keys this object "Addr.payload", since +// allOfEntry flattens the member's top-level "payload" property using the +// SAME "." derivation codecIDFor uses everywhere else). Applying +// that phantom key made Generate succeed while the collision -- and the +// rename-time data loss it causes -- was still fully live. +// +// The rule this test holds the fix to, verbatim from the review: "every +// key this guard prints must name a namespace that actually exists in the +// emitted table." It extracts the printed key, looks up its namespace +// (everything before the last ".") in the emitted CODECS table, and fails +// if that namespace is absent. +func TestGenerateFailsOnAllOfNestedInlineObjectCollision(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Flattened Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Addr": { + AllOf: []*client.Schema{ + { + Type: "object", + Properties: map[string]*client.Schema{ + "payload": { + Type: "object", + Properties: map[string]*client.Schema{ + "full_name": {Type: "string"}, + "fullName": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for a field-name collision nested inside an allOf member's own inline object") + } + + for _, want := range []string{"full_name", "fullName", `FieldOverrides["Addr.payload.full_name"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } + + key := extractFieldOverrideKey(t, err.Error()) // "Addr.payload.full_name" + + lastDot := strings.LastIndex(key, ".") + if lastDot == -1 { + t.Fatalf("printed key %q has no namespace component", key) + } + + namespace := key[:lastDot] // "Addr.payload" + + code, _ := NewCodecGenerator().Generate(spec, collisionConfig()) + if !strings.Contains(code, `"`+namespace+`":`) { + t.Fatalf("printed key names namespace %q, which is absent from the emitted CODECS table:\n%s", namespace, code) + } + + // The printed key must actually resolve the collision when pasted in. + config := collisionConfig() + config.FieldOverrides = map[string]string{key: "fullNameAlt"} + + if _, err := NewGenerator().Generate(context.Background(), spec, config); err != nil { + t.Fatalf("expected the printed FieldOverrides key to resolve the nested collision, got: %v", err) + } +} + +// --- Fix round 3, FINDING 3: no more double-reporting with a bogus second key --- + +// TestGenerateDedupesOwnPropertiesAllOfDuplicateMessage: a schema declaring +// BOTH its own direct Properties (with an internal collision) AND an AllOf +// (legal, unusual) triggers the SAME collision message from two +// independent passes -- the top-level own-Properties check, and +// checkFlattenedAllOfCollisions, which includes the schema's own +// Properties as the allOf's last layer. Both messages are byte-identical +// (same schema id, same wire names, same key), so checkFieldNameCollisions +// dedupes them rather than printing the same actionable message twice. +func TestGenerateDedupesOwnPropertiesAllOfDuplicateMessage(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Dedup API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Base": {Type: "object", Properties: map[string]*client.Schema{"other": {Type: "string"}}}, + "Addr": { + Type: "object", + Properties: map[string]*client.Schema{ + "full_name": {Type: "string"}, + "fullName": {Type: "string"}, + }, + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/Base"}, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for the own-properties collision") + } + + count := strings.Count(err.Error(), `wire names "fullName" and "full_name"`) + if count != 1 { + t.Errorf("expected the own-properties collision reported exactly once (not duplicated by the allOf pass), got %d occurrences; message:\n%s", count, err.Error()) + } +} + +// TestGenerateFailsOnAllOfCollisionSpanningMembersOneAndThree pins that +// removing the old per-member "id.allOf" loop did not lose +// cross-member detection for a collision that spans two members that are +// NOT adjacent in declaration order (member 0 and member 2 of three) -- +// the flattened pass compares every layer against every other regardless +// of position, so this is not expected to behave differently from the +// adjacent-member case, but it is cheap to pin directly rather than assume. +func TestGenerateFailsOnAllOfCollisionSpanningMembersOneAndThree(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Spanning Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Addr": { + AllOf: []*client.Schema{ + {Type: "object", Properties: map[string]*client.Schema{"street_name": {Type: "string"}}}, + {Type: "object", Properties: map[string]*client.Schema{"zip_code": {Type: "string"}}}, + {Type: "object", Properties: map[string]*client.Schema{"streetName": {Type: "string"}}}, + }, + }, + }, + } + + err := checkFieldNameCollisions(spec, collisionConfig()) + if err == nil { + t.Fatal("expected an error for wire names colliding across the first and third allOf members") + } + + for _, want := range []string{"street_name", "streetName", `FieldOverrides["Addr.streetName"]`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} From 819b86b290814166a4a183988c54f8e4900de330 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 17:34:04 -0500 Subject: [PATCH 49/71] feat(client/typescript)!: derive client-side property names from FieldNaming Schema properties now render their client-side (derived) name instead of the wire name verbatim, and the codec table's per-field `ts` is the same derived name instead of an identity copy of the wire name. objectPropsLiteral and schemaToTypeScript's named-interface case both now thread a namespace id and config through to tsFieldName before calling tsPropertyKey; codecTable.add and allOfEntry now derive `ts` via tsFieldName instead of hardcoding it to the wire name. The namespace id threaded through generator.go (nsID) uses exactly the same derivation rules codecIDFor (codecs.go) and checkSchemaFieldCollisions (fieldname.go) already established, so a FieldOverrides entry that silences a collision error now actually applies at render time too -- proven directly by regenerating three different collision shapes with an override set and asserting the override value appears in both types.ts and codecs.ts. Also corrects checkSchemaFieldCollisions' doc comment, which overclaimed that `visited` guards a hand-built Go pointer cycle in Properties; it doesn't, since the synthetic id grows on every recursive step and never repeats. Adds a cheap depth cap (maxFieldCollisionDepth) as the actual bound for that shape, which is not reachable from a parsed OpenAPI document. BREAKING CHANGE: generated TypeScript property names and the codec table's `ts` values now reflect the configured FieldNaming strategy (camel by default) instead of always matching the wire name verbatim. --- .../client/generators/typescript/codecs.go | 36 ++- .../client/generators/typescript/fieldname.go | 41 ++- .../client/generators/typescript/gate_test.go | 18 +- .../client/generators/typescript/generator.go | 86 ++++-- .../generators/typescript/rename_test.go | 287 ++++++++++++++++++ 5 files changed, 417 insertions(+), 51 deletions(-) create mode 100644 internal/client/generators/typescript/rename_test.go diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 016a4411..349ed6bb 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -13,11 +13,11 @@ import ( // payload maps onto its TypeScript shape, plus the encode/decode runtime that // walks it. // -// The table is the deliverable Phase 3 depends on. Today every field's `ts` -// name equals its wire name, so encode/decode are effectively identity — the -// point of emitting it now is that the SHAPE is in place and gate-tested, so -// Phase 3 only has to change what the names are, not build the machinery -// that renames them. +// Each field's `ts` name is derived from its wire name via tsFieldName -- +// the same function generator.go's objectPropsLiteral/schemaToTSType use to +// render property keys, and fieldname.go's collision guard uses to detect a +// clash before generation ever reaches this table -- so all three agree on +// what a field is called and encode/decode are real renames, not identity. type CodecGenerator struct{} // NewCodecGenerator mirrors the other generators' constructor shape. @@ -74,6 +74,16 @@ type codecDiscriminator struct { type codecTable struct { entries map[string]codecEntry + // config is consulted for every field's client-side (`ts`) name, via + // tsFieldName, keyed by the SAME namespace id (see codecIDFor's doc + // comment) that fieldname.go's collision guard and generator.go's + // objectPropsLiteral/schemaToTSType also key their own tsFieldName calls + // by. All three must agree on that id -- otherwise a FieldOverrides + // entry that silences a collision error at generation time would not + // apply to this table, silently emitting an encode/decode pair that + // still drops data instead of renaming it. + config client.GeneratorConfig + // warnings accumulates generation-time messages that don't abort // generation but are worth surfacing -- currently just "this union has // no discriminator". Sorted before being handed back to the caller (see @@ -194,10 +204,7 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) fields := make(map[string]codecField, len(schema.Properties)) for _, prop := range sortedKeys(schema.Properties) { fields[prop] = codecField{ - // ts == wire for now: renaming lands in Phase 3. The field - // map has to exist today regardless, because it is what - // Phase 3 rewrites. - TS: prop, + TS: tsFieldName(id, prop, t.config), Codec: t.codecIDFor(id, prop, schema.Properties[prop], spec), } } @@ -544,7 +551,7 @@ func (t *codecTable) allOfEntry(id string, schema *client.Schema, spec *client.A } fieldCodec[prop] = codecID - fields[prop] = codecField{TS: prop, Codec: codecID} + fields[prop] = codecField{TS: tsFieldName(id, prop, t.config), Codec: codecID} } required = append(required, layer.Required...) @@ -614,7 +621,7 @@ func requiredWireFields(fields map[string]codecField, required []string) []strin // sorted before being returned, so their order is deterministic regardless // of the schema walk's recursion shape. func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) (string, []string) { - table := &codecTable{entries: map[string]codecEntry{}} + table := &codecTable{entries: map[string]codecEntry{}, config: config} for _, name := range sortedKeys(spec.Schemas) { table.add(name, spec, spec.Schemas[name]) @@ -627,10 +634,9 @@ func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorC buf.WriteString("// Generated codec table\n") buf.WriteString("//\n") buf.WriteString("// Describes, per schema, how a wire payload maps onto its TypeScript\n") - buf.WriteString("// shape. `ts` currently equals the wire name for every field — property\n") - buf.WriteString("// renaming lands in a later phase — so encode/decode are effectively\n") - buf.WriteString("// identity today. The table and the walk exist now so that turning\n") - buf.WriteString("// renaming on is a change of names, not a change of machinery.\n\n") + buf.WriteString("// shape. `ts` is the client-side field name derived from the wire name by\n") + buf.WriteString("// the configured FieldNaming strategy (or a FieldOverrides entry); encode\n") + buf.WriteString("// and decode below walk this table to rename between the two.\n\n") buf.WriteString("export type Codec =\n") buf.WriteString(" | { kind: 'object'; fields: Record; required?: string[] }\n") diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index c93e5144..635b1b02 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -246,15 +246,30 @@ func dedupeMessages(messages []string) []string { // recurse forever -- codecIDFor makes exactly the same "$ref means reuse // the target's name and stop" choice for the same reason. // -// visited guards against the one remaining cycle shape: a hand-built -// *client.Schema graph containing a literal Go pointer cycle with no $ref -// involved at all (e.g. schema.Properties["self"] pointing back to schema -// itself). This is not a shape spec_parser.go or introspector.go can -// produce from a real OpenAPI document (see +// visited guards against re-entering a namespace id that has already been +// walked. In practice this catches the finite, real overlap +// checkFlattenedAllOfCollisions' doc comment describes (an allOf schema that +// also declares its own direct Properties: the own-Properties block above +// and the allOf pass both recurse into the SAME id for that last layer, and +// visited turns the second visit into a no-op instead of a duplicate walk) +// and, more generally, any two different paths through the schema graph that +// happen to derive the identical synthetic id. +// +// It does NOT guard against a hand-built *client.Schema graph containing a +// literal Go pointer cycle with no $ref involved at all (e.g. +// schema.Properties["self"] pointing back to schema itself, reproduced +// directly against this function) -- every recursive step here appends a new +// path segment (id+".self", id+".self.self", ...), so the id keeps growing +// and never repeats, and visited[id] never comes back true. That shape is +// not reachable from a parsed OpenAPI document ($ref properties are never +// recursed into here -- see the paragraph above -- and that is the only way +// spec_parser.go or introspector.go could produce a cycle; see // TestOneOfSelfReferenceDoesNotInfiniteLoop's doc comment for the same -// observation about schemaToTSType), but the guard is cheap and -// codecTable.add already sets the same precedent: reserve the id before -// recursing, so re-entering the same id hits the guard instead of looping. +// observation about schemaToTSType), so it is not worth a real fix. The +// maxFieldCollisionDepth check below is a cheap, unconditional bound against +// it (and against any other pathologically deep hand-built schema graph) +// rather than leaving an unbounded id string and an unbounded stack as the +// only limits. // // allOf is handled by ONE pass -- checkFlattenedAllOfCollisions, below -- // not a per-member loop, and this is a deliberate correction of an earlier @@ -281,8 +296,16 @@ func dedupeMessages(messages []string) []string { // independently maintained notion of what an allOf's fields (and their // nested structure) even are; the guard and the table cannot disagree // about the namespace if there is only one function that computes it. +// maxFieldCollisionDepth bounds checkSchemaFieldCollisions' recursion depth +// (approximated by the number of "." path segments in id) against a +// hand-built schema graph containing a genuine Go pointer cycle with no +// $ref involved -- see checkSchemaFieldCollisions' doc comment for why +// visited cannot catch that shape. No real OpenAPI-derived spec nests this +// deep, so the cap is generous rather than tight. +const maxFieldCollisionDepth = 200 + func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig, visited map[string]bool) []string { - if schema == nil || visited[id] { + if schema == nil || visited[id] || strings.Count(id, ".") > maxFieldCollisionDepth { return nil } diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go index 7ff57d53..ea8d9d00 100644 --- a/internal/client/generators/typescript/gate_test.go +++ b/internal/client/generators/typescript/gate_test.go @@ -79,10 +79,24 @@ func TestTypesQuoteNonIdentifierKeys(t *testing.T) { types := out.Files["src/types.ts"] - if !strings.Contains(types, "\"content-type\"?: string;") { - t.Errorf("expected quoted \"content-type\" key, got:\n%s", types) + // "content-type" derives to "contentType" under this fixture's default + // camel naming -- a valid TS identifier -- so it must NOT be quoted. + // tsPropertyKey quotes based on the DERIVED (client-side) name, not the + // wire name, and this is the case that proves it: pinning the wire-name + // form here would actually be testing the old identity-naming behaviour, + // not tsPropertyKey's quoting logic. + if !strings.Contains(types, "contentType?: string;") { + t.Errorf("expected unquoted, camelCased \"contentType\" key, got:\n%s", types) } + if strings.Contains(types, "\"content-type\"?: string;") || strings.Contains(types, "\"contentType\"?: string;") { + t.Errorf("\"contentType\" is a valid identifier and must not be quoted, got:\n%s", types) + } + + // These three wire names remain invalid TS identifiers even after camel + // derivation (a leading digit, an apostrophe, a backslash respectively), + // so tsPropertyKey must still quote them -- this is the Phase 2 quoting + // logic surviving the property rename. if !strings.Contains(types, "\"3dtiles\"?: string;") { t.Errorf("expected quoted \"3dtiles\" key, got:\n%s", types) } diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 46997918..344109c5 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -549,7 +549,7 @@ func (g *Generator) generateTypes(spec *client.APISpec, config client.GeneratorC // Generate types from schemas for _, name := range sortedKeys(spec.Schemas) { - typeCode := g.schemaToTypeScript(name, spec.Schemas[name], spec) + typeCode := g.schemaToTypeScript(name, spec.Schemas[name], spec, config) buf.WriteString(typeCode) buf.WriteString("\n") } @@ -833,7 +833,18 @@ func additionalPropsSchema(v any) (*client.Schema, bool) { // ordinary `export interface` case and the additionalProperties intersection // case, both of which need the identical property rendering — only the // wrapper differs (interface vs. `{...} & Record`). -func (g *Generator) objectPropsLiteral(schema *client.Schema, spec *client.APISpec) string { +// +// nsID is the object namespace's own id, in the exact scheme fieldname.go's +// checkSchemaFieldCollisions and codecs.go's codecIDFor already use: a +// top-level schema's own name ("User"), or a synthetic dotted path for an +// inline nested object ("Order.shipping", "Order.line_items.items", +// "Order.extras.values", "Order.payment.oneOf0", ...). tsFieldName's +// schema-scoped override lookup is keyed by nsID + "." + wireName, so all +// three consumers of that namespace id -- the collision guard, the codec +// table, and this renderer -- must agree on it; otherwise a FieldOverrides +// entry that silences a collision error would not apply here, silently +// losing data instead of renaming it. +func (g *Generator) objectPropsLiteral(schema *client.Schema, spec *client.APISpec, nsID string, config client.GeneratorConfig) string { var buf strings.Builder buf.WriteString("{\n") @@ -849,8 +860,9 @@ func (g *Generator) objectPropsLiteral(schema *client.Schema, spec *client.APISp buf.WriteString(propertyJSDoc(prop, " ")) - tsType := g.schemaToTSType(prop, spec) - buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(propName), optional, tsType)) + clientName := tsFieldName(nsID, propName, config) + tsType := g.schemaToTSType(prop, spec, nsID+"."+propName, config) + buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(clientName), optional, tsType)) } buf.WriteString("}") @@ -858,8 +870,10 @@ func (g *Generator) objectPropsLiteral(schema *client.Schema, spec *client.APISp return buf.String() } -// schemaToTypeScript converts a schema to TypeScript. -func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec *client.APISpec) string { +// schemaToTypeScript converts a schema to TypeScript. name doubles as the +// schema's own namespace id (see objectPropsLiteral's doc comment) for every +// property tsFieldName resolves at this level. +func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig) string { if schema == nil { return "" } @@ -893,7 +907,7 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec // " & " (and applies Nullable), so this delegates rather than // reimplementing union logic here. if len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 || len(schema.AllOf) > 0 { - buf.WriteString(fmt.Sprintf("export type %s = %s;\n", name, g.schemaToTSType(schema, spec))) + buf.WriteString(fmt.Sprintf("export type %s = %s;\n", name, g.schemaToTSType(schema, spec, name, config))) return buf.String() } @@ -908,7 +922,7 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec // valueSchema means additionalProperties was `true` ("any" value). valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec) + valueType = g.schemaToTSType(valueSchema, spec, name+".values", config) } buf.WriteString(fmt.Sprintf("export type %s = Record;\n", name, valueType)) @@ -927,10 +941,10 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec // declared properties and a typed additionalProperties. valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec) + valueType = g.schemaToTSType(valueSchema, spec, name+".values", config) } - buf.WriteString(fmt.Sprintf("export type %s = %s & Record;\n", name, g.objectPropsLiteral(schema, spec), valueType)) + buf.WriteString(fmt.Sprintf("export type %s = %s & Record;\n", name, g.objectPropsLiteral(schema, spec, name, config), valueType)) default: buf.WriteString(fmt.Sprintf("export interface %s {\n", name)) @@ -946,8 +960,9 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec buf.WriteString(propertyJSDoc(prop, " ")) - tsType := g.schemaToTSType(prop, spec) - buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(propName), optional, tsType)) + clientName := tsFieldName(name, propName, config) + tsType := g.schemaToTSType(prop, spec, name+"."+propName, config) + buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", tsPropertyKey(clientName), optional, tsType)) } buf.WriteString("}\n") @@ -955,12 +970,12 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec case "array": if schema.Items != nil { - itemType := g.schemaToTSType(schema.Items, spec) + itemType := g.schemaToTSType(schema.Items, spec, name+".items", config) buf.WriteString(fmt.Sprintf("export type %s = %s[];\n", name, itemType)) } default: - tsType := g.schemaToTSType(schema, spec) + tsType := g.schemaToTSType(schema, spec, name, config) buf.WriteString(fmt.Sprintf("export type %s = %s;\n", name, tsType)) } @@ -1024,7 +1039,26 @@ func propertyJSDoc(schema *client.Schema, indent string) string { } // schemaToTSType converts a schema to a TypeScript type string. -func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) string { +// +// nsID is schema's OWN namespace id -- what tsFieldName is called with as +// the "schema name" for any property schema declares directly (see +// objectPropsLiteral's doc comment for the full scheme). Recursion into a +// child schema derives the child's own id from nsID exactly the way +// codecIDFor (codecs.go) and checkSchemaFieldCollisions (fieldname.go) do, +// so all three consumers of a namespace id agree on what it is: +// - array items: nsID + ".items" +// - a oneOf/anyOf member with no $ref of its own: nsID + ".oneOf"/".anyOf" + index +// - an allOf member: nsID unchanged -- allOf's members are flattened into +// ONE namespace (the composition's own id), never a per-member one; see +// checkSchemaFieldCollisions' doc comment for why a per-member id here +// would print a FieldOverrides key the codec table never builds an entry +// under. +// +// A $ref member needs no derived id: schemaToTSType returns its type name +// directly without recursing into its properties (those render under the +// ref target's own top-level namespace, elsewhere), so nsID is simply unused +// for that branch. +func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec, nsID string, config client.GeneratorConfig) string { if schema == nil { return "any" } @@ -1044,8 +1078,8 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) // Handle polymorphic types if len(schema.OneOf) > 0 { var types []string - for _, s := range schema.OneOf { - types = append(types, g.schemaToTSType(s, spec)) + for i, s := range schema.OneOf { + types = append(types, g.schemaToTSType(s, spec, fmt.Sprintf("%s.oneOf%d", nsID, i), config)) } result := strings.Join(types, " | ") @@ -1058,8 +1092,8 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) if len(schema.AnyOf) > 0 { var types []string - for _, s := range schema.AnyOf { - types = append(types, g.schemaToTSType(s, spec)) + for i, s := range schema.AnyOf { + types = append(types, g.schemaToTSType(s, spec, fmt.Sprintf("%s.anyOf%d", nsID, i), config)) } result := strings.Join(types, " | ") @@ -1073,7 +1107,9 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) if len(schema.AllOf) > 0 { var types []string for _, s := range schema.AllOf { - types = append(types, g.schemaToTSType(s, spec)) + // nsID unchanged for every member -- see this function's doc + // comment. + types = append(types, g.schemaToTSType(s, spec, nsID, config)) } result := strings.Join(types, " & ") @@ -1129,7 +1165,7 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) return "boolean" case "array": if schema.Items != nil { - itemType := g.schemaToTSType(schema.Items, spec) + itemType := g.schemaToTSType(schema.Items, spec, nsID+".items", config) if schema.Nullable { return itemType + "[] | null" } @@ -1151,15 +1187,15 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) case allowed && len(schema.Properties) > 0: valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec) + valueType = g.schemaToTSType(valueSchema, spec, nsID+".values", config) } - result = g.objectPropsLiteral(schema, spec) + " & Record" + result = g.objectPropsLiteral(schema, spec, nsID, config) + " & Record" case allowed: valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec) + valueType = g.schemaToTSType(valueSchema, spec, nsID+".values", config) } result = "Record" @@ -1171,7 +1207,7 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec) // literal via the same helper the named-schema "object" case // uses, rather than collapsing to Record and losing // every declared field. - result = g.objectPropsLiteral(schema, spec) + result = g.objectPropsLiteral(schema, spec, nsID, config) default: result = "Record" diff --git a/internal/client/generators/typescript/rename_test.go b/internal/client/generators/typescript/rename_test.go new file mode 100644 index 00000000..82d69286 --- /dev/null +++ b/internal/client/generators/typescript/rename_test.go @@ -0,0 +1,287 @@ +package typescript + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// --- Task 5: schema properties emit client-side names, and the codec +// table's `ts` actually differs from the wire name. -------------------- +// +// Before this task, objectPropsLiteral and schemaToTypeScript rendered +// schema.Properties' WIRE names verbatim, and codecTable.add set every +// field's `ts` to the wire name too -- so encode/decode were identity and +// the emitted TypeScript never matched the configured FieldNaming strategy +// at all. tsFieldName (fieldname.go) and the collision guard already existed +// and were exercised directly by unit tests, but nothing actually rendered +// through them, which is exactly what these tests close. + +// TestObjectPropsLiteralAppliesFieldNaming pins the failing case from the +// task brief: baseSpec()'s "User" schema declares wire property "user_id", +// and under baseConfig()'s default camel strategy the rendered TypeScript +// must show the derived name "userId", not the wire name. +// +// Before this fix, this assertion failed: types.ts contained +// "user_id?: string;" verbatim (objectPropsLiteral and schemaToTypeScript's +// interface case both called tsPropertyKey(propName) directly on the wire +// name, with no call to tsFieldName at all). +func TestObjectPropsLiteralAppliesFieldNaming(t *testing.T) { + out, err := NewGenerator().Generate(context.Background(), baseSpec(), baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + userInterface := extractInterfaceBlock(t, types, "User") + + if !strings.Contains(userInterface, "userId?: string;") { + t.Errorf("expected wire \"user_id\" to render as client field \"userId\" under camel naming, got:\n%s", userInterface) + } + + // Scoped to the "User" interface specifically: baseSpec()'s streaming + // boilerplate (Message, Member, ...) declares its own hardcoded + // "user_id" fields unrelated to schema.Properties rename, so a + // whole-file search would false-negative on those instead of proving + // anything about THIS schema's rename. + if strings.Contains(userInterface, "user_id") { + t.Errorf("wire name \"user_id\" must not appear verbatim in the User interface, got:\n%s", userInterface) + } +} + +// extractInterfaceBlock returns the `export interface { ... }` block +// from types (up to and including the first closing brace on its own +// line), or fails the test if it cannot be found. +func extractInterfaceBlock(t *testing.T, types, name string) string { + t.Helper() + + marker := "export interface " + name + " {" + + start := strings.Index(types, marker) + if start == -1 { + t.Fatalf("interface %q not found in generated types.ts:\n%s", name, types) + } + + end := strings.Index(types[start:], "\n}\n") + if end == -1 { + t.Fatalf("could not find closing brace for interface %q", name) + } + + return types[start : start+end] +} + +// TestCodecTableTSNameIsDerived pins the second half of the failing case: +// the emitted CODECS entry for "User" must record fields.user_id.ts == +// "userId", not "user_id". Before this fix, codecTable.add hardcoded +// `TS: prop` (the wire name) for every field, making the codec table's +// encode/decode identity regardless of the configured strategy. +func TestCodecTableTSNameIsDerived(t *testing.T) { + code, _ := NewCodecGenerator().Generate(baseSpec(), baseConfig()) + + assert.Contains(t, code, `"user_id": {"ts": "userId"}`, + "the codec table must record the DERIVED client name as ts, keyed by the wire name") +} + +// TestCodecRuntimeRenamesUserID is the execution proof for the round trip +// the brief specifies directly: decode({user_id:'x'}, 'User') must yield +// {userId:'x'}, and encode must reverse it. A string-only assertion on the +// table (TestCodecTableTSNameIsDerived, above) can show the `ts` mapping +// exists; only actually running the generated walk shows decode/encode use +// it correctly in both directions. +func TestCodecRuntimeRenamesUserID(t *testing.T) { + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(baseSpec(), baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +const decoded = decode({ user_id: 'x' }, 'User'); +results.decoded = decoded; + +results.encoded = encode(decoded, 'User'); + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_rename.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_rename.ts") + + var got struct { + Decoded map[string]any `json:"decoded"` + Encoded map[string]any `json:"encoded"` + } + decodeLastLine(t, stdout, &got) + + assert.Equal(t, map[string]any{"userId": "x"}, got.Decoded, + "decode({user_id:'x'}, 'User') must yield {userId:'x'}; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"user_id": "x"}, got.Encoded, + "encode must reverse decode back to the wire name; driver stdout:\n%s", stdout) +} + +// TestNamingPreserveLeavesFieldsAndCodecUnchanged asserts that under +// NamingPreserve, both the rendered TypeScript AND the codec table keep the +// wire name verbatim -- the rename this task adds must be strategy-gated, +// not unconditional. +func TestNamingPreserveLeavesFieldsAndCodecUnchanged(t *testing.T) { + config := baseConfig() + config.FieldNaming = client.NamingPreserve + + out, err := NewGenerator().Generate(context.Background(), baseSpec(), config) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + if !strings.Contains(types, "user_id?: string;") { + t.Errorf("expected wire name \"user_id\" preserved verbatim under NamingPreserve, got:\n%s", types) + } + + code, _ := NewCodecGenerator().Generate(baseSpec(), config) + assert.Contains(t, code, `"user_id": {"ts": "user_id"}`, + "NamingPreserve must keep ts == wire in the codec table too") +} + +// --- Phase 2 survival: required optionality, JSDoc, @deprecated, and +// quoted non-identifier keys must all still work once the property key +// itself is a derived name rather than the wire name verbatim. ----------- + +// TestRenamedPropertySurvivesPhase2Rendering is the single combined +// regression guard the brief asks for explicitly: one schema exercising +// every Phase 2 behavior at once, all on properties that are ALSO renamed +// by this task, so a regression in any of the four could not hide behind a +// property that happens not to need renaming. +func TestRenamedPropertySurvivesPhase2Rendering(t *testing.T) { + spec := baseSpec() + spec.Schemas["Mixed"] = &client.Schema{ + Type: "object", + Required: []string{"user_id"}, + Properties: map[string]*client.Schema{ + // Required (by its WIRE name) AND renamed: must render with no + // "?" under its DERIVED name. + "user_id": {Type: "string"}, + // Renamed, with a description and @deprecated: JSDoc must still + // be emitted, keyed off the property's schema (unaffected by + // the rename), immediately above the renamed key. + "old_flag": {Type: "boolean", Description: "Old flag.", Deprecated: true}, + // Renamed to a name that is STILL not a valid TS identifier + // (leading digit survives camel derivation): tsPropertyKey must + // still quote it. + "3d_tiles": {Type: "string"}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + mixed := extractInterfaceBlock(t, types, "Mixed") + + // Required property, renamed: no "?". + assert.Contains(t, mixed, "userId: string;", "a required property must render without \"?\" under its derived name") + assert.NotContains(t, mixed, "userId?: string;") + + // JSDoc + @deprecated survive on the renamed key. Deprecated forces the + // multi-line JSDoc form (propertyJSDoc only uses the single-line "/** ... */" + // shorthand when Deprecated is false), so this matches that form rather + // than TestPropertyJSDocIsEmitted's non-deprecated "kept" case. + assert.Contains(t, mixed, " * Old flag.\n * @deprecated\n */") + assert.Contains(t, mixed, "oldFlag?: boolean;", "the deprecated property must still render under its derived camelCase name") + + // A derived name that remains a non-identifier is still quoted. + assert.Contains(t, mixed, `"3dTiles"?: string;`, "camel-derivation of \"3d_tiles\" (\"3dTiles\") still starts with a digit and must still be quoted") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + errs := typeCheck(t, dir) + assert.Empty(t, errs, "the combined Phase 2 + rename case must still type-check cleanly") +} + +// --- The override-actually-applies proof --------------------------------- +// +// Task 3's review left this half-proven: it verified a FieldOverrides entry +// silences the collision ERROR, but could not verify it takes effect at +// RENDER time, because nothing rendered through tsFieldName yet. These +// tests close that gap directly: they regenerate with the override set and +// assert the OVERRIDE VALUE -- not just the absence of an error -- appears +// in both the rendered types.ts and the codec table's `ts`. + +// TestFieldOverrideAppliesAtTopLevel is the simplest case: a top-level +// schema's own property, overridden. +func TestFieldOverrideAppliesAtTopLevel(t *testing.T) { + config := collisionConfig() + config.FieldOverrides = map[string]string{"User.user_id": "userIdentifier"} + + out, err := NewGenerator().Generate(context.Background(), collisionSpec(), config) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + assert.Contains(t, types, "userIdentifier", "the override value must appear in rendered TypeScript, not just silence the collision error") + + code, _ := NewCodecGenerator().Generate(collisionSpec(), config) + assert.Contains(t, code, `"user_id": {"ts": "userIdentifier"}`, "the override value must be the codec table's ts, not the strategy-derived name") +} + +// TestFieldOverrideAppliesInsideNestedInlineObject proves the override key +// printed for a nested inline-object collision (Order.shipping.street_name) +// resolves at RENDER time, not just at the collision-guard level: the +// override value must appear in the nested object's rendered literal AND in +// the codec table's "Order.shipping" entry. +func TestFieldOverrideAppliesInsideNestedInlineObject(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Properties: map[string]*client.Schema{ + "shipping": { + Type: "object", + Properties: map[string]*client.Schema{ + "street_name": {Type: "string"}, + "streetName": {Type: "string"}, + }, + }, + }, + }, + }, + } + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Order.shipping.street_name": "streetNameAlt"} + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + assert.Contains(t, types, "streetNameAlt", "the override value for a nested inline object's property must appear in the rendered object literal") + + code, _ := NewCodecGenerator().Generate(spec, config) + assert.Contains(t, code, `"Order.shipping": {"kind": "object", "fields": {"streetName": {"ts": "streetName"}, "street_name": {"ts": "streetNameAlt"}}}`, + "the codec table's \"Order.shipping\" namespace must record the override value as ts") +} + +// TestFieldOverrideAppliesAcrossFlattenedAllOf proves the same for the +// flattened-allOf namespace (Addr, composed of a $ref member and an inline +// member): the printed key "Addr.streetName" must resolve at render time in +// both the allOf intersection's rendered object literal and the codec +// table's merged "Addr" entry. +func TestFieldOverrideAppliesAcrossFlattenedAllOf(t *testing.T) { + spec := allOfFlattenedCollisionSpec() + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Addr.streetName": "streetNameAlt"} + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + assert.Contains(t, types, "streetNameAlt", "the override value for a flattened allOf member's property must appear in the rendered intersection type") + + code, _ := NewCodecGenerator().Generate(spec, config) + assert.Contains(t, code, `"streetNameAlt"`, "the codec table's merged \"Addr\" entry must record the override value as ts") + assert.Contains(t, code, `"Addr":`) +} From e9ea554f689abcdffa974c602d44cc652138e645 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 18:07:15 -0500 Subject: [PATCH 50/71] fix(client/typescript): key allOf $ref-member renames under the ref target, not the composition Round 1 fixes for the property-renaming task: - checkFlattenedAllOfCollisions and allOfEntry derived every allOf layer's client name using the COMPOSITION's own namespace id, even for a layer reached via a $ref. schemaToTSType never recurses into a $ref member's properties (it renders the bare type name and lets that schema's own top-level export own its fields), so a $ref-contributed field's real namespace is the ref target's name, not the composition's. The guard printed a FieldOverrides key scoped to the composition, which silenced the collision error while having zero effect on the rendered type -- the exact "prints a key that doesn't work" defect an earlier commit already fixed for a different allOf shape, reintroduced here. flattenAllOfLayers now returns each layer's own namespace id alongside its schema, and both callers key off that instead of the composition id unconditionally. - codecTable.add returned before reaching the additionalProperties branch whenever a schema also declared Properties, so a schema with BOTH never registered a codec entry for its additionalProperties value schema. The rendered TypeScript (an intersection type) already promised renamed fields inside those values; decode/encode had no codec to walk them with. The object-kind entry now also carries an optional `values` id, and the runtime renames an unknown key's value through it when present. - A field literally named "__proto__" is now emitted as a computed object key (`["__proto__"]:`) rather than a plain literal key, which the JS spec special-cases as a prototype assignment instead of an own property. The runtime's key/value assignment also now goes through a `setOwn` helper (Object.defineProperty) instead of bracket assignment, since that assignment form hits the same legacy __proto__ accessor on the target side. - Corrected an overclaiming doc comment: the collision guard's depth cap bounds only its own traversal, not generator.go's renderer, which has no equivalent guard and was confirmed to hang on the same hand-built, non-reachable-from-real-input pointer-cycle schema. No fixture output changed except src/codecs.ts's runtime machinery (the `values` field and the setOwn helper) -- none of the 9 gate fixtures exercise the shapes these fixes target, which are covered by new, dedicated tests instead. --- .../client/generators/typescript/codecs.go | 190 +++++++++- .../generators/typescript/codecs_test.go | 2 +- .../client/generators/typescript/fieldname.go | 134 ++++--- .../generators/typescript/rename_test.go | 355 +++++++++++++++++- 4 files changed, 610 insertions(+), 71 deletions(-) diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 349ed6bb..32461410 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -45,7 +45,13 @@ type codecEntry struct { // array Items string `json:"items,omitempty"` - // record + // record, AND an "object" entry for a schema that declares BOTH + // Properties and additionalProperties (see codecTable.add's Properties + // case): decode/encode's 'object' runtime case renames Fields as usual + // and, for any key not in Fields, walks its VALUE through Values instead + // of leaving it untouched -- matching the rendered intersection type + // (objectPropsLiteral & Record), which promises that + // same value schema's fields are renamed too. Values string `json:"values,omitempty"` // union. Discriminator is absent for an undiscriminated union: Members @@ -209,7 +215,27 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) } } - t.entries[id] = codecEntry{Kind: "object", Fields: fields, Required: requiredWireFields(fields, schema.Required)} + entry := codecEntry{Kind: "object", Fields: fields, Required: requiredWireFields(fields, schema.Required)} + + // A schema can declare BOTH Properties and additionalProperties -- + // schemaToTSType/schemaToTypeScript render this as an intersection + // (objectPropsLiteral & Record), and generator.go + // now renames properties INSIDE valueType too (via nsID+".values"). + // Falling through to the additionalProperties-only branch below + // never runs for this shape (this `case` already returns), so + // without this, such a schema's `.values` codec entry never got + // registered at all: a declared-and-renamed value schema with no + // codec id to walk it by, silently identity for every "additional" + // key's value even though the emitted TYPE promises renamed fields. + // Recording Values here, on the SAME "object" entry, is enough -- + // no new `kind` is needed, since decode/encode's 'object' case + // (codecRuntime) already renames declared fields and can fall back + // to `values` for anything left over. + if values, ok := additionalPropsSchema(schema.AdditionalProperties); ok { + entry.Values = t.codecIDFor(id, "values", values, spec) + } + + t.entries[id] = entry return } @@ -341,6 +367,34 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A } } +// allOfLayer is one contributing layer flattenAllOfLayers resolves an allOf +// composition down to: the schema that directly owns the properties, plus +// the namespace id those properties must be keyed under for tsFieldName +// (and codecIDFor's synthetic-id derivation for any of the layer's own +// nested composites) to agree with what actually renders. +// +// nsID is "" for an INLINE layer -- one reached without crossing a $ref at +// all -- meaning its properties render as part of the allOf composition's +// own intersection member (objectPropsLiteral called with the +// composition's own id), so callers must substitute the composition's own +// id for an empty nsID. nsID is the resolved schema NAME for a layer +// reached via one or more $ref hops (the most immediate one before +// properties were found -- see the "label" parameter below): that layer's +// properties do NOT render as part of the composition at all -- +// schemaToTSType's AllOf case returns a $ref member's bare type name +// without recursing into it (generator.go) -- they render under the ref +// target's OWN top-level `export interface`/`export type`, so that target +// name is the only namespace id whose FieldOverrides entries, or whose +// codec-table entry, the rendered output will ever actually consult. +// Using the composition's id for such a layer -- the behaviour before this +// fix -- let a printed FieldOverrides key silence the collision guard +// while having no effect on the rendered type at all (see allOfEntry and +// checkFlattenedAllOfCollisions for where nsID is consumed). +type allOfLayer struct { + schema *client.Schema + nsID string +} + // flattenAllOfLayers recursively resolves schema into an ordered list of the // schemas that directly own the properties composing it: each AllOf member // resolved through however many further $ref hops and nested AllOf @@ -391,14 +445,18 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A // spec.Schemas, tracking pointers alone catches both cycle shapes with one // mechanism. // -// label carries "how did we get here" purely for the union-member warning -// above -- the $ref name that led to this schema, or "" for an inline -// schema reached directly from an AllOf slice (rendered as "an inline -// member" in the warning). It is reset to "" for every AllOf member +// label carries "how did we get here" for two purposes: the union-member +// warning above (the $ref name that led to this schema, or "" for an +// inline schema reached directly from an AllOf slice, rendered as "an +// inline member" in the warning), and -- doubling as allOfLayer.nsID -- the +// namespace id a contributing layer's properties must be keyed under (see +// allOfLayer's doc comment). It is reset to "" for every AllOf member // recursed into below, then set to the resolved name whenever a $ref hop -// is followed, so a union found ANY number of hops deep is still -// attributed to the $ref (if any) that most immediately led to it. -func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpec, visited map[*client.Schema]bool) (layers []*client.Schema, polymorphicMembers []string) { +// is followed, so a schema found ANY number of hops deep is still +// attributed to the $ref (if any) that most immediately led to it -- the +// exact $ref name whose OWN top-level rendering will actually own these +// properties. +func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpec, visited map[*client.Schema]bool) (layers []allOfLayer, polymorphicMembers []string) { if schema == nil || visited[schema] { return nil, nil } @@ -428,7 +486,7 @@ func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpe } if len(schema.Properties) > 0 { - layers = append(layers, schema) + layers = append(layers, allOfLayer{schema: schema, nsID: label}) } return layers, polymorphicMembers @@ -518,6 +576,18 @@ func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpe // `undefined`. Passthrough is the safe, honest degradation: an // unresolvable composition can't be walked, so the table must not claim it // can. +// +// Each layer's `ts` (and, for a nested composite property, the further +// synthetic id it recurses under) is derived using THAT layer's own nsID +// (see allOfLayer's doc comment), not unconditionally `id`: a field +// contributed by a $ref member renders under the ref target's own +// top-level export, so its FieldOverrides key and its codec-table +// namespace are the ref target's name, never this composition's. Getting +// this wrong (using `id` for every layer, the behaviour before this fix) +// let a FieldOverrides entry the collision guard printed apply to this +// table's entry while having zero effect on the actually-rendered type -- +// exactly the "prints a key that doesn't work" failure this whole area of +// the guard exists to eliminate, reintroduced on the renderer side. func (t *codecTable) allOfEntry(id string, schema *client.Schema, spec *client.APISpec) codecEntry { layers, polymorphicMembers := flattenAllOfLayers(schema, "", spec, map[*client.Schema]bool{}) @@ -543,18 +613,30 @@ func (t *codecTable) allOfEntry(id string, schema *client.Schema, spec *client.A var required []string for _, layer := range layers { - for _, prop := range sortedKeys(layer.Properties) { - codecID := t.codecIDFor(id, prop, layer.Properties[prop], spec) + // An inline layer (nsID == "") renders as part of this composition's + // own intersection member, so it is keyed under the composition's + // own id. A layer reached via a $ref (nsID != "") renders under + // that ref target's own top-level namespace instead -- see + // allOfLayer's doc comment for why using `id` unconditionally here + // (the pre-fix behaviour) produced a FieldOverrides key the + // rendered type never actually consults. + layerNSID := layer.nsID + if layerNSID == "" { + layerNSID = id + } + + for _, prop := range sortedKeys(layer.schema.Properties) { + codecID := t.codecIDFor(layerNSID, prop, layer.schema.Properties[prop], spec) if prev, ok := fieldCodec[prop]; ok && prev != codecID { conflicts[prop] = true } fieldCodec[prop] = codecID - fields[prop] = codecField{TS: tsFieldName(id, prop, t.config), Codec: codecID} + fields[prop] = codecField{TS: tsFieldName(layerNSID, prop, t.config), Codec: codecID} } - required = append(required, layer.Required...) + required = append(required, layer.schema.Required...) } if len(conflicts) > 0 { @@ -639,7 +721,7 @@ func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorC buf.WriteString("// and decode below walk this table to rename between the two.\n\n") buf.WriteString("export type Codec =\n") - buf.WriteString(" | { kind: 'object'; fields: Record; required?: string[] }\n") + buf.WriteString(" | { kind: 'object'; fields: Record; required?: string[]; values?: string }\n") buf.WriteString(" | { kind: 'array'; items?: string }\n") buf.WriteString(" | { kind: 'record'; values?: string }\n") buf.WriteString(" | { kind: 'union'; discriminator?: { wire: string; map: Record }; members: string[] }\n") @@ -661,7 +743,17 @@ func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorC continue } - fmt.Fprintf(&buf, " %s: %s,\n", key, spacedJSON(string(encoded))) + // keyText guards a schema (or synthetic) id literally named + // "__proto__" directly, since `id` is already a plain Go string here + // -- no substring surgery needed for this side. protectDunderProto + // below handles the other side: a `fields`/discriminator-`map` key + // of the same name, buried inside the already-marshalled entry text. + keyText := string(key) + if id == "__proto__" { + keyText = `["__proto__"]` + } + + fmt.Fprintf(&buf, " %s: %s,\n", keyText, protectDunderProto(spacedJSON(string(encoded)))) } buf.WriteString("};\n\n") @@ -701,6 +793,35 @@ func spacedJSON(s string) string { return strings.ReplaceAll(s, `,"`, `, "`) } +// protectDunderProto rewrites a plain `"__proto__": value` object-literal +// key -- wherever it appears in s, whether the top-level CODECS id, a +// `fields` key, or a discriminator `map` key -- into computed-key syntax, +// `["__proto__"]: value`. +// +// This is not cosmetic: `{ "__proto__": x }` written as a JS object LITERAL +// sets the object's PROTOTYPE rather than an own property (a special case +// in the language spec that applies only to a non-computed literal +// PropertyName, not to bracket/computed syntax). codecRuntime's rename map +// is built with `Object.entries(codec.fields)`, which only sees OWN +// enumerable properties -- so a wire field literally named "__proto__" +// would silently vanish from that map, and decode/encode would leave it +// completely untouched (passed through as an unrecognised key) even though +// the codec table claims it has a `ts` mapping for it. +// +// The replacement is safe as a blind substring search: json.Marshal has +// already escaped every key, so the exact 13-byte sequence `"__proto__":` +// (opening quote, "__proto__", closing quote, colon) can only occur when a +// JSON key is EXACTLY "__proto__" -- a longer key like "myproto__proto__x" +// marshals with its own quotes wrapping the WHOLE key +// (`"myproto__proto__x":`), never producing an embedded, separately-quoted +// `"__proto__"` substring. The pattern also cannot match inside a VALUE: +// object-position `"__proto__"` is always immediately followed by `:`; +// value-position `"__proto__"` (e.g. a `ts` that happens to equal the +// string "__proto__") is followed by `,` or `}`, never `:`. +func protectDunderProto(s string) string { + return strings.ReplaceAll(s, `"__proto__":`, `["__proto__"]:`) +} + // codecRuntime is the emitted encode/decode implementation. The rules it // enforces are load-bearing: // @@ -712,11 +833,32 @@ func spacedJSON(s string) string { // - a union WITHOUT one tries each declared member in order, structurally: // the first whose required wire fields are all present on the value // wins. No match falls back to passthrough -- never a best-effort guess, -// because guessing could rename fields based on a match that is wrong. +// because guessing could rename fields based on a match that is wrong; +// - every key written onto a walked result goes through setOwn +// (Object.defineProperty), not bracket/dot assignment, so a field +// literally named "__proto__" (wire or client name) becomes a real own +// property instead of silently reassigning the result's prototype via +// the legacy Object.prototype.__proto__ accessor. const codecRuntime = `function codecFor(id?: string): Codec | undefined { return id ? CODECS[id] : undefined; } +// setOwn assigns obj[key] = value via Object.defineProperty rather than +// bracket/dot assignment. This matters for exactly one key: "__proto__". +// obj[key] = value, when key is the literal string "__proto__", goes +// through the legacy Object.prototype.__proto__ ACCESSOR -- it sets obj's +// PROTOTYPE (silently ignoring the assignment entirely if value is not an +// object or null) instead of creating an own data property. A wire (or, +// under NamingPreserve/an override, client) field literally named +// "__proto__" would otherwise silently vanish from the walked result: not +// an error, just an object one property short of what the codec table +// claims it renamed. Object.defineProperty has no such special case for any +// key, "__proto__" included, so it is used unconditionally here rather than +// only when key happens to be "__proto__" -- one code path, not two. +function setOwn(obj: Record, key: string, value: unknown): void { + Object.defineProperty(obj, key, { value, writable: true, enumerable: true, configurable: true }); +} + function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { const codec = codecFor(id); if (!codec || value === null || value === undefined) { @@ -746,10 +888,18 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { for (const [key, val] of Object.entries(src)) { const mapped = rename.get(key); if (mapped) { - out[mapped.to] = walk(val, mapped.codec, toTS); + setOwn(out, mapped.to, walk(val, mapped.codec, toTS)); + } else if (codec.values) { + // Not a declared field, but this object also has a typed + // additionalProperties value schema (a declared-Properties-AND- + // additionalProperties schema, rendered as an intersection type): + // the KEY is data and stays untouched, exactly like the 'record' + // case below, but the VALUE still needs walking so its own + // fields get renamed too. + setOwn(out, key, walk(val, codec.values, toTS)); } else { // Unknown key: pass through verbatim, name and value untouched. - out[key] = val; + setOwn(out, key, val); } } @@ -774,7 +924,7 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { // Keys are data here, not field names — they are never renamed. for (const [key, val] of Object.entries(src)) { - out[key] = walk(val, codec.values, toTS); + setOwn(out, key, walk(val, codec.values, toTS)); } return out; diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index a28aa2f6..e3734a9c 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -223,7 +223,7 @@ func TestCodecWarningsSurfaceOnGeneratedClient(t *testing.T) { func TestCodecRuntimeRulesArePresent(t *testing.T) { code, _ := NewCodecGenerator().Generate(baseSpec(), baseConfig()) - assert.Contains(t, code, "out[key] = val;", "unknown keys must pass through verbatim") + assert.Contains(t, code, "setOwn(out, key, val);", "unknown keys must pass through verbatim") assert.Contains(t, code, "Keys are data here", "a record must rename values but never keys") assert.True(t, strings.Contains(code, "if (typeof tag !== 'string')"), "a union whose discriminator value is missing or non-string must fall back to passthrough") diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index 635b1b02..f7cecd4d 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -265,11 +265,21 @@ func dedupeMessages(messages []string) []string { // recursed into here -- see the paragraph above -- and that is the only way // spec_parser.go or introspector.go could produce a cycle; see // TestOneOfSelfReferenceDoesNotInfiniteLoop's doc comment for the same -// observation about schemaToTSType), so it is not worth a real fix. The -// maxFieldCollisionDepth check below is a cheap, unconditional bound against -// it (and against any other pathologically deep hand-built schema graph) -// rather than leaving an unbounded id string and an unbounded stack as the -// only limits. +// observation about schemaToTSType), so it is not worth a real fix here. +// +// The maxFieldCollisionDepth check below bounds ONLY this function's own +// traversal (reproduced directly: without it, this call does not return). +// It does NOT make generation as a whole safe for this input: generator.go's +// objectPropsLiteral/schemaToTSType recurse through the identical shape (a +// growing nsID string standing in for id here) with no visited set and no +// depth bound of their own, and were reproduced hanging on the same +// hand-built pointer-cycle schema for over 10s with no sign of returning. +// This is accepted as a documented, non-reachable-from-real-input gap +// rather than fixed, for the same reason the guard's own gap is: no parser +// in this codebase can produce the shape that triggers it. A future task +// that needs generation to be safe against arbitrary hand-built (not +// parsed) *client.Schema graphs would need to add an equivalent bound to +// the renderer too, not just here. // // allOf is handled by ONE pass -- checkFlattenedAllOfCollisions, below -- // not a per-member loop, and this is a deliberate correction of an earlier @@ -296,12 +306,14 @@ func dedupeMessages(messages []string) []string { // independently maintained notion of what an allOf's fields (and their // nested structure) even are; the guard and the table cannot disagree // about the namespace if there is only one function that computes it. -// maxFieldCollisionDepth bounds checkSchemaFieldCollisions' recursion depth -// (approximated by the number of "." path segments in id) against a -// hand-built schema graph containing a genuine Go pointer cycle with no -// $ref involved -- see checkSchemaFieldCollisions' doc comment for why -// visited cannot catch that shape. No real OpenAPI-derived spec nests this -// deep, so the cap is generous rather than tight. +// maxFieldCollisionDepth bounds ONLY checkSchemaFieldCollisions' own +// recursion depth (approximated by the number of "." path segments in id) +// against a hand-built schema graph containing a genuine Go pointer cycle +// with no $ref involved -- see checkSchemaFieldCollisions' doc comment for +// why visited cannot catch that shape, and for why this bound does not +// extend to generator.go's renderer, which has no equivalent guard. No +// real OpenAPI-derived spec nests this deep, so the cap is generous rather +// than tight. const maxFieldCollisionDepth = 200 func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig, visited map[string]bool) []string { @@ -368,25 +380,31 @@ func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.A return messages } -// checkFlattenedAllOfCollisions checks the namespace allOfEntry +// checkFlattenedAllOfCollisions checks the namespace(s) allOfEntry // (codecs.go) actually builds for an allOf composition: every contributing -// member's properties, flattened into ONE merged set keyed by id, exactly -// as flattenAllOfLayers resolves it (the SAME function codecs.go's -// allOfEntry calls -- see checkSchemaFieldCollisions's doc comment for why -// reusing it, rather than reimplementing allOf resolution a second time, -// is the whole point of this function existing). +// layer's properties, resolved exactly as flattenAllOfLayers resolves them +// (the SAME function codecs.go's allOfEntry calls -- see +// checkSchemaFieldCollisions's doc comment for why reusing it, rather than +// reimplementing allOf resolution a second time, is the whole point of this +// function existing). // -// tsFieldName is called with id as the schema name for every layer's -// TOP-level property, matching how allOfEntry's own field derivation works -// (parentID stays the allOf schema's own id for every layer, never a -// per-layer id) -- so the FieldOverrides key this prints for a top-level -// collision, "id.wireName", is the exact key that will actually take -// effect. +// tsFieldName is called with EACH layer's OWN nsID (allOfLayer.nsID, or id +// -- this composition's own id -- when a layer's nsID is "", meaning it was +// reached without crossing a $ref): allOfEntry derives `ts` the identical +// way, and generator.go's renderer only ever recurses into a $ref member's +// properties under that ref target's own top-level namespace (schemaToTSType +// returns a $ref member's bare type name without recursing into it at all), +// never under this composition's id. Using `id` unconditionally for every +// layer -- the behaviour before this fix -- printed a FieldOverrides key +// that silenced this guard's error while having NO effect on the actually +// rendered type for any $ref-contributed field: exactly the "prints a key +// that doesn't work" failure class Finding 1 (below) already fixed for a +// nested-inline nsID, reintroduced here for the $ref-layer case. // // A property that is itself an inline composite (a nested object, an // array's inline-object items, an inline additionalProperties value // schema, or a further oneOf/anyOf/allOf) is recursed into via -// checkSchemaFieldCollisions using id+"."+wireName as the child's own +// checkSchemaFieldCollisions using layerNSID+"."+wireName as the child's own // namespace -- codecIDFor derives that SAME "." id for any // property, allOf-contributed or not, so this reuses the identical // resolution codecs.go performs rather than inventing a different scheme @@ -401,46 +419,74 @@ func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.A // checkSchemaFieldCollisions walk (not a fresh one per allOf composition): // if schema also has its own direct Properties (allOf permits this // alongside AllOf, unusual but legal), flattenAllOfLayers includes schema -// itself as the LAST layer, so this function's per-property recursion into -// e.g. id+"."+wireName for one of schema's own properties would otherwise -// re-enter a namespace the OWN-Properties block above already recursed -// into -- reusing visited makes that a no-op instead of a duplicate walk, -// which is also why the schema-own-Properties-collision case can still -// surface a duplicate MESSAGE (the top-level owner-map check above and -// this function's owner-map check both run independently over the same -// properties) -- checkFieldNameCollisions dedupes exact-duplicate message -// strings for that reason, deliberately, rather than this function trying -// to detect and suppress the overlap itself. +// itself as the LAST layer (with nsID == "", since reaching schema's own +// Properties crosses no $ref), so this function's per-property recursion +// into e.g. id+"."+wireName for one of schema's own properties would +// otherwise re-enter a namespace the OWN-Properties block above already +// recursed into -- reusing visited makes that a no-op instead of a +// duplicate walk, which is also why the schema-own-Properties-collision +// case can still surface a duplicate MESSAGE (the top-level owner-map +// check above and this function's owner-map check both run independently +// over the same properties) -- checkFieldNameCollisions dedupes +// exact-duplicate message strings for that reason, deliberately, rather +// than this function trying to detect and suppress the overlap itself. +// The same reasoning now also applies to a $ref layer: its properties are +// ALSO checked independently by the top-level walk under its own name, so +// a duplicate (byte-identical) message is possible there too, and is +// deduped the same way. func checkFlattenedAllOfCollisions(id string, schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig, visited map[string]bool) []string { layers, _ := flattenAllOfLayers(schema, "", spec, map[*client.Schema]bool{}) var messages []string - owner := make(map[string]string) // client name -> first wire name that claimed it + // owner tracks, per derived client name, the first WIRE NAME that + // claimed it -- deliberately just the wire name, not also the layer, + // matching the pre-existing "first == wireName means not a real + // collision" rule: the SAME wire name declared by two different layers + // (e.g. Dup = allOf[$ref MemberA, $ref MemberB], both declaring + // "payload") is not a naming ambiguity at all -- there is only ONE wire + // key in the merged JSON object either way -- it is the separate + // "conflicting shapes, last-declared-wins" concern allOfEntry already + // warns about. A real collision is two DIFFERENT wire names landing on + // the same client name. + owner := make(map[string]string) for _, layer := range layers { - for _, wireName := range sortedKeys(layer.Properties) { - prop := layer.Properties[wireName] - clientName := tsFieldName(id, wireName, config) + // An inline layer (nsID == "") renders as part of this + // composition's own intersection member; a layer reached via a + // $ref renders under that ref target's own top-level export + // instead (see allOfLayer's doc comment). The printed key below + // uses THIS layer's nsID -- the one that actually owns the + // conflicting property at render time -- not the first claimer's. + layerNSID := layer.nsID + if layerNSID == "" { + layerNSID = id + } + + for _, wireName := range sortedKeys(layer.schema.Properties) { + prop := layer.schema.Properties[wireName] + clientName := tsFieldName(layerNSID, wireName, config) if first, claimed := owner[clientName]; claimed { if first != wireName { messages = append(messages, fmt.Sprintf( "schema %q: wire names %q and %q both resolve to client field %q; add FieldOverrides[%q] to disambiguate", - id, first, wireName, clientName, id+"."+wireName)) + id, first, wireName, clientName, layerNSID+"."+wireName)) } // first == wireName: the identical wire name reachable // through two different paths to the same layer (e.g. a - // diamond allOf graph) -- not a real collision. The - // recursion below still runs for this occurrence, but - // visited makes a repeat walk of the same namespace a - // no-op rather than a duplicate. + // diamond allOf graph), OR the same wire name legitimately + // redeclared by two different layers (the Dup case above) + // -- neither is a real naming collision. The recursion + // below still runs for this occurrence, but visited makes + // a repeat walk of the same namespace a no-op rather than + // a duplicate. } else { owner[clientName] = wireName } if prop != nil && prop.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(id+"."+wireName, prop, spec, config, visited)...) + messages = append(messages, checkSchemaFieldCollisions(layerNSID+"."+wireName, prop, spec, config, visited)...) } } } diff --git a/internal/client/generators/typescript/rename_test.go b/internal/client/generators/typescript/rename_test.go index 82d69286..4190c427 100644 --- a/internal/client/generators/typescript/rename_test.go +++ b/internal/client/generators/typescript/rename_test.go @@ -265,13 +265,28 @@ func TestFieldOverrideAppliesInsideNestedInlineObject(t *testing.T) { } // TestFieldOverrideAppliesAcrossFlattenedAllOf proves the same for the -// flattened-allOf namespace (Addr, composed of a $ref member and an inline -// member): the printed key "Addr.streetName" must resolve at render time in -// both the allOf intersection's rendered object literal and the codec -// table's merged "Addr" entry. +// flattened-allOf namespace (Addr, composed of a $ref member Base and an +// INLINE member declaring "streetName" second): the printed key +// "Addr.streetName" must resolve at render time in both the allOf +// intersection's rendered object literal and the codec table's merged +// "Addr" entry. allOfFlattenedCollisionSpec() declares Base ($ref, "street_name") +// FIRST and the inline member ("streetName") SECOND, so the inline member is +// the one flagged (Base's own "street_name" claims "streetName" first) -- +// the printed key is therefore the COMPOSITION's own namespace, "Addr", +// which is correct because the inline member genuinely renders as part of +// Addr's own intersection member. The mirror case, where the $ref member is +// the one flagged, is TestFieldOverrideResolvesRefContributedAllOfCollision +// below -- fixing round 1's CRITICAL 1 finding is specifically about that +// mirror case, where the pre-fix code printed this SAME "Addr." key +// unconditionally even when the wire name in question was contributed by +// the $ref member, which does not render under "Addr" at all. func TestFieldOverrideAppliesAcrossFlattenedAllOf(t *testing.T) { spec := allOfFlattenedCollisionSpec() + err := checkFieldNameCollisions(spec, collisionConfig()) + require.Error(t, err) + assert.Contains(t, err.Error(), `FieldOverrides["Addr.streetName"]`) + config := collisionConfig() config.FieldOverrides = map[string]string{"Addr.streetName": "streetNameAlt"} @@ -281,7 +296,335 @@ func TestFieldOverrideAppliesAcrossFlattenedAllOf(t *testing.T) { types := out.Files["src/types.ts"] assert.Contains(t, types, "streetNameAlt", "the override value for a flattened allOf member's property must appear in the rendered intersection type") + // Exact entries, not a loose whole-file Contains: pins that the OVERRIDE + // value lands on the INLINE member's field ("streetName") while Base's + // own $ref-contributed field ("street_name") keeps its ordinary + // strategy-derived name, in BOTH the merged "Addr" entry and Base's own + // independent top-level entry. code, _ := NewCodecGenerator().Generate(spec, config) - assert.Contains(t, code, `"streetNameAlt"`, "the codec table's merged \"Addr\" entry must record the override value as ts") - assert.Contains(t, code, `"Addr":`) + assert.Contains(t, code, `"Addr": {"kind": "object", "fields": {"streetName": {"ts": "streetNameAlt"}, "street_name": {"ts": "streetName"}}}`) + assert.Contains(t, code, `"Base": {"kind": "object", "fields": {"street_name": {"ts": "streetName"}}}`) +} + +// allOfFlattenedCollisionSpecRefSecond is allOfFlattenedCollisionSpec with +// the two AllOf members swapped: the INLINE member ("streetName") declared +// FIRST, the $ref member (Base, "street_name") declared SECOND. sortedKeys +// visits "streetName" (claimed first, by the inline layer) before +// "street_name" (the second, conflicting claim, contributed by the $ref +// layer) either way -- but swapping which MEMBER is declared first changes +// nothing about processing order, since flattenAllOfLayers preserves AllOf +// DECLARATION order, not property sort order, and each layer's OWN +// properties are visited in sortedKeys order independently. What actually +// flips which layer is "first claimer" here is which layer's properties +// happen to enumerate first alphabetically WITHIN that layer -- so this +// spec is built so that the roles are reversed from +// allOfFlattenedCollisionSpec: the review's exact reproduction had the +// inline member first and Base second, and found that ordering is what +// exposed CRITICAL 1 (the composition id was printed and applied even +// though the SECOND, conflicting claim came from Base, the $ref member). +func allOfFlattenedCollisionSpecRefSecond() *client.APISpec { + return &client.APISpec{ + Info: client.APIInfo{Title: "Flattened Collision API (ref second)", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Base": {Type: "object", Properties: map[string]*client.Schema{"street_name": {Type: "string"}}}, + "Addr": { + AllOf: []*client.Schema{ + {Type: "object", Properties: map[string]*client.Schema{"streetName": {Type: "string"}}}, + {Ref: "#/components/schemas/Base"}, + }, + }, + }, + } +} + +// TestFieldOverrideResolvesRefContributedAllOfCollision is round 1's +// CRITICAL 1 regression guard: reproduced directly against the pre-fix +// code, this failed as follows. +// +// checkFieldNameCollisions printed FieldOverrides["Addr.street_name"] (the +// COMPOSITION's own namespace) for the wire name "street_name", which is +// actually contributed by Base, a $ref member. Setting that exact key: +// +// types.ts: export type Addr = { streetName?: string; } & Base; +// export interface Base { street_name?: string; } <-- override absent +// codecs.ts: "Addr": {... "street_name": {"ts": "OVERRIDDEN"} ...} +// "Base": {... "street_name": {"ts": "streetName"} ...} +// +// The printed key silenced checkFieldNameCollisions' error (Generate +// returned no error) while the rendered "Base" interface -- what actually +// declares this field, since schemaToTSType returns a $ref member's bare +// type name without recursing into it -- still collided with the inline +// member's "streetName", and the "Addr" and "Base" codec entries disagreed +// about the field's ts. A key that silences the error while the collision +// is still live in the rendered type is exactly the class of defect fixed +// on the guard side in an earlier round (fix/ts-client-generator-phase1's +// "stop printing phantom FieldOverrides keys for nested allOf collisions"), +// reintroduced here for a $ref-contributed allOf member. +// +// After the fix: the guard must print "Base.street_name" instead (the +// namespace that actually owns this field), and setting THAT key must +// resolve the collision consistently in both the rendered "Base" interface +// and both codec entries that reference it. +func TestFieldOverrideResolvesRefContributedAllOfCollision(t *testing.T) { + spec := allOfFlattenedCollisionSpecRefSecond() + + err := checkFieldNameCollisions(spec, collisionConfig()) + require.Error(t, err, "the inline member's \"streetName\" and Base's \"street_name\" must still collide") + assert.Contains(t, err.Error(), `FieldOverrides["Base.street_name"]`, + "the printed key must name Base -- the namespace that actually owns this $ref-contributed field -- not the composition") + assert.NotContains(t, err.Error(), `FieldOverrides["Addr.street_name"]`, + "a composition-scoped key for a $ref-contributed field would silence the error without the render side ever consulting it") + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Base.street_name": "streetNameAlt"} + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err, "the printed key must actually resolve the collision") + + types := out.Files["src/types.ts"] + base := extractInterfaceBlock(t, types, "Base") + assert.Contains(t, base, "streetNameAlt?: string;", + "Base's own rendered interface -- what actually declares this field -- must show the override") + + code, _ := NewCodecGenerator().Generate(spec, config) + assert.Contains(t, code, `"Base": {"kind": "object", "fields": {"street_name": {"ts": "streetNameAlt"}}}`, + "Base's own independent codec entry must show the override") + assert.Contains(t, code, `"Addr": {"kind": "object", "fields": {"streetName": {"ts": "streetName"}, "street_name": {"ts": "streetNameAlt"}}}`, + "the merged Addr entry must agree -- both entries derive this field's ts from Base's own namespace") +} + +// TestCodecRuntimeRefContributedAllOfFieldRenamesCorrectly is the execution +// proof for the fix above: decoding through the MERGED "Addr" codec entry +// must actually rename the $ref-contributed field using the override, and +// encode must reverse it -- not just that the two entries' `ts` strings +// happen to match textually. +func TestCodecRuntimeRefContributedAllOfFieldRenamesCorrectly(t *testing.T) { + spec := allOfFlattenedCollisionSpecRefSecond() + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Base.street_name": "streetNameAlt"} + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, config) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +const decoded = decode({ streetName: 'x', street_name: 'y' }, 'Addr'); +results.decoded = decoded; +results.encoded = encode(decoded, 'Addr'); + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_allof_ref.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_allof_ref.ts") + + var got struct { + Decoded map[string]any `json:"decoded"` + Encoded map[string]any `json:"encoded"` + } + decodeLastLine(t, stdout, &got) + + assert.Equal(t, map[string]any{"streetName": "x", "streetNameAlt": "y"}, got.Decoded, + "decode must rename the $ref-contributed field via the override, alongside the inline member's untouched \"streetName\"; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"streetName": "x", "street_name": "y"}, got.Encoded, + "encode must reverse both fields back to their wire names; driver stdout:\n%s", stdout) +} + +// --- IMPORTANT 2: a schema declaring BOTH Properties and +// additionalProperties (rendered as an intersection: objectPropsLiteral & +// Record) must rename fields inside the additional +// VALUE schema too, not just the declared Properties. Before this fix, +// codecTable.add's `len(schema.Properties) > 0` case returned before ever +// reaching the additionalProperties block below it, so such a schema never +// got a `values` codec id at all -- the emitted TYPE promised renamed +// fields inside every "additional" value, but decode/encode had no codec to +// walk them with, silently leaving them at their wire names (and, going the +// other way, never renaming an ENCODE-direction "additional" value's client +// names back to wire names either). + +// propsPlusAdditionalSpec returns a schema ("Order") with both a declared +// property ("order_id") and a typed additionalProperties value schema +// (itself an object with property "unit_price") -- the exact shape the +// review reproduced. +func propsPlusAdditionalSpec() *client.APISpec { + spec := baseSpec() + spec.Schemas["Order"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "order_id": {Type: "string"}, + }, + AdditionalProperties: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "unit_price": {Type: "string"}, + }, + }, + } + + return spec +} + +// TestCodecTablePropsPlusAdditionalPropertiesGetsValuesEntry pins the +// failing case directly against the table: before this fix, "Order"'s +// entry had no "values" key at all -- `assert.Contains(t, code, +// `"values":`)` scoped to the Order entry failed outright, since the +// entire additionalProperties branch in codecTable.add was unreachable for +// a schema with any declared Properties. +func TestCodecTablePropsPlusAdditionalPropertiesGetsValuesEntry(t *testing.T) { + code, _ := NewCodecGenerator().Generate(propsPlusAdditionalSpec(), baseConfig()) + + assert.Contains(t, code, `"Order": {"kind": "object", "fields": {"order_id": {"ts": "orderId"}}, "values": "Order.values"}`, + "a schema with both Properties and additionalProperties must register a \"values\" codec id, not silently drop the additionalProperties side") + assert.Contains(t, code, `"Order.values": {"kind": "object", "fields": {"unit_price": {"ts": "unitPrice"}}}`, + "the synthetic \"Order.values\" entry must itself rename the additional value schema's own properties") +} + +// TestCodecRuntimeRenamesInsideAdditionalPropertiesValue is the execution +// proof: decode({order_id:'o', extra:{unit_price:'9'}}, 'Order') must yield +// {orderId:'o', extra:{unitPrice:'9'}} -- the declared field renamed as +// usual, AND the value nested under the unknown ("extra") key ALSO renamed, +// with "extra" itself left untouched since additionalProperties keys are +// data. encode must reverse both directions. +func TestCodecRuntimeRenamesInsideAdditionalPropertiesValue(t *testing.T) { + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(propsPlusAdditionalSpec(), baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +const decoded = decode({ order_id: 'o', extra: { unit_price: '9' } }, 'Order'); +results.decoded = decoded; +results.encoded = encode(decoded, 'Order'); + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_props_plus_additional.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_props_plus_additional.ts") + + var got struct { + Decoded map[string]any `json:"decoded"` + Encoded map[string]any `json:"encoded"` + } + decodeLastLine(t, stdout, &got) + + assert.Equal(t, map[string]any{"orderId": "o", "extra": map[string]any{"unitPrice": "9"}}, got.Decoded, + "decode must rename the declared field AND the value nested under the additionalProperties key, while leaving that key itself (\"extra\") untouched; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"order_id": "o", "extra": map[string]any{"unit_price": "9"}}, got.Encoded, + "encode must reverse both directions; driver stdout:\n%s", stdout) +} + +// --- MINOR 3: a wire (or client) field literally named "__proto__" must +// not silently vanish from the codec table or the walked result. ---------- +// +// Two distinct JS gotchas compound here: +// - `{ "__proto__": x }` as an object LITERAL sets the object's +// prototype instead of creating an own property, so the emitted +// CODECS table's own `fields: {"__proto__": {...}}` would never be +// seen by `Object.entries(codec.fields)` at all; +// - `obj["__proto__"] = x` (or `obj.__proto__ = x`) as a plain +// assignment goes through the legacy `Object.prototype.__proto__` +// accessor, silently reassigning `obj`'s prototype (a no-op if `x` +// isn't itself an object) instead of creating an own property -- so +// even with the table fixed, decode/encode's own `out[key] = value` +// writes would still have silently dropped a "__proto__" field. + +// dunderProtoSpec returns a schema ("Weird2") with a property literally +// named "__proto__". +func dunderProtoSpec() *client.APISpec { + spec := baseSpec() + spec.Schemas["Weird2"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "__proto__": {Type: "string"}, + }, + } + + return spec +} + +// TestCodecTableEmitsProtoAsComputedKey pins the table-literal half of the +// fix: the emitted `fields` entry for a property named "__proto__" must use +// computed-key syntax (`["__proto__"]:`), not the plain literal form +// (`"__proto__":`) that the object-literal special case would silently +// swallow. +func TestCodecTableEmitsProtoAsComputedKey(t *testing.T) { + code, _ := NewCodecGenerator().Generate(dunderProtoSpec(), baseConfig()) + + assert.Contains(t, code, `"Weird2": {"kind": "object", "fields": {["__proto__"]: {"ts": "proto"}}}`) + assert.NotContains(t, code, `"__proto__": {"ts"`, "a plain literal \"__proto__\" key would silently become a prototype reassignment instead of an own property") +} + +// TestGeneratedProtoFieldTypeChecks proves the computed-key rewrite is +// still valid, type-checking TypeScript -- not just a string that happens +// to look right. +func TestGeneratedProtoFieldTypeChecks(t *testing.T) { + out, err := NewGenerator().Generate(context.Background(), dunderProtoSpec(), baseConfig()) + require.NoError(t, err) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "the computed-key \"__proto__\" rewrite must still type-check cleanly") +} + +// TestCodecRuntimeRoundTripsProtoField is the execution proof for BOTH +// gotchas at once. The input is built via JSON.parse, not an object +// literal, specifically so the TEST's own input genuinely has "__proto__" +// as an own property (an object literal `{ __proto__: 'x' }` in the driver +// itself would suffer the identical special-casing and prove nothing). +func TestCodecRuntimeRoundTripsProtoField(t *testing.T) { + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(dunderProtoSpec(), baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +const input: Record = JSON.parse('{"__proto__":"x"}'); +results.inputIsOwn = Object.prototype.hasOwnProperty.call(input, '__proto__'); + +const decoded = decode(input, 'Weird2') as Record; +results.decodedIsOwn = Object.prototype.hasOwnProperty.call(decoded, 'proto'); +results.decoded = { ...decoded }; + +const encoded = encode(decoded, 'Weird2') as Record; +results.encodedIsOwn = Object.prototype.hasOwnProperty.call(encoded, '__proto__'); +results.encoded = { ...encoded }; + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_proto.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_proto.ts") + + var got struct { + InputIsOwn bool `json:"inputIsOwn"` + DecodedIsOwn bool `json:"decodedIsOwn"` + Decoded map[string]any `json:"decoded"` + EncodedIsOwn bool `json:"encodedIsOwn"` + Encoded map[string]any `json:"encoded"` + } + decodeLastLine(t, stdout, &got) + + require.True(t, got.InputIsOwn, "test setup sanity: JSON.parse must produce a genuine own \"__proto__\" property; driver stdout:\n%s", stdout) + + assert.True(t, got.DecodedIsOwn, + "decode must produce a genuine own \"proto\" property, not silently reassign the result's prototype; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"proto": "x"}, got.Decoded, "driver stdout:\n%s", stdout) + + assert.True(t, got.EncodedIsOwn, + "encode must produce a genuine own \"__proto__\" property, not silently drop the field; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"__proto__": "x"}, got.Encoded, "driver stdout:\n%s", stdout) } From 8c869a5e2651d089533d865d0c107b04e473d8c8 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 18:38:01 -0500 Subject: [PATCH 51/71] fix(client/typescript): propagate allOf namespace through inline members and fix two data-loss gaps Round 2 fixes for the property-renaming task: - flattenAllOfLayers reset its `label` parameter to "" on every AllOf-member recursion, unconditionally. That is correct for a member declared on the composition currently being flattened (which has no enclosing $ref of its own), but wrong one level deeper: an inline member nested inside a schema that was ITSELF reached via a $ref (e.g. Mid = allOf[$ref Leaf, inline{street_name}], reached via Addr's own $ref to Mid) renders as part of THAT $ref target's own top-level export, not the outermost composition's -- schemaToTSType only ever changes nsID for a further $ref hop, never for an inline member. Resetting label unconditionally reproduced CRITICAL 1's exact failure one level deeper: the guard printed (and the codec table used) the outermost composition's namespace for a field that renders, and is overridable, only under the nearer $ref's name. label is now propagated through the AllOf-member loop instead of reset; a member that is itself a $ref still overwrites it with its own resolved name immediately; the guard's collision coverage was re-verified intact across all 19 existing collision-shape tests. - codecTable.add's additionalProperties companion id used the literal segment "values" unconditionally -- the exact same synthetic id a DECLARED property literally named "values" derives via the ordinary "." scheme. A schema declaring both collided: t.add is idempotent, so whichever reached the id first silently won, and the additionalProperties value schema (introduced by the prior commit's Properties+additionalProperties fix) was sometimes never registered at all -- an unknown key's value would then decode through the wrong codec. Replaced the literal "values" with a new additionalPropertiesSegment constant ("additionalProperties") everywhere this companion id is built -- codecTable.add (both branches), the guard, and all four render-side call sites -- closing the realistic collision (a plausible property name) the same way "items" was already safe by construction (array and object are mutually exclusive `add()` branches, so no such collision was possible there). - A schema declaring both its own Properties AND AllOf rendered only the AllOf members' intersection, silently dropping its own properties from the TypeScript type -- pre-existing since the first commit of this task, but decode/encode already included those properties (the codec table's flattenAllOfLayers already treats a schema's own Properties as a real, contributing last layer), so the declared type and the actual decoded value disagreed. schemaToTSType's AllOf branch now appends the schema's own object literal as one more intersection member when Properties is non-empty, matching flattenAllOfLayers' own layer order. Also corrected round 1's report: the "allof" gate fixture's codec TABLE, not just its runtime machinery, changed as a direct and correct consequence of round 1's $ref-namespace fix (three duplicate per-composition synthetic entries collapsed into direct references to the shared ref-target entry). --- .../client/generators/typescript/codecs.go | 119 +++++- .../client/generators/typescript/fieldname.go | 17 +- .../generators/typescript/fieldname_test.go | 7 +- .../client/generators/typescript/generator.go | 31 +- .../generators/typescript/rename_test.go | 346 +++++++++++++++++- 5 files changed, 486 insertions(+), 34 deletions(-) diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 32461410..314e35f4 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -123,6 +123,39 @@ func refName(ref string) string { return strings.TrimPrefix(ref, prefix) } +// additionalPropertiesSegment is the synthetic path segment used for an +// additionalProperties VALUE schema's own codec/collision namespace ("." +// + additionalPropertiesSegment), by codecTable.add, checkSchemaFieldCollisions +// (fieldname.go), and generator.go's objectPropsLiteral/schemaToTSType alike +// -- all three must agree on this token, same as every other namespace id +// scheme in this package. +// +// This is deliberately NOT "values": a schema declaring BOTH Properties and +// additionalProperties can also have a DECLARED property literally named +// "values" (an ordinary, plausible property name -- e.g. a paginated list +// wrapper `{ "values": [...] }`), which derives its OWN synthetic id via the +// exact same "." scheme codecIDFor uses for every property. Before +// this constant existed, both used the literal "values" unconditionally, +// so the two would collide on the SAME synthetic id for the SAME parent -- +// t.add is idempotent (a no-op once an id is already registered), so +// whichever call reached it first silently won, and additionalProperties' +// actual value schema was never registered at all: an "unknown" key's value +// would decode through the wrong codec (the declared property's schema) +// instead of its own, silently corrupting data for any wire payload with +// both a "values" key and other, genuinely-unknown keys. +// +// "additionalProperties" -- the actual OpenAPI/JSON-Schema keyword this +// represents -- is not itself immune to an equally adversarial schema +// declaring a property literally named "additionalProperties" (no fixed +// string concatenated naively into a shared namespace can be made immune to +// an arbitrary wire name reproducing it exactly, without an escaping +// scheme -- see tsFieldName's doc comment, which already accepts this same +// class of ambiguity for a dotted schema/wire name). This constant closes +// the REALISTIC collision the review measured (a property plausibly named +// "values") rather than claiming adversarial-proof uniqueness that no +// single fixed token can actually provide. +const additionalPropertiesSegment = "additionalProperties" + // codecIDFor returns the codec id a property's value should be decoded with, // registering a synthetic entry first when the property is an inline // composite. Primitives get "" — there is nothing to rename inside a string @@ -220,19 +253,22 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) // A schema can declare BOTH Properties and additionalProperties -- // schemaToTSType/schemaToTypeScript render this as an intersection // (objectPropsLiteral & Record), and generator.go - // now renames properties INSIDE valueType too (via nsID+".values"). - // Falling through to the additionalProperties-only branch below - // never runs for this shape (this `case` already returns), so - // without this, such a schema's `.values` codec entry never got - // registered at all: a declared-and-renamed value schema with no - // codec id to walk it by, silently identity for every "additional" - // key's value even though the emitted TYPE promises renamed fields. - // Recording Values here, on the SAME "object" entry, is enough -- - // no new `kind` is needed, since decode/encode's 'object' case - // (codecRuntime) already renames declared fields and can fall back - // to `values` for anything left over. + // now renames properties INSIDE valueType too (via + // nsID+"."+additionalPropertiesSegment). Falling through to the + // additionalProperties-only branch below never runs for this shape + // (this `case` already returns), so without this, such a schema's + // codec entry never got registered at all: a declared-and-renamed + // value schema with no codec id to walk it by, silently identity + // for every "additional" key's value even though the emitted TYPE + // promises renamed fields. Recording Values here, on the SAME + // "object" entry, is enough -- no new `kind` is needed, since + // decode/encode's 'object' case (codecRuntime) already renames + // declared fields and can fall back to `values` for anything left + // over. additionalPropertiesSegment (not the literal "values") is + // what keeps this id distinct from a DECLARED property that + // happens to be named "values" -- see that constant's doc comment. if values, ok := additionalPropsSchema(schema.AdditionalProperties); ok { - entry.Values = t.codecIDFor(id, "values", values, spec) + entry.Values = t.codecIDFor(id, additionalPropertiesSegment, values, spec) } t.entries[id] = entry @@ -245,7 +281,13 @@ func (t *codecTable) add(id string, spec *client.APISpec, schema *client.Schema) Kind: "record", // A nil values schema means `additionalProperties: true` — the // values are unconstrained, so there is nothing to descend into. - Values: t.codecIDFor(id, "values", values, spec), + // This branch only runs when schema.Properties is empty (the + // switch above already returned otherwise), so there is no + // DECLARED property to collide with here -- additionalPropertiesSegment + // is used anyway, for consistency with the combined case above + // and with fieldname.go's guard, which checks this shape + // unconditionally regardless of whether Properties is empty. + Values: t.codecIDFor(id, additionalPropertiesSegment, values, spec), } return @@ -450,12 +492,29 @@ type allOfLayer struct { // inline schema reached directly from an AllOf slice, rendered as "an // inline member" in the warning), and -- doubling as allOfLayer.nsID -- the // namespace id a contributing layer's properties must be keyed under (see -// allOfLayer's doc comment). It is reset to "" for every AllOf member -// recursed into below, then set to the resolved name whenever a $ref hop -// is followed, so a schema found ANY number of hops deep is still -// attributed to the $ref (if any) that most immediately led to it -- the -// exact $ref name whose OWN top-level rendering will actually own these -// properties. +// allOfLayer's doc comment). +// +// It is PROPAGATED UNCHANGED into every AllOf member recursed into below +// (NOT reset to "") -- an inline member declared directly in schema.AllOf +// renders as part of whatever schema itself is currently being rendered as +// (schemaToTSType's AllOf case only ever changes nsID for a FURTHER $ref +// hop; an inline member always inherits the parent's own nsID), so its +// layer must inherit that SAME namespace, whatever schema's own label +// currently is. label is then OVERWRITTEN with the resolved name whenever a +// $ref hop is actually followed (see the refName branch below), which is +// what still correctly attributes a schema found any number of $ref hops +// deep to the nearest one, regardless of what was passed in -- so +// propagating label through the AllOf loop only ever matters for a +// genuinely inline member; a member that is itself a $ref discards whatever +// was passed to it the moment its own ref is resolved. +// +// Getting this wrong (resetting to "" unconditionally, as an earlier fix +// round did) reproduces CRITICAL 1's exact failure one level deeper: for +// Mid = allOf[$ref Leaf, inline{street_name}] reached via Addr's own +// allOf[inline{streetName}, $ref Mid], the inline "street_name" member +// nested inside Mid would incorrectly fall back to Addr's id instead of +// Mid's -- the same "prints/uses a namespace nothing renders under" defect, +// just one $ref hop further from the top-level composition. func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpec, visited map[*client.Schema]bool) (layers []allOfLayer, polymorphicMembers []string) { if schema == nil || visited[schema] { return nil, nil @@ -480,7 +539,27 @@ func flattenAllOfLayers(schema *client.Schema, label string, spec *client.APISpe } for _, member := range schema.AllOf { - subLayers, subPolymorphic := flattenAllOfLayers(member, "", spec, visited) + // label, not "": an inline member declared directly in schema.AllOf + // must inherit whatever namespace schema ITSELF is currently + // rendering under. If schema was reached via a $ref (label != ""), + // an inline member nested inside it renders as part of THAT ref + // target's own top-level export (e.g. Mid = allOf[$ref Leaf, + // inline{street_name}]: when Mid is reached via another + // composition's $ref, schemaToTSType still renders Mid's inline + // member under Mid's own name -- schemaToTSType's AllOf case only + // ever resets nsID for a FURTHER $ref hop, never for an inline + // member) -- so passing "" here, unconditionally, is exactly the + // bug this comment fixes: it made such a member fall back to the + // OUTERMOST composition's id instead of the nearest enclosing + // $ref's, one level short of where CRITICAL 1 fixed the direct + // $ref-member case. If schema was NOT reached via a $ref (label == + // ""), member correctly still gets "" (falls back to whatever the + // caller's own composition id is). A member that is ITSELF a $ref + // ignores whatever label is passed to it anyway -- the refName + // branch below immediately overwrites it with the resolved name -- + // so passing label through here is a no-op for that case and only + // matters for a genuinely inline member. + subLayers, subPolymorphic := flattenAllOfLayers(member, label, spec, visited) layers = append(layers, subLayers...) polymorphicMembers = append(polymorphicMembers, subPolymorphic...) } diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index f7cecd4d..b133eac1 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -215,8 +215,11 @@ func dedupeMessages(messages []string) []string { // behavior exactly. For a nested namespace, id is a synthetic dotted path // built the same way codecTable's codecIDFor already builds codec-table ids // for the same shapes ("Order.shipping" for a nested property, -// "Order.line_items.items" for array items, "Order.extras.values" for an -// additionalProperties value) -- reusing that scheme, rather than inventing +// "Order.line_items.items" for array items, +// "Order.extras.additionalProperties" for an additionalProperties value -- +// see codecs.go's additionalPropertiesSegment for why that segment, not the +// more obvious "values", is what both sides actually use) -- reusing that +// scheme, rather than inventing // a second one, is what makes the FieldOverrides key this function prints // actually work: tsFieldName builds its schema-scoped lookup key as // id + "." + wireName, so calling it with id="Order.shipping" and @@ -351,7 +354,15 @@ func checkSchemaFieldCollisions(id string, schema *client.Schema, spec *client.A } if values, ok := additionalPropsSchema(schema.AdditionalProperties); ok && values != nil && values.Ref == "" { - messages = append(messages, checkSchemaFieldCollisions(id+".values", values, spec, config, visited)...) + // additionalPropertiesSegment (codecs.go), not the literal + // "values": this check runs regardless of whether schema ALSO + // declares Properties (no early return above), so a DECLARED + // property literally named "values" would otherwise recurse into + // this SAME id too (via the loop above, id+"."+"values"), silently + // merging two unrelated namespaces under one visited entry -- the + // same collision this segment change closes on the codec-table + // side. + messages = append(messages, checkSchemaFieldCollisions(id+"."+additionalPropertiesSegment, values, spec, config, visited)...) } for i, member := range schema.OneOf { diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go index 78684d1c..c18cc161 100644 --- a/internal/client/generators/typescript/fieldname_test.go +++ b/internal/client/generators/typescript/fieldname_test.go @@ -485,7 +485,10 @@ func TestGenerateFailsOnArrayItemsInlineObjectCollision(t *testing.T) { // (Order.extras, a map whose values are inline objects). schemaToTSType's // "object" case's `allowed` branches call schemaToTSType on the value // schema, which hits objectPropsLiteral the same way. codecIDFor registers -// such a value schema under ".values", reused here. +// such a value schema under "." + additionalPropertiesSegment +// (codecs.go) -- not the more obvious ".values", which a +// declared property literally named "values" could otherwise collide with +// -- reused here. func TestGenerateFailsOnAdditionalPropertiesValueInlineObjectCollision(t *testing.T) { spec := &client.APISpec{ Info: client.APIInfo{Title: "Nested Collision API", Version: "1.0.0"}, @@ -513,7 +516,7 @@ func TestGenerateFailsOnAdditionalPropertiesValueInlineObjectCollision(t *testin t.Fatal("expected an error for a field-name collision inside an additionalProperties value schema") } - for _, want := range []string{"display_name", "displayName", `FieldOverrides["Order.extras.values.display_name"]`} { + for _, want := range []string{"display_name", "displayName", `FieldOverrides["Order.extras.additionalProperties.display_name"]`} { if !strings.Contains(err.Error(), want) { t.Errorf("error message missing %q; got: %s", want, err.Error()) } diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 344109c5..d549d499 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -838,7 +838,8 @@ func additionalPropsSchema(v any) (*client.Schema, bool) { // checkSchemaFieldCollisions and codecs.go's codecIDFor already use: a // top-level schema's own name ("User"), or a synthetic dotted path for an // inline nested object ("Order.shipping", "Order.line_items.items", -// "Order.extras.values", "Order.payment.oneOf0", ...). tsFieldName's +// "Order.extras."+additionalPropertiesSegment, "Order.payment.oneOf0", +// ...). tsFieldName's // schema-scoped override lookup is keyed by nsID + "." + wireName, so all // three consumers of that namespace id -- the collision guard, the codec // table, and this renderer -- must agree on it; otherwise a FieldOverrides @@ -922,7 +923,7 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec // valueSchema means additionalProperties was `true` ("any" value). valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec, name+".values", config) + valueType = g.schemaToTSType(valueSchema, spec, name+"."+additionalPropertiesSegment, config) } buf.WriteString(fmt.Sprintf("export type %s = Record;\n", name, valueType)) @@ -941,7 +942,7 @@ func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec // declared properties and a typed additionalProperties. valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec, name+".values", config) + valueType = g.schemaToTSType(valueSchema, spec, name+"."+additionalPropertiesSegment, config) } buf.WriteString(fmt.Sprintf("export type %s = %s & Record;\n", name, g.objectPropsLiteral(schema, spec, name, config), valueType)) @@ -1112,6 +1113,26 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec, types = append(types, g.schemaToTSType(s, spec, nsID, config)) } + // A schema can declare its own direct Properties ALONGSIDE AllOf -- + // legal but unusual OpenAPI, and allOf's own doc comment already + // notes allOfEntry/flattenAllOfLayers (codecs.go) treat that case + // as a real, contributing layer: schema's own Properties are + // flattened in as the LAST layer of the composition, and its + // fields appear in the emitted codec table under this schema's own + // id. Before this fix, THIS RENDERER silently dropped them: the + // AllOf branch returned only the joined member types, so + // `export type Addr = Base;` when Addr ALSO declared its own + // "own_field" -- decode/encode would still rename and emit + // "own_field" (the codec table has no trouble with it), producing + // a value with a property the declared type claims doesn't exist. + // Appending the schema's own object literal as one more + // intersection member, last (matching flattenAllOfLayers' own + // layer order), keeps the rendered type honest about what the + // codec table -- and therefore decode -- actually produces. + if len(schema.Properties) > 0 { + types = append(types, g.objectPropsLiteral(schema, spec, nsID, config)) + } + result := strings.Join(types, " & ") if schema.Nullable { result = "(" + result + ")" @@ -1187,7 +1208,7 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec, case allowed && len(schema.Properties) > 0: valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec, nsID+".values", config) + valueType = g.schemaToTSType(valueSchema, spec, nsID+"."+additionalPropertiesSegment, config) } result = g.objectPropsLiteral(schema, spec, nsID, config) + " & Record" @@ -1195,7 +1216,7 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec, case allowed: valueType := "any" if valueSchema != nil { - valueType = g.schemaToTSType(valueSchema, spec, nsID+".values", config) + valueType = g.schemaToTSType(valueSchema, spec, nsID+"."+additionalPropertiesSegment, config) } result = "Record" diff --git a/internal/client/generators/typescript/rename_test.go b/internal/client/generators/typescript/rename_test.go index 4190c427..05977856 100644 --- a/internal/client/generators/typescript/rename_test.go +++ b/internal/client/generators/typescript/rename_test.go @@ -474,14 +474,115 @@ func propsPlusAdditionalSpec() *client.APISpec { // entry had no "values" key at all -- `assert.Contains(t, code, // `"values":`)` scoped to the Order entry failed outright, since the // entire additionalProperties branch in codecTable.add was unreachable for -// a schema with any declared Properties. +// a schema with any declared Properties. The synthetic entry is keyed +// "Order.additionalProperties", not the more obvious "Order.values" -- see +// codecs.go's additionalPropertiesSegment doc comment for why: a schema +// with a DECLARED property literally named "values" would otherwise +// collide with this exact id (BLOCKER 2, round 2 review) -- +// TestCodecTablePropsPlusAdditionalPropertiesWithValuesNamedProperty below +// is that regression guard. func TestCodecTablePropsPlusAdditionalPropertiesGetsValuesEntry(t *testing.T) { code, _ := NewCodecGenerator().Generate(propsPlusAdditionalSpec(), baseConfig()) - assert.Contains(t, code, `"Order": {"kind": "object", "fields": {"order_id": {"ts": "orderId"}}, "values": "Order.values"}`, + assert.Contains(t, code, `"Order": {"kind": "object", "fields": {"order_id": {"ts": "orderId"}}, "values": "Order.additionalProperties"}`, "a schema with both Properties and additionalProperties must register a \"values\" codec id, not silently drop the additionalProperties side") - assert.Contains(t, code, `"Order.values": {"kind": "object", "fields": {"unit_price": {"ts": "unitPrice"}}}`, - "the synthetic \"Order.values\" entry must itself rename the additional value schema's own properties") + assert.Contains(t, code, `"Order.additionalProperties": {"kind": "object", "fields": {"unit_price": {"ts": "unitPrice"}}}`, + "the synthetic \"Order.additionalProperties\" entry must itself rename the additional value schema's own properties") +} + +// --- BLOCKER 2 (round 2 review): the additionalProperties companion id +// must not collide with a DECLARED property of the same schema that +// happens to share the token used to build it. ------------------------- +// +// Before this fix, codecTable.add used the literal segment "values" +// unconditionally for the additionalProperties companion id -- exactly the +// SAME synthetic id a declared property literally named "values" derives +// via the ordinary "." scheme. t.add is idempotent (a no-op once +// an id is already registered), so whichever call reached "Order.values" +// first silently won, and the OTHER one -- typically the actual +// additionalProperties value schema -- never got registered at all: an +// "unknown" key's value would then decode through the WRONG codec (the +// declared "values" property's own nested schema) instead of its own, +// silently corrupting data for any payload carrying both a "values" key +// and a genuinely unknown one. + +// valuesNamedPropertySpec reproduces round 2 review's exact measurement: a +// declared property literally named "values" (itself an object with +// property "a_b"), alongside additionalProperties (an object with property +// "c_d"). +func valuesNamedPropertySpec() *client.APISpec { + spec := baseSpec() + spec.Schemas["Order"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "values": {Type: "object", Properties: map[string]*client.Schema{"a_b": {Type: "string"}}}, + }, + AdditionalProperties: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"c_d": {Type: "string"}}, + }, + } + + return spec +} + +// TestCodecTablePropsPlusAdditionalPropertiesWithValuesNamedProperty pins +// the failing case directly against the table, reproduced first against +// the pre-fix code: +// +// "Order": {"fields": {"values": {"ts": "values", "codec": "Order.values"}}, "values": "Order.values"} +// "Order.values": {"fields": {"a_b": {"ts": "aB"}}} <-- the DECLARED property's own schema +// +// "Order"'s OWN `values` pointer (meant to reference the +// additionalProperties value schema, {c_d}) aliased the DECLARED +// property's own nested entry instead -- the additionalProperties value +// schema ({c_d}) was never registered at all, so decoding any genuinely +// unknown key's value would walk it through the wrong codec. +func TestCodecTablePropsPlusAdditionalPropertiesWithValuesNamedProperty(t *testing.T) { + code, _ := NewCodecGenerator().Generate(valuesNamedPropertySpec(), baseConfig()) + + assert.Contains(t, code, `"Order": {"kind": "object", "fields": {"values": {"ts": "values", "codec": "Order.values"}}, "values": "Order.additionalProperties"}`, + "Order's own \"values\" pointer must reference the additionalProperties companion, distinct from the declared \"values\" property's own codec id") + assert.Contains(t, code, `"Order.values": {"kind": "object", "fields": {"a_b": {"ts": "aB"}}}`, + "the declared property literally named \"values\" must keep its ordinary \".\" synthetic id") + assert.Contains(t, code, `"Order.additionalProperties": {"kind": "object", "fields": {"c_d": {"ts": "cD"}}}`, + "the additionalProperties value schema must be registered under its own, non-colliding id") +} + +// TestCodecRuntimeDistinguishesValuesPropertyFromAdditionalPropertiesValue +// is the execution proof: a payload carrying both the declared "values" key +// and a genuinely unknown key must decode EACH through its own correct +// codec, not the same one. +func TestCodecRuntimeDistinguishesValuesPropertyFromAdditionalPropertiesValue(t *testing.T) { + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(valuesNamedPropertySpec(), baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +const decoded = decode({ values: { a_b: '1' }, extra: { c_d: '2' } }, 'Order'); +results.decoded = decoded; +results.encoded = encode(decoded, 'Order'); + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_values_named_property.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_values_named_property.ts") + + var got struct { + Decoded map[string]any `json:"decoded"` + Encoded map[string]any `json:"encoded"` + } + decodeLastLine(t, stdout, &got) + + assert.Equal(t, map[string]any{"values": map[string]any{"aB": "1"}, "extra": map[string]any{"cD": "2"}}, got.Decoded, + "the declared \"values\" property and the additionalProperties \"extra\" key must each decode through their OWN correct codec; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"values": map[string]any{"a_b": "1"}, "extra": map[string]any{"c_d": "2"}}, got.Encoded, + "driver stdout:\n%s", stdout) } // TestCodecRuntimeRenamesInsideAdditionalPropertiesValue is the execution @@ -628,3 +729,240 @@ console.log(JSON.stringify(results)); "encode must produce a genuine own \"__proto__\" property, not silently drop the field; driver stdout:\n%s", stdout) assert.Equal(t, map[string]any{"__proto__": "x"}, got.Encoded, "driver stdout:\n%s", stdout) } + +// --- BLOCKER 1 (round 2 review): an INLINE member nested inside a $ref'd +// composition must inherit that $ref's namespace, not fall back to the +// OUTERMOST composition's id. ------------------------------------------- +// +// flattenAllOfLayers reset `label` to "" for every AllOf-member recursion, +// unconditionally -- correct for a member declared directly on the +// TOP-level composition being flattened (which has no enclosing $ref of +// its own), but wrong one level deeper: Mid = allOf[$ref Leaf, +// inline{street_name}] reached via Addr = allOf[inline{streetName}, $ref +// Mid] renders Mid's inline "street_name" member as part of MID's own +// top-level `export type Mid = ...`, not Addr's -- schemaToTSType's AllOf +// case only ever changes nsID for a FURTHER $ref hop, never for an inline +// member, so an inline member always inherits whatever nsID its immediate +// parent is rendering under. Resetting label unconditionally made the +// guard (and the codec table) key that member under Addr's id instead -- +// the exact same "prints/uses a namespace nothing renders under" defect +// CRITICAL 1 fixed for a DIRECT $ref member, reproduced one level deeper +// for an INLINE member nested inside one. + +// nestedRefAllOfSpec returns the exact three-schema reproduction from round +// 2 review: Leaf (leaf_field), Mid = allOf[$ref Leaf, inline{street_name}], +// Addr = allOf[inline{streetName}, $ref Mid]. "streetName" (Addr's own +// inline member) and "street_name" (nested inside Mid, one $ref hop away +// from Addr) collide under camel. +func nestedRefAllOfSpec() *client.APISpec { + return &client.APISpec{ + Info: client.APIInfo{Title: "Nested Ref AllOf API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Leaf": {Type: "object", Properties: map[string]*client.Schema{"leaf_field": {Type: "string"}}}, + "Mid": { + AllOf: []*client.Schema{ + {Ref: "#/components/schemas/Leaf"}, + {Type: "object", Properties: map[string]*client.Schema{"street_name": {Type: "string"}}}, + }, + }, + "Addr": { + AllOf: []*client.Schema{ + {Type: "object", Properties: map[string]*client.Schema{"streetName": {Type: "string"}}}, + {Ref: "#/components/schemas/Mid"}, + }, + }, + }, + } +} + +// TestFieldOverrideResolvesNestedInlineMemberInsideRefdAllOf is round 2's +// BLOCKER 1 regression guard, reproduced directly against the pre-fix code +// first: +// +// err := checkFieldNameCollisions(nestedRefAllOfSpec(), collisionConfig()) +// // pre-fix: FieldOverrides["Addr.street_name"] -- Addr's own id, wrong +// // post-fix: FieldOverrides["Mid.street_name"] -- Mid's id, correct +// +// Setting the PRE-FIX key ("Addr.street_name") demonstrated the exact +// failure the review measured: Generate returned no error, but Mid's own +// rendered `export type Mid = Leaf & { streetName?: string; };` still +// showed the UNRENAMED wire name, and Mid's own codec entry still recorded +// `"ts": "streetName"` while Addr's merged entry recorded the override -- +// two entries disagreeing about the same field, and a value in the decoded +// result (`streetNameAlt`) that appears nowhere in the declared Addr type. +func TestFieldOverrideResolvesNestedInlineMemberInsideRefdAllOf(t *testing.T) { + spec := nestedRefAllOfSpec() + + err := checkFieldNameCollisions(spec, collisionConfig()) + require.Error(t, err) + assert.Contains(t, err.Error(), `FieldOverrides["Mid.street_name"]`, + "the printed key must name Mid -- the namespace the inline \"street_name\" member actually renders under -- not Addr") + assert.NotContains(t, err.Error(), `FieldOverrides["Addr.street_name"]`, + "an Addr-scoped key would silence the error without Mid's own rendered type ever consulting it") + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Mid.street_name": "streetNameAlt"} + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err, "the printed key must actually resolve the collision") + + types := out.Files["src/types.ts"] + assert.Contains(t, types, "export type Mid = Leaf & {\n streetNameAlt?: string;\n};", + "Mid's own rendered type -- what actually declares this field -- must show the override") + + code, _ := NewCodecGenerator().Generate(spec, config) + assert.Contains(t, code, `"Mid": {"kind": "object", "fields": {"leaf_field": {"ts": "leafField"}, "street_name": {"ts": "streetNameAlt"}}}`, + "Mid's own independent codec entry must show the override") + assert.Contains(t, code, `"Addr": {"kind": "object", "fields": {"leaf_field": {"ts": "leafField"}, "streetName": {"ts": "streetName"}, "street_name": {"ts": "streetNameAlt"}}}`, + "Addr's merged entry must agree -- it derives this field's ts from Mid's own namespace, not its own") +} + +// TestCodecRuntimeRenamesNestedInlineMemberInsideRefdAllOf is the execution +// proof: decoding through BOTH the merged "Addr" entry and Mid's own +// independent entry must rename "street_name" identically (the override), +// while leaving "streetName" (Addr's own inline member) and "leaf_field" +// (two $ref hops away) each renamed under their own, separate rules. +func TestCodecRuntimeRenamesNestedInlineMemberInsideRefdAllOf(t *testing.T) { + spec := nestedRefAllOfSpec() + + config := collisionConfig() + config.FieldOverrides = map[string]string{"Mid.street_name": "streetNameAlt"} + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, config) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +const input = { streetName: 'inline', street_name: 'fromMid', leaf_field: 'l' }; +results.decodedAddr = decode(input, 'Addr'); +results.encodedAddr = encode(results.decodedAddr, 'Addr'); + +const midInput = { street_name: 'fromMid', leaf_field: 'l' }; +results.decodedMid = decode(midInput, 'Mid'); +results.encodedMid = encode(results.decodedMid, 'Mid'); + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_nested_ref_allof.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_nested_ref_allof.ts") + + var got struct { + DecodedAddr map[string]any `json:"decodedAddr"` + EncodedAddr map[string]any `json:"encodedAddr"` + DecodedMid map[string]any `json:"decodedMid"` + EncodedMid map[string]any `json:"encodedMid"` + } + decodeLastLine(t, stdout, &got) + + assert.Equal(t, map[string]any{"streetName": "inline", "streetNameAlt": "fromMid", "leafField": "l"}, got.DecodedAddr, + "driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"streetName": "inline", "street_name": "fromMid", "leaf_field": "l"}, got.EncodedAddr, + "driver stdout:\n%s", stdout) + + assert.Equal(t, map[string]any{"streetNameAlt": "fromMid", "leafField": "l"}, got.DecodedMid, + "decoding through Mid's OWN entry must rename \"street_name\" identically to decoding through Addr's merged entry; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"street_name": "fromMid", "leaf_field": "l"}, got.EncodedMid, + "driver stdout:\n%s", stdout) +} + +// --- BLOCKER 3 (round 2 review, pre-existing but now wrong data on the +// wire): a schema declaring BOTH its own Properties AND AllOf must render +// BOTH in the TypeScript type, not just the AllOf members. ------------- +// +// schemaToTSType's AllOf branch joined only `schema.AllOf`'s members, +// silently ignoring `schema.Properties` entirely -- byte-identical since +// the very first commit of this task (5139609), so this predates Task 5's +// rename, but it is fixed here because the codec table (flattenAllOfLayers, +// codecs.go) already treats a schema's own Properties as a real, +// contributing LAST layer of the composition, and decode/encode rename and +// emit those fields regardless of what the rendered type says -- so the +// declared type and the actual decoded value have disagreed since before +// this task, and Task 5's renaming makes that disagreement land on +// specific, renamed field names rather than merely being an abstractly +// "impossible" shape. + +// ownPropsPlusAllOfSpec returns Base ($ref, declaring "base_field") and +// Addr = {Properties:{own_field}, AllOf:[$ref Base]} -- the exact +// reproduction from round 2 review. +func ownPropsPlusAllOfSpec() *client.APISpec { + spec := baseSpec() + spec.Schemas["Base"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"base_field": {Type: "string"}}} + spec.Schemas["Addr"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"own_field": {Type: "string"}}, + AllOf: []*client.Schema{{Ref: "#/components/schemas/Base"}}, + } + + return spec +} + +// TestSchemaWithOwnPropertiesAndAllOfRendersBoth pins the failing case +// directly against types.ts, reproduced first against the pre-fix code: +// +// export type Addr = Base; +// +// "own_field" -- a property Addr declares directly -- is completely absent +// from the declared type, even though decode({base_field:'b', +// own_field:'o'}, 'Addr') already produced {"baseField":"b","ownField":"o"} +// before this fix (the codec table was always correct here; only the +// rendered type lied about it). tsc reported 0 errors for the lying type, +// since `Base` alone is a syntactically valid (just incomplete) type. +func TestSchemaWithOwnPropertiesAndAllOfRendersBoth(t *testing.T) { + spec := ownPropsPlusAllOfSpec() + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + assert.Contains(t, types, "export type Addr = Base & {\n ownField?: string;\n};", + "Addr's own declared property must appear in the rendered type ALONGSIDE its allOf member, not be silently dropped") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + errs := typeCheck(t, dir) + assert.Empty(t, errs, "the corrected intersection type must still type-check cleanly") +} + +// TestCodecRuntimeMatchesRenderedTypeForOwnPropertiesPlusAllOf is the +// execution proof that decode's actual output now matches the declared +// type exactly (round-tripped through JSON-shaped map comparison, since the +// declared type itself cannot be checked at runtime, only its consistency +// with decode's output can). +func TestCodecRuntimeMatchesRenderedTypeForOwnPropertiesPlusAllOf(t *testing.T) { + spec := ownPropsPlusAllOfSpec() + + dir := t.TempDir() + code, _ := NewCodecGenerator().Generate(spec, baseConfig()) + writeTree(t, dir, map[string]string{"src/codecs.ts": code}) + + driver := ` +import { decode, encode } from './codecs'; + +const results: Record = {}; + +const decoded = decode({ base_field: 'b', own_field: 'o' }, 'Addr'); +results.decoded = decoded; +results.encoded = encode(decoded, 'Addr'); + +console.log(JSON.stringify(results)); +` + writeTree(t, dir, map[string]string{"src/__driver_own_props_plus_allof.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_own_props_plus_allof.ts") + + var got struct { + Decoded map[string]any `json:"decoded"` + Encoded map[string]any `json:"encoded"` + } + decodeLastLine(t, stdout, &got) + + assert.Equal(t, map[string]any{"baseField": "b", "ownField": "o"}, got.Decoded, + "decode must produce exactly baseField (from the allOf $ref member) and ownField (Addr's own property) -- matching the now-corrected declared type; driver stdout:\n%s", stdout) + assert.Equal(t, map[string]any{"base_field": "b", "own_field": "o"}, got.Encoded, + "driver stdout:\n%s", stdout) +} From 30bacdd48d16fe0c80ae240f8460355b2b17038b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 19:02:03 -0500 Subject: [PATCH 52/71] feat(client/typescript): encode and decode payloads at the HTTP boundary Add RequestConfig.bodyCodec/responseCodec, populated by rest.go from statically known ($ref) schema ids at each call site, and apply them in executeRequest: encode() immediately before JSON.stringify in the JSON-body branch, decode() immediately after JSON.parse in the application/json response branch. Every native BodyInit shape (FormData, Blob, URLSearchParams, ReadableStream, ArrayBuffer/ TypedArray) and every non-JSON response path (204/205, allowEmptyBody, text/*, Blob) are structurally unreachable by encode/decode, since both calls sit inside the branches already gated to JSON-only values. Generated clients now actually rename between camelCase TypeScript shapes and wire-cased JSON payloads at runtime, instead of only carrying the type-level illusion of it. --- .../generators/typescript/fetch_client.go | 45 ++- .../typescript/fetch_client_test.go | 327 +++++++++++++++++- internal/client/generators/typescript/rest.go | 122 +++++++ .../client/generators/typescript/rest_test.go | 245 +++++++++++++ 4 files changed, 731 insertions(+), 8 deletions(-) diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index d285d22d..35077e78 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -19,7 +19,8 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c var buf strings.Builder // Imports - buf.WriteString("// Base HTTP client using native fetch\n\n") + buf.WriteString("// Base HTTP client using native fetch\n") + buf.WriteString("import { decode, encode } from './codecs';\n\n") // HTTPError class buf.WriteString("/** Error thrown for non-2xx responses. */\n") @@ -51,6 +52,20 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // genuinely empty text/plain or binary body is valid data for an\n") buf.WriteString(" // endpoint that never declared a no-content response.\n") buf.WriteString(" allowEmptyBody?: boolean;\n") + buf.WriteString(" // Schema id (a key into src/codecs.ts's CODECS table) used to rename\n") + buf.WriteString(" // this request's JSON body from its TypeScript (camelCase) shape to\n") + buf.WriteString(" // the wire shape before serialisation. Set by the generated method\n") + buf.WriteString(" // only when the endpoint's request body is application/json AND\n") + buf.WriteString(" // resolves to a named component schema (see rest.go's\n") + buf.WriteString(" // requestBodyCodecRef) -- there is no codec-table entry for an inline\n") + buf.WriteString(" // schema to reference. A FormData/Blob/string/stream/typed-array body\n") + buf.WriteString(" // ignores this field entirely: executeRequest only calls encode() in\n") + buf.WriteString(" // the branch that already decided the body is JSON-serialisable.\n") + buf.WriteString(" bodyCodec?: string;\n") + buf.WriteString(" // Same idea, for decoding a JSON response back into its TypeScript\n") + buf.WriteString(" // shape. Only applied inside the application/json content-type branch\n") + buf.WriteString(" // below -- a void/Blob/text response is never walked by decode().\n") + buf.WriteString(" responseCodec?: string;\n") buf.WriteString("}\n\n") // RetryConfig interface @@ -337,7 +352,21 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // index-by-index into {\\\"0\\\":1,\\\"1\\\":2,...} and an ArrayBuffer into \"{}\".\n") buf.WriteString(" body = requestConfig.body as ArrayBuffer | ArrayBufferView;\n") buf.WriteString(" } else {\n") - buf.WriteString(" body = JSON.stringify(requestConfig.body);\n") + buf.WriteString(" // Only a JSON-serialisable body ever reaches this branch -- every\n") + buf.WriteString(" // native BodyInit shape (FormData, Blob/File, URLSearchParams,\n") + buf.WriteString(" // ReadableStream, ArrayBuffer/TypedArray) and the plain-string case\n") + buf.WriteString(" // above already returned/assigned before this point. That is what\n") + buf.WriteString(" // makes it safe to call encode() unconditionally here: it is only\n") + buf.WriteString(" // ever given a value this branch has already established is meant\n") + buf.WriteString(" // to be walked and JSON.stringify-ed, never one of the native\n") + buf.WriteString(" // BodyInit values above. bodyCodec is only set by the generated\n") + buf.WriteString(" // method when the endpoint's body resolves to a named schema (see\n") + buf.WriteString(" // rest.go's requestBodyCodecRef); encode() is a no-op passthrough\n") + buf.WriteString(" // for an undefined codec id.\n") + buf.WriteString(" const encodedBody = requestConfig.bodyCodec !== undefined\n") + buf.WriteString(" ? encode(requestConfig.body, requestConfig.bodyCodec)\n") + buf.WriteString(" : requestConfig.body;\n") + buf.WriteString(" body = JSON.stringify(encodedBody);\n") buf.WriteString(" isJSONBody = true;\n") buf.WriteString(" }\n\n") @@ -437,7 +466,17 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // legitimate value (unless allowEmptyBody already returned above) —\n") buf.WriteString(" // JSON.parse('') throws, matching what response.json() would have\n") buf.WriteString(" // thrown before this method read the body itself.\n") - buf.WriteString(" return JSON.parse(await blob.text());\n") + buf.WriteString(" const parsed = JSON.parse(await blob.text());\n") + buf.WriteString(" // responseCodec is only set by the generated method when this\n") + buf.WriteString(" // response resolves to a named component schema (see rest.go's\n") + buf.WriteString(" // responseCodecRef); decode() is a no-op passthrough for an\n") + buf.WriteString(" // undefined codec id. This is the ONLY place decode() is called --\n") + buf.WriteString(" // the 204/205 and allowEmptyBody early returns above, and the\n") + buf.WriteString(" // text/* and Blob branches below, all return before reaching here,\n") + buf.WriteString(" // so a void/text/Blob response is never walked by it.\n") + buf.WriteString(" return requestConfig.responseCodec !== undefined\n") + buf.WriteString(" ? (decode(parsed, requestConfig.responseCodec) as T)\n") + buf.WriteString(" : parsed;\n") buf.WriteString(" }\n\n") buf.WriteString(" // A declared text return type (e.g. `string`) must be read as text —\n") diff --git a/internal/client/generators/typescript/fetch_client_test.go b/internal/client/generators/typescript/fetch_client_test.go index 18dfe7a3..e1460e07 100644 --- a/internal/client/generators/typescript/fetch_client_test.go +++ b/internal/client/generators/typescript/fetch_client_test.go @@ -9,16 +9,24 @@ import ( "github.com/stretchr/testify/require" ) -// writeFetchOnly generates just src/fetch.ts (self-contained: HTTPClient has -// no imports of its own) into a fresh temp dir, for tests that only exercise -// HTTPClient's timeout/abort/retry machinery and don't need the rest of the -// generated tree (rest.ts, types.ts, etc). +// writeFetchOnly generates src/fetch.ts plus src/codecs.ts into a fresh temp +// dir, for tests that exercise HTTPClient's timeout/abort/retry/serialization +// machinery and don't need the rest of the generated tree (rest.ts, types.ts, +// etc). codecs.ts is included — not just fetch.ts alone — because executeRequest +// imports { encode, decode } from './codecs' to apply RequestConfig's +// bodyCodec/responseCodec at the HTTP boundary; without it, esbuild would fail +// to resolve that import for every test using this helper, including the ones +// that never set bodyCodec/responseCodec at all. func writeFetchOnly(t *testing.T) string { t.Helper() code := NewFetchClientGenerator().GenerateBaseClient(baseSpec(), baseConfig()) + codecCode, _ := NewCodecGenerator().Generate(baseSpec(), baseConfig()) dir := t.TempDir() - writeTree(t, dir, map[string]string{"src/fetch.ts": code}) + writeTree(t, dir, map[string]string{ + "src/fetch.ts": code, + "src/codecs.ts": codecCode, + }) return dir } @@ -974,3 +982,312 @@ main().catch((err) => { console.error(err); process.exit(1); }); assert.Equal(t, "application/json", *result.PlainObject.ContentType) } } + +// TestExecuteRequestEncodesBodyViaBodyCodec is the runtime proof for task 6's +// core behaviour: a JSON request body carrying client-side (camelCase) field +// names must be renamed to their wire (snake_case) names before +// JSON.stringify, when RequestConfig.bodyCodec names a schema in the codec +// table. baseSpec()'s "User" schema declares "user_id", which tsFieldName +// (under the default TypeScript camel-case strategy) renames to "userId" — +// exactly the case the task brief specifies. +func TestExecuteRequestEncodesBodyViaBodyCodec(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + + await client.request({ + method: 'POST', + url: '/users', + body: { userId: 'x' }, + bodyCodec: 'User', + allowEmptyBody: true, + }); + + console.log(JSON.stringify({ wireBody: captured.body })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_codec_encode.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_codec_encode.ts") + + var result struct { + WireBody string `json:"wireBody"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, `{"user_id":"x"}`, result.WireBody, + "a request body with bodyCodec:'User' must put the wire-cased {\"user_id\":\"x\"} on the wire; driver stdout:\n%s", stdout) +} + +// TestExecuteRequestDecodesResponseViaResponseCodec is the decode-direction +// counterpart: a JSON response carrying wire (snake_case) field names must be +// renamed to their client-side (camelCase) names when RequestConfig. +// responseCodec names a schema in the codec table. +func TestExecuteRequestDecodesResponseViaResponseCodec(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + (globalThis as any).fetch = async () => new Response(JSON.stringify({ user_id: 'x' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + const result = await client.request({ method: 'GET', url: '/users/x', responseCodec: 'User' }); + + console.log(JSON.stringify({ result })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_codec_decode.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_codec_decode.ts") + + var result struct { + Result map[string]any `json:"result"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "x", result.Result["userId"], + "a response of {\"user_id\":\"x\"} with responseCodec:'User' must resolve to {userId:'x'}; driver stdout:\n%s", stdout) + _, hasWireKey := result.Result["user_id"] + assert.False(t, hasWireKey, "the wire key must be renamed away, not left alongside the renamed one") +} + +// TestExecuteRequestWithoutCodecRefsPassesThroughUntouched proves the +// negative case: when a call site sets neither bodyCodec nor responseCodec (a +// request against a schema the codec table doesn't cover, or a caller that +// never opted in), the body and response are passed through exactly as they +// were before this task — no renaming, in either direction. +func TestExecuteRequestWithoutCodecRefsPassesThroughUntouched(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + const results: Record = {}; + + { + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + await client.request({ method: 'POST', url: '/users', body: { userId: 'x' }, allowEmptyBody: true }); + results.wireBody = captured.body; + } + + { + (globalThis as any).fetch = async () => new Response(JSON.stringify({ user_id: 'x' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + results.response = await client.request({ method: 'GET', url: '/users/x' }); + } + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_codec_none.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_codec_none.ts") + + var result struct { + WireBody string `json:"wireBody"` + Response map[string]any `json:"response"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, `{"userId":"x"}`, result.WireBody, + "no bodyCodec means no renaming: the body must be serialized exactly as the caller wrote it; driver stdout:\n%s", stdout) + assert.Equal(t, "x", result.Response["user_id"], + "no responseCodec means no renaming: the response must resolve exactly as parsed; driver stdout:\n%s", stdout) +} + +// TestBodyCodecNeverAppliesToNativeBodyInitTypes is the runtime proof for +// hazard 1 in the task brief: encode() must never walk a FormData, Blob, +// URLSearchParams, ReadableStream, ArrayBuffer, or TypedArray body, even when +// bodyCodec is set on the same RequestConfig — those native BodyInit shapes +// are dispatched to fetch() by reference in executeRequest, before the +// JSON-only branch that calls encode() is ever reached. If encode() were +// mistakenly invoked on one of these, `walk`'s 'object' case would call +// Object.entries on it (a FormData/URLSearchParams instance has no own +// enumerable properties) and return a brand-new plain object instead of the +// original reference — so a same-reference assertion catches a misplaced +// encode() call even though none of these instances have a "user_id"/"userId" +// field for a rename to visibly corrupt. +func TestBodyCodecNeverAppliesToNativeBodyInitTypes(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function send(client: any, body: any) { + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + await client.request({ method: 'POST', url: '/x', body, bodyCodec: 'User', allowEmptyBody: true }); + return captured; +} + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + const results: Record = {}; + + { + const fd = new FormData(); + fd.append('user_id', 'x'); + const c = await send(client, fd); + results.formData = { sameReference: c.body === fd }; + } + + { + const blob = new Blob(['{"user_id":"x"}'], { type: 'application/json' }); + const c = await send(client, blob); + results.blob = { sameReference: c.body === blob }; + } + + { + const usp = new URLSearchParams({ user_id: 'x' }); + const c = await send(client, usp); + results.urlSearchParams = { sameReference: c.body === usp }; + } + + { + const bytes = new Uint8Array([1, 2, 3]); + const c = await send(client, bytes); + results.typedArray = { sameReference: c.body === bytes }; + } + + { + const buf = new ArrayBuffer(4); + const c = await send(client, buf); + results.arrayBuffer = { sameReference: c.body === buf }; + } + + { + const stream = new ReadableStream({ start(ctrl) { ctrl.enqueue(new TextEncoder().encode('x')); ctrl.close(); } }); + const c = await send(client, stream); + results.stream = { sameReference: c.body === stream }; + } + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_codec_bodyinit.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_codec_bodyinit.ts") + + var result struct { + FormData struct{ SameReference bool } `json:"formData"` + Blob struct{ SameReference bool } `json:"blob"` + URLSearchParams struct{ SameReference bool } `json:"urlSearchParams"` + TypedArray struct{ SameReference bool } `json:"typedArray"` + ArrayBuffer struct{ SameReference bool } `json:"arrayBuffer"` + Stream struct{ SameReference bool } `json:"stream"` + } + decodeLastLine(t, stdout, &result) + + assert.True(t, result.FormData.SameReference, "bodyCodec:'User' must not cause encode() to walk a FormData body; driver stdout:\n%s", stdout) + assert.True(t, result.Blob.SameReference, "bodyCodec:'User' must not cause encode() to walk a Blob body; driver stdout:\n%s", stdout) + assert.True(t, result.URLSearchParams.SameReference, "bodyCodec:'User' must not cause encode() to walk a URLSearchParams body; driver stdout:\n%s", stdout) + assert.True(t, result.TypedArray.SameReference, "bodyCodec:'User' must not cause encode() to walk a TypedArray body; driver stdout:\n%s", stdout) + assert.True(t, result.ArrayBuffer.SameReference, "bodyCodec:'User' must not cause encode() to walk an ArrayBuffer body; driver stdout:\n%s", stdout) + assert.True(t, result.Stream.SameReference, "bodyCodec:'User' must not cause encode() to walk a ReadableStream body; driver stdout:\n%s", stdout) +} + +// TestResponseCodecNeverAppliesToNonJSONResponses is the runtime proof for +// hazard 2 in the task brief: decode() must never run for a response +// executeRequest resolves as void/undefined, a Blob, or a text/* string, even +// when responseCodec is set on the same RequestConfig. +func TestResponseCodecNeverAppliesToNonJSONResponses(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function main() { + const client = new HTTPClient('http://example.invalid', 5000); + const results: Record = {}; + + // 204: the unconditional no-body status path. + { + (globalThis as any).fetch = async () => new Response(null, { status: 204 }); + const r = await client.request({ method: 'GET', url: '/x', responseCodec: 'User' }); + results.status204 = r === undefined ? 'undefined' : typeof r; + } + + // Empty 202 with allowEmptyBody: the spec-gated empty-to-undefined path. + { + (globalThis as any).fetch = async () => new Response(null, { status: 202 }); + const r = await client.request({ method: 'GET', url: '/x', responseCodec: 'User', allowEmptyBody: true }); + results.empty202 = r === undefined ? 'undefined' : typeof r; + } + + // text/plain: a raw string response. + { + (globalThis as any).fetch = async () => new Response('user_id=raw-text', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }); + results.textPlain = await client.request({ method: 'GET', url: '/x', responseCodec: 'User' }); + } + + // application/octet-stream: a Blob response. + { + (globalThis as any).fetch = async () => new Response(new Blob(['bytes']), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }); + const r = await client.request({ method: 'GET', url: '/x', responseCodec: 'User' }); + results.octetStream = { isBlob: typeof Blob !== 'undefined' && r instanceof Blob, size: r.size }; + } + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_codec_nonjson.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_codec_nonjson.ts") + + var result struct { + Status204 string `json:"status204"` + Empty202 string `json:"empty202"` + TextPlain string `json:"textPlain"` + OctetStream struct { + IsBlob bool `json:"isBlob"` + Size int `json:"size"` + } `json:"octetStream"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "undefined", result.Status204, "responseCodec:'User' must not affect the 204 no-body path; driver stdout:\n%s", stdout) + assert.Equal(t, "undefined", result.Empty202, "responseCodec:'User' must not affect the allowEmptyBody empty-202 path; driver stdout:\n%s", stdout) + assert.Equal(t, "user_id=raw-text", result.TextPlain, "responseCodec:'User' must not walk a text/plain response; driver stdout:\n%s", stdout) + assert.True(t, result.OctetStream.IsBlob, "responseCodec:'User' must not walk an application/octet-stream response; driver stdout:\n%s", stdout) +} diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index 416c66cc..a60f514e 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -319,6 +319,15 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien // (see generateParameters); otherwise the shorthand references nothing. if r.hasBodyParam(endpoint) { fmt.Fprintf(buf, "%s body,\n", indentStr) + + // Only set when the request body is application/json AND resolves to + // a named component schema -- see requestBodyCodecRef's doc comment + // for why an inline schema (or any non-JSON content type) must not + // get a codec ref at all. + if codecID := r.requestBodyCodecRef(endpoint); codecID != "" { + literal, _ := json.Marshal(codecID) + fmt.Fprintf(buf, "%s bodyCodec: %s,\n", indentStr, literal) + } } // Headers @@ -353,6 +362,15 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien fmt.Fprintf(buf, "%s allowEmptyBody: true,\n", indentStr) } + // Only set when every JSON 2xx response in the union agrees on a single + // named component schema -- see responseCodecRef's doc comment for why an + // inline schema, or two DIFFERENT named schemas across the status codes, + // must leave this unset rather than guess. + if codecID := r.responseCodecRef(endpoint); codecID != "" { + literal, _ := json.Marshal(codecID) + fmt.Fprintf(buf, "%s responseCodec: %s,\n", indentStr, literal) + } + fmt.Fprintf(buf, "%s};\n\n", indentStr) // Make request. @@ -478,6 +496,110 @@ func (r *RESTGenerator) requestBodyParamType(endpoint *client.Endpoint, spec *cl } } +// requestBodyCodecRef returns the codec table id (see codecs.go) for an +// endpoint's request body, or "" when no codec applies. +// +// Only application/json ever gets one: the codecs.go table renames JSON +// object shapes, and executeRequest's encode() call site (fetch_client.go) is +// itself gated to the JSON-serialisation branch only, so a codec ref on a +// FormData/URLSearchParams/Blob/octet-stream body would be inert at best — +// simplest to never emit it for those content types at all. +// +// Within application/json, only a DIRECT $ref to a named component schema +// resolves to something real: codecs.go's CodecGenerator.Generate only ever +// walks spec.Schemas (see its top-level loop), registering entries keyed by +// component schema NAME. An inline (non-$ref) request body schema has no such +// entry — CODECS[id] would be undefined and codecFor/walk would silently +// no-op — so returning "" here (no bodyCodec emitted at all) is both correct +// and honest, rather than wiring up a reference to an id that resolves to +// nothing. +func (r *RESTGenerator) requestBodyCodecRef(endpoint *client.Endpoint) string { + if r.requestBodyContentType(endpoint) != "application/json" { + return "" + } + + media := endpoint.RequestBody.Content["application/json"] + if media == nil || media.Schema == nil { + return "" + } + + return refName(media.Schema.Ref) +} + +// responseCodecRef returns the codec table id (see codecs.go) for an +// endpoint's response, or "" when no single codec safely applies. +// +// generateReturnType unions every 2xx response into one TypeScript type, but +// decode() is applied unconditionally by executeRequest's JSON branch +// regardless of which status code the server actually returned — there is no +// per-call information about which 2xx a given response is at the point +// decode() runs. That makes a SINGLE codec id safe only when every JSON 2xx +// response in the set agrees on it: +// +// - a 2xx with no content (e.g. a 202 ack) contributes nothing to check — +// it can never reach the JSON decode branch at all; +// - a 2xx whose content is JSON but has no schema, or isn't JSON at all +// (text/*, Blob), also contributes nothing — decode() never runs for +// those response shapes either (see fetch_client.go's content-type +// branching); +// - a 2xx whose JSON schema is INLINE (no $ref) has no codec-table entry to +// reference at all (same reasoning as requestBodyCodecRef) — bail out +// entirely rather than risk decoding some OTHER status's differently- +// shaped, inline response through an unrelated named schema's codec; +// - two DIFFERENT named schemas across the 2xx set (e.g. 200 -> User, +// 201 -> Team) have no single id correct for every status this call could +// resolve to — leave it unset rather than guess and silently mis-rename +// whichever status wasn't chosen. +// +// Responses is a map[int]*client.Response, so status codes are collected and +// sorted before iterating, matching generateReturnType's own determinism +// requirement (ranging the map directly would make the emitted output +// non-deterministic across runs). +func (r *RESTGenerator) responseCodecRef(endpoint *client.Endpoint) string { + codes := make([]int, 0, len(endpoint.Responses)) + + for code := range endpoint.Responses { + if code >= 200 && code < 300 { + codes = append(codes, code) + } + } + + sort.Ints(codes) + + var ref string + sawJSON := false + + for _, code := range codes { + resp := endpoint.Responses[code] + if resp == nil || len(resp.Content) == 0 { + continue + } + + media, ok := resp.Content["application/json"] + if !ok || media.Schema == nil { + continue + } + + name := refName(media.Schema.Ref) + if name == "" { + return "" + } + + if !sawJSON { + sawJSON = true + ref = name + + continue + } + + if ref != name { + return "" + } + } + + return ref +} + // generateParameters generates method parameters. Query and header parameters // are always emitted as optional, so required parameters (path params and a // required body) are grouped first to avoid an optional-before-required diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index 530518c5..9701bc33 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -956,3 +956,248 @@ func TestNoRequestBodyStillGeneratesNoBodyParam(t *testing.T) { assert.NotContains(t, code, "body,") assert.NotContains(t, code, "body?") } + +// requestConfigBlock extracts one generated method's `const config: +// RequestConfig = { ... };` literal, isolated via the method's distinctive +// `let __path = ...;` line (every method builds `url: __path,` identically, +// so that alone can't disambiguate one method from another — the path +// template is what's unique per endpoint). Brace-counting rather than a fixed +// indentation offset keeps this correct regardless of how deeply the +// endpoint's operation ID nests it in the generated tree. +func requestConfigBlock(t *testing.T, code, pathMarker string) string { + t.Helper() + + idx := strings.Index(code, pathMarker) + require.NotEqual(t, -1, idx, "marker %q not found in generated code:\n%s", pathMarker, code) + + tail := code[idx:] + start := strings.Index(tail, "const config: RequestConfig = {") + require.NotEqual(t, -1, start, "no RequestConfig literal found after %q", pathMarker) + + tail = tail[start:] + braceStart := strings.Index(tail, "{") + require.NotEqual(t, -1, braceStart) + + depth := 0 + for i := braceStart; i < len(tail); i++ { + switch tail[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return tail[:i+1] + } + } + } + + t.Fatalf("unbalanced braces scanning RequestConfig literal after %q", pathMarker) + + return "" +} + +// TestRestPopulatesCodecRefsFromStaticSchemaIDs is the string-level proof that +// rest.ts populates RequestConfig.bodyCodec/responseCodec from statically +// known schema ids at each call site — only when the endpoint's JSON body or +// response resolves to a NAMED component schema (a direct $ref), since that is +// the only shape the codec table (codecs.go) ever registers an entry for. +func TestRestPopulatesCodecRefsFromStaticSchemaIDs(t *testing.T) { + spec := baseSpec() + code := NewRESTGenerator().Generate(spec, baseConfig()) + + // users.create (POST /users): required $ref-User request body, 201 $ref + // User response — both fields populated from the same schema id. + usersCreate := requestConfigBlock(t, code, "let __path = `/users`;\n") + assert.Contains(t, usersCreate, `bodyCodec: "User"`, + "users.create's request body is $ref User; bodyCodec must reference it") + assert.Contains(t, usersCreate, `responseCodec: "User"`, + "users.create's 201 response is $ref User; responseCodec must reference it") + + // users.get (GET /users/{id}): no request body at all, and a 2xx set of + // {200: $ref User, 202: no content} — the 202 contributes nothing, so the + // single named ref among the JSON responses still wins. + usersGet := requestConfigBlock(t, code, "let __path = `/users/${encodeURIComponent(String(id))}`;\n") + assert.NotContains(t, usersGet, "bodyCodec", "users.get has no request body") + assert.Contains(t, usersGet, `responseCodec: "User"`, + "users.get's 200 response is $ref User; responseCodec must reference it") + + // texts.get (GET /text): text/plain response only — not JSON, so neither + // codec field is meaningful. + textsGet := requestConfigBlock(t, code, "let __path = `/text`;\n") + assert.NotContains(t, textsGet, "Codec", "texts.get's response is text/plain, not JSON") + + // downloads.get (GET /download): application/octet-stream response only — + // not JSON either. + downloadsGet := requestConfigBlock(t, code, "let __path = `/download`;\n") + assert.NotContains(t, downloadsGet, "Codec", "downloads.get's response is application/octet-stream, not JSON") + + // uploads.create (POST /uploads): multipart/form-data request body (not + // JSON, so no bodyCodec) but a real $ref-User JSON 201 response (so + // responseCodec must still apply). + uploadsCreate := requestConfigBlock(t, code, "let __path = `/uploads`;\n") + assert.NotContains(t, uploadsCreate, "bodyCodec", "uploads.create's request body is multipart/form-data, not JSON") + assert.Contains(t, uploadsCreate, `responseCodec: "User"`, "uploads.create's 201 response is still $ref User") + + // raw.create (POST /raw): application/octet-stream request body (not + // JSON) and a 204 (no content) response — neither field applies. + rawCreate := requestConfigBlock(t, code, "let __path = `/raw`;\n") + assert.NotContains(t, rawCreate, "bodyCodec", "raw.create's request body is application/octet-stream, not JSON") + assert.NotContains(t, rawCreate, "responseCodec", "raw.create's response is 204 (no content)") +} + +// TestRestResponseCodecRefOmittedWhenAmbiguousAcrossStatuses asserts the +// conservative choice for an endpoint whose 2xx set resolves to two DIFFERENT +// named JSON schemas (e.g. 200 -> User, 201 -> Team): there is no single +// codec id correct for every status this call could resolve to, so +// responseCodec is left unset entirely rather than guessing one of them (which +// would silently mis-rename the other status's shape). +func TestRestResponseCodecRefOmittedWhenAmbiguousAcrossStatuses(t *testing.T) { + spec := baseSpec() + spec.Schemas["Team"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"name": {Type: "string"}}} + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "POST", Path: "/ambiguous", OperationID: "ambiguous.create", + Responses: map[int]*client.Response{ + 200: {Content: map[string]*client.MediaType{"application/json": {Schema: &client.Schema{Ref: "#/components/schemas/User"}}}}, + 201: {Content: map[string]*client.MediaType{"application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Team"}}}}, + }, + }) + + code := NewRESTGenerator().Generate(spec, baseConfig()) + block := requestConfigBlock(t, code, "let __path = `/ambiguous`;\n") + assert.NotContains(t, block, "responseCodec", + "two different named schemas across the 2xx set have no single codec id correct for every status this call could resolve") +} + +// TestRestResponseCodecRefOmittedForInlineJSONSchema asserts that an inline +// (non-$ref) JSON response schema never gets a responseCodec: the codec table +// (codecs.go) only registers entries reachable by walking spec.Schemas, keyed +// by named component schema — an inline schema at an endpoint boundary has no +// such entry, so referencing it would hand decode() a dangling id. +func TestRestResponseCodecRefOmittedForInlineJSONSchema(t *testing.T) { + spec := baseSpec() + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "GET", Path: "/inline", OperationID: "inline.get", + Responses: map[int]*client.Response{ + 200: {Content: map[string]*client.MediaType{"application/json": {Schema: &client.Schema{ + Type: "object", Properties: map[string]*client.Schema{"x": {Type: "string"}}, + }}}}, + }, + }) + + code := NewRESTGenerator().Generate(spec, baseConfig()) + block := requestConfigBlock(t, code, "let __path = `/inline`;\n") + assert.NotContains(t, block, "responseCodec", + "an inline JSON response schema has no codec-table entry; referencing it would be a dangling id") +} + +// TestRESTClientRoundTripsBodyAndResponseCodecsAtRuntime is the full, +// generated-client execution proof: a nested object, an array of objects, and +// a record all round-trip correctly through both encode (request) and decode +// (response) at every nesting level, and an unknown key at both the top level +// and inside a nested object survives untouched in both directions. This +// drives the REAL generated RESTClient (rest.ts + fetch.ts + codecs.ts + +// client.ts + types.ts all wired together), not a hand-built RequestConfig — +// the thing task 6 is actually about is rest.ts populating bodyCodec/ +// responseCodec correctly AND fetch.ts's executeRequest applying them, and +// only the full generated tree exercises both halves together. +func TestRESTClientRoundTripsBodyAndResponseCodecsAtRuntime(t *testing.T) { + spec := nestedSpec() + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "POST", Path: "/nested", OperationID: "nested.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Nested"}}}}, + Responses: map[int]*client.Response{201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Nested"}}}}}, + }) + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + require.Contains(t, rest, `bodyCodec: "Nested"`, + "sanity check: nested.create's request body is $ref Nested, or this test is not exercising the codec path at all") + require.Contains(t, rest, `responseCodec: "Nested"`, + "sanity check: nested.create's response is $ref Nested, or this test is not exercising the codec path at all") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + const wireResponse = { + user: { id: 'u2', user_id: 'w2', created_at: '2020-01-02', extra_user_field: 'keep-me' }, + items: [{ id: 'u3', user_id: 'w3', created_at: '2020-01-03' }], + tags: { alpha: 'one', beta: 'two' }, + extra_top_level: 'also-keep', + }; + return new Response(JSON.stringify(wireResponse), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await client.nested.create({ + user: { id: 'u1', userId: 'w1', createdAt: '2020-01-01' }, + items: [{ id: 'u1b', userId: 'w1b', createdAt: '2020-01-01b' }], + tags: { alpha: 'one', beta: 'two' }, + }); + + console.log(JSON.stringify({ sentBody: JSON.parse(captured.body), result })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_codec_roundtrip.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_codec_roundtrip.ts") + + var result struct { + SentBody map[string]any `json:"sentBody"` + Result map[string]any `json:"result"` + } + decodeLastLine(t, stdout, &result) + + // --- encode direction: camelCase -> wire, at every nesting level. + sentUser, ok := result.SentBody["user"].(map[string]any) + require.True(t, ok, "driver stdout:\n%s", stdout) + assert.Equal(t, "w1", sentUser["user_id"], "nested object: userId must encode to user_id on the wire") + + sentItems, ok := result.SentBody["items"].([]any) + require.True(t, ok && len(sentItems) == 1, "driver stdout:\n%s", stdout) + sentItem0, ok := sentItems[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "w1b", sentItem0["user_id"], "array of objects: each item's userId must encode to user_id") + + sentTags, ok := result.SentBody["tags"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "one", sentTags["alpha"], "record: keys are data and must stay untouched") + assert.Equal(t, "two", sentTags["beta"], "record: keys are data and must stay untouched") + + // --- decode direction: wire -> camelCase, at every nesting level, plus + // unknown-key survival at the top level and inside a nested object. + resultUser, ok := result.Result["user"].(map[string]any) + require.True(t, ok, "driver stdout:\n%s", stdout) + assert.Equal(t, "w2", resultUser["userId"], "nested object: user_id must decode to userId") + assert.Equal(t, "keep-me", resultUser["extra_user_field"], + "an unknown key inside a nested object must pass through verbatim, not be renamed or dropped") + + resultItems, ok := result.Result["items"].([]any) + require.True(t, ok && len(resultItems) == 1, "driver stdout:\n%s", stdout) + resultItem0, ok := resultItems[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "w3", resultItem0["userId"], "array of objects: each item's user_id must decode to userId") + + resultTags, ok := result.Result["tags"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "one", resultTags["alpha"]) + assert.Equal(t, "two", resultTags["beta"]) + + assert.Equal(t, "also-keep", result.Result["extra_top_level"], "a top-level unknown key must pass through verbatim") +} From 974ae4d4e17ba2d08e2b519a96729da9f9b694b3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 19:30:39 -0500 Subject: [PATCH 53/71] fix(client/typescript): rename union tags/required by TS name when encoding codecRuntime's union case resolved a discriminator tag, and an undiscriminated union's required-field match, by wire name in both directions. Decoding (wire-shaped src) was already correct; encoding (TS-shaped src) looked up a key that never exists on it, so the whole union body shipped unrenamed with no warning. Both directions now map through the matching member's own field table before testing/reading under the right name. Also: - codecs.go/rest.go: register a synthetic array-of-$ref codec entry and resolve it from both requestBodyCodecRef and responseCodecRef, so `{type: array, items: $ref X}` request bodies and responses are codec'd too, not just a direct $ref. - rest.go/generator.go: RESTGenerator.Generate now returns (string, []string), mirroring CodecGenerator.Generate, and warns per endpoint when a JSON body/response can't be resolved to a codec id (inline schema, ambiguous 2xx set) instead of silently leaving a camelCase-typed value that will never actually be renamed. - fetch_client.go: document that a request interceptor returning a from-scratch replacement object (rather than spreading) silently drops bodyCodec/responseCodec, same as the pre-existing allowEmptyBody/signal/retry hazard. --- .../typescript/codec_fixround1_test.go | 414 ++++++++++++++++++ .../client/generators/typescript/codecs.go | 157 ++++++- .../generators/typescript/fetch_client.go | 14 + .../client/generators/typescript/generator.go | 3 +- internal/client/generators/typescript/rest.go | 213 ++++++--- .../client/generators/typescript/rest_test.go | 69 ++- 6 files changed, 789 insertions(+), 81 deletions(-) create mode 100644 internal/client/generators/typescript/codec_fixround1_test.go diff --git a/internal/client/generators/typescript/codec_fixround1_test.go b/internal/client/generators/typescript/codec_fixround1_test.go new file mode 100644 index 00000000..abe64a99 --- /dev/null +++ b/internal/client/generators/typescript/codec_fixround1_test.go @@ -0,0 +1,414 @@ +package typescript + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// discriminatedPetSnakeCaseSpec returns baseSpec() plus a discriminated union (Pet = +// oneOf[Cat, Dog], discriminator "pet_kind") and a POST /pets endpoint whose +// body and response are both $ref Pet. This is the review's own reproduction +// scenario for the fix-round-1 CRITICAL finding: Cat/Dog's "pet_kind" and +// Dog's "bark_volume" both have underscores, so a camelCase client renders +// them as "petKind"/"barkVolume" -- exactly what's needed to prove a rename +// actually happened, not merely that the same string passed through. +func discriminatedPetSnakeCaseSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["Cat"] = &client.Schema{ + Type: "object", Required: []string{"pet_kind"}, + Properties: map[string]*client.Schema{ + "pet_kind": {Type: "string", Enum: []any{"cat"}}, + "meows": {Type: "boolean"}, + }, + } + spec.Schemas["Dog"] = &client.Schema{ + Type: "object", Required: []string{"pet_kind", "bark_volume"}, + Properties: map[string]*client.Schema{ + "pet_kind": {Type: "string", Enum: []any{"dog"}}, + "bark_volume": {Type: "integer"}, + }, + } + spec.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Cat"}, + {Ref: "#/components/schemas/Dog"}, + }, + Discriminator: &client.Discriminator{ + PropertyName: "pet_kind", + Mapping: map[string]string{ + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog", + }, + }, + } + + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "POST", Path: "/pets", OperationID: "pets.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Pet"}}}}, + Responses: map[int]*client.Response{201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Pet"}}}}}, + }) + + return spec +} + +// TestCriticalDiscriminatedUnionBodyEncodesCorrectly is the runtime proof for +// fix-round-1 review's CRITICAL finding: codecRuntime's union case resolved +// the discriminator tag (and, for an undiscriminated union, `required`) by +// WIRE name in both directions. Decoding a wire-shaped payload was already +// correct (the tag IS the wire name there); encoding a TS-shaped payload +// looked up a key ("pet_kind") that plainly does not exist on +// { petKind: 'dog', barkVolume: 11 } -- so `tag` was always undefined, +// codecRuntime fell through to its passthrough branch, and the ENTIRE union +// body shipped unrenamed, silently, with zero generation-time warning. +// +// Measured BEFORE the fix (review's own reproduction, quoted verbatim): +// +// call: c.pets.create({ petKind: 'dog', barkVolume: 11 }) +// wire: {"petKind":"dog","barkVolume":11} <-- UNRENAMED +// resp: {"pet_kind":"dog","bark_volume":9} -> {petKind:'dog', barkVolume:9} (decode fine) +func TestCriticalDiscriminatedUnionBodyEncodesCorrectly(t *testing.T) { + spec := discriminatedPetSnakeCaseSpec() + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + require.Contains(t, rest, `bodyCodec: "Pet"`, + "sanity check: pets.create's request body is $ref Pet, or this test is not exercising the union codec path at all") + require.Contains(t, rest, `responseCodec: "Pet"`) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(JSON.stringify({ pet_kind: 'dog', bark_volume: 9 }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await client.pets.create({ petKind: 'dog', barkVolume: 11 }); + + console.log(JSON.stringify({ sentBody: JSON.parse(captured.body), result })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_critical_discriminated_union.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_critical_discriminated_union.ts") + + var result struct { + SentBody map[string]any `json:"sentBody"` + Result map[string]any `json:"result"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "dog", result.SentBody["pet_kind"], + "discriminator tag must encode from petKind to pet_kind on the wire, not ship as the unrenamed TS key; driver stdout:\n%s", stdout) + _, hadUnrenamedTag := result.SentBody["petKind"] + assert.False(t, hadUnrenamedTag, "the TS-cased key must not survive encoding onto the wire") + + assert.Equal(t, float64(11), result.SentBody["bark_volume"], + "the discriminated member's OWN required field must also be renamed on encode, not just the tag; driver stdout:\n%s", stdout) + + assert.Equal(t, "dog", result.Result["petKind"], + "decode direction (already correct pre-fix) must still work: pet_kind -> petKind; driver stdout:\n%s", stdout) + assert.Equal(t, float64(9), result.Result["barkVolume"], "driver stdout:\n%s", stdout) +} + +// undiscriminatedThingSpec returns baseSpec() plus an UNDISCRIMINATED union +// (Thing = oneOf[Widget, Gadget], no discriminator) whose two members' +// required fields ("widget_id"/"gadget_id") both have underscores, so a +// camelCase client renders them as "widgetId"/"gadgetId" -- proving the +// structural-match half of the same CRITICAL bug: `required` lists wire +// names, and testing them directly against a TS-shaped `src` during encode +// always failed to match ANY member, falling through to the same silent +// passthrough. +func undiscriminatedThingSpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["Widget"] = &client.Schema{ + Type: "object", Required: []string{"widget_id"}, + Properties: map[string]*client.Schema{"widget_id": {Type: "string"}}, + } + spec.Schemas["Gadget"] = &client.Schema{ + Type: "object", Required: []string{"gadget_id"}, + Properties: map[string]*client.Schema{"gadget_id": {Type: "string"}}, + } + spec.Schemas["Thing"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/Widget"}, + {Ref: "#/components/schemas/Gadget"}, + }, + } + + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "POST", Path: "/things", OperationID: "things.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Thing"}}}}, + Responses: map[int]*client.Response{201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Thing"}}}}}, + }) + + return spec +} + +// TestCriticalUndiscriminatedUnionBodyEncodesCorrectly is the structural-match +// half of the CRITICAL finding: an undiscriminated union's `required` wire +// names, tested directly against a TS-shaped encode() `src`, never matched +// any member, so the whole body passed through unrenamed on encode even +// though decode (wire-shaped `src`, wire-named `required`) already worked. +func TestCriticalUndiscriminatedUnionBodyEncodesCorrectly(t *testing.T) { + spec := undiscriminatedThingSpec() + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + require.Contains(t, rest, `bodyCodec: "Thing"`, + "sanity check: things.create's request body is $ref Thing, or this test is not exercising the union codec path at all") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(JSON.stringify({ gadget_id: 'g1' }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await client.things.create({ widgetId: 'w1' }); + + console.log(JSON.stringify({ sentBody: JSON.parse(captured.body), result })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_critical_undiscriminated_union.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_critical_undiscriminated_union.ts") + + var result struct { + SentBody map[string]any `json:"sentBody"` + Result map[string]any `json:"result"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "w1", result.SentBody["widget_id"], + "an undiscriminated union member's required field (widgetId) must be matched via its TS name and encoded to widget_id on the wire; driver stdout:\n%s", stdout) + _, hadUnrenamedKey := result.SentBody["widgetId"] + assert.False(t, hadUnrenamedKey, "the TS-cased key must not survive encoding onto the wire") + + assert.Equal(t, "g1", result.Result["gadgetId"], "decode direction must still work: gadget_id -> gadgetId; driver stdout:\n%s", stdout) +} + +// TestImportant1ArrayOfRefBodyAndResponseAreCodecd is the runtime proof for +// fix-round-1 review's IMPORTANT 1 finding: an endpoint whose JSON body or +// response is a bare array wrapping a direct $ref (`{type: array, items: +// $ref User}` -- the single most common OpenAPI "list of X" shape) got no +// codec on either side. requestBodyCodecRef/responseCodecRef both bottomed +// out at a direct refName(schema.Ref) check, which an array wrapper always +// fails, so the declared `types.User[]`/`body: types.User[]` camelCase type +// shipped over a wire-cased runtime payload for every list endpoint. +// +// Measured BEFORE the fix (review's own reproduction, quoted verbatim): +// +// list.get(): Promise -> [{"id":"i","user_id":"srv","created_at":"ts"}] UNDECODED +// arraybody.create(body: types.User[]) -> [{"id":"i","userId":"cli",...}] UNENCODED +func TestImportant1ArrayOfRefBodyAndResponseAreCodecd(t *testing.T) { + spec := baseSpec() + spec.Endpoints = append(spec.Endpoints, + client.Endpoint{ + Method: "GET", Path: "/list", OperationID: "list.get", + Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}}}}}, + }, + client.Endpoint{ + Method: "POST", Path: "/arraybody", OperationID: "arraybody.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}}}}, + Responses: map[int]*client.Response{201: {Description: "ok"}}, + }, + ) + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + rest := out.Files["src/rest.ts"] + require.Contains(t, rest, `responseCodec: "[]User"`, + "sanity check: list.get's response is an array of $ref User, or this test is not exercising the array-of-ref codec path") + require.Contains(t, rest, `bodyCodec: "[]User"`, + "sanity check: arraybody.create's request body is an array of $ref User, or this test is not exercising the array-of-ref codec path") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + const results: Record = {}; + + // list.get(): a wire-cased array response must decode to camelCase. + (globalThis as any).fetch = async () => new Response( + JSON.stringify([{ id: 'i', user_id: 'srv', created_at: 'ts' }]), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + results.listGet = await client.list.get(); + + // arraybody.create(): a camelCase array body must encode to wire-cased. + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 201 }); + }; + await client.arraybody.create([{ id: 'i', userId: 'cli', createdAt: 'ts' }]); + results.sentBody = JSON.parse(captured.body); + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_important1_array_of_ref.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_important1_array_of_ref.ts") + + var result struct { + ListGet []map[string]any `json:"listGet"` + SentBody []map[string]any `json:"sentBody"` + } + decodeLastLine(t, stdout, &result) + + require.Len(t, result.ListGet, 1, "driver stdout:\n%s", stdout) + assert.Equal(t, "srv", result.ListGet[0]["userId"], + "each item of an array-of-$ref response must be decoded (user_id -> userId), not left wire-cased; driver stdout:\n%s", stdout) + _, hadWireKey := result.ListGet[0]["user_id"] + assert.False(t, hadWireKey) + + require.Len(t, result.SentBody, 1, "driver stdout:\n%s", stdout) + assert.Equal(t, "cli", result.SentBody[0]["user_id"], + "each item of an array-of-$ref request body must be encoded (userId -> user_id), not shipped camelCase; driver stdout:\n%s", stdout) + _, hadTSKey := result.SentBody[0]["userId"] + assert.False(t, hadTSKey) +} + +// TestMinorRequestConfigDocWarnsInterceptorsMustSpread pins fix-round-1 +// review's MINOR finding: the generated RequestConfig doc comment must warn +// interceptor authors that a request interceptor which RETURNS A REPLACEMENT +// object (rather than spreading the incoming config) silently drops fields +// it didn't name -- bodyCodec/responseCodec included. No behavior change was +// requested here (encode-after-interceptors is correct and stays), only +// documentation. +func TestMinorRequestConfigDocWarnsInterceptorsMustSpread(t *testing.T) { + code := NewFetchClientGenerator().GenerateBaseClient(baseSpec(), baseConfig()) + + idx := strings.Index(code, "export interface RequestConfig") + require.NotEqual(t, -1, idx) + + preceding := code[:idx] + assert.Contains(t, preceding, "spread", + "the doc comment immediately above RequestConfig must tell interceptor authors to spread the incoming config, not replace it") + assert.Contains(t, preceding, "bodyCodec", + "the warning must name bodyCodec (and, by the same sentence, responseCodec) as fields a replacing interceptor silently drops") +} + +// TestMinorReplacingInterceptorDropsBodyCodecButSpreadingPreservesIt is the +// runtime characterization (not a fix -- review confirmed +// encode-after-interceptors is correct and must stay) of the same MINOR +// finding: a request interceptor is free to return either a spread copy or a +// from-scratch replacement object, per RequestConfig | Promise. +// A replacement that only lists the fields it cares about silently drops +// bodyCodec, so a JSON body that would otherwise be renamed ships wire-cased +// and unrenamed instead -- with no error anywhere. Spreading preserves it. +// +// Measured (review's own reproduction, quoted verbatim): +// +// spread ({...cfg, headers:...}) -> {"user_id":"x"} OK +// observe (return cfg) -> {"user_id":"x"} OK +// replace ({method, url, body}) -> {"userId":"x"} bodyCodec lost +func TestMinorReplacingInterceptorDropsBodyCodecButSpreadingPreservesIt(t *testing.T) { + dir := writeFetchOnly(t) + + driver := ` +import { HTTPClient } from './fetch'; + +async function send(interceptor: (config: any) => any) { + const client: any = new HTTPClient('http://example.invalid', 5000); + client.addRequestInterceptor({ onRequest: interceptor }); + + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + + await client.request({ + method: 'POST', + url: '/users', + body: { userId: 'x' }, + bodyCodec: 'User', + allowEmptyBody: true, + }); + + return captured.body; +} + +async function main() { + const results: Record = {}; + + results.spread = await send((config: any) => ({ ...config, headers: { ...config.headers } })); + results.observe = await send((config: any) => config); + results.replace = await send((config: any) => ({ method: config.method, url: config.url, body: config.body })); + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_minor_interceptor_replace.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_minor_interceptor_replace.ts") + + var result struct { + Spread string `json:"spread"` + Observe string `json:"observe"` + Replace string `json:"replace"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, `{"user_id":"x"}`, result.Spread, + "an interceptor that spreads the incoming config must preserve bodyCodec; driver stdout:\n%s", stdout) + assert.Equal(t, `{"user_id":"x"}`, result.Observe, + "an interceptor that returns config unchanged must preserve bodyCodec; driver stdout:\n%s", stdout) + assert.Equal(t, `{"userId":"x"}`, result.Replace, + "documented, pre-existing hazard: an interceptor that builds a from-scratch replacement object silently drops bodyCodec -- this is what the RequestConfig doc comment now warns about, not a defect this task fixes; driver stdout:\n%s", stdout) +} diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 314e35f4..7e47b436 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -123,6 +123,90 @@ func refName(ref string) string { return strings.TrimPrefix(ref, prefix) } +// arrayRefCodecID returns the synthetic CODECS table id for an endpoint +// request/response body of the shape `{type: array, items: $ref X}` -- the +// single most common OpenAPI "list of X" wire shape, and (fix-round-1 review, +// IMPORTANT 1) one that previously got no codec on either side: rest.go's +// schemaCodecRef only ever accepted a DIRECT top-level $ref, so an array +// wrapping one fell through to "" on both the request-body and response +// paths, leaving every list endpoint's declared `types.X[]` camelCase type a +// lie over a wire-cased runtime payload. +// +// This is deliberately NOT derived via codecIDFor/codecTable.add's usual +// "." scheme: an endpoint has no schema id of its own to key +// that scheme under, and reusing codecIDFor here would also start +// registering (and silently codec'ing) other endpoint-boundary shapes -- +// inline objects, oneOf/anyOf, allOf -- that rest.go's +// requestBodyCodecRef/responseCodecRef deliberately warn about and skip +// rather than guess at (see IMPORTANT 2 in the same review). Keeping this +// narrowly scoped to "array wrapping a direct $ref" is what lets +// registerEndpointArrayBodyCodecs register exactly this one additional shape +// without silently widening what gets codec'd. +// +// "[]" is not a valid OpenAPI component schema name (schema names are +// identifiers), so prefixing it here cannot collide with a real named +// schema's own top-level entry. +func arrayRefCodecID(itemName string) string { + return "[]" + itemName +} + +// registerEndpointArrayBodyCodecs registers a synthetic array-of-$ref codec +// entry (arrayRefCodecID) for every endpoint request/response body of the +// shape `{type: array, items: $ref X}`. Endpoint bodies are not schemas -- +// CodecGenerator.Generate's main loop only ever walks spec.Schemas -- so +// without this, an endpoint whose JSON body or response is a bare array +// wrapping a named schema has no codec-table entry reachable by rest.go's +// schemaCodecRef, even though the table already has everything an "array" +// kind entry needs (see codecTable.add's own `array` case, which this +// mirrors for the one shape endpoints can carry that a schema property +// walk never reaches). +// +// Idempotent via t.entries' existing "already seen" guard: two different +// endpoints referencing the same item schema (e.g. one endpoint returning +// `User[]`, another accepting `User[]` as a body) register the identical +// entry, harmlessly, regardless of which is walked first -- there is nothing +// endpoint-specific in the registered entry itself, only in the id used to +// look it up. +func registerEndpointArrayBodyCodecs(table *codecTable, spec *client.APISpec) { + register := func(schema *client.Schema) { + if schema == nil || schema.Type != "array" || schema.Items == nil { + return + } + + itemName := refName(schema.Items.Ref) + if itemName == "" { + return + } + + id := arrayRefCodecID(itemName) + if _, seen := table.entries[id]; seen { + return + } + + table.entries[id] = codecEntry{Kind: "array", Items: itemName} + } + + for i := range spec.Endpoints { + endpoint := &spec.Endpoints[i] + + if endpoint.RequestBody != nil { + if media, ok := endpoint.RequestBody.Content["application/json"]; ok && media != nil { + register(media.Schema) + } + } + + for _, resp := range endpoint.Responses { + if resp == nil { + continue + } + + if media, ok := resp.Content["application/json"]; ok && media != nil { + register(media.Schema) + } + } + } +} + // additionalPropertiesSegment is the synthetic path segment used for an // additionalProperties VALUE schema's own codec/collision namespace ("." // + additionalPropertiesSegment), by codecTable.add, checkSchemaFieldCollisions @@ -788,6 +872,8 @@ func (g *CodecGenerator) Generate(spec *client.APISpec, config client.GeneratorC table.add(name, spec, spec.Schemas[name]) } + registerEndpointArrayBodyCodecs(table, spec) + sort.Strings(table.warnings) var buf strings.Builder @@ -908,11 +994,18 @@ func protectDunderProto(s string) string { // not have it silently dropped by an older client; // - `record` renames its VALUES but never its KEYS, because a record's keys // are data (user-chosen ids), not schema-defined field names; -// - a union WITH a discriminator resolves by its tag, exactly as before; +// - a union WITH a discriminator resolves by its tag; the tag is read +// under the WIRE name when decoding and under the tag property's TS +// name when encoding (derived from a member's own `fields` table), +// since an encode()-direction `src` is TS-shaped and never carries the +// wire key at all; // - a union WITHOUT one tries each declared member in order, structurally: -// the first whose required wire fields are all present on the value -// wins. No match falls back to passthrough -- never a best-effort guess, -// because guessing could rename fields based on a match that is wrong; +// the first whose required fields are all present on the value wins -- +// `required` lists wire names, tested directly when decoding and mapped +// through the member's own `fields` table to TS names when encoding, +// for the same TS-shaped-`src` reason as the discriminator case. No +// match falls back to passthrough -- never a best-effort guess, because +// guessing could rename fields based on a match that is wrong; // - every key written onto a walked result goes through setOwn // (Object.defineProperty), not bracket/dot assignment, so a field // literally named "__proto__" (wire or client name) becomes a real own @@ -1017,7 +1110,38 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { const src = value as Record; if (codec.discriminator) { - const tag = src[codec.discriminator.wire]; + // codec.discriminator.wire is the WIRE name of the tag property + // (e.g. "pet_kind"). When decoding (toTS), src IS wire-shaped, so + // reading it directly is correct -- this is the pre-existing, + // already-correct half. + // + // When ENCODING (!toTS), src is TS-shaped (e.g. { petKind: 'dog' }): + // reading src[codec.discriminator.wire] looks up a key that does not + // exist on a TS-shaped object at all, so 'tag' was always undefined + // and this fell through to the passthrough below -- silently + // shipping the whole union unrenamed. (Fix-round-1 review, + // CRITICAL.) Fixed by finding the TS name the tag property renders + // as under one of this union's own members -- every discriminated + // member is expected to declare it, directly or via a flattened + // allOf, so member.fields[wire].ts is where that mapping already + // lives -- and reading src under THAT key instead when encoding. + // Members are assumed to agree on this name, which holds for any + // realistic discriminated union: the whole point of a discriminator + // is that every member carries the same tag property. + let tagKey = codec.discriminator.wire; + + if (!toTS) { + for (const memberID of codec.members ?? []) { + const member = codecFor(memberID); + const declared = member && member.kind === 'object' ? member.fields[codec.discriminator.wire] : undefined; + if (declared) { + tagKey = declared.ts; + break; + } + } + } + + const tag = src[tagKey]; if (typeof tag !== 'string') { return value; } @@ -1031,7 +1155,7 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { } // No discriminator: try each member in the order it was declared, - // taking the first whose required wire fields are ALL present on the + // taking the first whose required fields are ALL present on the // value. A member that is evidence-free -- not an 'object' at all, or // an object with no required fields -- is SKIPPED, not treated as a // vacuous match: an empty required list is satisfied by every object @@ -1044,12 +1168,29 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { // offer (see unionEntry), so this is never a silent surprise. for (const memberID of codec.members ?? []) { const member = codecFor(memberID); - const required = member && member.kind === 'object' ? member.required : undefined; + if (!member || member.kind !== 'object') { + continue; + } + + const required = member.required; if (!required || required.length === 0) { continue; } - if (required.every((field) => Object.prototype.hasOwnProperty.call(src, field))) { + // 'required' lists WIRE names (see requiredWireFields). When + // decoding, 'src' is wire-shaped, so those names are tested + // directly -- the pre-existing, already-correct half. When + // ENCODING, 'src' is TS-shaped: testing a wire name like + // "bark_volume" against it always failed, silently, because that + // key never exists on a TS-shaped object -- the same class of bug + // as the discriminator case above, just for the undiscriminated + // structural match. (Fix-round-1 review, CRITICAL.) Fixed by + // mapping each required wire name through this SAME member's own + // 'fields' table to the ts name it renders as before testing + // presence on 'src'. + const keys = toTS ? required : required.map((wireKey) => member.fields[wireKey]?.ts ?? wireKey); + + if (keys.every((key) => Object.prototype.hasOwnProperty.call(src, key))) { return walk(value, memberID, toTS); } } diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index 35077e78..25a5cd36 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -38,6 +38,20 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString("}\n\n") // RequestConfig interface + buf.WriteString("/**\n") + buf.WriteString(" * A request interceptor's onRequest(config) may return a REPLACEMENT\n") + buf.WriteString(" * object instead of a mutated/spread copy of `config` -- both are legal\n") + buf.WriteString(" * readings of `RequestConfig | Promise`. A replacement that\n") + buf.WriteString(" * only lists the fields it cares about (e.g. `return { method, url, body };`)\n") + buf.WriteString(" * silently drops every field it didn't name -- bodyCodec/responseCodec\n") + buf.WriteString(" * included, alongside the pre-existing allowEmptyBody/signal/retry. Before\n") + buf.WriteString(" * bodyCodec/responseCodec existed, that hazard meant a missing timeout\n") + buf.WriteString(" * signal or a wrongly-collapsed empty body; now it also means a JSON body\n") + buf.WriteString(" * silently ships wire-cased and unrenamed, or a JSON response is silently\n") + buf.WriteString(" * left un-decoded, with no error anywhere. Always spread the incoming\n") + buf.WriteString(" * config -- `return { ...config, headers: { ...config.headers, ... } };` --\n") + buf.WriteString(" * rather than building a replacement object from scratch.\n") + buf.WriteString(" */\n") buf.WriteString("export interface RequestConfig {\n") buf.WriteString(" method: string;\n") buf.WriteString(" url: string;\n") diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index d549d499..98f5a164 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -278,8 +278,9 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, // Generate REST methods if len(spec.Endpoints) > 0 { restGen := NewRESTGenerator() - restCode := restGen.Generate(spec, config) + restCode, restWarnings := restGen.Generate(spec, config) genClient.Files["src/rest.ts"] = restCode + genClient.Warnings = append(genClient.Warnings, restWarnings...) } // Generate pagination helpers if enabled diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index a60f514e..f20f2908 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -10,7 +10,18 @@ import ( ) // RESTGenerator generates TypeScript REST client code. -type RESTGenerator struct{} +type RESTGenerator struct { + // warnings accumulates generation-time messages that don't abort + // generation but are worth surfacing -- currently, one per endpoint whose + // JSON request body or response could not be resolved to a codec-table + // id (see requestBodyCodecRef/responseCodecRef): an inline schema, an + // array wrapping anything but a direct $ref, or a 2xx set that resolves + // to more than one named schema. Reset at the start of each Generate + // call (see Generate) so a reused *RESTGenerator (several tests call + // Generate more than once on the same instance) never leaks a prior + // call's warnings into the next one. + warnings []string +} // NewRESTGenerator creates a new REST generator. func NewRESTGenerator() *RESTGenerator { @@ -121,8 +132,19 @@ func (r *RESTGenerator) generateOperationIDFromPath(endpoint client.Endpoint) st return method + "." + path } -// Generate generates the REST client methods. -func (r *RESTGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) string { +// Generate generates the REST client methods. The second return value lists +// generation-time warnings -- currently, one per endpoint whose JSON request +// body or response could not be resolved to a codec-table id (see +// requestBodyCodecRef/responseCodecRef) -- mirroring CodecGenerator.Generate's +// own (string, []string) shape (codecs.go) so callers have exactly one place +// to look for out-of-band information about a generation run. Without this, +// an endpoint whose declared TypeScript type still promises renamed fields +// (e.g. `Promise`) but will never actually be +// decoded at runtime would fail completely silently -- the fields still look +// renamed at the type level, but nothing renames them. +func (r *RESTGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) (string, []string) { + r.warnings = nil + var buf strings.Builder base := config.APIName @@ -145,7 +167,9 @@ func (r *RESTGenerator) Generate(spec *client.APISpec, config client.GeneratorCo buf.WriteString("}\n") - return buf.String() + sort.Strings(r.warnings) + + return buf.String(), r.warnings } // isValidTSIdentifier reports whether name can be used verbatim as an unquoted @@ -321,12 +345,19 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien fmt.Fprintf(buf, "%s body,\n", indentStr) // Only set when the request body is application/json AND resolves to - // a named component schema -- see requestBodyCodecRef's doc comment - // for why an inline schema (or any non-JSON content type) must not - // get a codec ref at all. - if codecID := r.requestBodyCodecRef(endpoint); codecID != "" { + // a named component schema (or an array of one) -- see + // requestBodyCodecRef's doc comment for why an inline schema (or any + // non-JSON content type) must not get a codec ref at all. When the + // body IS JSON but no id could be resolved, requestBodyCodecRef + // returns a warning instead: the generated body parameter's + // TypeScript type still promises a renamed shape, but it will be + // sent wire-cased and unrenamed, so that must be visible somewhere, + // not silent. + if codecID, warning := r.requestBodyCodecRef(endpoint); codecID != "" { literal, _ := json.Marshal(codecID) fmt.Fprintf(buf, "%s bodyCodec: %s,\n", indentStr, literal) + } else if warning != "" { + r.warnings = append(r.warnings, warning) } } @@ -363,12 +394,17 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien } // Only set when every JSON 2xx response in the union agrees on a single - // named component schema -- see responseCodecRef's doc comment for why an - // inline schema, or two DIFFERENT named schemas across the status codes, - // must leave this unset rather than guess. - if codecID := r.responseCodecRef(endpoint); codecID != "" { + // named component schema (or array of one) -- see responseCodecRef's doc + // comment for why an inline schema, or two DIFFERENT named schemas across + // the status codes, must leave this unset rather than guess. Either skip + // reason is returned as a warning instead: generateReturnType's declared + // union still promises a renamed shape for a response that will never + // actually be decoded, which must be visible, not silent. + if codecID, warning := r.responseCodecRef(endpoint); codecID != "" { literal, _ := json.Marshal(codecID) fmt.Fprintf(buf, "%s responseCodec: %s,\n", indentStr, literal) + } else if warning != "" { + r.warnings = append(r.warnings, warning) } fmt.Fprintf(buf, "%s};\n\n", indentStr) @@ -496,38 +532,102 @@ func (r *RESTGenerator) requestBodyParamType(endpoint *client.Endpoint, spec *cl } } -// requestBodyCodecRef returns the codec table id (see codecs.go) for an -// endpoint's request body, or "" when no codec applies. +// endpointLabel returns a short, human-identifiable name for an endpoint, for +// use in a generation-time warning: its OperationID when the spec declares +// one (the common case, and the same identifier the generated method itself +// is named after), or "METHOD path" as a fallback for an endpoint with no +// OperationID at all. +func endpointLabel(endpoint *client.Endpoint) string { + if endpoint.OperationID != "" { + return endpoint.OperationID + } + + return endpoint.Method + " " + endpoint.Path +} + +// schemaCodecRef returns the codec table id (see codecs.go) for a JSON +// body/response schema at an endpoint boundary, or "" when none applies. // -// Only application/json ever gets one: the codecs.go table renames JSON -// object shapes, and executeRequest's encode() call site (fetch_client.go) is -// itself gated to the JSON-serialisation branch only, so a codec ref on a -// FormData/URLSearchParams/Blob/octet-stream body would be inert at best — -// simplest to never emit it for those content types at all. +// Two shapes resolve to something real: // -// Within application/json, only a DIRECT $ref to a named component schema -// resolves to something real: codecs.go's CodecGenerator.Generate only ever -// walks spec.Schemas (see its top-level loop), registering entries keyed by -// component schema NAME. An inline (non-$ref) request body schema has no such -// entry — CODECS[id] would be undefined and codecFor/walk would silently -// no-op — so returning "" here (no bodyCodec emitted at all) is both correct -// and honest, rather than wiring up a reference to an id that resolves to -// nothing. -func (r *RESTGenerator) requestBodyCodecRef(endpoint *client.Endpoint) string { - if r.requestBodyContentType(endpoint) != "application/json" { +// - a direct $ref to a named component schema -- codecs.go's +// CodecGenerator.Generate walks spec.Schemas (see its top-level loop), +// registering entries keyed by component schema NAME; +// - an array wrapping a direct $ref (`{type: array, items: $ref X}`), the +// single most common OpenAPI "list of X" wire shape. This does NOT come +// from the same top-level walk -- an endpoint body/response is not +// itself a named schema -- so codecs.go's +// registerEndpointArrayBodyCodecs registers a synthetic id for exactly +// this shape (see arrayRefCodecID), and this function must return that +// SAME id for the two sides to agree on what to look up. +// +// Anything else -- an inline object, oneOf/anyOf, allOf, or an array of +// anything but a direct $ref (an inline item schema, a nested array, etc.) -- +// returns "": there is no codec-table entry for those shapes, and referencing +// a nonexistent id would be silently inert at best. +func schemaCodecRef(schema *client.Schema) string { + if schema == nil { return "" } + if name := refName(schema.Ref); name != "" { + return name + } + + if schema.Type == "array" && schema.Items != nil { + if itemName := refName(schema.Items.Ref); itemName != "" { + return arrayRefCodecID(itemName) + } + } + + return "" +} + +// requestBodyCodecRef returns the codec table id (see schemaCodecRef) for an +// endpoint's request body, and a warning to append to RESTGenerator.warnings +// when one is needed. +// +// Only application/json ever gets an id at all: the codecs.go table renames +// JSON object shapes, and executeRequest's encode() call site +// (fetch_client.go) is itself gated to the JSON-serialisation branch only, so +// a codec ref on a FormData/URLSearchParams/Blob/octet-stream body would be +// inert at best -- simplest to never emit it, and never warn about it +// either, for those content types: there is no "declared as renamed but +// isn't" lie for a body whose declared TypeScript type was never +// schema-driven in the first place (FormData, Blob, etc. are fixed DOM +// types, not derived from the schema's shape). +// +// Within application/json, a warning is returned (id "") specifically when +// the body has a resolvable schema that schemaCodecRef could NOT turn into +// an id -- an inline schema, or an array of anything but a direct $ref. +// Silence there would be worse than the wrong-rename bug this fixes: the +// generated `body` parameter is still typed in its camelCase TypeScript +// shape (requestBodyParamType/getSchemaTypeName don't change), so it LOOKS +// renamed at the type level while actually being sent wire-cased and +// unrenamed -- exactly the "never renamed but still typed as if it were" +// failure a silent skip would leave in place. +func (r *RESTGenerator) requestBodyCodecRef(endpoint *client.Endpoint) (id string, warning string) { + if r.requestBodyContentType(endpoint) != "application/json" { + return "", "" + } + media := endpoint.RequestBody.Content["application/json"] if media == nil || media.Schema == nil { - return "" + return "", "" } - return refName(media.Schema.Ref) + if ref := schemaCodecRef(media.Schema); ref != "" { + return ref, "" + } + + return "", fmt.Sprintf( + "endpoint %q: request body is application/json but its schema is not a direct $ref (or an array of one) to a named component schema -- the generated body parameter is still declared in its camelCase TypeScript shape, but it will be sent wire-cased, unrenamed, because there is no codec-table entry to encode it with", + endpointLabel(endpoint)) } -// responseCodecRef returns the codec table id (see codecs.go) for an -// endpoint's response, or "" when no single codec safely applies. +// responseCodecRef returns the codec table id (see schemaCodecRef) for an +// endpoint's response, and a warning to append to RESTGenerator.warnings when +// one is needed. // // generateReturnType unions every 2xx response into one TypeScript type, but // decode() is applied unconditionally by executeRequest's JSON branch @@ -537,25 +637,34 @@ func (r *RESTGenerator) requestBodyCodecRef(endpoint *client.Endpoint) string { // response in the set agrees on it: // // - a 2xx with no content (e.g. a 202 ack) contributes nothing to check — -// it can never reach the JSON decode branch at all; +// it can never reach the JSON decode branch at all, so it needs no +// warning either; // - a 2xx whose content is JSON but has no schema, or isn't JSON at all -// (text/*, Blob), also contributes nothing — decode() never runs for -// those response shapes either (see fetch_client.go's content-type -// branching); -// - a 2xx whose JSON schema is INLINE (no $ref) has no codec-table entry to -// reference at all (same reasoning as requestBodyCodecRef) — bail out -// entirely rather than risk decoding some OTHER status's differently- -// shaped, inline response through an unrelated named schema's codec; -// - two DIFFERENT named schemas across the 2xx set (e.g. 200 -> User, -// 201 -> Team) have no single id correct for every status this call could -// resolve to — leave it unset rather than guess and silently mis-rename -// whichever status wasn't chosen. +// (text/*, Blob), also contributes nothing and needs no warning — decode() +// never runs for those response shapes either (see fetch_client.go's +// content-type branching), and their declared TypeScript type was never +// schema-rename-shaped to begin with; +// - a 2xx whose JSON schema resolves to no codec id at all (an inline +// schema, or an array of anything but a direct $ref) returns a warning +// immediately — bailing out entirely rather than risk decoding some +// OTHER status's differently-shaped response through an unrelated named +// schema's codec; +// - two DIFFERENT resolved ids across the 2xx set (e.g. 200 -> "User", +// 201 -> "Team") have no single id correct for every status this call +// could resolve to — a warning naming both is returned rather than +// guessing and silently mis-rendering whichever status wasn't chosen. +// +// Both warning paths matter for the same reason requestBodyCodecRef's does: +// generateReturnType's declared union still promises a renamed shape +// (`Promise`), so silently emitting no +// responseCodec at all would leave that promise looking honored at the type +// level while nothing actually renames the value at runtime. // // Responses is a map[int]*client.Response, so status codes are collected and // sorted before iterating, matching generateReturnType's own determinism // requirement (ranging the map directly would make the emitted output // non-deterministic across runs). -func (r *RESTGenerator) responseCodecRef(endpoint *client.Endpoint) string { +func (r *RESTGenerator) responseCodecRef(endpoint *client.Endpoint) (id string, warning string) { codes := make([]int, 0, len(endpoint.Responses)) for code := range endpoint.Responses { @@ -580,9 +689,11 @@ func (r *RESTGenerator) responseCodecRef(endpoint *client.Endpoint) string { continue } - name := refName(media.Schema.Ref) + name := schemaCodecRef(media.Schema) if name == "" { - return "" + return "", fmt.Sprintf( + "endpoint %q: response status %d is application/json but its schema is not a direct $ref (or an array of one) to a named component schema -- the declared return type is still a renamed-shaped TypeScript type, but this response will never actually be decoded", + endpointLabel(endpoint), code) } if !sawJSON { @@ -593,11 +704,13 @@ func (r *RESTGenerator) responseCodecRef(endpoint *client.Endpoint) string { } if ref != name { - return "" + return "", fmt.Sprintf( + "endpoint %q: JSON 2xx responses resolve to more than one named schema (%q and %q) -- there is no single codec id correct for every status this call could resolve to, so none of them will be decoded", + endpointLabel(endpoint), ref, name) } } - return ref + return ref, "" } // generateParameters generates method parameters. Query and header parameters diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index 9701bc33..e858d64e 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -38,7 +38,7 @@ func TestRESTGenerator_NestedStructure(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Verify nested structure assert.Contains(t, code, "public readonly capabilities = {") @@ -80,7 +80,7 @@ func TestRESTGenerator_MixedMethodsAndProperties(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Should support both users.list() and users.active.list() assert.Contains(t, code, "public readonly users = {") @@ -112,7 +112,7 @@ func TestRESTGenerator_SingleLevelOperationID(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Single level operation ID should be a root-level property assert.Contains(t, code, "public readonly getStatus = ") @@ -140,7 +140,7 @@ func TestRESTGenerator_EmptyOperationID(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Should generate from path: get.api.users.profile assert.Contains(t, code, "public readonly get = {") @@ -178,7 +178,7 @@ func TestRESTGenerator_WithParameters(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Verify nested structure assert.Contains(t, code, "public readonly connections = {") @@ -240,7 +240,7 @@ func TestRESTGenerator_WithRequestBody(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Verify nested structure assert.Contains(t, code, "public readonly users = {") @@ -273,7 +273,7 @@ func TestRESTGenerator_DeprecatedEndpoint(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Verify deprecated annotation assert.Contains(t, code, "@deprecated") @@ -297,8 +297,8 @@ func TestRESTGenerator_DeterministicOutput(t *testing.T) { gen := NewRESTGenerator() // Generate twice - code1 := gen.Generate(spec, config) - code2 := gen.Generate(spec, config) + code1, _ := gen.Generate(spec, config) + code2, _ := gen.Generate(spec, config) // Output should be identical (sorted alphabetically) assert.Equal(t, code1, code2) @@ -336,7 +336,7 @@ func TestRESTGenerator_DeepNesting(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Verify deep nesting structure assert.Contains(t, code, "public readonly level1 = {") @@ -375,7 +375,7 @@ func TestRESTGenerator_ConflictingOperationIDs(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Both should be accessible // "users" becomes "users.users" to avoid conflict @@ -435,7 +435,7 @@ func TestRESTGenerator_ReturnTypes(t *testing.T) { config := client.DefaultConfig() gen := NewRESTGenerator() - code := gen.Generate(spec, config) + code, _ := gen.Generate(spec, config) // Verify return types. Referenced schemas are qualified with the `types` // namespace the generated file imports (`import * as types from './types'`). @@ -456,13 +456,15 @@ func TestRESTGenerator_ReturnTypes(t *testing.T) { // a silent lie. func TestReturnTypeCoversAll2xxAndNonJSON(t *testing.T) { mk := func(responses map[int]*client.Response) string { - return NewRESTGenerator().Generate(&client.APISpec{ + code, _ := NewRESTGenerator().Generate(&client.APISpec{ Info: client.APIInfo{Title: "T", Version: "1"}, Endpoints: []client.Endpoint{{ Method: "GET", Path: "/x", OperationID: "x.get", Responses: responses, }}, Schemas: map[string]*client.Schema{"A": {Type: "object"}, "B": {Type: "object"}}, }, client.DefaultConfig()) + + return code } // 202 with a JSON body must not degrade to any. @@ -792,12 +794,12 @@ func TestEndpointTreeKeepsBothOrders(t *testing.T) { gen := NewRESTGenerator() config := client.DefaultConfig() - branchThenLeaf := gen.Generate(&client.APISpec{ + branchThenLeaf, _ := gen.Generate(&client.APISpec{ Info: client.APIInfo{Title: "T", Version: "1"}, Endpoints: []client.Endpoint{mk("users.active.list"), mk("users")}, }, config) - leafThenBranch := gen.Generate(&client.APISpec{ + leafThenBranch, _ := gen.Generate(&client.APISpec{ Info: client.APIInfo{Title: "T", Version: "1"}, Endpoints: []client.Endpoint{mk("users"), mk("users.active.list")}, }, config) @@ -823,7 +825,7 @@ func TestPathParamsAreURLEncoded(t *testing.T) { }}, } - code := NewRESTGenerator().Generate(spec, client.DefaultConfig()) + code, _ := NewRESTGenerator().Generate(spec, client.DefaultConfig()) assert.Contains(t, code, "${encodeURIComponent(String(path))}") } @@ -845,7 +847,7 @@ func TestPathParamsAreURLEncoded(t *testing.T) { // Blob (the DOM type for an opaque byte payload). func TestNonJSONRequestBodies(t *testing.T) { mk := func(contentType string, schema *client.Schema) string { - return NewRESTGenerator().Generate(&client.APISpec{ + code, _ := NewRESTGenerator().Generate(&client.APISpec{ Info: client.APIInfo{Title: "T", Version: "1"}, Endpoints: []client.Endpoint{{ Method: "POST", Path: "/up", OperationID: "up.post", @@ -855,6 +857,8 @@ func TestNonJSONRequestBodies(t *testing.T) { }}, Schemas: map[string]*client.Schema{}, }, client.DefaultConfig()) + + return code } code := mk("multipart/form-data", &client.Schema{Type: "object"}) @@ -877,7 +881,7 @@ func TestNonJSONRequestBodies(t *testing.T) { // type wins" decisions in the generator consistent instead of diverging. func TestRequestBodyContentTypePrecedenceMatchesResponse(t *testing.T) { mk := func(content map[string]*client.MediaType) string { - return NewRESTGenerator().Generate(&client.APISpec{ + code, _ := NewRESTGenerator().Generate(&client.APISpec{ Info: client.APIInfo{Title: "T", Version: "1"}, Endpoints: []client.Endpoint{{ Method: "POST", Path: "/up", OperationID: "up.post", @@ -886,6 +890,8 @@ func TestRequestBodyContentTypePrecedenceMatchesResponse(t *testing.T) { }}, Schemas: map[string]*client.Schema{"User": {Type: "object"}}, }, client.DefaultConfig()) + + return code } // application/json beats both text/plain and multipart/form-data when all @@ -944,7 +950,7 @@ func TestRequestBodyContentTypePrecedenceMatchesResponse(t *testing.T) { // still generate no `body` parameter and no `body,` forwarding line, exactly // as before this task. func TestNoRequestBodyStillGeneratesNoBodyParam(t *testing.T) { - code := NewRESTGenerator().Generate(&client.APISpec{ + code, _ := NewRESTGenerator().Generate(&client.APISpec{ Info: client.APIInfo{Title: "T", Version: "1"}, Endpoints: []client.Endpoint{{ Method: "GET", Path: "/noop", OperationID: "noop.get", @@ -1003,7 +1009,7 @@ func requestConfigBlock(t *testing.T, code, pathMarker string) string { // the only shape the codec table (codecs.go) ever registers an entry for. func TestRestPopulatesCodecRefsFromStaticSchemaIDs(t *testing.T) { spec := baseSpec() - code := NewRESTGenerator().Generate(spec, baseConfig()) + code, _ := NewRESTGenerator().Generate(spec, baseConfig()) // users.create (POST /users): required $ref-User request body, 201 $ref // User response — both fields populated from the same schema id. @@ -1051,6 +1057,14 @@ func TestRestPopulatesCodecRefsFromStaticSchemaIDs(t *testing.T) { // codec id correct for every status this call could resolve to, so // responseCodec is left unset entirely rather than guessing one of them (which // would silently mis-rename the other status's shape). +// +// Fix-round-1 review (IMPORTANT 2): leaving responseCodec unset here is only +// honest if it's ALSO surfaced — the method still declares +// Promise, a camelCase-typed value that will never +// actually be renamed, and silence would just move the lie from "wrong +// rename" to "quietly no rename at all". RESTGenerator.Generate now returns +// (string, []string) — mirroring CodecGenerator's own warnings channel — and +// must name the endpoint and the reason here. func TestRestResponseCodecRefOmittedWhenAmbiguousAcrossStatuses(t *testing.T) { spec := baseSpec() spec.Schemas["Team"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{"name": {Type: "string"}}} @@ -1062,10 +1076,13 @@ func TestRestResponseCodecRefOmittedWhenAmbiguousAcrossStatuses(t *testing.T) { }, }) - code := NewRESTGenerator().Generate(spec, baseConfig()) + code, warnings := NewRESTGenerator().Generate(spec, baseConfig()) block := requestConfigBlock(t, code, "let __path = `/ambiguous`;\n") assert.NotContains(t, block, "responseCodec", "two different named schemas across the 2xx set have no single codec id correct for every status this call could resolve") + + require.Len(t, warnings, 1, "the skip must be surfaced as a warning, not silent: %v", warnings) + assert.Contains(t, warnings[0], "ambiguous.create", "the warning must name the endpoint it applies to") } // TestRestResponseCodecRefOmittedForInlineJSONSchema asserts that an inline @@ -1073,6 +1090,11 @@ func TestRestResponseCodecRefOmittedWhenAmbiguousAcrossStatuses(t *testing.T) { // (codecs.go) only registers entries reachable by walking spec.Schemas, keyed // by named component schema — an inline schema at an endpoint boundary has no // such entry, so referencing it would hand decode() a dangling id. +// +// Fix-round-1 review (IMPORTANT 2): same reasoning as the ambiguous-status +// case above — the skip must be a visible warning, not silence, since +// generateReturnType still declares a camelCase-typed return value that will +// never be decoded. func TestRestResponseCodecRefOmittedForInlineJSONSchema(t *testing.T) { spec := baseSpec() spec.Endpoints = append(spec.Endpoints, client.Endpoint{ @@ -1084,10 +1106,13 @@ func TestRestResponseCodecRefOmittedForInlineJSONSchema(t *testing.T) { }, }) - code := NewRESTGenerator().Generate(spec, baseConfig()) + code, warnings := NewRESTGenerator().Generate(spec, baseConfig()) block := requestConfigBlock(t, code, "let __path = `/inline`;\n") assert.NotContains(t, block, "responseCodec", "an inline JSON response schema has no codec-table entry; referencing it would be a dangling id") + + require.Len(t, warnings, 1, "the skip must be surfaced as a warning, not silent: %v", warnings) + assert.Contains(t, warnings[0], "inline.get", "the warning must name the endpoint it applies to") } // TestRESTClientRoundTripsBodyAndResponseCodecsAtRuntime is the full, From 29edb979fc6b8a8dce789299fa8583b44b076288 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 19:51:40 -0500 Subject: [PATCH 54/71] fix(client/typescript): try every candidate discriminator tag name on encode Round 1 fixed encode-side discriminator resolution by scanning members for the first one declaring the wire tag property and using that member's TS name as a single global candidate. That's only correct when every member agrees on the name: - members disagreeing (e.g. a schema-scoped FieldOverrides entry renaming just one) made correctness declaration-order dependent -- a later member's own payload never matched, since only the first member's name was ever tried; - no member declaring the discriminator property at all left only the bare wire name to try, which is never present on a TS-shaped payload, silently degrading to passthrough with no warning. Encode now collects every distinct TS name any member declares for the wire property, plus the wire name itself as a last resort, and tries each in order; a candidate is accepted only when it both exists on the value and resolves through the discriminator mapping, and two candidates resolving to different members is treated as ambiguous (passthrough) rather than guessed. Generation now warns when members disagree on, or entirely omit, the discriminator property, so both are visible spec smells instead of silent gaps. Decode is untouched. Also fixes two pre-existing tests whose fixtures/pinned strings no longer matched: TestCodecTableUnionRequiresDiscriminator's Cat/Dog schemas didn't actually declare the discriminator property (exactly the now-warned-about shape), and TestCodecRuntimeRulesArePresent pinned a literal that the restructured runtime no longer emits verbatim. --- .../typescript/codec_fixround2_test.go | 229 ++++++++++++++++++ .../client/generators/typescript/codecs.go | 181 ++++++++++++-- .../generators/typescript/codecs_test.go | 12 +- 3 files changed, 394 insertions(+), 28 deletions(-) create mode 100644 internal/client/generators/typescript/codec_fixround2_test.go diff --git a/internal/client/generators/typescript/codec_fixround2_test.go b/internal/client/generators/typescript/codec_fixround2_test.go new file mode 100644 index 00000000..ec463d67 --- /dev/null +++ b/internal/client/generators/typescript/codec_fixround2_test.go @@ -0,0 +1,229 @@ +package typescript + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// TestFixRound2DiscriminatorTagNameDisagreementEncodesCorrectly is the +// runtime proof for fix-round-2 finding (a): the encode-side discriminator +// resolution took the FIRST member declaring the wire tag property and used +// THAT member's ts name as a single global candidate for every member -- +// correct only when every member happens to render the tag under the same +// name. A schema-scoped FieldOverrides entry naming just one member's +// rendering differently ("Dog.pet_kind" -> "kindOfDog") breaks this: Dog's +// actual payload uses "kindOfDog", not "petKind" (Cat's name, which won the +// scan because Cat is declared first), so `src["petKind"]` was always +// undefined and the WHOLE union passed through unrenamed -- silently, +// regardless of declaration order. +// +// Measured BEFORE this round's fix (review's own reproduction, quoted +// verbatim): +// +// c.pets.create({kindOfDog:'dog', barkVolume:11}) -> wire {"kindOfDog":"dog","barkVolume":11} +func TestFixRound2DiscriminatorTagNameDisagreementEncodesCorrectly(t *testing.T) { + spec := discriminatedPetSnakeCaseSpec() + + cfg := baseConfig() + cfg.FieldOverrides = map[string]string{"Dog.pet_kind": "kindOfDog"} + + out, err := NewGenerator().Generate(context.Background(), spec, cfg) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + require.Contains(t, types, "kindOfDog", + "sanity check: the FieldOverrides entry must actually change Dog's rendered TS name, or this test isn't exercising the disagreement at all") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + + await client.pets.create({ kindOfDog: 'dog', barkVolume: 11 }); + + console.log(JSON.stringify({ sentBody: JSON.parse(captured.body) })); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_fixround2_disagreement.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_fixround2_disagreement.ts") + + var result struct { + SentBody map[string]any `json:"sentBody"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, "dog", result.SentBody["pet_kind"], + "Dog's OWN rendered tag name (kindOfDog) must be tried as a candidate, not just Cat's (petKind, which won the old first-declared scan); driver stdout:\n%s", stdout) + assert.Equal(t, float64(11), result.SentBody["bark_volume"], + "once the correct member (Dog) is resolved, its other fields must be renamed too, not just the tag; driver stdout:\n%s", stdout) + + _, hadUnrenamedTag := result.SentBody["kindOfDog"] + assert.False(t, hadUnrenamedTag, "the TS-cased key must not survive encoding onto the wire") + _, hadUnrenamedField := result.SentBody["barkVolume"] + assert.False(t, hadUnrenamedField, "the TS-cased key must not survive encoding onto the wire") +} + +// TestFixRound2WarnsOnDiscriminatorTagNameDisagreement is the generation-time +// proof half of finding (a): members disagreeing on the discriminator +// property's rendered TS name is a real spec smell -- encode() must now try +// every distinct name, but which one resolves is runtime-data-dependent, and +// that ambiguity should be visible to whoever generated the client, not +// silent. +func TestFixRound2WarnsOnDiscriminatorTagNameDisagreement(t *testing.T) { + spec := discriminatedPetSnakeCaseSpec() + + cfg := baseConfig() + cfg.FieldOverrides = map[string]string{"Dog.pet_kind": "kindOfDog"} + + _, warnings := NewCodecGenerator().Generate(spec, cfg) + + found := false + for _, w := range warnings { + if strings.Contains(w, "Pet") && strings.Contains(w, "pet_kind") { + found = true + } + } + assert.True(t, found, "expected a warning naming schema %q and discriminator property %q; got: %v", "Pet", "pet_kind", warnings) +} + +// noDiscriminatorPropertySpec returns baseSpec() plus an oneOf union where +// the discriminator names a property NEITHER member actually declares: +// APet = oneOf[ACat, ADog], discriminator "pet_kind", but neither ACat nor +// ADog has a "pet_kind" property at all. This is finding (b): a lenient +// (non-strictly-conforming) spec where the discriminator names a property no +// member carries -- strict OpenAPI requires every member to declare and +// require the discriminator property, but nothing in this codebase enforces +// that today. +func noDiscriminatorPropertySpec() *client.APISpec { + spec := baseSpec() + + spec.Schemas["ACat"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"meows": {Type: "boolean"}}, + } + spec.Schemas["ADog"] = &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{"bark_volume": {Type: "integer"}}, + } + spec.Schemas["APet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/ACat"}, + {Ref: "#/components/schemas/ADog"}, + }, + Discriminator: &client.Discriminator{ + PropertyName: "pet_kind", + Mapping: map[string]string{ + "cat": "#/components/schemas/ACat", + "dog": "#/components/schemas/ADog", + }, + }, + } + + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "POST", Path: "/apets", OperationID: "apets.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/APet"}}}}, + Responses: map[int]*client.Response{201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/APet"}}}}}, + }) + + return spec +} + +// TestFixRound2NoMemberDeclaresDiscriminatorPropertyWarnsAndStillDecodes is +// the runtime + generation-time proof for finding (b): when NO member +// declares the discriminator property at all, encode() has no candidate key +// to try besides the bare wire name -- which, correctly, will almost never +// be present on a TS-shaped payload (the property isn't even part of any +// member's declared TypeScript type) -- so encoding this union safely stays +// a passthrough (NOT a silent corruption: nothing is mis-renamed, nothing is +// invented). What changes in this round is that generation now WARNS about +// this spec smell, and decode (which reads the wire name directly against a +// wire-shaped payload) is confirmed to still work fine -- exactly the +// asymmetry the original Critical finding was about. +func TestFixRound2NoMemberDeclaresDiscriminatorPropertyWarnsAndStillDecodes(t *testing.T) { + spec := noDiscriminatorPropertySpec() + + out, err := NewGenerator().Generate(context.Background(), spec, baseConfig()) + require.NoError(t, err) + + foundWarning := false + for _, w := range out.Warnings { + if strings.Contains(w, "APet") && strings.Contains(w, "pet_kind") { + foundWarning = true + } + } + assert.True(t, foundWarning, + "expected a generation-time warning naming schema %q and discriminator property %q (declared by no member); got: %v", + "APet", "pet_kind", out.Warnings) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; + +async function main() { + const client: any = new RESTClient({ baseURL: 'http://example.invalid' }); + const results: Record = {}; + + // Encode: no candidate key exists on the TS-shaped payload, so this must + // stay a safe passthrough -- not corrupted, just unresolved. + let captured: any; + (globalThis as any).fetch = async (_url: string, init: any) => { + captured = init; + return new Response(null, { status: 204 }); + }; + await client.apets.create({ barkVolume: 11 }); + results.sentBody = JSON.parse(captured.body); + + // Decode: reading the wire name directly against a wire-shaped payload + // must still resolve the member correctly. + (globalThis as any).fetch = async () => new Response( + JSON.stringify({ pet_kind: 'dog', bark_volume: 9 }), + { status: 201, headers: { 'content-type': 'application/json' } }, + ); + results.decoded = await client.apets.create({ barkVolume: 11 }); + + console.log(JSON.stringify(results)); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +` + writeTree(t, dir, map[string]string{"src/__driver_fixround2_no_tag_property.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_fixround2_no_tag_property.ts") + + var result struct { + SentBody map[string]any `json:"sentBody"` + Decoded map[string]any `json:"decoded"` + } + decodeLastLine(t, stdout, &result) + + assert.Equal(t, float64(11), result.SentBody["barkVolume"], + "safe, documented passthrough: with no candidate key at all, the union cannot be resolved, so it must stay untouched rather than guess; driver stdout:\n%s", stdout) + _, hadWireKey := result.SentBody["bark_volume"] + assert.False(t, hadWireKey, "an unresolved union must not partially rename -- it is all-or-nothing passthrough") + + assert.Equal(t, float64(9), result.Decoded["barkVolume"], + "decode direction must still work correctly -- it reads the wire name directly against a wire-shaped payload; driver stdout:\n%s", stdout) +} diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index 7e47b436..a085e163 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -483,6 +483,15 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A } } + // Fix-round-2 review: warn when members disagree on, or entirely omit, + // the discriminator property's rendered TS name -- see codecRuntime's + // encode-direction discriminator handling (the 'union' case), which + // must try every distinct name any member declares (falling back to the + // wire name itself) precisely because this can happen, and cannot + // always resolve it (an ambiguous or absent candidate set still passes + // through unrenamed on encode). + t.checkDiscriminatorTSNameAgreement(id, schema.Discriminator.PropertyName, memberIDs) + return codecEntry{ Kind: "union", Discriminator: &codecDiscriminator{ @@ -493,6 +502,69 @@ func (t *codecTable) unionEntry(id string, schema *client.Schema, spec *client.A } } +// checkDiscriminatorTSNameAgreement warns when a discriminated union's +// members disagree on -- or entirely omit -- the TS name their own `fields` +// table renders the discriminator's WIRE property as. codecRuntime's +// encode-direction discriminator resolution (the JS 'union' case) tries +// every DISTINCT ts name any member declares for this property (plus the +// wire name itself as a last resort) rather than guessing a single one, so +// neither case below is a hard failure -- but both are real spec smells: +// +// - if no member declares the property at all, encode() can only ever try +// the bare wire name, which is virtually never present on a TS-shaped +// `src` (the property isn't even part of any member's declared +// TypeScript type) -- an effectively un-encodable union in practice, +// even though decode() (which reads the wire name directly against a +// wire-shaped `src`) works fine. Strict OpenAPI requires the +// discriminator property to be declared and required on every member; +// this is what catches a spec that doesn't conform; +// - if members declare it under DIFFERENT ts names (e.g. a schema-scoped +// FieldOverrides entry naming only one of them differently), +// codecRuntime's encode-direction resolution tries every one of those +// names as a candidate and accepts whichever single one both exists on +// the payload and resolves via the discriminator mapping -- correct, +// but genuinely dependent on runtime data rather than something +// generation time can fully verify in advance, so it's surfaced here +// too. +// +// Only members that actually built to an 'object' kind entry are +// consulted -- a member that is itself a union, or failed to resolve at +// all, contributes no evidence either way (the same "evidence-free member" +// treatment the undiscriminated match's own warning already applies). +func (t *codecTable) checkDiscriminatorTSNameAgreement(id, wire string, memberIDs []string) { + names := map[string]bool{} + + for _, memberID := range memberIDs { + entry, ok := t.entries[memberID] + if !ok || entry.Kind != "object" { + continue + } + + if field, ok := entry.Fields[wire]; ok { + names[field.TS] = true + } + } + + switch len(names) { + case 0: + t.warnings = append(t.warnings, fmt.Sprintf( + "schema %q: discriminator property %q is declared by NO member -- decoding still works (it reads the wire name directly against a wire-shaped payload), but encoding a value into this union can only ever try that same wire name against a TypeScript-shaped payload, which will almost never be present since the property isn't part of any member's declared type -- add %q as a required property on every member to fix this", + id, wire, wire)) + case 1: + // Every member that declares it agrees -- nothing to warn about. + default: + sorted := make([]string, 0, len(names)) + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + + t.warnings = append(t.warnings, fmt.Sprintf( + "schema %q: discriminator property %q renders under different TypeScript names across members (%s) -- encoding tries every declared name and accepts whichever one resolves, but a payload that ambiguously matches more than one is passed through unrenamed rather than guessed; give every member the same rendered name (e.g. a matching FieldOverrides entry) to remove the ambiguity", + id, wire, strings.Join(sorted, ", "))) + } +} + // allOfLayer is one contributing layer flattenAllOfLayers resolves an allOf // composition down to: the schema that directly owns the properties, plus // the namespace id those properties must be keyed under for tsFieldName @@ -1113,40 +1185,99 @@ function walk(value: unknown, id: string | undefined, toTS: boolean): unknown { // codec.discriminator.wire is the WIRE name of the tag property // (e.g. "pet_kind"). When decoding (toTS), src IS wire-shaped, so // reading it directly is correct -- this is the pre-existing, - // already-correct half. + // already-correct half, and is untouched below. // // When ENCODING (!toTS), src is TS-shaped (e.g. { petKind: 'dog' }): // reading src[codec.discriminator.wire] looks up a key that does not - // exist on a TS-shaped object at all, so 'tag' was always undefined - // and this fell through to the passthrough below -- silently - // shipping the whole union unrenamed. (Fix-round-1 review, - // CRITICAL.) Fixed by finding the TS name the tag property renders - // as under one of this union's own members -- every discriminated - // member is expected to declare it, directly or via a flattened - // allOf, so member.fields[wire].ts is where that mapping already - // lives -- and reading src under THAT key instead when encoding. - // Members are assumed to agree on this name, which holds for any - // realistic discriminated union: the whole point of a discriminator - // is that every member carries the same tag property. - let tagKey = codec.discriminator.wire; - - if (!toTS) { - for (const memberID of codec.members ?? []) { - const member = codecFor(memberID); - const declared = member && member.kind === 'object' ? member.fields[codec.discriminator.wire] : undefined; + // exist on a TS-shaped object at all, so a naive single read was + // always undefined and fell through to the passthrough below -- + // silently shipping the whole union unrenamed. (Fix-round-1 review, + // CRITICAL.) + // + // A first fix tried ONE candidate: the ts name the FIRST member + // (in declared order) that happens to declare this wire property + // renders it as. That is correct only when every member agrees on + // that name -- fix-round-2 review measured two ways it silently + // breaks: + // + // (a) members DISAGREE on the ts name (e.g. a schema-scoped + // FieldOverrides entry renaming only one member's rendering). + // The first-declared member's name wins the scan regardless + // of which member the payload actually is, so a payload + // matching a LATER member under ITS OWN name never resolves; + // (b) NO member declares the property at all (a lenient spec -- + // strict OpenAPI requires it on every member, but nothing + // here enforces that). The scan finds nothing, falls back to + // the bare wire name, which is virtually never present on a + // TS-shaped payload since the property isn't even part of any + // member's declared TypeScript type. + // + // Fixed by trying every DISTINCT ts name any member declares for + // this wire property, in declared order, plus the wire name itself + // as a final fallback (covers a lenient spec where a member + // legitimately preserves the wire name, e.g. under + // NamingPreserve/an override that reproduces it exactly). A + // candidate is accepted only when BOTH it exists as a string key on + // src AND codec.discriminator.map resolves that string to a real + // member -- so trying more than one candidate can never invent a + // match a single-candidate read wouldn't also have found under the + // right name. If two DIFFERENT candidates each resolve to a + // DIFFERENT member, the payload is genuinely ambiguous (which + // member did the caller actually mean?) and this passes through + // rather than guessing, exactly like the undiscriminated + // structural match below already does for its own ambiguous case. + // codecTable.checkDiscriminatorTSNameAgreement (codecs.go) warns at + // generation time when members disagree on, or entirely omit, this + // property -- both are real spec smells the caller should see. + let memberID: string | undefined; + + if (toTS) { + const tag = src[codec.discriminator.wire]; + memberID = typeof tag === 'string' ? codec.discriminator.map[tag] : undefined; + } else { + const candidates: string[] = []; + const seenCandidates = new Set(); + const addCandidate = (key: string) => { + if (!seenCandidates.has(key)) { + seenCandidates.add(key); + candidates.push(key); + } + }; + + for (const mID of codec.members ?? []) { + const m = codecFor(mID); + const declared = m && m.kind === 'object' ? m.fields[codec.discriminator.wire] : undefined; if (declared) { - tagKey = declared.ts; - break; + addCandidate(declared.ts); } } - } + addCandidate(codec.discriminator.wire); - const tag = src[tagKey]; - if (typeof tag !== 'string') { - return value; + let ambiguous = false; + + for (const key of candidates) { + const candidateTag = src[key]; + if (typeof candidateTag !== 'string') { + continue; + } + + const candidateMemberID = codec.discriminator.map[candidateTag]; + if (!candidateMemberID) { + continue; + } + + if (memberID === undefined) { + memberID = candidateMemberID; + } else if (memberID !== candidateMemberID) { + ambiguous = true; + } + } + + if (ambiguous) { + memberID = undefined; + } } - const memberID = codec.discriminator.map[tag]; if (!memberID) { return value; } diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index e3734a9c..35ec755a 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -78,8 +78,14 @@ func TestCodecTableUnionRequiresDiscriminator(t *testing.T) { }, }, } - withDisc.Schemas["Cat"] = &client.Schema{Type: "object", Required: []string{"meows"}, Properties: map[string]*client.Schema{"meows": {Type: "boolean"}}} - withDisc.Schemas["Dog"] = &client.Schema{Type: "object", Required: []string{"barks"}, Properties: map[string]*client.Schema{"barks": {Type: "boolean"}}} + // Both members declare the discriminator property itself ("kind"), as a + // conforming spec must -- this is what TestFixRound2NoMemberDeclaresDiscriminatorPropertyWarnsAndStillDecodes + // (codec_fixround2_test.go) exercises directly: a discriminated union + // whose members DON'T declare it is a real spec smell and warns, so it + // would be wrong for this "ordinary, conforming" fixture to omit it and + // still expect zero warnings. + withDisc.Schemas["Cat"] = &client.Schema{Type: "object", Required: []string{"kind", "meows"}, Properties: map[string]*client.Schema{"kind": {Type: "string", Enum: []any{"cat"}}, "meows": {Type: "boolean"}}} + withDisc.Schemas["Dog"] = &client.Schema{Type: "object", Required: []string{"kind", "barks"}, Properties: map[string]*client.Schema{"kind": {Type: "string", Enum: []any{"dog"}}, "barks": {Type: "boolean"}}} code, warnings := NewCodecGenerator().Generate(withDisc, baseConfig()) assert.Contains(t, code, `"kind": "union"`) @@ -225,7 +231,7 @@ func TestCodecRuntimeRulesArePresent(t *testing.T) { assert.Contains(t, code, "setOwn(out, key, val);", "unknown keys must pass through verbatim") assert.Contains(t, code, "Keys are data here", "a record must rename values but never keys") - assert.True(t, strings.Contains(code, "if (typeof tag !== 'string')"), + assert.True(t, strings.Contains(code, "typeof tag === 'string'"), "a union whose discriminator value is missing or non-string must fall back to passthrough") assert.Contains(t, code, "hasOwnProperty", "an undiscriminated union must test each member's required wire fields structurally") From 70984530e82117f67d1c428d2fb5af2e7f5f6952 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 20:17:28 -0500 Subject: [PATCH 55/71] feat(client/typescript): skip codec emission under preserve naming Under FieldNaming: NamingPreserve with no FieldOverrides, every codec-table entry would be an identity rename, so src/codecs.ts, its index.ts export, its import/call sites in fetch.ts, and the bodyCodec/responseCodec refs in rest.ts are now skipped entirely -- pure dead weight otherwise. The gate is codecsNeeded(config), not effectiveFieldNaming(config) alone: FieldOverrides bypasses FieldNaming entirely, so a preserve config with an override still needs the codec table live for that one field. Also gates the corresponding warnings (undiscriminated union, unresolvable codec ref), since they describe renaming machinery that isn't running under preserve. Adds a tenth gate fixture (NamingPreserve, reusing the odd-keys non- identifier property names) to prove preserve still quotes non-identifier keys via tsPropertyKey without renaming them, and pins the fixture count so a future silent drop is caught. --- .../generators/typescript/codecs_test.go | 153 ++++++++++++++++++ .../generators/typescript/fetch_client.go | 136 +++++++++++----- .../client/generators/typescript/fieldname.go | 46 ++++++ .../generators/typescript/fixtures_test.go | 60 ++++++- .../client/generators/typescript/generator.go | 22 ++- internal/client/generators/typescript/rest.go | 40 +++-- .../client/generators/typescript/rest_test.go | 27 ++++ 7 files changed, 420 insertions(+), 64 deletions(-) diff --git a/internal/client/generators/typescript/codecs_test.go b/internal/client/generators/typescript/codecs_test.go index 35ec755a..3f555d2b 100644 --- a/internal/client/generators/typescript/codecs_test.go +++ b/internal/client/generators/typescript/codecs_test.go @@ -1333,3 +1333,156 @@ func TestCodecTableAllOfWithPolymorphicMemberWarningStatesCollisionLimitation(t assert.NoError(t, err, "this composition's guard-invisible collision shape is expected to pass the guard silently; the warning (not an error) is what documents the residual risk") } + +// TestCodecsSuppressedUnderNamingPreserve is Task 7's core requirement: +// under NamingPreserve (and no FieldOverrides), every codec entry would +// rename nothing, so the whole codec table -- and every reference to it +// anywhere in the generated tree -- is dead weight and must not be emitted. +func TestCodecsSuppressedUnderNamingPreserve(t *testing.T) { + out, err := NewGenerator().Generate(context.Background(), baseSpec(), preserveConfig()) + require.NoError(t, err) + + assert.NotContains(t, out.Files, "src/codecs.ts", "codecs.ts is pure identity under preserve and must not be emitted") + assert.NotContains(t, out.Files["src/index.ts"], "./codecs", "index.ts must not export a file that was never emitted") + + for path, content := range out.Files { + assert.NotContains(t, content, "from './codecs'", "%s must not import the never-emitted codecs.ts", path) + } + + assert.NotContains(t, out.Files["src/rest.ts"], "bodyCodec", "rest.ts must not reference a codec id under preserve") + assert.NotContains(t, out.Files["src/rest.ts"], "responseCodec", "rest.ts must not reference a codec id under preserve") + + assert.NotContains(t, out.Files["src/fetch.ts"], "encode(", "fetch.ts must not call encode() under preserve") + assert.NotContains(t, out.Files["src/fetch.ts"], "decode(", "fetch.ts must not call decode() under preserve") +} + +// TestCodecsEmittedUnderNamingCamel is the negative control for +// TestCodecsSuppressedUnderNamingPreserve: under the ordinary (camel) +// strategy every one of those assertions must flip, proving the preserve +// test above is actually exercising a gate rather than describing an +// unconditional absence. +func TestCodecsEmittedUnderNamingCamel(t *testing.T) { + config := baseConfig() + config.FieldNaming = client.NamingCamel + + out, err := NewGenerator().Generate(context.Background(), baseSpec(), config) + require.NoError(t, err) + + assert.Contains(t, out.Files, "src/codecs.ts") + assert.Contains(t, out.Files["src/index.ts"], "export * from './codecs';") + assert.Contains(t, out.Files["src/rest.ts"], "bodyCodec") + assert.Contains(t, out.Files["src/rest.ts"], "responseCodec") + assert.Contains(t, out.Files["src/fetch.ts"], "encode(") + assert.Contains(t, out.Files["src/fetch.ts"], "decode(") +} + +// TestCodecsEmittedUnderZeroValueConfigDefaultsToCamel is the trap the task +// brief calls out by name: GeneratorConfig.FieldNaming's zero value ("") is +// not NamingPreserve, and almost every real caller (e.g. +// cmd/forge/plugins/client.go, which hand-builds a GeneratorConfig without +// ever setting FieldNaming) leaves it unset. Gating on the raw field instead +// of effectiveFieldNaming(config)/codecsNeeded(config) would misread that +// zero value as "preserve" and silently drop the codec table for the +// generator's ordinary, default TypeScript case. +func TestCodecsEmittedUnderZeroValueConfigDefaultsToCamel(t *testing.T) { + config := client.GeneratorConfig{Language: "typescript", PackageName: "probe"} + + out, err := NewGenerator().Generate(context.Background(), baseSpec(), config) + require.NoError(t, err) + + assert.Contains(t, out.Files, "src/codecs.ts", "a zero-value FieldNaming on a typescript config must resolve to camel (codec emitted), not preserve") + assert.Contains(t, out.Files["src/rest.ts"], "bodyCodec") +} + +// TestCodecsStayLiveUnderPreserveWithFieldOverrides is the correctness +// corollary of gating on codecsNeeded rather than effectiveFieldNaming +// alone: FieldOverrides bypasses FieldNaming entirely (tsFieldName consults +// it before ever looking at the strategy -- see tsFieldName's doc comment), +// so a config that sets NamingPreserve but ALSO overrides one field's name +// still needs the codec table to actually rename that field at runtime. +// Gating purely on effectiveFieldNaming(config) == NamingPreserve would +// silently drop codecs.ts here and make the override inert: the type would +// say "userIdentifier" while the wire payload stayed "user_id" forever. +func TestCodecsStayLiveUnderPreserveWithFieldOverrides(t *testing.T) { + config := preserveConfig() + config.FieldOverrides = map[string]string{"User.user_id": "userIdentifier"} + + out, err := NewGenerator().Generate(context.Background(), baseSpec(), config) + require.NoError(t, err) + + assert.Contains(t, out.Files, "src/codecs.ts", "an override under preserve still renames one field; the codec table must stay live") + assert.Contains(t, out.Files["src/index.ts"], "export * from './codecs';") + assert.Contains(t, out.Files["src/codecs.ts"], `"ts": "userIdentifier"`, "the codec table must carry the overridden name so encode/decode actually rename it") +} + +// TestPreserveKeepsWireNamesButStillQuotesNonIdentifiers proves preserve +// means no RENAMING, not no ESCAPING: a wire name that is not a valid bare +// TypeScript identifier (a hyphen, a leading digit, an apostrophe, a +// backslash) must still be rendered as a quoted property key by +// tsPropertyKey, exactly as under any other strategy -- tsPropertyKey is +// called with the (under preserve, unchanged) client name regardless of +// strategy, so quoting is orthogonal to renaming. +func TestPreserveKeepsWireNamesButStillQuotesNonIdentifiers(t *testing.T) { + spec := baseSpec() + spec.Schemas["Weird"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "content-type": {Type: "string"}, + "3dtiles": {Type: "string"}, + "it's": {Type: "string"}, + "back\\slash": {Type: "string"}, + }} + + out, err := NewGenerator().Generate(context.Background(), spec, preserveConfig()) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + // Preserve: the wire name IS the client name, unlike camel's + // "content-type" -> "contentType". Still not a valid bare identifier + // (contains a hyphen), so it must still be quoted. + assert.Contains(t, types, "\"content-type\"?: string;") + assert.NotContains(t, types, "contentType", `preserve must not case-convert; "contentType" would only appear under camel`) + + assert.Contains(t, types, "\"3dtiles\"?: string;") + assert.Contains(t, types, "\"it's\"?: string;") + assert.Contains(t, types, "\"back\\\\slash\"?: string;") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + errs := typeCheck(t, dir) + assert.Empty(t, errs, "preserve naming with non-identifier wire names must still type-check cleanly") +} + +// TestWarningsSuppressedUnderPreserve proves the codec-related warnings (an +// undiscriminated union, an unresolvable codec ref) are not surfaced under +// preserve with no FieldOverrides: nothing is being renamed, so a warning +// about the renaming machinery is meaningless noise for a caller who opted +// out of it entirely. This falls out of skipping codec generation and +// rest.go's codec-ref resolution altogether (codecsNeeded(config) == false) +// rather than a separate filter, so it costs nothing extra to compute +// either. +func TestWarningsSuppressedUnderPreserve(t *testing.T) { + spec := baseSpec() + // An undiscriminated union: under camel this warns ("union has no + // discriminator..."); under preserve it must not. + spec.Schemas["Pet"] = &client.Schema{ + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/User"}, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, preserveConfig()) + require.NoError(t, err) + + assert.Empty(t, out.Warnings, "preserve with no FieldOverrides must not surface codec-table warnings for machinery that never runs") + + // Negative control: the same spec under camel DOES warn -- proving the + // preserve case above actually suppresses something, rather than + // describing a spec that never warns at all. + camelConfig := baseConfig() + camelConfig.FieldNaming = client.NamingCamel + + camelOut, err := NewGenerator().Generate(context.Background(), spec, camelConfig) + require.NoError(t, err) + assert.NotEmpty(t, camelOut.Warnings, "sanity check: the same spec must warn under camel, or this isn't testing suppression at all") +} diff --git a/internal/client/generators/typescript/fetch_client.go b/internal/client/generators/typescript/fetch_client.go index 25a5cd36..363967aa 100644 --- a/internal/client/generators/typescript/fetch_client.go +++ b/internal/client/generators/typescript/fetch_client.go @@ -18,9 +18,21 @@ func NewFetchClientGenerator() *FetchClientGenerator { func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config client.GeneratorConfig) string { var buf strings.Builder + // codecsNeeded gates the './codecs' import and every encode()/decode() + // call site below: under NamingPreserve with no FieldOverrides, + // generator.go never emits src/codecs.ts at all (see codecsNeeded's doc + // comment, fieldname.go), so an unconditional import here would dangle + // and fail tsc (TS2307 "Cannot find module './codecs'"). + needsCodecs := codecsNeeded(config) + // Imports buf.WriteString("// Base HTTP client using native fetch\n") - buf.WriteString("import { decode, encode } from './codecs';\n\n") + + if needsCodecs { + buf.WriteString("import { decode, encode } from './codecs';\n") + } + + buf.WriteString("\n") // HTTPError class buf.WriteString("/** Error thrown for non-2xx responses. */\n") @@ -43,12 +55,20 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" * object instead of a mutated/spread copy of `config` -- both are legal\n") buf.WriteString(" * readings of `RequestConfig | Promise`. A replacement that\n") buf.WriteString(" * only lists the fields it cares about (e.g. `return { method, url, body };`)\n") - buf.WriteString(" * silently drops every field it didn't name -- bodyCodec/responseCodec\n") - buf.WriteString(" * included, alongside the pre-existing allowEmptyBody/signal/retry. Before\n") - buf.WriteString(" * bodyCodec/responseCodec existed, that hazard meant a missing timeout\n") - buf.WriteString(" * signal or a wrongly-collapsed empty body; now it also means a JSON body\n") - buf.WriteString(" * silently ships wire-cased and unrenamed, or a JSON response is silently\n") - buf.WriteString(" * left un-decoded, with no error anywhere. Always spread the incoming\n") + + if needsCodecs { + buf.WriteString(" * silently drops every field it didn't name -- bodyCodec/responseCodec\n") + buf.WriteString(" * included, alongside the pre-existing allowEmptyBody/signal/retry. Before\n") + buf.WriteString(" * bodyCodec/responseCodec existed, that hazard meant a missing timeout\n") + buf.WriteString(" * signal or a wrongly-collapsed empty body; now it also means a JSON body\n") + buf.WriteString(" * silently ships wire-cased and unrenamed, or a JSON response is silently\n") + buf.WriteString(" * left un-decoded, with no error anywhere. Always spread the incoming\n") + } else { + buf.WriteString(" * silently drops every field it didn't name -- allowEmptyBody/signal/retry\n") + buf.WriteString(" * included, which can mean a missing timeout signal or a wrongly-collapsed\n") + buf.WriteString(" * empty body, with no error anywhere. Always spread the incoming\n") + } + buf.WriteString(" * config -- `return { ...config, headers: { ...config.headers, ... } };` --\n") buf.WriteString(" * rather than building a replacement object from scratch.\n") buf.WriteString(" */\n") @@ -66,20 +86,29 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // genuinely empty text/plain or binary body is valid data for an\n") buf.WriteString(" // endpoint that never declared a no-content response.\n") buf.WriteString(" allowEmptyBody?: boolean;\n") - buf.WriteString(" // Schema id (a key into src/codecs.ts's CODECS table) used to rename\n") - buf.WriteString(" // this request's JSON body from its TypeScript (camelCase) shape to\n") - buf.WriteString(" // the wire shape before serialisation. Set by the generated method\n") - buf.WriteString(" // only when the endpoint's request body is application/json AND\n") - buf.WriteString(" // resolves to a named component schema (see rest.go's\n") - buf.WriteString(" // requestBodyCodecRef) -- there is no codec-table entry for an inline\n") - buf.WriteString(" // schema to reference. A FormData/Blob/string/stream/typed-array body\n") - buf.WriteString(" // ignores this field entirely: executeRequest only calls encode() in\n") - buf.WriteString(" // the branch that already decided the body is JSON-serialisable.\n") - buf.WriteString(" bodyCodec?: string;\n") - buf.WriteString(" // Same idea, for decoding a JSON response back into its TypeScript\n") - buf.WriteString(" // shape. Only applied inside the application/json content-type branch\n") - buf.WriteString(" // below -- a void/Blob/text response is never walked by decode().\n") - buf.WriteString(" responseCodec?: string;\n") + + // bodyCodec/responseCodec are declared only when codecsNeeded(config): + // under NamingPreserve with no FieldOverrides, rest.go never sets + // either field (see its own codecsNeeded gate) and src/codecs.ts, + // which they'd reference, is never emitted -- so the fields would only + // ever be undefined dead weight on every RequestConfig value. + if needsCodecs { + buf.WriteString(" // Schema id (a key into src/codecs.ts's CODECS table) used to rename\n") + buf.WriteString(" // this request's JSON body from its TypeScript (camelCase) shape to\n") + buf.WriteString(" // the wire shape before serialisation. Set by the generated method\n") + buf.WriteString(" // only when the endpoint's request body is application/json AND\n") + buf.WriteString(" // resolves to a named component schema (see rest.go's\n") + buf.WriteString(" // requestBodyCodecRef) -- there is no codec-table entry for an inline\n") + buf.WriteString(" // schema to reference. A FormData/Blob/string/stream/typed-array body\n") + buf.WriteString(" // ignores this field entirely: executeRequest only calls encode() in\n") + buf.WriteString(" // the branch that already decided the body is JSON-serialisable.\n") + buf.WriteString(" bodyCodec?: string;\n") + buf.WriteString(" // Same idea, for decoding a JSON response back into its TypeScript\n") + buf.WriteString(" // shape. Only applied inside the application/json content-type branch\n") + buf.WriteString(" // below -- a void/Blob/text response is never walked by decode().\n") + buf.WriteString(" responseCodec?: string;\n") + } + buf.WriteString("}\n\n") // RetryConfig interface @@ -369,18 +398,29 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // Only a JSON-serialisable body ever reaches this branch -- every\n") buf.WriteString(" // native BodyInit shape (FormData, Blob/File, URLSearchParams,\n") buf.WriteString(" // ReadableStream, ArrayBuffer/TypedArray) and the plain-string case\n") - buf.WriteString(" // above already returned/assigned before this point. That is what\n") - buf.WriteString(" // makes it safe to call encode() unconditionally here: it is only\n") - buf.WriteString(" // ever given a value this branch has already established is meant\n") - buf.WriteString(" // to be walked and JSON.stringify-ed, never one of the native\n") - buf.WriteString(" // BodyInit values above. bodyCodec is only set by the generated\n") - buf.WriteString(" // method when the endpoint's body resolves to a named schema (see\n") - buf.WriteString(" // rest.go's requestBodyCodecRef); encode() is a no-op passthrough\n") - buf.WriteString(" // for an undefined codec id.\n") - buf.WriteString(" const encodedBody = requestConfig.bodyCodec !== undefined\n") - buf.WriteString(" ? encode(requestConfig.body, requestConfig.bodyCodec)\n") - buf.WriteString(" : requestConfig.body;\n") - buf.WriteString(" body = JSON.stringify(encodedBody);\n") + buf.WriteString(" // above already returned/assigned before this point.\n") + + if needsCodecs { + buf.WriteString(" // That is what\n") + buf.WriteString(" // makes it safe to call encode() unconditionally here: it is only\n") + buf.WriteString(" // ever given a value this branch has already established is meant\n") + buf.WriteString(" // to be walked and JSON.stringify-ed, never one of the native\n") + buf.WriteString(" // BodyInit values above. bodyCodec is only set by the generated\n") + buf.WriteString(" // method when the endpoint's body resolves to a named schema (see\n") + buf.WriteString(" // rest.go's requestBodyCodecRef); encode() is a no-op passthrough\n") + buf.WriteString(" // for an undefined codec id.\n") + buf.WriteString(" const encodedBody = requestConfig.bodyCodec !== undefined\n") + buf.WriteString(" ? encode(requestConfig.body, requestConfig.bodyCodec)\n") + buf.WriteString(" : requestConfig.body;\n") + buf.WriteString(" body = JSON.stringify(encodedBody);\n") + } else { + // This config's naming strategy never renames a property + // (codecsNeeded(config) == false), so src/codecs.ts was never + // emitted and there is no encode() to call at all -- the body is + // serialized exactly as the caller wrote it. + buf.WriteString(" body = JSON.stringify(requestConfig.body);\n") + } + buf.WriteString(" isJSONBody = true;\n") buf.WriteString(" }\n\n") @@ -481,16 +521,26 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c buf.WriteString(" // JSON.parse('') throws, matching what response.json() would have\n") buf.WriteString(" // thrown before this method read the body itself.\n") buf.WriteString(" const parsed = JSON.parse(await blob.text());\n") - buf.WriteString(" // responseCodec is only set by the generated method when this\n") - buf.WriteString(" // response resolves to a named component schema (see rest.go's\n") - buf.WriteString(" // responseCodecRef); decode() is a no-op passthrough for an\n") - buf.WriteString(" // undefined codec id. This is the ONLY place decode() is called --\n") - buf.WriteString(" // the 204/205 and allowEmptyBody early returns above, and the\n") - buf.WriteString(" // text/* and Blob branches below, all return before reaching here,\n") - buf.WriteString(" // so a void/text/Blob response is never walked by it.\n") - buf.WriteString(" return requestConfig.responseCodec !== undefined\n") - buf.WriteString(" ? (decode(parsed, requestConfig.responseCodec) as T)\n") - buf.WriteString(" : parsed;\n") + + if needsCodecs { + buf.WriteString(" // responseCodec is only set by the generated method when this\n") + buf.WriteString(" // response resolves to a named component schema (see rest.go's\n") + buf.WriteString(" // responseCodecRef); decode() is a no-op passthrough for an\n") + buf.WriteString(" // undefined codec id. This is the ONLY place decode() is called --\n") + buf.WriteString(" // the 204/205 and allowEmptyBody early returns above, and the\n") + buf.WriteString(" // text/* and Blob branches below, all return before reaching here,\n") + buf.WriteString(" // so a void/text/Blob response is never walked by it.\n") + buf.WriteString(" return requestConfig.responseCodec !== undefined\n") + buf.WriteString(" ? (decode(parsed, requestConfig.responseCodec) as T)\n") + buf.WriteString(" : parsed;\n") + } else { + // This config's naming strategy never renames a property + // (codecsNeeded(config) == false), so src/codecs.ts was never + // emitted and there is no decode() to call at all -- the parsed + // JSON value is returned exactly as parsed. + buf.WriteString(" return parsed;\n") + } + buf.WriteString(" }\n\n") buf.WriteString(" // A declared text return type (e.g. `string`) must be read as text —\n") diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index b133eac1..b7fc185d 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -96,6 +96,52 @@ func effectiveFieldNaming(config client.GeneratorConfig) client.NamingStrategy { return client.NamingPreserve } +// codecsNeeded reports whether ANY schema property's client-side name could +// possibly differ from its wire name under config -- i.e. whether the codec +// table (src/codecs.ts), its import/call sites in fetch.ts, and its +// bodyCodec/responseCodec refs in rest.ts would do any real work. +// +// This must be called wherever generation decides whether to emit the codec +// machinery at all -- generator.go (src/codecs.ts itself, and its index.ts +// export), rest.go (bodyCodec/responseCodec refs), and fetch_client.go (the +// './codecs' import and the encode()/decode() call sites) -- so all four +// agree on the same condition; a mismatch would either emit a dangling +// import (encode/decode referenced but codecs.ts skipped) or silently make a +// real rename inert (codecs.ts skipped but a property still needed one). +// +// False only when BOTH of these hold: +// 1. effectiveFieldNaming(config) is NamingPreserve, so no property gets a +// naming-strategy-driven rename (tsFieldName's camel/pascal/snake cases +// never apply). +// 2. config.FieldOverrides is empty. +// +// (2) is required, not just (1): an override bypasses FieldNaming entirely +// (see tsFieldName's doc comment) and is used verbatim, so a config that +// sets NamingPreserve but ALSO configures even one FieldOverrides entry can +// still rename that one field -- the codec table must stay live to encode +// and decode it. Checking effectiveFieldNaming alone would silently make +// that override inert: the property would render under its overridden +// TypeScript name (objectPropsLiteral/schemaToTypeScript both call +// tsFieldName directly, override-aware, regardless of this function) while +// nothing at runtime renamed it back, corrupting exactly the property a +// caller configured an override for. +// +// This is deliberately a conservative approximation, not an exact one: a +// FieldOverrides entry whose value happens to equal its own key (a no-op +// override) still counts as "codecs needed" here, keeping codecs.ts alive +// for a case where it would, in fact, be identity. That is the safe +// direction to err in -- the alternative (inspecting every override's value +// against every reachable wire name to prove none of them actually change +// anything) is real work for a config shape callers are not expected to +// build in practice. +func codecsNeeded(config client.GeneratorConfig) bool { + if effectiveFieldNaming(config) != client.NamingPreserve { + return true + } + + return len(config.FieldOverrides) > 0 +} + // checkFieldNameCollisions reports every case where two distinct wire-name // properties resolve, via tsFieldName, to the same client-side field name // within the same object namespace. This must run -- and must abort diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index 5236b88f..c6590c41 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -10,10 +10,12 @@ import ( ) func TestGateFixturesCoverKnownDefects(t *testing.T) { - want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse", "no-auth-ws-sse"} + want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse", "no-auth-ws-sse", "allof", "preserve"} + + fixtures := gateFixtures() got := make(map[string]bool) - for _, f := range gateFixtures() { + for _, f := range fixtures { got[f.Name] = true } @@ -22,6 +24,30 @@ func TestGateFixturesCoverKnownDefects(t *testing.T) { t.Errorf("fixture %q missing from corpus", name) } } + + // Pinning the exact count (not just presence of each named fixture) + // catches a fixture silently DROPPED from the corpus even when every + // name in `want` still happens to be present -- e.g. a duplicate name + // masking a missing one, or an unrelated future edit deleting an + // unnamed fixture this list never tracked. Task 7 grew the corpus from + // 9 fixtures to 10 (the "preserve" fixture above); this number must + // move in lockstep with `want` and with gateFixtures' own fixture + // literal. + if len(fixtures) != 10 { + t.Errorf("expected exactly 10 gate fixtures, got %d: %v", len(fixtures), fixtureNames(fixtures)) + } +} + +// fixtureNames extracts the Name field from a []gateFixture, for use in a +// test failure message (a raw %v on the slice would print every *client.Schema +// pointer too). +func fixtureNames(fixtures []gateFixture) []string { + names := make([]string, len(fixtures)) + for i, f := range fixtures { + names[i] = f.Name + } + + return names } func TestGenerateToProducesTSConfig(t *testing.T) { @@ -125,6 +151,17 @@ func baseConfig() client.GeneratorConfig { return cfg } +// preserveConfig returns baseConfig() with FieldNaming forced to +// NamingPreserve and no FieldOverrides -- effectiveFieldNaming(config) == +// NamingPreserve AND codecsNeeded(config) == false, i.e. every codec entry +// would rename nothing at all. +func preserveConfig() client.GeneratorConfig { + cfg := baseConfig() + cfg.FieldNaming = client.NamingPreserve + + return cfg +} + func gateFixtures() []gateFixture { oddKeys := baseSpec() oddKeys.Schemas["Weird"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ @@ -149,6 +186,22 @@ func gateFixtures() []gateFixture { noAuthWsSSE := baseConfig() noAuthWsSSE.IncludeAuth = false + // preserveOddKeys reuses oddKeys' non-identifier wire names (a hyphen, a + // leading digit, an apostrophe, a backslash) under NamingPreserve, so + // this fixture is type-checked proof -- not just a string assertion -- + // that preserve means no RENAMING, not no ESCAPING: tsPropertyKey must + // still quote these keys even though tsFieldName leaves them unchanged. + // It also exercises Task 7's actual deliverable end to end: a real tsc + // run against a client that never emits src/codecs.ts, never imports it + // anywhere, and never populates bodyCodec/responseCodec in rest.ts. + preserveOddKeys := baseSpec() + preserveOddKeys.Schemas["Weird"] = &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "content-type": {Type: "string"}, + "3dtiles": {Type: "string"}, + "it's": {Type: "string"}, + "back\\slash": {Type: "string"}, + }} + return []gateFixture{ {Name: "default", Spec: baseSpec(), Config: baseConfig()}, {Name: "apiname", Spec: baseSpec(), Config: apiName}, @@ -179,6 +232,9 @@ func gateFixtures() []gateFixture { // allOf or not), so a fixture containing one could never pass this // gate's zero-tsc-errors bar regardless of the allOf fix. {Name: "allof", Spec: allOfSpec(), Config: baseConfig()}, + // Task 7: NamingPreserve must type-check with no codecs.ts emitted + // at all -- see preserveOddKeys' own doc comment above. + {Name: "preserve", Spec: preserveOddKeys, Config: preserveConfig()}, } } diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 98f5a164..f44a7f77 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -295,12 +295,17 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, typesCode := g.generateTypes(spec, config) genClient.Files["src/types.ts"] = typesCode - // Generate the codec table. Always emitted for now: the config field - // that will let a caller opt out (preserving wire casing, making the - // table pointless) does not exist yet. - codecCode, codecWarnings := NewCodecGenerator().Generate(spec, config) - genClient.Files["src/codecs.ts"] = codecCode - genClient.Warnings = append(genClient.Warnings, codecWarnings...) + // Generate the codec table. Skipped entirely when codecsNeeded(config) + // is false -- under NamingPreserve with no FieldOverrides, every entry + // would rename nothing, so the table (and its runtime, and every + // import/reference to it elsewhere) would be pure dead weight. See + // codecsNeeded's doc comment (fieldname.go) for exactly which configs + // still need it despite NamingPreserve being set. + if codecsNeeded(config) { + codecCode, codecWarnings := NewCodecGenerator().Generate(spec, config) + genClient.Files["src/codecs.ts"] = codecCode + genClient.Warnings = append(genClient.Warnings, codecWarnings...) + } // Generate WebSocket clients if len(spec.WebSockets) > 0 && config.IncludeStreaming { @@ -1332,7 +1337,10 @@ func (g *Generator) generateIndex(spec *client.APISpec, config client.GeneratorC } buf.WriteString("export * from './types';\n") - buf.WriteString("export * from './codecs';\n") + + if codecsNeeded(config) { + buf.WriteString("export * from './codecs';\n") + } if !isAsyncAPIOnly { buf.WriteString("export * from './client';\n\n") diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go index f20f2908..23ec3ac0 100644 --- a/internal/client/generators/typescript/rest.go +++ b/internal/client/generators/typescript/rest.go @@ -287,7 +287,7 @@ func (r *RESTGenerator) generateArrowFunction(buf *strings.Builder, methodName s } // Generate method body (path, query params, request config) - r.generateMethodBody(buf, endpoint, spec, indent+2) + r.generateMethodBody(buf, endpoint, spec, config, indent+2) // Root members are class fields (terminated with ';'); nested members are // object-literal entries (terminated with ','). @@ -299,7 +299,7 @@ func (r *RESTGenerator) generateArrowFunction(buf *strings.Builder, methodName s } // generateMethodBody generates the method implementation. -func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *client.Endpoint, spec *client.APISpec, indent int) { +func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *client.Endpoint, spec *client.APISpec, config client.GeneratorConfig, indent int) { indentStr := strings.Repeat(" ", indent) // Computed once up front: both the config object (which needs to tell @@ -353,11 +353,20 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien // TypeScript type still promises a renamed shape, but it will be // sent wire-cased and unrenamed, so that must be visible somewhere, // not silent. - if codecID, warning := r.requestBodyCodecRef(endpoint); codecID != "" { - literal, _ := json.Marshal(codecID) - fmt.Fprintf(buf, "%s bodyCodec: %s,\n", indentStr, literal) - } else if warning != "" { - r.warnings = append(r.warnings, warning) + // + // Both the ref AND its unresolvable-ref warning are gated on + // codecsNeeded(config): under NamingPreserve with no + // FieldOverrides, src/codecs.ts is never emitted (generator.go), so + // a bodyCodec value here would reference a table that doesn't + // exist, and a warning about failing to resolve one would be noise + // about renaming machinery that isn't running at all. + if codecsNeeded(config) { + if codecID, warning := r.requestBodyCodecRef(endpoint); codecID != "" { + literal, _ := json.Marshal(codecID) + fmt.Fprintf(buf, "%s bodyCodec: %s,\n", indentStr, literal) + } else if warning != "" { + r.warnings = append(r.warnings, warning) + } } } @@ -400,11 +409,18 @@ func (r *RESTGenerator) generateMethodBody(buf *strings.Builder, endpoint *clien // reason is returned as a warning instead: generateReturnType's declared // union still promises a renamed shape for a response that will never // actually be decoded, which must be visible, not silent. - if codecID, warning := r.responseCodecRef(endpoint); codecID != "" { - literal, _ := json.Marshal(codecID) - fmt.Fprintf(buf, "%s responseCodec: %s,\n", indentStr, literal) - } else if warning != "" { - r.warnings = append(r.warnings, warning) + // + // Gated on codecsNeeded(config) for the same reason as the request-body + // ref above: no live src/codecs.ts to reference, and no live renaming to + // warn about failing to resolve, under NamingPreserve with no + // FieldOverrides. + if codecsNeeded(config) { + if codecID, warning := r.responseCodecRef(endpoint); codecID != "" { + literal, _ := json.Marshal(codecID) + fmt.Fprintf(buf, "%s responseCodec: %s,\n", indentStr, literal) + } else if warning != "" { + r.warnings = append(r.warnings, warning) + } } fmt.Fprintf(buf, "%s};\n\n", indentStr) diff --git a/internal/client/generators/typescript/rest_test.go b/internal/client/generators/typescript/rest_test.go index e858d64e..592379a7 100644 --- a/internal/client/generators/typescript/rest_test.go +++ b/internal/client/generators/typescript/rest_test.go @@ -1115,6 +1115,33 @@ func TestRestResponseCodecRefOmittedForInlineJSONSchema(t *testing.T) { assert.Contains(t, warnings[0], "inline.get", "the warning must name the endpoint it applies to") } +// TestRestCodecRefsAndWarningsSuppressedUnderPreserve is Task 7's REST-side +// negative control: under NamingPreserve (no FieldOverrides), rest.go must +// neither emit bodyCodec/responseCodec refs (codecsNeeded(config) == false) +// nor surface the unresolvable-codec-ref warning that fires for this exact +// same inline-schema shape under camel (see +// TestRestResponseCodecRefOmittedForInlineJSONSchema, just above) -- +// that warning is about renaming machinery that isn't running at all under +// preserve, so surfacing it would just be confusing a caller who opted out +// of renaming entirely. +func TestRestCodecRefsAndWarningsSuppressedUnderPreserve(t *testing.T) { + spec := baseSpec() + spec.Endpoints = append(spec.Endpoints, client.Endpoint{ + Method: "GET", Path: "/inline", OperationID: "inline.get", + Responses: map[int]*client.Response{ + 200: {Content: map[string]*client.MediaType{"application/json": {Schema: &client.Schema{ + Type: "object", Properties: map[string]*client.Schema{"x": {Type: "string"}}, + }}}}, + }, + }) + + code, warnings := NewRESTGenerator().Generate(spec, preserveConfig()) + + assert.NotContains(t, code, "bodyCodec", "no codec table exists under preserve; rest.ts must not reference one") + assert.NotContains(t, code, "responseCodec", "no codec table exists under preserve; rest.ts must not reference one") + assert.Empty(t, warnings, "an unresolvable codec ref is meaningless under preserve: nothing is being renamed") +} + // TestRESTClientRoundTripsBodyAndResponseCodecsAtRuntime is the full, // generated-client execution proof: a nested object, an array of objects, and // a record all round-trip correctly through both encode (request) and decode From 477900278f33c09a8af3aecc1eed7d35ae7af944 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 20:27:34 -0500 Subject: [PATCH 56/71] fix(client/typescript): catch FieldOverrides collisions under preserve naming checkFieldNameCollisions short-circuited to nil whenever effectiveFieldNaming(config) == NamingPreserve, without considering FieldOverrides. But an override renames a field even under preserve (tsFieldName checks FieldOverrides before consulting the naming strategy), so two different wire names given the same override value went uncaught and one silently overwrote the other in the rendered interface and codec table. Key the skip on codecsNeeded(config) instead of effectiveFieldNaming alone -- the same "preserve AND no overrides" test codecsNeeded already uses to decide whether the codec table is dead weight. The ordinary preserve case with no overrides (TestGenerateAllowsCollisionUnderPreserve) still skips the check and passes unchanged. --- .../client/generators/typescript/fieldname.go | 20 ++++++--- .../generators/typescript/fieldname_test.go | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/internal/client/generators/typescript/fieldname.go b/internal/client/generators/typescript/fieldname.go index b7fc185d..63776773 100644 --- a/internal/client/generators/typescript/fieldname.go +++ b/internal/client/generators/typescript/fieldname.go @@ -180,10 +180,20 @@ func codecsNeeded(config client.GeneratorConfig) bool { // colliding with a reserved streaming type name is a distinct namespace // already covered by checkSchemaNameCollisions. // -// Under NamingPreserve, tsFieldName returns wireName unchanged for every -// property that has no override, so two distinct wire names can never -// derive to the same name -- the walk is skipped entirely rather than -// running a pass that can only ever come back empty. +// Under NamingPreserve with NO FieldOverrides, tsFieldName returns wireName +// unchanged for every property, so two distinct wire names can never derive +// to the same name -- the walk is skipped entirely rather than running a +// pass that can only ever come back empty. But an override renames a field +// EVEN UNDER preserve (tsFieldName consults FieldOverrides before the naming +// strategy at all), so preserve alone is not sufficient to skip the check: +// two different wire names given the SAME override value still collide, and +// must be caught exactly like a camel/pascal/snake-derived collision would +// be. The skip condition below is therefore keyed on codecsNeeded, not on +// effectiveFieldNaming directly -- the same "preserve AND no overrides" +// test codecsNeeded already uses to decide whether the codec table itself +// is dead weight. If codecs.ts would be emitted (because a rename can +// happen), this check must run; if codecs.ts would be skipped (because +// nothing can rename), no walk can ever find a collision. // // All collisions found across the whole spec are reported at once, not just // the first, so a caller does not have to fix them one regeneration at a @@ -202,7 +212,7 @@ func codecsNeeded(config client.GeneratorConfig) bool { // two DIFFERENT messages describing the same collision (which would be a // sign the two passes disagree about something, not merely overlap). func checkFieldNameCollisions(spec *client.APISpec, config client.GeneratorConfig) error { - if effectiveFieldNaming(config) == client.NamingPreserve { + if !codecsNeeded(config) { return nil } diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go index c18cc161..63447049 100644 --- a/internal/client/generators/typescript/fieldname_test.go +++ b/internal/client/generators/typescript/fieldname_test.go @@ -306,6 +306,50 @@ func TestCheckFieldNameCollisionsDetectsOverrideValueCollision(t *testing.T) { } } +// TestCheckFieldNameCollisionsCatchesOverrideCollisionUnderPreserve is P3-T7's +// carried gap (progress.md): checkFieldNameCollisions short-circuited to nil +// whenever effectiveFieldNaming(config) == NamingPreserve, without ever +// considering FieldOverrides. But an override renames a field EVEN UNDER +// preserve (tsFieldName checks FieldOverrides before consulting the naming +// strategy at all -- see its doc comment), and codecsNeeded already treats +// preserve+overrides as "codecs are live" for exactly this reason. So two +// distinct wire names ("a_field", "b_field") given the SAME override value +// ("sameName") under preserve silently overwrite one another in the rendered +// interface and in the codec table -- and, before this fix, generation +// reported no error at all. +func TestCheckFieldNameCollisionsCatchesOverrideCollisionUnderPreserve(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Collision API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{ + "Thing": { + Type: "object", + Properties: map[string]*client.Schema{ + "a_field": {Type: "string"}, + "b_field": {Type: "string"}, + }, + }, + }, + } + + config := collisionConfig() + config.FieldNaming = client.NamingPreserve + config.FieldOverrides = map[string]string{ + "Thing.a_field": "sameName", + "Thing.b_field": "sameName", + } + + err := checkFieldNameCollisions(spec, config) + if err == nil { + t.Fatal("expected a collision error: two overrides map different wire names (a_field, b_field) to the same client name (sameName) under preserve, but checkFieldNameCollisions reported none") + } + + for _, want := range []string{"Thing", "a_field", "b_field", "sameName"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message missing %q; got: %s", want, err.Error()) + } + } +} + // --- Round 2: inline composite namespaces ----------------------------- // // Review round 1 found that checkFieldNameCollisions only ever looked at a From f47dfee40f20cc7e10f75689332e1661d26a79f8 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 20:35:13 -0500 Subject: [PATCH 57/71] test(client/typescript): prove the naming codec end to end Generates a full client for a spec whose wire names are entirely snake_case, bundles it with esbuild, and drives client.orders.create(...) (a GENERATED METHOD, not encode()/decode() called directly) under Node against a mocked fetch. Covers, in one round trip: a plain scalar rename, a nested $ref object, an array of $ref objects, a record (additionalProperties, whose keys are caller data and must never be renamed), and a discriminated union with narrowing on the tag. Unknown keys on both the request and the response (including one nested inside the customer object) are asserted to survive verbatim in both directions. The driver reads the awaited result's camelCase fields directly off the generated `types.Order` (through the PaymentMethod union), so the same file is run through both esbuild+Node (proving the runtime values) and tsc (proving the declared type actually matches what arrives) -- confirming the phase's stated goal end to end through real generated code, not a hand-built config or a unit-level stub. --- .../generators/typescript/runtime_test.go | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) diff --git a/internal/client/generators/typescript/runtime_test.go b/internal/client/generators/typescript/runtime_test.go index 4733c4b6..5d146758 100644 --- a/internal/client/generators/typescript/runtime_test.go +++ b/internal/client/generators/typescript/runtime_test.go @@ -2,9 +2,16 @@ package typescript import ( "context" + "encoding/json" "os/exec" "path/filepath" + "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" ) // findESBuild returns the argv prefix used to bundle generated TypeScript @@ -88,3 +95,332 @@ func runNodeDriver(t *testing.T, dir, entry string) string { return string(out) } + +// e2eSnakeCaseSpec returns a spec built specifically to prove Phase 3 end to +// end: every schema property is snake_case on the wire, and the shapes cover +// every codec kind the phase touches in one place -- +// +// - Order.order_id: a plain scalar rename +// - Order.customer: a $ref'd NESTED OBJECT (its own rename namespace) +// - Order.line_items: an ARRAY OF $ref'd OBJECTS +// - Order.metadata: a RECORD (additionalProperties) -- keys are data, +// never renamed, only declared field names are +// - Order.payment_info: a DISCRIMINATED UNION (CardPayment | BankPayment) +// +// Every schema declares its properties as required so the generated +// TypeScript never makes a field optional -- which would force the driver +// below to thread optional-chaining through every access purely to satisfy +// strict mode, obscuring the thing actually under test. +func e2eSnakeCaseSpec() *client.APISpec { + return &client.APISpec{ + Info: client.APIInfo{Title: "E2E Probe API", Version: "1.0.0"}, + Endpoints: []client.Endpoint{ + { + Method: "POST", Path: "/orders", OperationID: "orders.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Order"}}, + }}, + Responses: map[int]*client.Response{ + 201: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Ref: "#/components/schemas/Order"}}, + }}, + }, + }, + }, + Schemas: map[string]*client.Schema{ + "Order": { + Type: "object", + Required: []string{"order_id", "customer", "line_items", "metadata", "payment_info"}, + Properties: map[string]*client.Schema{ + "order_id": {Type: "string"}, + "customer": {Ref: "#/components/schemas/Customer"}, + "line_items": {Type: "array", Items: &client.Schema{Ref: "#/components/schemas/LineItem"}}, + "metadata": {Type: "object", AdditionalProperties: &client.Schema{Type: "string"}}, + "payment_info": {Ref: "#/components/schemas/PaymentMethod"}, + }, + }, + "Customer": { + Type: "object", + Required: []string{"full_name", "contact_email"}, + Properties: map[string]*client.Schema{ + "full_name": {Type: "string"}, + "contact_email": {Type: "string"}, + }, + }, + "LineItem": { + Type: "object", + Required: []string{"item_id", "unit_price"}, + Properties: map[string]*client.Schema{ + "item_id": {Type: "string"}, + "unit_price": {Type: "number"}, + }, + }, + "PaymentMethod": { + OneOf: []*client.Schema{ + {Ref: "#/components/schemas/CardPayment"}, + {Ref: "#/components/schemas/BankPayment"}, + }, + Discriminator: &client.Discriminator{ + PropertyName: "payment_type", + Mapping: map[string]string{ + "card": "#/components/schemas/CardPayment", + "bank": "#/components/schemas/BankPayment", + }, + }, + }, + "CardPayment": { + Type: "object", + Required: []string{"payment_type", "card_number"}, + Properties: map[string]*client.Schema{ + "payment_type": {Type: "string", Enum: []any{"card"}}, + "card_number": {Type: "string"}, + }, + }, + "BankPayment": { + Type: "object", + Required: []string{"payment_type", "account_number"}, + Properties: map[string]*client.Schema{ + "payment_type": {Type: "string", Enum: []any{"bank"}}, + "account_number": {Type: "string"}, + }, + }, + }, + } +} + +// TestEndToEndSnakeCaseRoundTripThroughGeneratedMethod is the execution proof +// that Phase 3 delivered its stated goal, through REAL generated code -- not +// a hand-built config, and not encode()/decode() called directly (every +// codec-level test elsewhere in this package does that; this is the one test +// that instead drives a generated RESTClient METHOD, exactly as a real +// consumer would). +// +// It generates a full client for e2eSnakeCaseSpec() (every property +// snake_case on the wire, camel-case naming configured), bundles it with +// esbuild, and under Node: +// +// 1. calls client.orders.create(...) with a camelCase request object and +// captures the exact JSON string handed to global fetch as the request +// body -- proving encode puts snake_case ON THE WIRE; +// 2. has the mocked fetch return a snake_case JSON response, and inspects +// what the awaited call resolves to -- proving decode hands back +// camelCase; +// 3. covers, in that one round trip, a nested object (customer), an array +// of objects (line_items), a record (metadata -- keys are caller-chosen +// data and must survive UNCHANGED, never renamed), and a discriminated +// union (payment_info: CardPayment | BankPayment); +// 4. includes an unknown key on BOTH sides (request: loyaltyPoints, not +// part of Order at all; response: server_note at the top level and +// internal_note nested inside customer) and asserts each survives +// verbatim, proving codecRuntime's "unknown key passes through, name and +// value untouched" rule holds through a real method call, not just +// encode()/decode() in isolation; +// 5. reads the awaited result's camelCase fields directly off the +// DECLARED TypeScript type (types.Order, through the PaymentMethod +// union, narrowed on paymentType) rather than through `any` -- so this +// same driver file is also run through tsc (typeCheck), proving the +// declared type actually matches what arrives at runtime, not just that +// *something* arrives. +func TestEndToEndSnakeCaseRoundTripThroughGeneratedMethod(t *testing.T) { + config := client.DefaultConfig() + config.Language = "typescript" + config.PackageName = "e2eprobe" + config.FieldNaming = client.NamingCamel + + out, err := NewGenerator().Generate(context.Background(), e2eSnakeCaseSpec(), config) + require.NoError(t, err, "generation must succeed for a conforming, fully-discriminated spec") + + types := out.Files["src/types.ts"] + require.Contains(t, types, "orderId", "sanity check: Order.order_id must render as camelCase orderId") + require.NotContains(t, types, "order_id", "sanity check: the wire name order_id must not leak into the rendered type") + require.Contains(t, types, "export type PaymentMethod = CardPayment | BankPayment;", + "sanity check: PaymentMethod must render as the exact discriminated union this test narrows on") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + driver := ` +import { RESTClient } from './rest'; +import * as types from './types'; + +let capturedBody: string | undefined; + +(globalThis as any).fetch = async (_url: string, init: any) => { + capturedBody = init.body; + + // The WIRE response: every field snake_case, plus two unknown keys (one + // top-level, one nested inside customer) that Order/Customer never + // declare at all -- proving decode leaves what it doesn't recognize alone. + const wireResponse = { + order_id: 'ord_99', + customer: { + full_name: 'Grace Hopper', + contact_email: 'grace@example.com', + internal_note: 'server-added, unknown to Customer', + }, + line_items: [ + { item_id: 'sku-100', unit_price: 12.5 }, + { item_id: 'sku-200', unit_price: 3.25 }, + ], + metadata: { region: 'us-east', channel: 'web' }, + payment_info: { payment_type: 'bank', account_number: '000-111-222' }, + server_note: 'server-added, unknown to Order', + }; + + return new Response(JSON.stringify(wireResponse), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); +}; + +async function main() { + const client = new RESTClient({ baseURL: 'http://example.invalid' }); + + // The CLIENT-SIDE (camelCase) request. loyaltyPoints is not part of Order + // at all -- proving encode leaves an unrecognized key alone, name and + // value untouched, rather than dropping it. + const rawInput = { + orderId: 'ord_1', + customer: { fullName: 'Ada Lovelace', contactEmail: 'ada@example.com' }, + lineItems: [ + { itemId: 'item-a', unitPrice: 5 }, + { itemId: 'item-b', unitPrice: 7.5 }, + ], + metadata: { sku123: 'blue-widget', sku456: 'red-widget' }, + paymentInfo: { paymentType: 'card' as const, cardNumber: '4242424242424242' }, + loyaltyPoints: 42, + }; + + const created: types.Order = await client.orders.create(rawInput); + + // --- Consumer snippet: reads camelCase fields straight off the DECLARED + // return type. tsc checks these accesses against types.Order, + // types.Customer, types.LineItem, and the PaymentMethod union (including + // the narrowing branch); the assertions in Go below check the VALUES. + const orderId: string = created.orderId; + const customerName: string = created.customer.fullName; + const customerEmail: string = created.customer.contactEmail; + const firstItemId: string = created.lineItems[0].itemId; + const firstItemPrice: number = created.lineItems[0].unitPrice; + const secondItemId: string = created.lineItems[1].itemId; + const region: string = created.metadata['region']; + const channel: string = created.metadata['channel']; + + let accountNumber = ''; + if (created.paymentInfo.paymentType === 'bank') { + accountNumber = created.paymentInfo.accountNumber; + } + + console.log(JSON.stringify({ + wireBody: capturedBody ? JSON.parse(capturedBody) : null, + decoded: { + orderId, + customerName, + customerEmail, + firstItemId, + firstItemPrice, + secondItemId, + region, + channel, + accountNumber, + // Unknown keys must have survived decode verbatim -- cast to any + // because the DECLARED type correctly has no member for them at all. + serverNote: (created as any).server_note, + customerInternalNote: (created.customer as any).internal_note, + }, + })); +} + +// No process.exit(1) in the catch handler here (unlike other drivers in +// this package): this file is ALSO run through tsc (see below), and the +// generated tsconfig's "lib" has no Node type definitions, so referencing +// the ambient "process" global would fail to type-check. An unhandled +// rejection already exits Node with a non-zero status on its own, which is +// all runNodeDriver needs to detect a broken driver. +main().catch((err) => { + console.error(err); + throw err; +}); +` + writeTree(t, dir, map[string]string{"src/__driver_e2e_snake_case.ts": driver}) + + // tsc first: confirm the declared type actually accepts every access the + // driver makes, BEFORE esbuild strips the types away and the runtime + // portion below runs regardless of whether they were ever valid. + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("generated client + consumer snippet must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } + + stdout := runNodeDriver(t, dir, "src/__driver_e2e_snake_case.ts") + + var result struct { + WireBody map[string]any `json:"wireBody"` + Decoded struct { + OrderID string `json:"orderId"` + CustomerName string `json:"customerName"` + CustomerEmail string `json:"customerEmail"` + FirstItemID string `json:"firstItemId"` + FirstItemPrice float64 `json:"firstItemPrice"` + SecondItemID string `json:"secondItemId"` + Region string `json:"region"` + Channel string `json:"channel"` + AccountNumber string `json:"accountNumber"` + ServerNote string `json:"serverNote"` + CustomerInternalNote string `json:"customerInternalNote"` + } `json:"decoded"` + } + + decodeLastLine(t, stdout, &result) + + // --- 1. camelCase input put snake_case ON THE WIRE. --- + wireJSON, err := json.Marshal(result.WireBody) + require.NoError(t, err) + wireStr := string(wireJSON) + + assert.Equal(t, "ord_1", result.WireBody["order_id"], "wire body:\n%s", wireStr) + assert.NotContains(t, result.WireBody, "orderId", "the camelCase client name must not leak onto the wire; wire body:\n%s", wireStr) + + customer, ok := result.WireBody["customer"].(map[string]any) + require.True(t, ok, "wire body customer must be an object; wire body:\n%s", wireStr) + assert.Equal(t, "Ada Lovelace", customer["full_name"], "nested object rename on the wire; wire body:\n%s", wireStr) + assert.Equal(t, "ada@example.com", customer["contact_email"], "nested object rename on the wire; wire body:\n%s", wireStr) + + lineItems, ok := result.WireBody["line_items"].([]any) + require.True(t, ok, "wire body line_items must be an array; wire body:\n%s", wireStr) + require.Len(t, lineItems, 2) + firstWireItem, ok := lineItems[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "item-a", firstWireItem["item_id"], "array-of-objects rename on the wire; wire body:\n%s", wireStr) + assert.Equal(t, float64(5), firstWireItem["unit_price"], "array-of-objects rename on the wire; wire body:\n%s", wireStr) + + metadata, ok := result.WireBody["metadata"].(map[string]any) + require.True(t, ok, "wire body metadata must be an object; wire body:\n%s", wireStr) + assert.Equal(t, "blue-widget", metadata["sku123"], "record KEYS are caller data and must survive unrenamed; wire body:\n%s", wireStr) + assert.Equal(t, "red-widget", metadata["sku456"], "record KEYS are caller data and must survive unrenamed; wire body:\n%s", wireStr) + + paymentInfo, ok := result.WireBody["payment_info"].(map[string]any) + require.True(t, ok, "wire body payment_info must be an object; wire body:\n%s", wireStr) + assert.Equal(t, "card", paymentInfo["payment_type"], "discriminated union tag on the wire; wire body:\n%s", wireStr) + assert.Equal(t, "4242424242424242", paymentInfo["card_number"], "discriminated union member field rename on the wire; wire body:\n%s", wireStr) + + assert.Equal(t, float64(42), result.WireBody["loyaltyPoints"], + "an UNKNOWN key (not declared anywhere on Order) must survive encode verbatim -- same name, same value; wire body:\n%s", wireStr) + + // --- 2. a snake_case response arrived as camelCase. --- + assert.Equal(t, "ord_99", result.Decoded.OrderID, "top-level scalar rename on decode") + assert.Equal(t, "Grace Hopper", result.Decoded.CustomerName, "nested object rename on decode") + assert.Equal(t, "grace@example.com", result.Decoded.CustomerEmail, "nested object rename on decode") + assert.Equal(t, "sku-100", result.Decoded.FirstItemID, "array-of-objects rename on decode") + assert.Equal(t, 12.5, result.Decoded.FirstItemPrice, "array-of-objects rename on decode") + assert.Equal(t, "sku-200", result.Decoded.SecondItemID, "array-of-objects rename on decode") + assert.Equal(t, "us-east", result.Decoded.Region, "record VALUES decode; keys (region/channel) were never wire names to begin with") + assert.Equal(t, "web", result.Decoded.Channel) + assert.Equal(t, "000-111-222", result.Decoded.AccountNumber, + "discriminated union: the BankPayment branch must be reachable and its own field renamed") + + // --- 3. unknown keys survive decode too, verbatim. --- + assert.Equal(t, "server-added, unknown to Order", result.Decoded.ServerNote, + "an UNKNOWN top-level key (not declared anywhere on Order) must survive decode verbatim") + assert.Equal(t, "server-added, unknown to Customer", result.Decoded.CustomerInternalNote, + "an UNKNOWN nested key (not declared anywhere on Customer) must survive decode verbatim") +} From cac5b145f3f593050d67fb93395d8b58b295c964 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 20:51:08 -0500 Subject: [PATCH 58/71] test(client/typescript): use case-shape-sensitive record keys in the e2e proof The e2e round-trip test's record (metadata) assertions used keys that are invariant under case conversion (sku123/sku456, region/channel), so a record-key case-conversion regression in codecs.go's 'record' kind (keys must never be renamed -- they are caller data) went completely undetected: injecting exactly that bug still left the whole package suite green. Replace them with keys that are NOT invariant: one snake_case-shaped and one camelCase-shaped on each side of the round trip (encode: shipping_region + deliveryZone; decode: billing_region + expressZone). Verified by temporarily injecting a record-key case-conversion mutation into codecs.go's runtime template -- the test now fails on exactly the two mutated assertions -- then reverting. --- .../generators/typescript/runtime_test.go | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/internal/client/generators/typescript/runtime_test.go b/internal/client/generators/typescript/runtime_test.go index 5d146758..f2b69edd 100644 --- a/internal/client/generators/typescript/runtime_test.go +++ b/internal/client/generators/typescript/runtime_test.go @@ -208,7 +208,16 @@ func e2eSnakeCaseSpec() *client.APISpec { // 3. covers, in that one round trip, a nested object (customer), an array // of objects (line_items), a record (metadata -- keys are caller-chosen // data and must survive UNCHANGED, never renamed), and a discriminated -// union (payment_info: CardPayment | BankPayment); +// union (payment_info: CardPayment | BankPayment). The record's keys are +// deliberately shaped so a case-conversion regression is visible: at +// least one already looks snake_case ("billing_region"/"shipping_region") +// and at least one already looks camelCase ("expressZone"/"deliveryZone") +// on EACH side of the round trip, so neither shape is invariant under +// toCamel/toSnake the way single-word keys ("region", "sku123") would +// have been -- an earlier version of this test used exactly such +// invariant keys and still passed under an injected record-key +// case-conversion bug (codecs.go's 'record' kind, which must never +// rename keys at all); // 4. includes an unknown key on BOTH sides (request: loyaltyPoints, not // part of Order at all; response: server_note at the top level and // internal_note nested inside customer) and asserts each survives @@ -262,7 +271,15 @@ let capturedBody: string | undefined; { item_id: 'sku-100', unit_price: 12.5 }, { item_id: 'sku-200', unit_price: 3.25 }, ], - metadata: { region: 'us-east', channel: 'web' }, + // Both keys are deliberately shaped so a record-key case-conversion + // regression is visible: "billing_region" is already snake_case, so + // decoding it through toCamel (the bug) turns it into "billingRegion" + // and the exact-key lookup below finds nothing; "expressZone" is + // camelCase, so a decode-direction bug that (wrongly) ran EVERY record + // key through toSnake would turn it into "express_zone" instead. Either + // wrong transform is caught; neither key is invariant under case + // conversion the way "region"/"channel" were. + metadata: { billing_region: 'north-zone', expressZone: 'zone-3' }, payment_info: { payment_type: 'bank', account_number: '000-111-222' }, server_note: 'server-added, unknown to Order', }; @@ -286,7 +303,14 @@ async function main() { { itemId: 'item-a', unitPrice: 5 }, { itemId: 'item-b', unitPrice: 7.5 }, ], - metadata: { sku123: 'blue-widget', sku456: 'red-widget' }, + // Same reasoning as the response's metadata, mirrored for the encode + // direction: "shipping_region" is snake_case-shaped caller data that + // must reach the wire completely unchanged (an encode-direction bug + // that ran record keys through toCamel would turn it into + // "shippingRegion" on the wire), and "deliveryZone" is camelCase-shaped + // caller data that must likewise survive (a bug running record keys + // through toSnake would turn it into "delivery_zone" on the wire). + metadata: { shipping_region: 'east-coast', deliveryZone: 'zone-9' }, paymentInfo: { paymentType: 'card' as const, cardNumber: '4242424242424242' }, loyaltyPoints: 42, }; @@ -303,8 +327,8 @@ async function main() { const firstItemId: string = created.lineItems[0].itemId; const firstItemPrice: number = created.lineItems[0].unitPrice; const secondItemId: string = created.lineItems[1].itemId; - const region: string = created.metadata['region']; - const channel: string = created.metadata['channel']; + const billingRegion: string = created.metadata['billing_region']; + const expressZone: string = created.metadata['expressZone']; let accountNumber = ''; if (created.paymentInfo.paymentType === 'bank') { @@ -320,8 +344,8 @@ async function main() { firstItemId, firstItemPrice, secondItemId, - region, - channel, + billingRegion, + expressZone, accountNumber, // Unknown keys must have survived decode verbatim -- cast to any // because the DECLARED type correctly has no member for them at all. @@ -362,8 +386,8 @@ main().catch((err) => { FirstItemID string `json:"firstItemId"` FirstItemPrice float64 `json:"firstItemPrice"` SecondItemID string `json:"secondItemId"` - Region string `json:"region"` - Channel string `json:"channel"` + BillingRegion string `json:"billingRegion"` + ExpressZone string `json:"expressZone"` AccountNumber string `json:"accountNumber"` ServerNote string `json:"serverNote"` CustomerInternalNote string `json:"customerInternalNote"` @@ -395,8 +419,10 @@ main().catch((err) => { metadata, ok := result.WireBody["metadata"].(map[string]any) require.True(t, ok, "wire body metadata must be an object; wire body:\n%s", wireStr) - assert.Equal(t, "blue-widget", metadata["sku123"], "record KEYS are caller data and must survive unrenamed; wire body:\n%s", wireStr) - assert.Equal(t, "red-widget", metadata["sku456"], "record KEYS are caller data and must survive unrenamed; wire body:\n%s", wireStr) + assert.Equal(t, "east-coast", metadata["shipping_region"], + "record KEYS are caller data and must survive unrenamed even when they are already snake_case-shaped -- a bug that ran record keys through toCamel on encode would turn this into \"shippingRegion\"; wire body:\n%s", wireStr) + assert.Equal(t, "zone-9", metadata["deliveryZone"], + "record KEYS are caller data and must survive unrenamed even when they are camelCase-shaped -- a bug that ran record keys through toSnake on encode would turn this into \"delivery_zone\"; wire body:\n%s", wireStr) paymentInfo, ok := result.WireBody["payment_info"].(map[string]any) require.True(t, ok, "wire body payment_info must be an object; wire body:\n%s", wireStr) @@ -413,8 +439,10 @@ main().catch((err) => { assert.Equal(t, "sku-100", result.Decoded.FirstItemID, "array-of-objects rename on decode") assert.Equal(t, 12.5, result.Decoded.FirstItemPrice, "array-of-objects rename on decode") assert.Equal(t, "sku-200", result.Decoded.SecondItemID, "array-of-objects rename on decode") - assert.Equal(t, "us-east", result.Decoded.Region, "record VALUES decode; keys (region/channel) were never wire names to begin with") - assert.Equal(t, "web", result.Decoded.Channel) + assert.Equal(t, "north-zone", result.Decoded.BillingRegion, + "record KEYS must survive decode unrenamed even when they are already snake_case-shaped -- a bug that ran record keys through toCamel on decode would turn \"billing_region\" into \"billingRegion\", and this exact-key lookup would find nothing") + assert.Equal(t, "zone-3", result.Decoded.ExpressZone, + "record KEYS must survive decode unrenamed even when they are camelCase-shaped -- a bug that ran record keys through toSnake on decode would turn \"expressZone\" into \"express_zone\", and this exact-key lookup would find nothing") assert.Equal(t, "000-111-222", result.Decoded.AccountNumber, "discriminated union: the BankPayment branch must be reachable and its own field renamed") From 31026fdfeef7ad5057b25443a297793cf3959520 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 20:55:17 -0500 Subject: [PATCH 59/71] feat(cmd/forge): add --field-naming and --field-overrides to client generate Phase 3 made FieldNaming default to camel for TypeScript generation (a breaking rename of every generated property), but cmd/forge/plugins/client.go built client.GeneratorConfig with no FieldNaming field at all, so effectiveFieldNaming resolved "" -> camel for every `forge client generate --language typescript` user with no way to opt out. Add --field-naming (camel|pascal|snake|preserve, empty/unset passes through unchanged so nothing changes for existing callers) and --field-overrides (a comma-separated "key=clientName" list, schema-scoped keys use "Schema.wire_name") to the `client generate` command, plus matching field_naming/field_overrides keys in .forge-client.yml. An unrecognised --field-naming value is rejected outright at the CLI layer (not silently treated as preserve the way the library's own effectiveFieldNaming does for a hand-built config) -- a human-typed typo should never silently become "never rename anything". --field-overrides is a single comma-separated flag rather than a repeated one: cli/context.go's parseFlagsForCommand overwrites a flag's parsed value on every repeated occurrence instead of accumulating them, so a repeated "--field-overrides a=b --field-overrides c=d" design would have silently kept only the last override. --- cmd/forge/plugins/client.go | 121 ++++++++++++++++ cmd/forge/plugins/client_config.go | 14 ++ cmd/forge/plugins/client_field_config_test.go | 129 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 cmd/forge/plugins/client_field_config_test.go diff --git a/cmd/forge/plugins/client.go b/cmd/forge/plugins/client.go index a89d147b..e83dc151 100644 --- a/cmd/forge/plugins/client.go +++ b/cmd/forge/plugins/client.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/xraph/forge/cli" "github.com/xraph/forge/cmd/forge/config" @@ -52,6 +53,12 @@ func (p *ClientPlugin) Commands() []cli.Command { cli.WithFlag(cli.NewStringFlag("base-url", "b", "API base URL", "")), cli.WithFlag(cli.NewStringFlag("module", "m", "Go module path (for Go only)", "")), + // Field naming (TypeScript). Empty ("") means "unset": the generator's + // own per-language default applies (camel for typescript, preserve + // otherwise) so omitting this flag changes nothing for existing users. + cli.WithFlag(cli.NewStringFlag("field-naming", "", "Client-side field naming strategy: camel, pascal, snake, or preserve (default: camel for typescript, preserve otherwise)", "")), + cli.WithFlag(cli.NewStringFlag("field-overrides", "", "Comma-separated field name overrides, e.g. 'User.user_id=userIdentifier,api_key=apiKey' (schema-scoped keys use \"Schema.wire_name\"; a bare \"wire_name\" applies globally)", "")), + // Authentication and streaming (optional, defaults from config) cli.WithFlag(cli.NewBoolFlag("auth", "", "Include authentication", true)), cli.WithFlag(cli.NewBoolFlag("no-auth", "", "Disable authentication", false)), @@ -153,6 +160,37 @@ func (p *ClientPlugin) generateClient(ctx cli.CommandContext) error { module = clientConfig.Defaults.Module } + // Field naming: CLI flag wins over .forge-client.yml, which wins over + // leaving it unset entirely. Unset ("") is passed straight through to + // client.GeneratorConfig.FieldNaming and resolved by the library's own + // effectiveFieldNaming (camel for typescript, preserve otherwise) -- + // nothing changes for a caller who never touches this. Unlike that + // library-level resolution (which silently falls back to preserve for + // an unrecognised NamingStrategy value -- see fieldname.go's + // effectiveFieldNaming), a typo'd --field-naming (or a bad + // field_naming in the config file) is rejected outright: a CLI user + // typing "--field-naming cammel" almost certainly wants camel, not + // preserve, and preserve is 100% silent about the rename it did not + // do. + fieldNamingFlag := ctx.String("field-naming") + if fieldNamingFlag == "" { + fieldNamingFlag = clientConfig.Defaults.FieldNaming + } + + fieldNaming, err := parseFieldNaming(fieldNamingFlag) + if err != nil { + return cli.NewError(err.Error(), cli.ExitUsageError) + } + + fieldOverrides, err := parseFieldOverrides(ctx.String("field-overrides")) + if err != nil { + return cli.NewError(fmt.Sprintf("invalid --field-overrides: %v", err), cli.ExitUsageError) + } + + if len(fieldOverrides) == 0 { + fieldOverrides = clientConfig.Defaults.FieldOverrides + } + // Authentication and streaming (handle both positive and negative flags) includeAuth := clientConfig.Defaults.Auth if ctx.Bool("no-auth") { @@ -355,6 +393,8 @@ func (p *ClientPlugin) generateClient(ctx cli.CommandContext) error { IncludeAuth: includeAuth, IncludeStreaming: includeStreaming, Version: "1.0.0", + FieldNaming: fieldNaming, + FieldOverrides: fieldOverrides, Features: client.Features{ Reconnection: reconnection, Heartbeat: heartbeat, @@ -786,3 +826,84 @@ func truncate(s string, maxLen int) string { return s[:maxLen-3] + "..." } + +// parseFieldNaming validates a --field-naming (or .forge-client.yml +// field_naming) value and maps it to a client.NamingStrategy. +// +// An empty string means "unset" and passes straight through as +// client.NamingStrategy(""), letting the library's own +// effectiveFieldNaming resolve it (camel for typescript, preserve +// otherwise) exactly as if the flag had never been introduced. +// +// Any non-empty value that is not one of the four recognised strategies is +// rejected outright, unlike the library layer: effectiveFieldNaming +// silently treats an unrecognised client.GeneratorConfig.FieldNaming as +// preserve (see fieldname.go), which is the right call for a Go API caller +// who already has to read the source to construct a GeneratorConfig at +// all, but wrong for a CLI flag a human typed -- a typo like "cammel" +// silently becoming "preserve" would produce a client that compiles fine +// and simply never renames anything, with no signal that the flag was +// misspelled. +func parseFieldNaming(value string) (client.NamingStrategy, error) { + switch value { + case "": + return "", nil + case "camel": + return client.NamingCamel, nil + case "pascal": + return client.NamingPascal, nil + case "snake": + return client.NamingSnake, nil + case "preserve": + return client.NamingPreserve, nil + default: + return "", fmt.Errorf("invalid --field-naming value %q: must be one of camel, pascal, snake, preserve", value) + } +} + +// parseFieldOverrides parses a --field-overrides value: a comma-separated +// list of "key=clientName" pairs, where key is either a bare wire name +// (applies globally) or "SchemaName.wire_name" (schema-scoped), exactly +// matching client.GeneratorConfig.FieldOverrides' own key format. +// +// Chosen over a repeated flag (--field-overrides a=b --field-overrides +// c=d) because this CLI's flag parser (cli/context.go's +// parseFlagsForCommand) overwrites a flag's value on each repeated +// occurrence rather than accumulating them -- a repeated-flag design would +// silently keep only the LAST override and drop the rest, which is worse +// than not offering the feature at all. A single comma-separated value has +// no such trap. +// +// An empty (or whitespace-only) value returns a nil map, not an error -- +// omitting --field-overrides is the common case. Any other malformed entry +// (missing "=", or an empty key/value on either side of it) is rejected +// with the exact offending entry quoted, rather than silently skipped: a +// dropped override is a silent rename that never happens, exactly the +// failure mode --field-naming's strict validation above exists to avoid. +func parseFieldOverrides(raw string) (map[string]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + overrides := make(map[string]string) + + for _, pair := range strings.Split(raw, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + + key, value, hasEquals := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + + if !hasEquals || key == "" || value == "" { + return nil, fmt.Errorf("malformed entry %q: expected \"wire_name=clientName\" or \"Schema.wire_name=clientName\"", pair) + } + + overrides[key] = value + } + + return overrides, nil +} diff --git a/cmd/forge/plugins/client_config.go b/cmd/forge/plugins/client_config.go index 82ec28ea..957e218b 100644 --- a/cmd/forge/plugins/client_config.go +++ b/cmd/forge/plugins/client_config.go @@ -91,6 +91,20 @@ type GenerationDefaults struct { // Output control ClientOnly bool `yaml:"client_only"` // Generate only client source files + + // FieldNaming selects the client-side identifier style for schema + // properties: "camel", "pascal", "snake", or "preserve". Empty means + // unset -- the generator's own per-language default applies (camel for + // typescript, preserve otherwise; see client.GeneratorConfig.FieldNaming's + // doc comment). Mirrors --field-naming; the CLI flag, when passed, wins + // over this. + FieldNaming string `yaml:"field_naming,omitempty"` + + // FieldOverrides maps a wire name (or "SchemaName.wire_name" for a + // schema-scoped override) to an explicit client-side name, exactly like + // client.GeneratorConfig.FieldOverrides. Mirrors --field-overrides; the + // CLI flag, when passed, wins over this. + FieldOverrides map[string]string `yaml:"field_overrides,omitempty"` } // ClientGenConfig defines configuration for generating a specific client. diff --git a/cmd/forge/plugins/client_field_config_test.go b/cmd/forge/plugins/client_field_config_test.go new file mode 100644 index 00000000..0d4af4b1 --- /dev/null +++ b/cmd/forge/plugins/client_field_config_test.go @@ -0,0 +1,129 @@ +// v2/cmd/forge/plugins/client_field_config_test.go +package plugins + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// TestParseFieldNaming covers --field-naming's resolution: the four +// recognised strategies, the empty/unset case (which must pass through as +// client.NamingStrategy("") so the library's own effectiveFieldNaming +// still applies its per-language default), and -- the case this flag +// exists specifically to get right where the library layer does not -- +// an unrecognised value must be REJECTED, not silently treated as +// "preserve" the way effectiveFieldNaming does for a hand-built +// GeneratorConfig (fieldname.go). +func TestParseFieldNaming(t *testing.T) { + cases := []struct { + name string + value string + want client.NamingStrategy + wantErr bool + }{ + {name: "empty is unset, not an error", value: "", want: ""}, + {name: "camel", value: "camel", want: client.NamingCamel}, + {name: "pascal", value: "pascal", want: client.NamingPascal}, + {name: "snake", value: "snake", want: client.NamingSnake}, + {name: "preserve", value: "preserve", want: client.NamingPreserve}, + {name: "unrecognised value is rejected, not silently preserved", value: "cammel", wantErr: true}, + {name: "case-sensitive: Camel is not camel", value: "Camel", wantErr: true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := parseFieldNaming(c.value) + + if c.wantErr { + require.Error(t, err, "parseFieldNaming(%q) should have been rejected", c.value) + assert.Contains(t, err.Error(), c.value, "error should quote the offending value") + + return + } + + require.NoError(t, err) + assert.Equal(t, c.want, got) + }) + } +} + +// TestParseFieldOverrides covers --field-overrides' comma-separated +// "key=clientName" format: the empty case (nil map, not an error -- this is +// the common "flag omitted" path), a single global override, a +// schema-scoped override (the "Schema.wire_name" key format +// client.GeneratorConfig.FieldOverrides itself uses), multiple +// comma-separated entries, and malformed entries (missing "=", empty key, +// empty value) that must be rejected with the exact offending entry named +// rather than silently skipped or partially applied. +func TestParseFieldOverrides(t *testing.T) { + t.Run("empty value returns nil, not an error", func(t *testing.T) { + got, err := parseFieldOverrides("") + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("whitespace-only value returns nil, not an error", func(t *testing.T) { + got, err := parseFieldOverrides(" ") + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("single global override", func(t *testing.T) { + got, err := parseFieldOverrides("api_key=apiKey") + require.NoError(t, err) + assert.Equal(t, map[string]string{"api_key": "apiKey"}, got) + }) + + t.Run("schema-scoped override", func(t *testing.T) { + got, err := parseFieldOverrides("User.user_id=userIdentifier") + require.NoError(t, err) + assert.Equal(t, map[string]string{"User.user_id": "userIdentifier"}, got) + }) + + t.Run("multiple comma-separated entries, mixed scoped and global", func(t *testing.T) { + got, err := parseFieldOverrides("User.user_id=userIdentifier,api_key=apiKey") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "User.user_id": "userIdentifier", + "api_key": "apiKey", + }, got) + }) + + t.Run("surrounding whitespace around entries and around = is tolerated", func(t *testing.T) { + got, err := parseFieldOverrides(" User.user_id = userIdentifier , api_key = apiKey ") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "User.user_id": "userIdentifier", + "api_key": "apiKey", + }, got) + }) + + t.Run("missing = is rejected", func(t *testing.T) { + _, err := parseFieldOverrides("api_key") + require.Error(t, err) + assert.Contains(t, err.Error(), "api_key") + }) + + t.Run("empty key is rejected", func(t *testing.T) { + _, err := parseFieldOverrides("=apiKey") + require.Error(t, err) + }) + + t.Run("empty value is rejected", func(t *testing.T) { + _, err := parseFieldOverrides("api_key=") + require.Error(t, err) + }) + + t.Run("one malformed entry among valid ones still fails the whole flag", func(t *testing.T) { + // Partially applying overrides -- silently keeping the valid ones and + // dropping the malformed one -- would be exactly the kind of silent + // rename-that-never-happens this CLI validation exists to prevent. + _, err := parseFieldOverrides("api_key=apiKey,broken") + require.Error(t, err) + assert.Contains(t, err.Error(), "broken") + }) +} From fd75ad8ad53abfbd06f1f31d9220ad366c490a65 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 20:56:22 -0500 Subject: [PATCH 60/71] docs(client): document FieldNaming/FieldOverrides and the breaking rename internal/client/README.md's Configuration Options section enumerated every GeneratorConfig field except FieldNaming/FieldOverrides, and the TypeScript output tree omitted codecs.ts entirely -- so the one config change most likely to surprise an upgrading user (every generated TypeScript property renaming from wire-cased to camelCase by default) was undocumented anywhere in the repository. Add a "Field Naming (TypeScript)" section: the four strategies, the FieldOverrides key format (schema-scoped vs. global, precedence, verbatim values), the CLI's --field-naming/--field-overrides flags, collision detection and its two known gaps, the preserve escape hatch, and the remaining known limitations (union-of-unions encode asymmetry, the additionalProperties-named-property alias risk, additionalProperties dropped on allOf compositions, WS/SSE payloads bypassing the codec, and parameterized JSON content types falling through the generator's spec-side content-type lookups). --- internal/client/README.md | 159 +++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/internal/client/README.md b/internal/client/README.md index fe8bf65f..db6f0598 100644 --- a/internal/client/README.md +++ b/internal/client/README.md @@ -69,9 +69,14 @@ forge client generate \ --streaming \ --reconnection \ --heartbeat \ - --state-management + --state-management \ + --field-naming camel \ + --field-overrides "User.user_id=userIdentifier" ``` +See "Field Naming" below for `--field-naming`/`--field-overrides` and the +breaking change they relate to. + #### List Endpoints ```bash @@ -204,12 +209,16 @@ client/ └── src/ ├── index.ts # Barrel exports ├── types.ts # Type definitions + ├── codecs.ts # Wire <-> client-side field name encode/decode (see "Field Naming" below) ├── client.ts # Main client class ├── rest.ts # REST methods ├── websocket.ts # WebSocket clients └── sse.ts # SSE clients ``` +`codecs.ts` is only emitted when it would do real work -- see "Field Naming" +below for exactly when that is (and is not) the case. + Example usage: ```typescript @@ -322,6 +331,12 @@ Each language generator must map OpenAPI/JSON Schema types to native types: - **IncludeStreaming**: Generate WebSocket/SSE clients - **Module**: Go module path (Go only) - **Version**: Generated client version +- **FieldNaming**: Client-side identifier style for schema properties -- + `camel`, `pascal`, `snake`, or `preserve` (TypeScript only; see "Field + Naming" below). Empty resolves to `camel` when `Language` is + `"typescript"`, `preserve` otherwise. +- **FieldOverrides**: Per-field client-side name overrides that bypass + `FieldNaming` entirely (see "Field Naming" below). ### Features @@ -334,6 +349,148 @@ Each language generator must map OpenAPI/JSON Schema types to native types: - **Middleware**: Request/response interceptors - **Logging**: Built-in logging support +## Field Naming (TypeScript) + +> **Breaking change.** Generating a TypeScript client with the default +> configuration now renames every schema property to camelCase -- +> `user.user_id` in an OpenAPI spec becomes `user.userId` in the generated +> client, not `user.user_id`. If you are upgrading an existing generated +> client and want the old, wire-cased behaviour back, set `FieldNaming: +> "preserve"` (Go API) or pass `--field-naming preserve` (CLI) -- see +> "Escape hatch" below. + +By default, the TypeScript generator renders every schema property under a +client-side name derived from its wire (JSON) name, and generates a codec +(`src/codecs.ts`) that renames payloads at the HTTP boundary so the actual +runtime values match the declared TypeScript types: a request encodes +client-side names back to wire names before the request is sent, and a +response decodes wire names to client-side names after it arrives. + +### `FieldNaming` strategies + +| Value | Example (wire `user_id`) | Notes | +|---|---|---| +| `camel` (default for TypeScript) | `userId` | Standard TypeScript/JavaScript convention | +| `pascal` | `UserId` | | +| `snake` | `user_id` | No-op if the wire name is already snake_case | +| `preserve` | `user_id` | Wire name rendered verbatim; the pre-Phase-3 behaviour | + +An unset `FieldNaming` (Go zero value, or omitting `--field-naming` on the +CLI) resolves to `camel` when `Language` is `"typescript"`, and to +`preserve` for every other language -- so a Go generator caller is +completely unaffected. An unrecognised strategy value passed to the Go API +directly (e.g. a typo'd `client.GeneratorConfig{FieldNaming: +"kebab"}`) silently falls back to `preserve` rather than erroring, since +`GeneratorConfig` currently has no validation path for this field; the CLI +layer (`--field-naming`) does NOT share this leniency -- an unrecognised +CLI value is rejected outright. + +### `FieldOverrides` + +`FieldOverrides` renames one specific field differently from whatever +`FieldNaming` strategy is configured -- including under `preserve` (see +"Escape hatch" below). Each key is either: + +- **Schema-scoped**: `"SchemaName.wire_name"` -- applies only within that + schema (and any inline nested object using that same namespace id, e.g. + `"Order.shipping.street_name"`). +- **Global**: `"wire_name"` -- applies everywhere that wire name occurs with + no schema-scoped entry taking precedence. + +A schema-scoped key always wins over a global one for the same wire name. +An override's value is used verbatim -- it is never case-converted, even if +it happens to look like a wire name. + +```go +config := client.GeneratorConfig{ + Language: "typescript", + FieldNaming: client.NamingCamel, + FieldOverrides: map[string]string{ + "User.user_id": "userIdentifier", // schema-scoped + "api_key": "apiKey", // global + }, +} +``` + +On the CLI, `--field-overrides` takes a single comma-separated +`key=clientName` list (schema-scoped and global keys use the same format as +above): + +```bash +forge client generate --language typescript \ + --field-overrides "User.user_id=userIdentifier,api_key=apiKey" +``` + +**Known ambiguity**: a schema name or wire name containing a literal `.` +makes the concatenated key ambiguous -- schema `"User.Detail"` + wire `"id"` +and schema `"User"` + wire `"Detail.id"` both produce the same key, +`"User.Detail.id"`. OpenAPI schema and property names are conventionally +dot-free, so this is treated as an accepted, unresolved edge case rather +than requiring an escaping scheme. + +**Known limitation**: when a nested object is reachable through more than +one composition path (e.g. both `Addr.payload.x` and `Base.payload.x` +resolve to logically "the same" property), an override must be repeated +once per namespace it is reachable through -- there is no single override +that applies to every path at once. + +### Collision detection + +Generation fails outright (producing no output files at all) if two +distinct wire names in the same object namespace would resolve to the same +client-side name under the configured `FieldNaming`/`FieldOverrides` -- +this includes top-level schemas, inline nested objects, array items, +`additionalProperties` values, and `oneOf`/`anyOf`/`allOf` members. The +error names both wire names, the schema, and the exact `FieldOverrides` key +that would resolve the collision. + +This check also runs under `preserve` whenever `FieldOverrides` is +non-empty -- an override renames a field even under `preserve`, so two +overrides that map different wire names to the same client name are still +a real collision, not just an ordinary no-op-renaming pair of wire names. + +Two narrower gaps are known and not yet closed: a collision cannot be +detected through an `allOf` member that is itself a union (its alternatives +are invisible to the guard), and an `allOf` member that is a bare array or +an `additionalProperties`-only schema is silently unwarned about (though +harmless, since such a member degrades to passthrough rather than +contributing fields). + +### Escape hatch + +Set `FieldNaming: "preserve"` (Go API) or `--field-naming preserve` (CLI) +to keep every generated field name exactly as the wire declares it -- byte- +identical to the pre-Phase-3 output. When `preserve` is set AND +`FieldOverrides` is empty, the entire codec table (`src/codecs.ts`, its +imports, and every `bodyCodec`/`responseCodec` reference) is omitted +entirely as dead weight, since nothing would ever need renaming. Setting +even one `FieldOverrides` entry keeps the codec table alive, since that one +field still needs to be renamed at the HTTP boundary. + +### Other known limitations + +- A discriminated union whose members are THEMSELVES discriminated unions + encodes entirely in camelCase (the rename does not apply) -- generation + warns, but the warning's stated reason (implemented as of this writing) + is inaccurate; the underlying gap is tracked, not yet fixed. +- A schema property literally named `additionalProperties` can alias an + internal codec id, risking silent data corruption if such a property + occurs in practice. +- `additionalProperties` declared on an `allOf` composition is dropped by + both the type renderer and the codec table (so it is silently untyped and + unrenamed), though the collision guard still walks it. +- WebSocket and SSE payload types do not go through the codec at all -- + `types.User` renders camelCase, but a streamed payload is parsed/ + stringified raw, so a streaming consumer reading a renamed field is + reading a value that was never actually renamed. +- A media type of `application/json; charset=utf-8` (or any other + parameterized JSON content type) is not recognized by the generator's + spec-side content-type lookups, which match `"application/json"` + exactly. A request/response body declared with a parameterized + content type is typed as `Blob` instead of the schema type, gets no codec + reference, and generation does not warn -- while the runtime HTTP client + still JSON-parses the response body regardless. + ## Testing The client generator includes comprehensive tests: From 5c3b4b2f9d99f52760866352b5b70b0655e34ec6 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 21:30:32 -0500 Subject: [PATCH 61/71] fix(client/typescript): decode and encode streaming payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property rename made spec-derived WebSocket and SSE payload types declare camelCase names, but both generators cast raw wire JSON straight to those types and serialised outgoing messages with a bare JSON.stringify: const message: types.User = JSON.parse(data); this.ws.send(JSON.stringify(message)); So types.User promised userId while the frame carried user_id. It type-checked cleanly and failed at runtime, in any generated client with WS or SSE endpoints. Incoming frames and SSE events now route through decode(), and outgoing sends through encode(), resolving a codec id from the message schema the same way rest.go resolves bodyCodec/responseCodec for HTTP. The import and every call site are gated on codecsNeeded(config), so a preserve build with no overrides — which emits no src/codecs.ts at all — does not dangle a reference to it. The hardcoded streaming boilerplate (Message, Member, Room, RoomOptions, HistoryQuery, UserPresence) is deliberately untouched. Those are string literals in the generator rather than schema-derived, they have no codec entry, and renaming only their TypeScript declaration would break wire correctness. Verified unchanged: the diff touches none of them, and room_id/display_name still render un-renamed. --- .../client/generators/typescript/generator.go | 6 +- internal/client/generators/typescript/sse.go | 101 +++++- .../typescript/streaming_codec_test.go | 323 ++++++++++++++++++ .../client/generators/typescript/websocket.go | 166 ++++++++- 4 files changed, 575 insertions(+), 21 deletions(-) create mode 100644 internal/client/generators/typescript/streaming_codec_test.go diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index f44a7f77..1703850b 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -310,15 +310,17 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, // Generate WebSocket clients if len(spec.WebSockets) > 0 && config.IncludeStreaming { wsGen := NewWebSocketGenerator() - wsCode := wsGen.Generate(spec, config) + wsCode, wsWarnings := wsGen.Generate(spec, config) genClient.Files["src/websocket.ts"] = wsCode + genClient.Warnings = append(genClient.Warnings, wsWarnings...) } // Generate SSE clients if len(spec.SSEs) > 0 && config.IncludeStreaming { sseGen := NewSSEGenerator() - sseCode := sseGen.Generate(spec, config) + sseCode, sseWarnings := sseGen.Generate(spec, config) genClient.Files["src/sse.ts"] = sseCode + genClient.Warnings = append(genClient.Warnings, sseWarnings...) } // Generate WebTransport clients diff --git a/internal/client/generators/typescript/sse.go b/internal/client/generators/typescript/sse.go index 45bc9f3e..0c1ae3ea 100644 --- a/internal/client/generators/typescript/sse.go +++ b/internal/client/generators/typescript/sse.go @@ -2,21 +2,38 @@ package typescript import ( "fmt" + "sort" "strings" "github.com/xraph/forge/internal/client" ) // SSEGenerator generates TypeScript SSE client code. -type SSEGenerator struct{} +type SSEGenerator struct { + // warnings accumulates generation-time messages that don't abort + // generation but are worth surfacing -- one per SSE event whose schema + // could not be resolved to a codec-table id (see sseEventCodecRef). + // Reset at the start of each Generate call so a reused *SSEGenerator + // never leaks a prior call's warnings into the next one -- mirrors + // RESTGenerator.warnings (rest.go) exactly. + warnings []string +} // NewSSEGenerator creates a new SSE generator. func NewSSEGenerator() *SSEGenerator { return &SSEGenerator{} } -// Generate generates the SSE clients. -func (s *SSEGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) string { +// Generate generates the SSE clients. The second return value lists +// generation-time warnings -- mirroring RESTGenerator.Generate's own +// (string, []string) shape (rest.go) -- currently one per event whose schema +// could not be resolved to a codec-table id: the generated event data type +// (getSchemaTypeName) still declares a camelCase TypeScript shape, but +// nothing will actually rename the payload at runtime, which must be +// visible, not silent. +func (s *SSEGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) (string, []string) { + s.warnings = nil + var buf strings.Builder // Generate environment detection and polyfill setup @@ -28,16 +45,67 @@ func (s *SSEGenerator) Generate(spec *client.APISpec, config client.GeneratorCon buf.WriteString("\n") buf.WriteString("import * as types from './types';\n") - buf.WriteString("import { ConnectionState } from './types';\n\n") + buf.WriteString("import { ConnectionState } from './types';\n") + + // codecsNeeded gates this import exactly as websocket.go's own gate + // does: under NamingPreserve with no FieldOverrides, generator.go never + // emits src/codecs.ts at all (see codecsNeeded's doc comment, + // fieldname.go), so an unconditional import here would dangle and fail + // tsc (TS2307 "Cannot find module './codecs'"). Event schema codec ids + // are only resolved below when this is true, so decode() is only ever + // referenced when the import exists. + needsCodecs := codecsNeeded(config) + if needsCodecs { + buf.WriteString("import { decode } from './codecs';\n") + } + + buf.WriteString("\n") // Generate client for each SSE endpoint for _, sse := range spec.SSEs { - clientCode := s.generateSSEClient(sse, spec, config) + clientCode := s.generateSSEClient(sse, spec, config, needsCodecs) buf.WriteString(clientCode) buf.WriteString("\n") } - return buf.String() + sort.Strings(s.warnings) + + return buf.String(), s.warnings +} + +// sseLabel returns a short, human-identifiable name for an SSE endpoint, for +// use in a generation-time warning -- mirrors rest.go's endpointLabel. +func sseLabel(sse client.SSEEndpoint) string { + if sse.ID != "" { + return sse.ID + } + + return sse.Path +} + +// sseEventCodecRef returns the codec table id (see schemaCodecRef, rest.go) +// for one SSE event's schema, and a warning to append to +// SSEGenerator.warnings when one is needed. +// +// A nil schema needs no warning: getSchemaTypeName renders it as "any", +// which makes no renamed-shape promise for decode to fail to honor. A schema +// that resolves (a direct $ref, or an array of one) gets its id silently. +// Anything else -- an inline object, oneOf/anyOf, allOf -- warns: the event +// data type is still declared in its camelCase TypeScript shape, but it will +// be received wire-cased and unrenamed, because there is no codec-table +// entry to decode it with. +func sseEventCodecRef(schema *client.Schema, sse client.SSEEndpoint, eventName string) (id string, warning string) { + if schema == nil { + return "", "" + } + + if ref := schemaCodecRef(schema); ref != "" { + return ref, "" + } + + return "", fmt.Sprintf( + "sse endpoint %q: event %q schema is not a direct $ref (or an array of one) to a named component schema -- the generated event data type is still declared in its camelCase TypeScript shape, but it will be received wire-cased, unrenamed, because there is no codec-table entry to decode it with", + sseLabel(sse), eventName) } // generatePolyfillSetup generates the browser/Node.js compatibility layer. @@ -146,7 +214,7 @@ func (s *SSEGenerator) generateBaseTypes(config client.GeneratorConfig) string { } // generateSSEClient generates an SSE client for an endpoint. -func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.APISpec, config client.GeneratorConfig) string { +func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.APISpec, config client.GeneratorConfig, needsCodecs bool) string { var buf strings.Builder className := s.generateClassName(sse) @@ -327,6 +395,23 @@ func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.AP schema := sse.EventSchemas[eventName] typeName := s.getSchemaTypeName(schema, spec) + // Codec id for this event's schema, resolved only when + // codecsNeeded(config) -- see sseEventCodecRef's doc comment, and + // Generate's needsCodecs gate above around the './codecs' import. + // codecID stays "" otherwise, which makes wireDecodeExpr below a + // no-op -- exactly the raw JSON.parse(event.data) cast that shipped + // before this fix. + var codecID string + + if needsCodecs { + var warning string + + codecID, warning = sseEventCodecRef(schema, sse, eventName) + if warning != "" { + s.warnings = append(s.warnings, warning) + } + } + buf.WriteString(fmt.Sprintf(" this.eventSource.addEventListener('%s', (event: MessageEvent) => {\n", eventName)) buf.WriteString(" try {\n") @@ -336,7 +421,7 @@ func (s *SSEGenerator) generateSSEClient(sse client.SSEEndpoint, spec *client.AP buf.WriteString(" }\n") } - buf.WriteString(fmt.Sprintf(" const data: %s = JSON.parse(event.data);\n", typeName)) + buf.WriteString(fmt.Sprintf(" const data: %s = %s;\n", typeName, wireDecodeExpr(codecID, "JSON.parse(event.data)"))) buf.WriteString(fmt.Sprintf(" this.emit('%s', data);\n", eventName)) buf.WriteString(" } catch (error) {\n") buf.WriteString(" this.emit('error', error);\n") diff --git a/internal/client/generators/typescript/streaming_codec_test.go b/internal/client/generators/typescript/streaming_codec_test.go new file mode 100644 index 00000000..2976f54b --- /dev/null +++ b/internal/client/generators/typescript/streaming_codec_test.go @@ -0,0 +1,323 @@ +package typescript + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// wsSSEFakeSetup is written as its own module (src/__setup_ws_sse.ts) and +// imported FIRST, before './websocket'/'./sse', by every driver in this +// file. This ordering matters: websocket.ts/sse.ts each compute +// `const isBrowser = typeof window !== 'undefined' && typeof +// window.WebSocket !== 'undefined'` (or ...EventSource...) at MODULE-EVAL +// time, once, the moment the module is first evaluated. ES module +// evaluation order is depth-first over the static import graph, in source +// order at each level, so a sibling import with no dependencies of its own +// (this setup module) is fully evaluated before the next sibling import +// runs -- which is what lets this module install a fake window.WebSocket/ +// window.EventSource global before websocket.ts/sse.ts ever read it. Without +// this ordering, isBrowser would be computed false (no `window` global +// exists in a bare Node process), sending both generated clients down the +// `require('ws')`/`require('eventsource')` Node branch instead, which is not +// what this test wants to exercise. +const wsSSEFakeSetup = ` +class FakeWebSocket { + static OPEN = 1; + static CLOSED = 3; + readyState = 1; + onopen: ((ev?: any) => void) | null = null; + onmessage: ((ev: any) => void) | null = null; + onerror: ((ev: any) => void) | null = null; + onclose: ((ev: any) => void) | null = null; + sent: string[] = []; + + constructor(public url: string) { + (globalThis as any).__lastFakeWS = this; + setTimeout(() => { if (this.onopen) this.onopen(); }, 0); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.onclose) this.onclose({}); + } +} + +class FakeEventSource { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + readyState = 1; + onopen: ((ev?: any) => void) | null = null; + onmessage: ((ev: any) => void) | null = null; + onerror: ((ev: any) => void) | null = null; + private listeners = new Map void>>(); + + constructor(public url: string, public opts?: any) { + (globalThis as any).__lastFakeES = this; + setTimeout(() => { if (this.onopen) this.onopen(); }, 0); + } + + addEventListener(type: string, handler: (ev: any) => void): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(handler); + } + + removeEventListener(type: string, handler: (ev: any) => void): void { + this.listeners.get(type)?.delete(handler); + } + + emit(type: string, data: string): void { + const ev = { data, lastEventId: '' }; + this.listeners.get(type)?.forEach((h) => h(ev)); + } + + close(): void {} +} + +(globalThis as any).window = { + WebSocket: FakeWebSocket, + EventSource: FakeEventSource, +}; +` + +// TestWebSocketAndSSEDecodeEncodeWirePayloads is the runtime execution proof +// that commit 5139609's regression is fixed: websocket.ts/sse.ts previously +// cast an incoming/outgoing payload straight through JSON.parse/ +// JSON.stringify with no rename at all, so a spec-derived message type +// declaring camelCase properties (e.g. types.User's userId/createdAt) lied +// about what was actually on the wire (snake_case: user_id/created_at). +// +// This drives REAL generated code (websocket.ts's ChatWSClient and sse.ts's +// NotificationsSSEClient, from wsSSESpec()'s User-typed WS/SSE endpoints) +// under Node via esbuild, exactly as runtime_test.go's own e2e test drives +// RESTClient -- not encode()/decode() called directly, and not a hand-built +// config. +// +// Covers, in one driver: +// 1. an incoming WS frame {"user_id":"x"} surfaces to the handler as +// {userId:'x'}; +// 2. an outgoing WS send({userId:'x'}) puts {"user_id":"x"} on the wire; +// 3. an SSE event {"user_id":"x"} surfaces as {userId:'x'}; +// 4. an unknown key not declared anywhere on User survives untouched in +// both directions (WS in/out) and on the SSE side. +func TestWebSocketAndSSEDecodeEncodeWirePayloads(t *testing.T) { + spec := wsSSESpec() + config := baseConfig() // NamingCamel by default -- codecsNeeded(config) == true + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + require.Contains(t, out.Files, "src/websocket.ts") + require.Contains(t, out.Files, "src/sse.ts") + require.Contains(t, out.Files, "src/codecs.ts") + + // Sanity checks on the fix's shape before ever running anything: the + // raw, unrenamed casts from the regression must be gone, replaced by a + // decode()/encode() call referencing the User codec id. + ws := out.Files["src/websocket.ts"] + assert.Contains(t, ws, "import { decode, encode } from './codecs';") + assert.NotContains(t, ws, "JSON.parse(data);", "the raw, un-decoded cast from the regression must be gone") + assert.Contains(t, ws, `decode(JSON.parse(data), "User")`) + assert.Contains(t, ws, `encode(message, "User")`) + + sseCode := out.Files["src/sse.ts"] + assert.Contains(t, sseCode, "import { decode } from './codecs';") + assert.NotContains(t, sseCode, "JSON.parse(event.data);", "the raw, un-decoded cast from the regression must be gone") + assert.Contains(t, sseCode, `decode(JSON.parse(event.data), "User")`) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + writeTree(t, dir, map[string]string{"src/__setup_ws_sse.ts": wsSSEFakeSetup}) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("generated ws-sse client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } + + driver := ` +import './__setup_ws_sse'; +import { ChatWSClient } from './websocket'; +import { NotificationsSSEClient } from './sse'; + +async function main() { + // --- WebSocket: incoming frame decodes, outgoing message encodes, --- + // --- unknown keys survive both directions. --- + const wsClient = new ChatWSClient({ baseURL: 'http://example.invalid' }); + await wsClient.connect(); + + const fakeWS = (globalThis as any).__lastFakeWS; + + const receivedWS: any[] = []; + wsClient.onMessage((msg) => receivedWS.push(msg)); + + // Wire frame: snake_case, plus an unknown key User never declares. + fakeWS.onmessage({ + data: JSON.stringify({ user_id: 'x', unknown_wire_key: 'w1' }), + }); + + // Outgoing: camelCase message, plus an unknown key. + await wsClient.send({ userId: 'y', unknownClientKey: 'w2' } as any); + + // --- SSE: incoming event decodes, unknown key survives. --- + const sseClient = new NotificationsSSEClient({ baseURL: 'http://example.invalid' }); + await sseClient.connect(); + + const fakeES = (globalThis as any).__lastFakeES; + + const receivedSSE: any[] = []; + sseClient.onCreated((data) => receivedSSE.push(data)); + + fakeES.emit('created', JSON.stringify({ user_id: 'z', unknown_wire_key: 'w3' })); + + console.log(JSON.stringify({ + wsReceived: receivedWS, + wsSentRaw: fakeWS.sent.map((s: string) => JSON.parse(s)), + sseReceived: receivedSSE, + })); + + // Both clients default to Features.Heartbeat/Reconnection on (baseConfig() + // -> DefaultConfig()), which means connect() started a setInterval + // heartbeat that is never unref'd. Without closing both clients here, + // Node never runs out of pending timers and this driver process hangs + // forever -- runNodeDriver would then block indefinitely waiting for it + // to exit. + wsClient.close(); + sseClient.close(); +} + +main().catch((err) => { + console.error(err); + throw err; +}); +` + writeTree(t, dir, map[string]string{"src/__driver_ws_sse.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_ws_sse.ts") + + var result struct { + WSReceived []map[string]any `json:"wsReceived"` + WSSentRaw []map[string]any `json:"wsSentRaw"` + SSEReceived []map[string]any `json:"sseReceived"` + } + decodeLastLine(t, stdout, &result) + + // 1. Incoming WS frame decodes wire -> camelCase. + require.Len(t, result.WSReceived, 1, "stdout:\n%s", stdout) + assert.Equal(t, "x", result.WSReceived[0]["userId"], "wire user_id must decode to camelCase userId; stdout:\n%s", stdout) + assert.NotContains(t, result.WSReceived[0], "user_id", "the wire-cased key must not survive decode; stdout:\n%s", stdout) + // 4a. Unknown key survives decode verbatim. + assert.Equal(t, "w1", result.WSReceived[0]["unknown_wire_key"], "an unrecognized key must pass through decode untouched; stdout:\n%s", stdout) + + // 2. Outgoing WS send encodes camelCase -> wire. + require.Len(t, result.WSSentRaw, 1, "stdout:\n%s", stdout) + assert.Equal(t, "y", result.WSSentRaw[0]["user_id"], "camelCase userId must encode to wire user_id; stdout:\n%s", stdout) + assert.NotContains(t, result.WSSentRaw[0], "userId", "the camelCase key must not leak onto the wire; stdout:\n%s", stdout) + // 4b. Unknown key survives encode verbatim. + assert.Equal(t, "w2", result.WSSentRaw[0]["unknownClientKey"], "an unrecognized key must pass through encode untouched; stdout:\n%s", stdout) + + // 3. SSE event decodes wire -> camelCase. + require.Len(t, result.SSEReceived, 1, "stdout:\n%s", stdout) + assert.Equal(t, "z", result.SSEReceived[0]["userId"], "wire user_id must decode to camelCase userId over SSE; stdout:\n%s", stdout) + assert.NotContains(t, result.SSEReceived[0], "user_id", "the wire-cased key must not survive decode over SSE; stdout:\n%s", stdout) + assert.Equal(t, "w3", result.SSEReceived[0]["unknown_wire_key"], "an unrecognized key must pass through SSE decode untouched; stdout:\n%s", stdout) +} + +// TestPreserveNamingSkipsCodecsInStreamingWSAndSSE is the "preserve" gate's +// WS/SSE counterpart: under NamingPreserve with no FieldOverrides, +// codecsNeeded(config) is false, src/codecs.ts is never emitted at all, and +// websocket.ts/sse.ts must not import from it or reference encode()/ +// decode() -- otherwise generated output fails tsc with TS2307 ("Cannot +// find module './codecs'"). This proves the gating this task's brief +// specifically called out (codecs.go's codecsNeeded doc comment) actually +// holds for the two files this task touches, not just fetch.ts (already +// covered by the "preserve" gate fixture in fixtures_test.go). +func TestPreserveNamingSkipsCodecsInStreamingWSAndSSE(t *testing.T) { + spec := wsSSESpec() + config := preserveConfig() + require.False(t, codecsNeeded(config), "sanity check: preserveConfig() must not need codecs") + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + require.NotContains(t, out.Files, "src/codecs.ts", "sanity check: codecs.ts must not be emitted under NamingPreserve") + + ws := out.Files["src/websocket.ts"] + sseCode := out.Files["src/sse.ts"] + + for name, code := range map[string]string{"websocket.ts": ws, "sse.ts": sseCode} { + assert.NotContains(t, code, "./codecs", "%s must not import from ./codecs under NamingPreserve", name) + assert.NotContains(t, code, "decode(", "%s must not call decode() under NamingPreserve", name) + assert.NotContains(t, code, "encode(", "%s must not call encode() under NamingPreserve", name) + } + + // The payload casts must be exactly the pre-codec raw JSON.parse/ + // JSON.stringify shape -- passthrough, not renamed (NamingPreserve means + // no renaming, not "renamed to the same name"). + assert.Contains(t, ws, "const message: types.User = JSON.parse(data);") + assert.Contains(t, ws, "this.ws.send(JSON.stringify(message));") + assert.Contains(t, sseCode, "const data: types.User = JSON.parse(event.data);") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("preserve-naming ws-sse client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } +} + +// TestHardcodedStreamingTypesUntouchedByCodecFix pins the exact, +// byte-identical hardcoded interfaces generateStreamingTypes emits for +// Message, Member, Room, RoomOptions, HistoryQuery, and UserPresence -- +// including their snake_case (room_id, display_name, etc.) field names. +// These are string literals in generator.go, not derived from any +// client.Schema, have no codec-table entry at all, and an earlier +// investigation (recorded in this task's brief) confirmed renaming only +// their TypeScript declaration -- without also changing every generator +// that hand-builds an object literal keyed by these same wire names +// (rooms.go, presence.go, channels.go, typing.go) -- would break wire +// correctness. This task's fix deliberately never touches generator.go's +// generateStreamingTypes, nor rooms.go/presence.go/channels.go/typing.go, +// so this asserts that boundary explicitly rather than leaving it as an +// unstated assumption. +func TestHardcodedStreamingTypesUntouchedByCodecFix(t *testing.T) { + spec := wsSSESpec() + config := baseConfig() // EnableRooms/EnablePresence/EnableHistory all default true + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + for _, want := range []string{ + "export interface Message {", + " room_id: string;", + " user_id?: string;", + "export interface Member {", + " user_id: string;", + " display_name?: string;", + " avatar_url?: string;", + " joined_at?: string;", + "export interface Room {", + " created_by?: string;", + " created_at?: string;", + "export interface RoomOptions {", + " max_members?: number;", + " is_private?: boolean;", + "export interface HistoryQuery {", + " before_id?: string;", + " after_id?: string;", + "export interface UserPresence {", + " userId: string;", + " customMessage?: string;", + " lastSeen?: string;", + " roomId?: string;", + } { + assert.Contains(t, types, want, "hardcoded streaming type field must be byte-identical to the pre-fix generator output") + } +} diff --git a/internal/client/generators/typescript/websocket.go b/internal/client/generators/typescript/websocket.go index 393f2dcd..301fce4b 100644 --- a/internal/client/generators/typescript/websocket.go +++ b/internal/client/generators/typescript/websocket.go @@ -1,22 +1,40 @@ package typescript import ( + "encoding/json" "fmt" + "sort" "strings" "github.com/xraph/forge/internal/client" ) // WebSocketGenerator generates TypeScript WebSocket client code. -type WebSocketGenerator struct{} +type WebSocketGenerator struct { + // warnings accumulates generation-time messages that don't abort + // generation but are worth surfacing -- one per WebSocket endpoint whose + // send or receive message schema could not be resolved to a codec-table + // id (see messageCodecRef). Reset at the start of each Generate call so a + // reused *WebSocketGenerator never leaks a prior call's warnings into the + // next one -- mirrors RESTGenerator.warnings (rest.go) exactly. + warnings []string +} // NewWebSocketGenerator creates a new WebSocket generator. func NewWebSocketGenerator() *WebSocketGenerator { return &WebSocketGenerator{} } -// Generate generates the WebSocket clients. -func (w *WebSocketGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) string { +// Generate generates the WebSocket clients. The second return value lists +// generation-time warnings -- mirroring RESTGenerator.Generate's own +// (string, []string) shape (rest.go) -- currently one per endpoint whose send +// or receive message schema could not be resolved to a codec-table id: the +// generated message type (getSchemaTypeName) still declares a camelCase +// TypeScript shape, but nothing will actually rename the payload at runtime, +// which must be visible, not silent. +func (w *WebSocketGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) (string, []string) { + w.warnings = nil + var buf strings.Builder // Generate environment detection and polyfill setup @@ -28,16 +46,105 @@ func (w *WebSocketGenerator) Generate(spec *client.APISpec, config client.Genera buf.WriteString("\n") buf.WriteString("import * as types from './types';\n") - buf.WriteString("import { ConnectionState } from './types';\n\n") + buf.WriteString("import { ConnectionState } from './types';\n") + + // codecsNeeded gates this import exactly as fetch_client.go's own + // needsCodecs gate does: under NamingPreserve with no FieldOverrides, + // generator.go never emits src/codecs.ts at all (see codecsNeeded's doc + // comment, fieldname.go), so an unconditional import here would dangle + // and fail tsc (TS2307 "Cannot find module './codecs'"). Message + // send/receive codec ids are only resolved below when this is true, so + // encode()/decode() are only ever referenced when the import exists. + needsCodecs := codecsNeeded(config) + if needsCodecs { + buf.WriteString("import { decode, encode } from './codecs';\n") + } + + buf.WriteString("\n") // Generate client for each WebSocket endpoint for _, ws := range spec.WebSockets { - clientCode := w.generateWebSocketClient(ws, spec, config) + clientCode := w.generateWebSocketClient(ws, spec, config, needsCodecs) buf.WriteString(clientCode) buf.WriteString("\n") } - return buf.String() + sort.Strings(w.warnings) + + return buf.String(), w.warnings +} + +// wsLabel returns a short, human-identifiable name for a WebSocket endpoint, +// for use in a generation-time warning -- mirrors rest.go's endpointLabel. +func wsLabel(ws client.WebSocketEndpoint) string { + if ws.ID != "" { + return ws.ID + } + + return ws.Path +} + +// messageCodecRef returns the codec table id (see schemaCodecRef, rest.go) +// for a WebSocket message schema, and a warning to append to +// WebSocketGenerator.warnings when one is needed. direction is "send" or +// "receive", used only to make the warning readable. +// +// A nil schema (this endpoint declares no SendSchema/ReceiveSchema at all) +// needs no warning: getSchemaTypeName renders that side as "any", which +// makes no renamed-shape promise for encode/decode to fail to honor -- the +// same reasoning rest.go's requestBodyCodecRef applies to a body-less +// endpoint. A schema that resolves (a direct $ref, or an array of one) gets +// its id silently. Anything else -- an inline object, oneOf/anyOf, allOf -- +// warns: the message type is still declared in its camelCase TypeScript +// shape, but it will be sent/received wire-cased and unrenamed, because +// there is no codec-table entry to walk it with. Silence there would +// reproduce exactly the regression this function exists to fix. +func messageCodecRef(schema *client.Schema, ws client.WebSocketEndpoint, direction string) (id string, warning string) { + if schema == nil { + return "", "" + } + + if ref := schemaCodecRef(schema); ref != "" { + return ref, "" + } + + return "", fmt.Sprintf( + "websocket endpoint %q: %s message schema is not a direct $ref (or an array of one) to a named component schema -- the generated message type is still declared in its camelCase TypeScript shape, but it will be sent/received wire-cased, unrenamed, because there is no codec-table entry to encode/decode it with", + wsLabel(ws), direction) +} + +// wireEncodeExpr wraps expr in an encode() call when codecID is non-empty, +// so an outgoing message is renamed from its TypeScript (client-side) shape +// back to the wire shape before being JSON.stringify-ed -- mirroring +// fetch_client.go's bodyCodec/encode() call site for HTTP request bodies. +// Returns expr unchanged when codecID is "" -- which is always the case +// when codecsNeeded(config) is false (messageCodecRef is never even called +// then, see generateWebSocketClient), so this never references encode() in +// a file that never imported it. +func wireEncodeExpr(codecID, expr string) string { + if codecID == "" { + return expr + } + + literal, _ := json.Marshal(codecID) + + return fmt.Sprintf("encode(%s, %s)", expr, literal) +} + +// wireDecodeExpr wraps expr in a decode() call when codecID is non-empty, so +// an incoming message is renamed from its wire shape into the TypeScript +// (client-side) shape its declared type promises -- mirroring +// fetch_client.go's responseCodec/decode() call site for HTTP responses. +// Returns expr unchanged when codecID is "" -- see wireEncodeExpr's doc +// comment for why that never dangles a reference to decode(). +func wireDecodeExpr(codecID, expr string) string { + if codecID == "" { + return expr + } + + literal, _ := json.Marshal(codecID) + + return fmt.Sprintf("decode(%s, %s)", expr, literal) } // generatePolyfillSetup generates the browser/Node.js compatibility layer. @@ -166,13 +273,38 @@ func (w *WebSocketGenerator) generateBaseTypes(config client.GeneratorConfig) st } // generateWebSocketClient generates a WebSocket client for an endpoint. -func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint, spec *client.APISpec, config client.GeneratorConfig) string { +func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint, spec *client.APISpec, config client.GeneratorConfig, needsCodecs bool) string { var buf strings.Builder className := w.generateClassName(ws) sendType := w.getSchemaTypeName(ws.SendSchema, spec) receiveType := w.getSchemaTypeName(ws.ReceiveSchema, spec) + // Codec ids for the send/receive message schemas, resolved only when + // codecsNeeded(config) -- under NamingPreserve with no FieldOverrides + // there is no live src/codecs.ts to reference, and warning about failing + // to resolve one would be noise about renaming machinery that isn't + // running at all (mirrors rest.go's own codecsNeeded gate around + // requestBodyCodecRef/responseCodecRef). sendCodecID/receiveCodecID stay + // "" otherwise, which makes wireEncodeExpr/wireDecodeExpr below no-ops -- + // so every call site is written once, unconditionally, rather than + // needing its own needsCodecs branch. + var sendCodecID, receiveCodecID string + + if needsCodecs { + var warning string + + sendCodecID, warning = messageCodecRef(ws.SendSchema, ws, "send") + if warning != "" { + w.warnings = append(w.warnings, warning) + } + + receiveCodecID, warning = messageCodecRef(ws.ReceiveSchema, ws, "receive") + if warning != "" { + w.warnings = append(w.warnings, warning) + } + } + // Class definition buf.WriteString(fmt.Sprintf("/**\n * %s\n", className)) @@ -324,7 +456,11 @@ func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint buf.WriteString(" this.ws.onmessage = (event: MessageEvent) => {\n") buf.WriteString(" try {\n") buf.WriteString(" const data = typeof event.data === 'string' ? event.data : event.data.toString();\n") - buf.WriteString(fmt.Sprintf(" const message: %s = JSON.parse(data);\n", receiveType)) + // Decoded from wire (snake_case, say) to the TypeScript shape receiveType + // promises, via decode() -- when receiveCodecID resolved to something + // (see messageCodecRef); otherwise wireDecodeExpr is a no-op and this is + // exactly the raw JSON.parse(data) cast that shipped before this fix. + buf.WriteString(fmt.Sprintf(" const message: %s = %s;\n", receiveType, wireDecodeExpr(receiveCodecID, "JSON.parse(data)"))) buf.WriteString(" this.emit('message', message);\n") buf.WriteString(" } catch (error) {\n") buf.WriteString(" this.emit('error', error);\n") @@ -379,7 +515,11 @@ func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint buf.WriteString(" const OPEN = 1; // WebSocket.OPEN\n") buf.WriteString(" \n") buf.WriteString(" if (this.ws && this.ws.readyState === OPEN) {\n") - buf.WriteString(" this.ws.send(JSON.stringify(message));\n") + // Encoded from the TypeScript (client-side) shape back to the wire shape + // via encode() -- when sendCodecID resolved to something; otherwise a + // no-op, exactly the raw JSON.stringify(message) that shipped before + // this fix. + buf.WriteString(fmt.Sprintf(" this.ws.send(JSON.stringify(%s));\n", wireEncodeExpr(sendCodecID, "message"))) buf.WriteString(" return;\n") buf.WriteString(" }\n\n") @@ -413,7 +553,7 @@ func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint buf.WriteString(" if (!this.ws || this.ws.readyState !== OPEN) {\n") buf.WriteString(" throw new Error('WebSocket is not connected');\n") buf.WriteString(" }\n") - buf.WriteString(" this.ws.send(JSON.stringify(message));\n") + buf.WriteString(fmt.Sprintf(" this.ws.send(JSON.stringify(%s));\n", wireEncodeExpr(sendCodecID, "message"))) buf.WriteString(" }\n\n") // Queue management methods @@ -455,7 +595,11 @@ func (w *WebSocketGenerator) generateWebSocketClient(ws client.WebSocketEndpoint buf.WriteString(" }\n\n") buf.WriteString(" try {\n") - buf.WriteString(" this.ws.send(JSON.stringify(msg.data));\n") + // msg.data was queued unencoded (see the `send` method's queue push + // above -- it stores `message` as-is, TypeScript-shaped), so it must be + // encoded here at actual send time, exactly like the immediate-send and + // sendSync paths above. + buf.WriteString(fmt.Sprintf(" this.ws.send(JSON.stringify(%s));\n", wireEncodeExpr(sendCodecID, "msg.data"))) buf.WriteString(" this.messageQueue.shift();\n") buf.WriteString(" msg.resolve();\n") buf.WriteString(" } catch (error) {\n") From 8f501253bf43c3dccfd3452dce78f0261842bc1b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 22:04:50 -0500 Subject: [PATCH 62/71] fix(client/typescript): decode and encode WebTransport payloads webtransport.go carried the identical regression the preceding commit fixed for WebSocket and SSE: every datagram, bidirectional-stream, and unidirectional-stream payload was cast through a bare JSON.parse/ JSON.stringify, so a spec-derived message type declaring camelCase properties (types.User's userId) lied about what was actually on the wire (snake_case: user_id). Incoming payloads now route through decode() and outgoing ones through encode(), resolving a codec id from the relevant stream/datagram schema the same way messageCodecRef/ sseEventCodecRef do for WebSocket/SSE, gated on codecsNeeded(config) exactly like those two generators. processIncomingUniStream's 'incomingUniStream' emit is now typed and decoded from UniStreamSchema.ReceiveSchema, a live IR field that was previously completely unused -- every incoming uni-stream was hardcoded as `any` with a raw, un-decoded cast. Writing a runtime test for BiDiStream.send/receive surfaced a second, pre-existing, unrelated defect: generateBiStreamMethods/ generateUniStreamMethods emitted `class BiDiStream`/`class UniStream` as string literals spliced INSIDE the outer client class's body, before its closing brace. A `class` statement is not a legal class-body member in TypeScript/JavaScript, so this was a parse error, not a type error -- confirmed by both tsc and esbuild's independent parsers, and present in the original webtransport.go, well before this fix. No generated WebTransport client with a BiStreamSchema or UniStreamSchema could ever have been compiled or bundled before now; it went unnoticed because no fixture or test in the corpus ran tsc/esbuild against webtransport.ts. Fixed by hoisting BiDiStream/UniStream into their own top-level class declarations, uniquely named per endpoint (`BiDiStream`/`UniStream`) so multiple WebTransport endpoints in one spec don't redeclare a shared name. The hardcoded streaming boilerplate (Message, Member, Room, RoomOptions, HistoryQuery, UserPresence) is untouched, verified against a spec that also declares a WebTransport endpoint. --- .../client/generators/typescript/generator.go | 3 +- .../generators/typescript/generator_test.go | 24 +- .../generators/typescript/webtransport.go | 311 +++++++++++--- .../typescript/webtransport_codec_test.go | 406 ++++++++++++++++++ 4 files changed, 680 insertions(+), 64 deletions(-) create mode 100644 internal/client/generators/typescript/webtransport_codec_test.go diff --git a/internal/client/generators/typescript/generator.go b/internal/client/generators/typescript/generator.go index 1703850b..c5711574 100644 --- a/internal/client/generators/typescript/generator.go +++ b/internal/client/generators/typescript/generator.go @@ -326,8 +326,9 @@ func (g *Generator) Generate(ctx context.Context, specIface generators.APISpec, // Generate WebTransport clients if len(spec.WebTransports) > 0 && config.IncludeStreaming { wtGen := NewWebTransportGenerator() - wtCode := wtGen.Generate(spec, config) + wtCode, wtWarnings := wtGen.Generate(spec, config) genClient.Files["src/webtransport.ts"] = wtCode + genClient.Warnings = append(genClient.Warnings, wtWarnings...) } // Generate event emitter utility for streaming clients diff --git a/internal/client/generators/typescript/generator_test.go b/internal/client/generators/typescript/generator_test.go index 59a74d18..1bc9a9c2 100644 --- a/internal/client/generators/typescript/generator_test.go +++ b/internal/client/generators/typescript/generator_test.go @@ -640,12 +640,22 @@ func TestTypeScriptGeneratorWebTransport(t *testing.T) { } } - // Verify BiDiStream and UniStream classes are generated - if !strings.Contains(wtCode, "class BiDiStream") { - t.Error("webtransport.ts should contain BiDiStream class") - } - - if !strings.Contains(wtCode, "class UniStream") { - t.Error("webtransport.ts should contain UniStream class") + // Verify BiDiStream and UniStream classes are generated. Each is a + // standalone, top-level `class` declaration named after this endpoint + // (className + "BiDiStream"/"UniStream"), not a bare "class BiDiStream"/ + // "class UniStream" -- generateWebTransportClient can no longer emit + // those as unqualified names spliced inside the outer client class body + // (a `class` statement is not a legal class-body member in TypeScript/ + // JavaScript, which made every previously-generated WebTransport client + // with a BiStreamSchema or UniStreamSchema a parse error, not just a + // type error -- neither tsc nor esbuild could ever have compiled or + // bundled it). Qualifying by endpoint name also avoids a redeclaration + // if a spec has more than one WebTransport endpoint. + if !strings.Contains(wtCode, "class DataWTClientBiDiStream") { + t.Error("webtransport.ts should contain the endpoint-qualified BiDiStream class") + } + + if !strings.Contains(wtCode, "class DataWTClientUniStream") { + t.Error("webtransport.ts should contain the endpoint-qualified UniStream class") } } diff --git a/internal/client/generators/typescript/webtransport.go b/internal/client/generators/typescript/webtransport.go index 01cf9e55..1b1edd0e 100644 --- a/internal/client/generators/typescript/webtransport.go +++ b/internal/client/generators/typescript/webtransport.go @@ -2,21 +2,41 @@ package typescript import ( "fmt" + "sort" "strings" "github.com/xraph/forge/internal/client" ) // WebTransportGenerator generates TypeScript WebTransport client code. -type WebTransportGenerator struct{} +type WebTransportGenerator struct { + // warnings accumulates generation-time messages that don't abort + // generation but are worth surfacing -- one per bidirectional-stream, + // unidirectional-stream, or datagram schema whose schema could not be + // resolved to a codec-table id (see wtCodecRef). Reset at the start of + // each Generate call so a reused *WebTransportGenerator never leaks a + // prior call's warnings into the next one -- mirrors + // WebSocketGenerator.warnings (websocket.go) and SSEGenerator.warnings + // (sse.go) exactly. + warnings []string +} // NewWebTransportGenerator creates a new WebTransport generator. func NewWebTransportGenerator() *WebTransportGenerator { return &WebTransportGenerator{} } -// Generate generates the WebTransport clients. -func (w *WebTransportGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) string { +// Generate generates the WebTransport clients. The second return value lists +// generation-time warnings -- mirroring WebSocketGenerator.Generate's and +// SSEGenerator.Generate's own (string, []string) shape -- currently one per +// stream/datagram schema whose declared TypeScript type could not be +// resolved to a codec-table id: the generated message type +// (getSchemaTypeName) still declares a camelCase TypeScript shape, but +// nothing will actually rename the payload at runtime, which must be +// visible, not silent. +func (w *WebTransportGenerator) Generate(spec *client.APISpec, config client.GeneratorConfig) (string, []string) { + w.warnings = nil + var buf strings.Builder buf.WriteString(w.generateHeader()) @@ -24,16 +44,72 @@ func (w *WebTransportGenerator) Generate(spec *client.APISpec, config client.Gen buf.WriteString(w.generateTypes(config)) buf.WriteString("\n") - buf.WriteString("import * as types from './types';\n\n") + buf.WriteString("import * as types from './types';\n") + + // codecsNeeded gates this import exactly as websocket.go's and sse.go's + // own gates do: under NamingPreserve with no FieldOverrides, + // generator.go never emits src/codecs.ts at all (see codecsNeeded's doc + // comment, fieldname.go), so an unconditional import here would dangle + // and fail tsc (TS2307 "Cannot find module './codecs'"). Stream/datagram + // schema codec ids are only resolved below when this is true, so + // encode()/decode() are only ever referenced when the import exists. + needsCodecs := codecsNeeded(config) + if needsCodecs { + buf.WriteString("import { decode, encode } from './codecs';\n") + } + + buf.WriteString("\n") // Generate client for each WebTransport endpoint for _, wt := range spec.WebTransports { - clientCode := w.generateWebTransportClient(wt, spec, config) + clientCode := w.generateWebTransportClient(wt, spec, config, needsCodecs) buf.WriteString(clientCode) buf.WriteString("\n") } - return buf.String() + sort.Strings(w.warnings) + + return buf.String(), w.warnings +} + +// wtLabel returns a short, human-identifiable name for a WebTransport +// endpoint, for use in a generation-time warning -- mirrors rest.go's +// endpointLabel, websocket.go's wsLabel, and sse.go's sseLabel. +func wtLabel(wt client.WebTransportEndpoint) string { + if wt.ID != "" { + return wt.ID + } + + return wt.Path +} + +// wtCodecRef returns the codec table id (see schemaCodecRef, rest.go) for one +// WebTransport bidirectional-stream, unidirectional-stream, or datagram +// schema, and a warning to append to WebTransportGenerator.warnings when one +// is needed. kind describes which schema this is (e.g. "bidirectional-stream +// send message"), used only to make the warning readable -- mirrors +// websocket.go's messageCodecRef and sse.go's sseEventCodecRef. +// +// A nil schema needs no warning: getSchemaTypeName renders it as "any", +// which makes no renamed-shape promise for encode/decode to fail to honor. A +// schema that resolves (a direct $ref, or an array of one) gets its id +// silently. Anything else -- an inline object, oneOf/anyOf, allOf -- warns: +// the message type is still declared in its camelCase TypeScript shape, but +// it will be sent/received wire-cased, unrenamed, because there is no +// codec-table entry to encode/decode it with. Silence there would reproduce +// exactly the regression this function exists to fix. +func wtCodecRef(schema *client.Schema, wt client.WebTransportEndpoint, kind string) (id string, warning string) { + if schema == nil { + return "", "" + } + + if ref := schemaCodecRef(schema); ref != "" { + return ref, "" + } + + return "", fmt.Sprintf( + "webtransport endpoint %q: %s schema is not a direct $ref (or an array of one) to a named component schema -- the generated message type is still declared in its camelCase TypeScript shape, but it will be sent/received wire-cased, unrenamed, because there is no codec-table entry to encode/decode it with", + wtLabel(wt), kind) } // generateHeader generates the header with environment detection. @@ -140,11 +216,85 @@ func (w *WebTransportGenerator) generateTypes(config client.GeneratorConfig) str } // generateWebTransportClient generates a WebTransport client for an endpoint. -func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTransportEndpoint, spec *client.APISpec, config client.GeneratorConfig) string { +func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTransportEndpoint, spec *client.APISpec, config client.GeneratorConfig, needsCodecs bool) string { var buf strings.Builder className := w.generateClassName(wt) + // Shared with generateBiStreamMethods/generateUniStreamMethods (which + // derive the SAME two names for their own standalone class + // declarations): handleIncomingBidiStreams below instantiates + // `${className}BiDiStream` regardless of whether this endpoint declares + // an outgoing BiStreamSchema of its own, since an incoming bidirectional + // stream is a connection-level event, not something gated on this + // endpoint's own send/receive schema. + biDiStreamName := className + "BiDiStream" + + // Codec ids for every stream/datagram schema this endpoint declares, + // resolved only when codecsNeeded(config) -- see wtCodecRef's doc + // comment, and Generate's needsCodecs gate above around the './codecs' + // import. Each stays "" otherwise, which makes wireEncodeExpr/ + // wireDecodeExpr below no-ops -- exactly the raw JSON.stringify/ + // JSON.parse casts that shipped before this fix. + var biSendCodecID, biReceiveCodecID string + if needsCodecs && wt.BiStreamSchema != nil { + var warning string + + biSendCodecID, warning = wtCodecRef(wt.BiStreamSchema.SendSchema, wt, "bidirectional-stream send message") + if warning != "" { + w.warnings = append(w.warnings, warning) + } + + biReceiveCodecID, warning = wtCodecRef(wt.BiStreamSchema.ReceiveSchema, wt, "bidirectional-stream receive message") + if warning != "" { + w.warnings = append(w.warnings, warning) + } + } + + var uniSendCodecID, uniReceiveCodecID string + if needsCodecs && wt.UniStreamSchema != nil { + var warning string + + uniSendCodecID, warning = wtCodecRef(wt.UniStreamSchema.SendSchema, wt, "unidirectional-stream send message") + if warning != "" { + w.warnings = append(w.warnings, warning) + } + + uniReceiveCodecID, warning = wtCodecRef(wt.UniStreamSchema.ReceiveSchema, wt, "unidirectional-stream receive message") + if warning != "" { + w.warnings = append(w.warnings, warning) + } + } + + var datagramCodecID string + if needsCodecs && wt.DatagramSchema != nil { + var warning string + + datagramCodecID, warning = wtCodecRef(wt.DatagramSchema, wt, "datagram") + if warning != "" { + w.warnings = append(w.warnings, warning) + } + } + + // Incoming unidirectional streams (server -> client, handled by + // handleIncomingUniStreams/processIncomingUniStream below) are typed from + // UniStreamSchema.ReceiveSchema -- previously unused anywhere in this + // generator despite being a live IR field (ir.go's StreamSchema), which + // left every incoming uni-stream hardcoded as `any` and its payload a + // raw, un-decoded JSON.parse. A nil ReceiveSchema (no endpoint declares + // one) keeps that exact previous behavior: getSchemaTypeName renders + // "any", and uniReceiveCodecID stays "", so wireDecodeExpr is a no-op. + uniReceiveType := "any" + + var uniReceiveSchema *client.Schema + if wt.UniStreamSchema != nil { + uniReceiveSchema = wt.UniStreamSchema.ReceiveSchema + } + + if uniReceiveSchema != nil { + uniReceiveType = w.getSchemaTypeName(uniReceiveSchema, spec) + } + // Class documentation buf.WriteString(fmt.Sprintf("/**\n * %s\n", className)) @@ -305,29 +455,54 @@ func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTranspor buf.WriteString(" }\n") buf.WriteString(" }\n\n") + // Bidirectional and unidirectional stream wrapper classes (BiDiStream, + // UniStream) are collected separately from the outer client class body: + // they must be emitted as their own top-level `class` declarations, AFTER + // this class's closing brace, not spliced in before it. A `class` + // statement is not a legal class-body member in TypeScript/JavaScript -- + // only property and method declarations are -- so embedding one directly + // inside `export class DataWTClient extends EventEmitter { ... }` is a + // parse error, not merely a type error: tsc reports a cascade of + // "Unexpected token"/"Declaration or statement expected" diagnostics from + // the point of the nested `class` keyword onward, and esbuild's parser + // (which runNodeDriver's bundling step depends on) rejects the same input + // outright ("Expected \";\" but found \"BiDiStream\""), so this was never + // actually possible to bundle or execute, only to generate as a Go + // string. This is a pre-existing defect, independent of the codec/rename + // fix below -- naming each class "BiDiStream"/ + // "UniStream" additionally disambiguates the two wrapper + // classes per WebTransport endpoint (a spec with more than one + // WebTransport endpoint, each declaring its own BiStreamSchema, would + // otherwise redeclare a single top-level `class BiDiStream` twice). + var auxClasses strings.Builder + // Bidirectional stream methods if wt.BiStreamSchema != nil { - buf.WriteString(w.generateBiStreamMethods(wt.BiStreamSchema, spec, config)) + methodCode, classCode := w.generateBiStreamMethods(wt.BiStreamSchema, spec, config, biSendCodecID, biReceiveCodecID, className) + buf.WriteString(methodCode) + auxClasses.WriteString(classCode) } // Unidirectional stream methods if wt.UniStreamSchema != nil { - buf.WriteString(w.generateUniStreamMethods(wt.UniStreamSchema, spec, config)) + methodCode, classCode := w.generateUniStreamMethods(wt.UniStreamSchema, spec, config, uniSendCodecID, className) + buf.WriteString(methodCode) + auxClasses.WriteString(classCode) } // Datagram methods with queue if wt.DatagramSchema != nil { - buf.WriteString(w.generateDatagramMethods(wt.DatagramSchema, spec, config)) + buf.WriteString(w.generateDatagramMethods(wt.DatagramSchema, spec, config, datagramCodecID)) } // Queue management buf.WriteString(w.generateQueueMethods()) // Handle incoming streams - buf.WriteString(w.generateIncomingStreamHandler()) + buf.WriteString(w.generateIncomingStreamHandler(uniReceiveCodecID, biDiStreamName)) // State management - buf.WriteString(w.generateStateManagement()) + buf.WriteString(w.generateStateManagement(uniReceiveType, biDiStreamName)) // Error handling buf.WriteString(w.generateErrorHandling()) @@ -386,24 +561,32 @@ func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTranspor buf.WriteString(" }\n") buf.WriteString(" }\n") - buf.WriteString("}\n") + buf.WriteString("}\n\n") + buf.WriteString(auxClasses.String()) return buf.String() } -// generateBiStreamMethods generates bidirectional stream methods. -func (w *WebTransportGenerator) generateBiStreamMethods(schema *client.StreamSchema, spec *client.APISpec, config client.GeneratorConfig) string { +// generateBiStreamMethods generates bidirectional stream methods. The first +// return value is the openBidiStream() method body, meant to be embedded +// inside the outer client class; the second is the standalone +// `class BiDiStream { ... }` declaration, meant to be emitted +// AFTER the outer class's closing brace (see generateWebTransportClient's +// auxClasses doc comment for why these can no longer be spliced together the +// way they were before). +func (w *WebTransportGenerator) generateBiStreamMethods(schema *client.StreamSchema, spec *client.APISpec, config client.GeneratorConfig, sendCodecID, receiveCodecID, className string) (string, string) { var buf strings.Builder sendType := w.getSchemaTypeName(schema.SendSchema, spec) receiveType := w.getSchemaTypeName(schema.ReceiveSchema, spec) + biDiStreamName := className + "BiDiStream" buf.WriteString(" /**\n") buf.WriteString(" * Open a new bidirectional stream.\n") - buf.WriteString(" * @returns Promise resolving to a BiDiStream instance\n") + buf.WriteString(fmt.Sprintf(" * @returns Promise resolving to a %s instance\n", biDiStreamName)) buf.WriteString(" * @throws Error if not connected or operation times out\n") buf.WriteString(" */\n") - buf.WriteString(" async openBidiStream(): Promise {\n") + buf.WriteString(fmt.Sprintf(" async openBidiStream(): Promise<%s> {\n", biDiStreamName)) buf.WriteString(" if (!this.transport || this.state !== WebTransportState.CONNECTED) {\n") buf.WriteString(" throw new Error('Not connected');\n") buf.WriteString(" }\n\n") @@ -415,14 +598,15 @@ func (w *WebTransportGenerator) generateBiStreamMethods(schema *client.StreamSch buf.WriteString(" setTimeout(() => reject(new Error('Stream creation timeout')), timeout)\n") buf.WriteString(" ),\n") buf.WriteString(" ]);\n") - buf.WriteString(" return new BiDiStream(stream);\n") + buf.WriteString(fmt.Sprintf(" return new %s(stream);\n", biDiStreamName)) buf.WriteString(" }\n\n") - // BiDiStream class - buf.WriteString(fmt.Sprintf(`/** + // BiDiStream class -- a standalone top-level declaration (see this + // function's doc comment), not embedded in the caller's class body. + classCode := fmt.Sprintf(`/** * Bidirectional stream wrapper for typed send/receive operations. */ -class BiDiStream { +class %s { private stream: WebTransportBidirectionalStream; private writer: WritableStreamDefaultWriter | null = null; private reader: ReadableStreamDefaultReader | null = null; @@ -440,7 +624,7 @@ class BiDiStream { this.writer = this.stream.writable.getWriter(); } const encoder = new TextEncoder(); - const data = encoder.encode(JSON.stringify(msg)); + const data = encoder.encode(JSON.stringify(%s)); await this.writer.write(data); } @@ -461,7 +645,7 @@ class BiDiStream { result += decoder.decode(value, { stream: true }); } - return JSON.parse(result); + return %s; } /** @@ -477,16 +661,16 @@ class BiDiStream { while (true) { const { done, value } = await this.reader.read(); if (done) break; - + buffer += decoder.decode(value, { stream: true }); - + // Try to parse complete JSON objects const lines = buffer.split('\n'); buffer = lines.pop() || ''; - + for (const line of lines) { if (line.trim()) { - yield JSON.parse(line); + yield %s; } } } @@ -507,23 +691,28 @@ class BiDiStream { } } -`, sendType, receiveType, receiveType)) +`, biDiStreamName, sendType, wireEncodeExpr(sendCodecID, "msg"), receiveType, wireDecodeExpr(receiveCodecID, "JSON.parse(result)"), receiveType, wireDecodeExpr(receiveCodecID, "JSON.parse(line)")) - return buf.String() + return buf.String(), classCode } -// generateUniStreamMethods generates unidirectional stream methods. -func (w *WebTransportGenerator) generateUniStreamMethods(schema *client.StreamSchema, spec *client.APISpec, config client.GeneratorConfig) string { +// generateUniStreamMethods generates unidirectional stream methods. Return +// values mirror generateBiStreamMethods': the openUniStream() method body +// (embedded in the outer client class) and the standalone +// `class UniStream { ... }` declaration (emitted after the outer +// class's closing brace). +func (w *WebTransportGenerator) generateUniStreamMethods(schema *client.StreamSchema, spec *client.APISpec, config client.GeneratorConfig, sendCodecID, className string) (string, string) { var buf strings.Builder sendType := w.getSchemaTypeName(schema.SendSchema, spec) + uniStreamName := className + "UniStream" buf.WriteString(" /**\n") buf.WriteString(" * Open a new unidirectional stream for sending.\n") - buf.WriteString(" * @returns Promise resolving to a UniStream instance\n") + buf.WriteString(fmt.Sprintf(" * @returns Promise resolving to a %s instance\n", uniStreamName)) buf.WriteString(" * @throws Error if not connected or operation times out\n") buf.WriteString(" */\n") - buf.WriteString(" async openUniStream(): Promise {\n") + buf.WriteString(fmt.Sprintf(" async openUniStream(): Promise<%s> {\n", uniStreamName)) buf.WriteString(" if (!this.transport || this.state !== WebTransportState.CONNECTED) {\n") buf.WriteString(" throw new Error('Not connected');\n") buf.WriteString(" }\n\n") @@ -535,14 +724,15 @@ func (w *WebTransportGenerator) generateUniStreamMethods(schema *client.StreamSc buf.WriteString(" setTimeout(() => reject(new Error('Stream creation timeout')), timeout)\n") buf.WriteString(" ),\n") buf.WriteString(" ]);\n") - buf.WriteString(" return new UniStream(stream);\n") + buf.WriteString(fmt.Sprintf(" return new %s(stream);\n", uniStreamName)) buf.WriteString(" }\n\n") - // UniStream class - buf.WriteString(fmt.Sprintf(`/** + // UniStream class -- a standalone top-level declaration (see this + // function's doc comment), not embedded in the caller's class body. + classCode := fmt.Sprintf(`/** * Unidirectional stream wrapper for typed send operations. */ -class UniStream { +class %s { private stream: WritableStream; private writer: WritableStreamDefaultWriter | null = null; @@ -559,7 +749,7 @@ class UniStream { this.writer = this.stream.getWriter(); } const encoder = new TextEncoder(); - const data = encoder.encode(JSON.stringify(msg)); + const data = encoder.encode(JSON.stringify(%s)); await this.writer.write(data); } @@ -574,13 +764,13 @@ class UniStream { } } -`, sendType)) +`, uniStreamName, sendType, wireEncodeExpr(sendCodecID, "msg")) - return buf.String() + return buf.String(), classCode } // generateDatagramMethods generates datagram methods with offline queue. -func (w *WebTransportGenerator) generateDatagramMethods(schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig) string { +func (w *WebTransportGenerator) generateDatagramMethods(schema *client.Schema, spec *client.APISpec, config client.GeneratorConfig, codecID string) string { var buf strings.Builder typeName := w.getSchemaTypeName(schema, spec) @@ -593,7 +783,7 @@ func (w *WebTransportGenerator) generateDatagramMethods(schema *client.Schema, s buf.WriteString(" */\n") buf.WriteString(fmt.Sprintf(" async sendDatagram(msg: %s): Promise {\n", typeName)) buf.WriteString(" const encoder = new TextEncoder();\n") - buf.WriteString(" const data = encoder.encode(JSON.stringify(msg));\n\n") + buf.WriteString(fmt.Sprintf(" const data = encoder.encode(JSON.stringify(%s));\n\n", wireEncodeExpr(codecID, "msg"))) buf.WriteString(" if (this.transport && this.state === WebTransportState.CONNECTED) {\n") buf.WriteString(" const writer = this.transport.datagrams.writable.getWriter();\n") @@ -635,7 +825,7 @@ func (w *WebTransportGenerator) generateDatagramMethods(schema *client.Schema, s buf.WriteString(" }\n\n") buf.WriteString(" const encoder = new TextEncoder();\n") - buf.WriteString(" const data = encoder.encode(JSON.stringify(msg));\n") + buf.WriteString(fmt.Sprintf(" const data = encoder.encode(JSON.stringify(%s));\n", wireEncodeExpr(codecID, "msg"))) buf.WriteString(" const writer = this.transport.datagrams.writable.getWriter();\n") buf.WriteString(" try {\n") buf.WriteString(" await writer.write(data);\n") @@ -658,7 +848,7 @@ func (w *WebTransportGenerator) generateDatagramMethods(schema *client.Schema, s buf.WriteString(" const { value } = await reader.read();\n") buf.WriteString(" const decoder = new TextDecoder();\n") buf.WriteString(" const text = decoder.decode(value);\n") - buf.WriteString(" return JSON.parse(text);\n") + buf.WriteString(fmt.Sprintf(" return %s;\n", wireDecodeExpr(codecID, "JSON.parse(text)"))) buf.WriteString(" } finally {\n") buf.WriteString(" reader.releaseLock();\n") buf.WriteString(" }\n") @@ -680,7 +870,7 @@ func (w *WebTransportGenerator) generateDatagramMethods(schema *client.Schema, s buf.WriteString(" const { done, value } = await reader.read();\n") buf.WriteString(" if (done) break;\n") buf.WriteString(" const text = decoder.decode(value);\n") - buf.WriteString(" yield JSON.parse(text);\n") + buf.WriteString(fmt.Sprintf(" yield %s;\n", wireDecodeExpr(codecID, "JSON.parse(text)"))) buf.WriteString(" }\n") buf.WriteString(" } finally {\n") buf.WriteString(" reader.releaseLock();\n") @@ -753,8 +943,14 @@ func (w *WebTransportGenerator) generateQueueMethods() string { } // generateIncomingStreamHandler generates handler for incoming streams. -func (w *WebTransportGenerator) generateIncomingStreamHandler() string { - return ` private async handleIncomingStreams(): Promise { +// biDiStreamName is the endpoint-specific `class` name generateBiStreamMethods +// declares (className + "BiDiStream") -- handleIncomingBidiStreams +// instantiates it by name, so the two must agree even though this function +// runs unconditionally (unlike generateBiStreamMethods, which only runs when +// wt.BiStreamSchema != nil; see generateWebTransportClient's biDiStreamName +// comment). +func (w *WebTransportGenerator) generateIncomingStreamHandler(uniReceiveCodecID, biDiStreamName string) string { + return fmt.Sprintf(` private async handleIncomingStreams(): Promise { if (!this.transport) return; // Handle incoming bidirectional streams @@ -775,7 +971,7 @@ func (w *WebTransportGenerator) generateIncomingStreamHandler() string { if (done) break; // Emit incoming stream for application handling - this.emit('incomingBidiStream', new BiDiStream(value)); + this.emit('incomingBidiStream', new %s(value)); } } catch (error) { if (!this.closed) { @@ -821,7 +1017,7 @@ func (w *WebTransportGenerator) generateIncomingStreamHandler() string { } if (data) { - this.emit('incomingUniStream', JSON.parse(data)); + this.emit('incomingUniStream', %s); } } catch (error) { this.emit('error', error); @@ -830,12 +1026,15 @@ func (w *WebTransportGenerator) generateIncomingStreamHandler() string { } } -` +`, biDiStreamName, wireDecodeExpr(uniReceiveCodecID, "JSON.parse(data)")) } -// generateStateManagement generates state management methods. -func (w *WebTransportGenerator) generateStateManagement() string { - return ` /** +// generateStateManagement generates state management methods. biDiStreamName +// is the same endpoint-specific class name generateIncomingStreamHandler uses +// (see its own doc comment) -- onIncomingBidiStream's handler signature must +// reference the same declared class. +func (w *WebTransportGenerator) generateStateManagement(uniReceiveType, biDiStreamName string) string { + return fmt.Sprintf(` /** * Register a handler for state changes. * @param handler - Function to call when state changes */ @@ -847,7 +1046,7 @@ func (w *WebTransportGenerator) generateStateManagement() string { * Register a handler for incoming bidirectional streams. * @param handler - Function to call when a bidi stream is received */ - onIncomingBidiStream(handler: (stream: BiDiStream) => void): void { + onIncomingBidiStream(handler: (stream: %s) => void): void { this.on('incomingBidiStream', handler); } @@ -855,7 +1054,7 @@ func (w *WebTransportGenerator) generateStateManagement() string { * Register a handler for incoming unidirectional stream data. * @param handler - Function to call when uni stream data is received */ - onIncomingUniStream(handler: (data: any) => void): void { + onIncomingUniStream(handler: (data: %s) => void): void { this.on('incomingUniStream', handler); } @@ -867,7 +1066,7 @@ func (w *WebTransportGenerator) generateStateManagement() string { this.on('close', handler); } -` +`, biDiStreamName, uniReceiveType) } // generateErrorHandling generates error handling methods. diff --git a/internal/client/generators/typescript/webtransport_codec_test.go b/internal/client/generators/typescript/webtransport_codec_test.go new file mode 100644 index 00000000..4131fba6 --- /dev/null +++ b/internal/client/generators/typescript/webtransport_codec_test.go @@ -0,0 +1,406 @@ +package typescript + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// wtSpec returns a fresh spec with one WebTransport endpoint declaring all +// three schema kinds (bidirectional stream, unidirectional stream, and +// datagram), every one of them a direct $ref to User -- the same schema +// wsSSESpec() uses for its WebSocket/SSE endpoints, so this exercises the +// identical regression (a spec-derived, camelCase-declared type cast straight +// over a snake_case wire) across the third streaming transport this package +// generates. Built from baseSpec() so it shares the REST/schema shape every +// other fixture in this package uses. +func wtSpec() *client.APISpec { + spec := baseSpec() + spec.WebTransports = []client.WebTransportEndpoint{ + { + ID: "data", + Path: "/wt/data", + Summary: "Data WebTransport", + BiStreamSchema: &client.StreamSchema{ + SendSchema: &client.Schema{Ref: "#/components/schemas/User"}, + ReceiveSchema: &client.Schema{Ref: "#/components/schemas/User"}, + }, + UniStreamSchema: &client.StreamSchema{ + SendSchema: &client.Schema{Ref: "#/components/schemas/User"}, + ReceiveSchema: &client.Schema{Ref: "#/components/schemas/User"}, + }, + DatagramSchema: &client.Schema{Ref: "#/components/schemas/User"}, + }, + } + + return spec +} + +// wtFakeSetup installs a fake global WebTransport class before webtransport.ts +// is ever imported, mirroring wsSSEFakeSetup's own module-eval-order +// reasoning (streaming_codec_test.go): webtransport.go computes +// `const isWebTransportSupported = typeof WebTransport !== 'undefined'` at +// MODULE-EVAL time, once, the moment the module is first evaluated, so the +// fake must exist as a global before that happens. Unlike WebSocket/ +// EventSource (attached to a faked `window`), WebTransport is checked as a +// bare global identifier, so the fake is assigned directly to globalThis. +// +// FakeWebTransport implements just enough of the real WebTransport surface +// for the driver below to exercise every one of this task's Finding-1 +// scenarios without a real network: `datagrams` (readable+writable, with +// test hooks to push an incoming datagram and capture outgoing ones), +// `createBidirectionalStream()` (a readable+writable pair, with test hooks to +// push incoming bytes and capture outgoing ones), and +// `incomingUnidirectionalStreams` (a readable stream of readable streams, so +// the driver can push one server-initiated uni-stream and read what +// processIncomingUniStream emits from it). +const wtFakeSetup = ` +class FakeWebTransport { + ready: Promise; + closed: Promise; + datagrams: { readable: ReadableStream; writable: WritableStream }; + incomingUnidirectionalStreams: ReadableStream>; + incomingBidirectionalStreams: ReadableStream; + + sentDatagrams: Uint8Array[] = []; + sentBidiData: Uint8Array[] = []; + + private datagramController!: ReadableStreamDefaultController; + private uniStreamController!: ReadableStreamDefaultController>; + bidiReadController!: ReadableStreamDefaultController; + + constructor(public url: string) { + (globalThis as any).__lastFakeWT = this; + + this.ready = Promise.resolve(); + this.closed = new Promise(() => {}); // never resolves during the test + + const sentDatagrams = this.sentDatagrams; + this.datagrams = { + writable: new WritableStream({ + write: (chunk) => { + sentDatagrams.push(chunk); + }, + }), + readable: new ReadableStream({ + start: (controller) => { + this.datagramController = controller; + }, + }), + }; + + this.incomingUnidirectionalStreams = new ReadableStream>({ + start: (controller) => { + this.uniStreamController = controller; + }, + }); + + this.incomingBidirectionalStreams = new ReadableStream({ start: () => {} }); + } + + pushDatagram(bytes: Uint8Array): void { + this.datagramController.enqueue(bytes); + } + + pushUniStream(bytes: Uint8Array): void { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + this.uniStreamController.enqueue(stream); + } + + createBidirectionalStream(): Promise { + const sentBidiData = this.sentBidiData; + let readController!: ReadableStreamDefaultController; + const readable = new ReadableStream({ + start(controller) { + readController = controller; + }, + }); + this.bidiReadController = readController; + const writable = new WritableStream({ + write(chunk) { + sentBidiData.push(chunk); + }, + }); + return Promise.resolve({ readable, writable }); + } + + createUnidirectionalStream(): Promise> { + return Promise.resolve(new WritableStream({ write: () => {} })); + } + + close(): void {} +} + +(globalThis as any).WebTransport = FakeWebTransport; +` + +// TestWebTransportDecodeEncodeWirePayloads is the runtime execution proof +// that Finding 1 (task 5c) is fixed: webtransport.go previously cast every +// incoming/outgoing payload straight through JSON.parse/JSON.stringify with +// no rename at all -- across datagrams (sendDatagram/receiveDatagram), the +// bidirectional stream wrapper (BiDiStream.send/receive), and incoming +// unidirectional streams (the 'incomingUniStream' event) -- so a spec-derived +// message type declaring camelCase properties (types.User's userId) lied +// about what was actually on the wire (snake_case: user_id). +// +// Drives REAL generated code (webtransport.ts's DataWTClient, from +// wtSpec()'s User-typed WebTransport endpoint) under Node via esbuild, +// exactly as streaming_codec_test.go's own WS/SSE driver does -- not +// encode()/decode() called directly. +func TestWebTransportDecodeEncodeWirePayloads(t *testing.T) { + spec := wtSpec() + config := baseConfig() // NamingCamel by default -- codecsNeeded(config) == true + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + require.Contains(t, out.Files, "src/webtransport.ts") + require.Contains(t, out.Files, "src/codecs.ts") + assert.Empty(t, out.Warnings, "every schema here is a direct $ref, so no warning should fire") + + wt := out.Files["src/webtransport.ts"] + + // Sanity checks on the fix's shape before ever running anything: the + // raw, unrenamed casts from the regression must be gone, replaced by + // decode()/encode() calls referencing the User codec id. + assert.Contains(t, wt, "import { decode, encode } from './codecs';") + assert.NotContains(t, wt, "JSON.parse(text);", "the raw, un-decoded datagram cast from the regression must be gone") + assert.NotContains(t, wt, "JSON.parse(result);", "the raw, un-decoded BiDiStream.receive cast from the regression must be gone") + assert.NotContains(t, wt, "JSON.parse(data));", "the raw, un-decoded incomingUniStream cast from the regression must be gone") + assert.Contains(t, wt, `decode(JSON.parse(text), "User")`, "receiveDatagram/receiveDatagrams must decode via the User codec") + assert.Contains(t, wt, `encode(msg, "User")`, "sendDatagram/sendDatagramSync/BiDiStream.send/UniStream.send must encode via the User codec") + assert.Contains(t, wt, `decode(JSON.parse(result), "User")`, "BiDiStream.receive must decode via the User codec") + assert.Contains(t, wt, `decode(JSON.parse(line), "User")`, "BiDiStream.receiveIterator must decode via the User codec") + assert.Contains(t, wt, `decode(JSON.parse(data), "User")`, "processIncomingUniStream must decode via the User codec") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + writeTree(t, dir, map[string]string{"src/__setup_wt.ts": wtFakeSetup}) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("generated webtransport client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } + + driver := ` +import './__setup_wt'; +import { DataWTClient } from './webtransport'; + +async function main() { + const wt = new DataWTClient({ baseURL: 'http://example.invalid' }); + await wt.connect(); + + const fake = (globalThis as any).__lastFakeWT; + + // --- Datagram: incoming decodes, outgoing encodes, unknown keys survive both directions. --- + fake.pushDatagram(new TextEncoder().encode(JSON.stringify({ user_id: 'x', unknown_wire_key: 'w1' }))); + const receivedDatagram = await wt.receiveDatagram(); + + await wt.sendDatagram({ userId: 'y', unknownClientKey: 'w2' } as any); + const sentDatagramRaw = JSON.parse(new TextDecoder().decode(fake.sentDatagrams[0])); + + // --- BiDiStream: outgoing send encodes, incoming receive decodes, unknown keys survive. --- + const bidi = await wt.openBidiStream(); + + await bidi.send({ userId: 'y2', unknownClientKey: 'w4' } as any); + const sentBidiRaw = JSON.parse(new TextDecoder().decode(fake.sentBidiData[0])); + + fake.bidiReadController.enqueue(new TextEncoder().encode(JSON.stringify({ user_id: 'z', unknown_wire_key: 'w3' }))); + fake.bidiReadController.close(); + const receivedBidi = await bidi.receive(); + + // --- incomingUniStream: server-initiated uni-stream decodes, unknown key survives. --- + const uniPromise = new Promise((resolve) => { + wt.onIncomingUniStream((data: any) => resolve(data)); + }); + fake.pushUniStream(new TextEncoder().encode(JSON.stringify({ user_id: 'u', unknown_wire_key: 'w5' }))); + const receivedUni = await uniPromise; + + console.log(JSON.stringify({ + receivedDatagram, + sentDatagramRaw, + sentBidiRaw, + receivedBidi, + receivedUni, + })); + + wt.close(); +} + +main().catch((err) => { + console.error(err); + throw err; +}); +` + writeTree(t, dir, map[string]string{"src/__driver_wt.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_wt.ts") + + var result struct { + ReceivedDatagram map[string]any `json:"receivedDatagram"` + SentDatagramRaw map[string]any `json:"sentDatagramRaw"` + SentBidiRaw map[string]any `json:"sentBidiRaw"` + ReceivedBidi map[string]any `json:"receivedBidi"` + ReceivedUni map[string]any `json:"receivedUni"` + } + decodeLastLine(t, stdout, &result) + + // 1. Incoming datagram decodes wire -> camelCase; unknown key survives. + assert.Equal(t, "x", result.ReceivedDatagram["userId"], "wire user_id must decode to camelCase userId; stdout:\n%s", stdout) + assert.NotContains(t, result.ReceivedDatagram, "user_id", "the wire-cased key must not survive decode; stdout:\n%s", stdout) + assert.Equal(t, "w1", result.ReceivedDatagram["unknown_wire_key"], "an unrecognized key must pass through decode untouched; stdout:\n%s", stdout) + + // 2. Outgoing datagram encodes camelCase -> wire; unknown key survives. + assert.Equal(t, "y", result.SentDatagramRaw["user_id"], "camelCase userId must encode to wire user_id; stdout:\n%s", stdout) + assert.NotContains(t, result.SentDatagramRaw, "userId", "the camelCase key must not leak onto the wire; stdout:\n%s", stdout) + assert.Equal(t, "w2", result.SentDatagramRaw["unknownClientKey"], "an unrecognized key must pass through encode untouched; stdout:\n%s", stdout) + + // 3. BiDiStream.send encodes camelCase -> wire; unknown key survives. + assert.Equal(t, "y2", result.SentBidiRaw["user_id"], "camelCase userId must encode to wire user_id over BiDiStream.send; stdout:\n%s", stdout) + assert.NotContains(t, result.SentBidiRaw, "userId", "the camelCase key must not leak onto the wire over BiDiStream.send; stdout:\n%s", stdout) + assert.Equal(t, "w4", result.SentBidiRaw["unknownClientKey"], "an unrecognized key must pass through BiDiStream.send encode untouched; stdout:\n%s", stdout) + + // 4. BiDiStream.receive decodes wire -> camelCase; unknown key survives. + assert.Equal(t, "z", result.ReceivedBidi["userId"], "wire user_id must decode to camelCase userId over BiDiStream.receive; stdout:\n%s", stdout) + assert.NotContains(t, result.ReceivedBidi, "user_id", "the wire-cased key must not survive BiDiStream.receive decode; stdout:\n%s", stdout) + assert.Equal(t, "w3", result.ReceivedBidi["unknown_wire_key"], "an unrecognized key must pass through BiDiStream.receive decode untouched; stdout:\n%s", stdout) + + // 5. incomingUniStream decodes wire -> camelCase; unknown key survives. + assert.Equal(t, "u", result.ReceivedUni["userId"], "wire user_id must decode to camelCase userId over incomingUniStream; stdout:\n%s", stdout) + assert.NotContains(t, result.ReceivedUni, "user_id", "the wire-cased key must not survive incomingUniStream decode; stdout:\n%s", stdout) + assert.Equal(t, "w5", result.ReceivedUni["unknown_wire_key"], "an unrecognized key must pass through incomingUniStream decode untouched; stdout:\n%s", stdout) +} + +// TestPreserveNamingSkipsCodecsInWebTransport is the "preserve" gate's +// WebTransport counterpart to streaming_codec_test.go's +// TestPreserveNamingSkipsCodecsInStreamingWSAndSSE: under NamingPreserve with +// no FieldOverrides, codecsNeeded(config) is false, src/codecs.ts is never +// emitted at all, and webtransport.ts must not import from it or reference +// encode()/decode() -- otherwise generated output fails tsc with TS2307 +// ("Cannot find module './codecs'"). Proves the gating this task's brief +// specifically called out (codecs.go's codecsNeeded doc comment) holds for +// webtransport.ts too, not just websocket.ts/sse.ts (already covered) or +// fetch.ts (covered by the "preserve" gate fixture). +func TestPreserveNamingSkipsCodecsInWebTransport(t *testing.T) { + spec := wtSpec() + config := preserveConfig() + require.False(t, codecsNeeded(config), "sanity check: preserveConfig() must not need codecs") + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + require.NotContains(t, out.Files, "src/codecs.ts", "sanity check: codecs.ts must not be emitted under NamingPreserve") + + wt := out.Files["src/webtransport.ts"] + assert.NotContains(t, wt, "./codecs", "webtransport.ts must not import from ./codecs under NamingPreserve") + // Note: a blanket NotContains(wt, "decode(")/NotContains(wt, "encode(") + // would false-positive on the Web APIs this file legitimately calls -- + // `new TextEncoder().encode(...)` and `new TextDecoder().decode(...)` -- + // which have nothing to do with this package's own encode()/decode() + // codec functions. Checking for the codec table's own call shape + // (`decode(, "")` / `encode(, "")`) is what actually + // distinguishes "codec machinery referenced" from "the Encoding API used + // as always". + assert.NotContains(t, wt, `decode(JSON.parse`, "webtransport.ts must not call this package's decode() under NamingPreserve") + assert.NotContains(t, wt, `encode(msg,`, "webtransport.ts must not call this package's encode() under NamingPreserve") + + // The payload casts must be exactly the pre-codec raw JSON.parse/ + // JSON.stringify shape -- passthrough, not renamed (NamingPreserve means + // no renaming, not "renamed to the same name"). + assert.Contains(t, wt, "const data = encoder.encode(JSON.stringify(msg));") + assert.Contains(t, wt, "return JSON.parse(text);") + assert.Contains(t, wt, "return JSON.parse(result);") + assert.Contains(t, wt, "this.emit('incomingUniStream', JSON.parse(data));") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("preserve-naming webtransport client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } +} + +// TestPreserveNamingWithFieldOverridesUsesCodecsInWebTransport proves the +// gate is codecsNeeded(config), not a raw effectiveFieldNaming(config) == +// NamingPreserve check: a config that sets NamingPreserve but ALSO +// configures a FieldOverrides entry still needs the codec machinery live +// (see codecsNeeded's doc comment, fieldname.go), so webtransport.ts must +// import './codecs' and reference encode()/decode() in this configuration -- +// mirroring the same proof point tests already establish for websocket.ts/ +// sse.ts and fetch.ts elsewhere in this package. +func TestPreserveNamingWithFieldOverridesUsesCodecsInWebTransport(t *testing.T) { + spec := wtSpec() + config := preserveConfig() + config.FieldOverrides = map[string]string{"User.user_id": "userId"} + require.True(t, codecsNeeded(config), "sanity check: a FieldOverrides entry must keep codecsNeeded true even under NamingPreserve") + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + require.Contains(t, out.Files, "src/codecs.ts", "sanity check: codecs.ts must be emitted when FieldOverrides is non-empty") + + wt := out.Files["src/webtransport.ts"] + assert.Contains(t, wt, "import { decode, encode } from './codecs';") + assert.Contains(t, wt, `decode(JSON.parse(text), "User")`) + assert.Contains(t, wt, `encode(msg, "User")`) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("preserve-with-overrides webtransport client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } +} + +// TestHardcodedStreamingTypesUntouchedByWebTransportCodecFix is +// streaming_codec_test.go's TestHardcodedStreamingTypesUntouchedByCodecFix +// re-run against a spec that ALSO declares a WebTransport endpoint (wtSpec() +// builds on baseSpec(), same as wsSSESpec()), pinning that this task's +// WebTransport-specific fix touches none of generateStreamingTypes' hardcoded +// Message/Member/Room/RoomOptions/HistoryQuery/UserPresence interfaces -- +// exactly the boundary the task brief calls out as deliberately untouched. +func TestHardcodedStreamingTypesUntouchedByWebTransportCodecFix(t *testing.T) { + spec := wtSpec() + config := baseConfig() // EnableRooms/EnablePresence/EnableHistory all default true + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + types := out.Files["src/types.ts"] + + for _, want := range []string{ + "export interface Message {", + " room_id: string;", + " user_id?: string;", + "export interface Member {", + " user_id: string;", + " display_name?: string;", + " avatar_url?: string;", + " joined_at?: string;", + "export interface Room {", + " created_by?: string;", + " created_at?: string;", + "export interface RoomOptions {", + " max_members?: number;", + " is_private?: boolean;", + "export interface HistoryQuery {", + " before_id?: string;", + " after_id?: string;", + "export interface UserPresence {", + " userId: string;", + " customMessage?: string;", + " lastSeen?: string;", + " roomId?: string;", + } { + assert.Contains(t, types, want, "hardcoded streaming type field must be byte-identical to the pre-fix generator output, even with a WebTransport endpoint present") + } +} From 91ba992e9bdfa22b8a7311e2571593452f37df2c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 22:05:04 -0500 Subject: [PATCH 63/71] fix(client/typescript): register array-of-$ref codecs for streaming endpoints schemaCodecRef resolves `{type: array, items: $ref X}` to the id "[]X" for a WebSocket/SSE/WebTransport schema exactly as it does for a REST endpoint body/response, but registerEndpointArrayBodyCodecs only ever walked spec.Endpoints to actually register that id in the codec table. spec.WebSockets/spec.SSEs/spec.WebTransports were never walked, so messageCodecRef/sseEventCodecRef/wtCodecRef would resolve "[]X" with no warning at all -- schemaCodecRef isn't wrong, only silent about what the table actually contains -- and decode()/encode() found nothing under that id at runtime, silently passing the array through unrenamed. Whether this manifested depended entirely on an unrelated REST endpoint existing elsewhere in the same spec: if any other endpoint happened to return/accept the same item type as an array, the REST-only walk would register the id as a side effect and the streaming endpoint would work by accident, despite its own generated source being byte-identical either way. Fixed by registering array-of-$ref schemas from WebSocket send/receive schemas, every SSE event schema, and WebTransport bidirectional/ unidirectional stream and datagram schemas -- the same narrow shape and the same idempotent register() closure the REST-endpoint walk already uses, not new logic. Chosen over rejecting the array shape and warning instead: this is the single most common OpenAPI "list of X" wire shape, and registering makes every such endpoint actually work rather than leaving it permanently un-renamed with a diagnostic. --- .../codec_arrayref_streaming_test.go | 185 ++++++++++++++++++ .../client/generators/typescript/codecs.go | 48 +++++ 2 files changed, 233 insertions(+) create mode 100644 internal/client/generators/typescript/codec_arrayref_streaming_test.go diff --git a/internal/client/generators/typescript/codec_arrayref_streaming_test.go b/internal/client/generators/typescript/codec_arrayref_streaming_test.go new file mode 100644 index 00000000..a83f4d01 --- /dev/null +++ b/internal/client/generators/typescript/codec_arrayref_streaming_test.go @@ -0,0 +1,185 @@ +package typescript + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge/internal/client" +) + +// arrayRefWSSpec returns a minimal spec with exactly one WebSocket endpoint +// whose SendSchema and ReceiveSchema are both `{type: array, items: $ref +// User}` -- the single most common OpenAPI "list of X" wire shape +// (arrayRefCodecID's own doc comment, codecs.go), and the shape Finding 2 +// (task 5c review) identified as silently un-registered for WebSocket/SSE/ +// WebTransport endpoints. +// +// When includeUnrelatedListEndpoint is true, an unrelated REST endpoint +// (GET /users -> User[]) is added -- one that has NOTHING to do with the +// WebSocket endpoint above, beyond sharing the User schema. Before this +// task's fix, THIS unrelated endpoint was the only thing that could ever +// cause registerEndpointArrayBodyCodecs to register the "[]User" entry (it +// only ever walked spec.Endpoints), so whether the WebSocket payload actually +// got renamed at runtime depended entirely on whether some unrelated REST +// endpoint happened to also return/accept an array of the same item schema -- +// exactly the non-obvious, spooky-action-at-a-distance bug this test pins +// closed. +func arrayRefWSSpec(includeUnrelatedListEndpoint bool) *client.APISpec { + user := &client.Schema{ + Type: "object", + Required: []string{"id"}, + Properties: map[string]*client.Schema{ + "id": {Type: "string"}, + "user_id": {Type: "string"}, + }, + } + + spec := &client.APISpec{ + Info: client.APIInfo{Title: "Array Ref WS API", Version: "1.0.0"}, + Schemas: map[string]*client.Schema{"User": user}, + WebSockets: []client.WebSocketEndpoint{ + { + ID: "userList", + Path: "/ws/users", + SendSchema: &client.Schema{Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + ReceiveSchema: &client.Schema{Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}, + }, + }, + } + + if includeUnrelatedListEndpoint { + spec.Endpoints = []client.Endpoint{ + { + Method: "GET", Path: "/users", OperationID: "users.list", + Responses: map[int]*client.Response{200: {Content: map[string]*client.MediaType{ + "application/json": {Schema: &client.Schema{Type: "array", Items: &client.Schema{Ref: "#/components/schemas/User"}}}}}}, + }, + } + } + + return spec +} + +// TestArrayRefWSSchemaCodecRegisteredRegardlessOfUnrelatedRESTEndpoint is the +// regression test for Finding 2 (task 5c review): schemaCodecRef (rest.go) +// resolves `{type: array, items: $ref User}` to the id "[]User" for +// messageCodecRef (websocket.go) exactly as it does for an ordinary REST +// endpoint body/response, but registerEndpointArrayBodyCodecs (codecs.go) +// previously only ever walked spec.Endpoints to actually REGISTER that id in +// the codec table -- spec.WebSockets/spec.SSEs were never walked. That left +// "[]User" resolving to a real id with NO warning (schemaCodecRef legitimately +// recognises the shape) and NO table entry either, so decode()/encode() +// silently found nothing under "[]User" and passed the array through +// completely unrenamed -- a defect invisible from websocket.ts's own source +// (byte-identical either way) and only reachable by checking codecs.ts and +// actual runtime behaviour. +// +// Measured BEFORE this task's fix (quoting the task brief's own reproduction): +// +// WITHOUT an unrelated GET /users -> User[]: codecs.ts has "[]User"=false, warnings=[] +// runtime: {"decoded":[{"user_id":"x"}],"encoded":[{"userId":"y"}]} <- no rename +// WITH an unrelated GET /users -> User[]: codecs.ts has "[]User"=true, warnings=[] +// runtime: {"decoded":[{"userId":"x"}],"encoded":[{"user_id":"y"}]} <- renamed +// +// This test drives BOTH variants and asserts they now behave IDENTICALLY -- +// both renamed, regardless of whether the unrelated REST endpoint exists. +func TestArrayRefWSSchemaCodecRegisteredRegardlessOfUnrelatedRESTEndpoint(t *testing.T) { + for _, tc := range []struct { + name string + includeUnrelatedList bool + }{ + {"without unrelated REST endpoint", false}, + {"with unrelated REST endpoint", true}, + } { + t.Run(tc.name, func(t *testing.T) { + spec := arrayRefWSSpec(tc.includeUnrelatedList) + config := baseConfig() + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + require.Contains(t, out.Files, "src/codecs.ts") + require.Contains(t, out.Files, "src/websocket.ts") + assert.Empty(t, out.Warnings, "the array-of-$ref shape resolves cleanly; no warning should fire either way") + + codecs := out.Files["src/codecs.ts"] + assert.Contains(t, codecs, `"[]User":`, + "the \"[]User\" entry must be registered in the codec table regardless of whether an unrelated REST endpoint also references User[]") + + ws := out.Files["src/websocket.ts"] + assert.Contains(t, ws, `decode(JSON.parse(data), "[]User")`) + assert.Contains(t, ws, `encode(message, "[]User")`) + + dir := t.TempDir() + writeTree(t, dir, out.Files) + writeTree(t, dir, map[string]string{"src/__setup_ws_sse.ts": wsSSEFakeSetup}) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("generated array-ref WS client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } + + driver := ` +import './__setup_ws_sse'; +import { UserListWSClient } from './websocket'; + +async function main() { + const wsClient = new UserListWSClient({ baseURL: 'http://example.invalid' }); + await wsClient.connect(); + + const fakeWS = (globalThis as any).__lastFakeWS; + + const received: any[] = []; + wsClient.onMessage((msg) => received.push(msg)); + + // Wire frame: an ARRAY of snake_case items. + fakeWS.onmessage({ + data: JSON.stringify([{ id: 'i', user_id: 'x' }]), + }); + + // Outgoing: an ARRAY of camelCase items. + await wsClient.send([{ id: 'i', userId: 'y' }] as any); + + console.log(JSON.stringify({ + decoded: received, + encoded: fakeWS.sent.map((s: string) => JSON.parse(s)), + })); + + wsClient.close(); +} + +main().catch((err) => { + console.error(err); + throw err; +}); +` + writeTree(t, dir, map[string]string{"src/__driver_arrayref_ws.ts": driver}) + + stdout := runNodeDriver(t, dir, "src/__driver_arrayref_ws.ts") + + var result struct { + // Each of these is a []Message, where a Message is itself an + // ARRAY of User items (SendSchema/ReceiveSchema are both + // `{type: array, items: $ref User}`) -- one message was sent + // on each side, so exactly one element is expected at the + // outer level, itself wrapping the one-item array payload. + Decoded [][]map[string]any `json:"decoded"` + Encoded [][]map[string]any `json:"encoded"` + } + decodeLastLine(t, stdout, &result) + + require.Len(t, result.Decoded, 1, "stdout:\n%s", stdout) + require.Len(t, result.Decoded[0], 1, "the decoded message must contain exactly one item; stdout:\n%s", stdout) + assert.Equal(t, "x", result.Decoded[0][0]["userId"], + "each item of an array-of-$ref WS message must decode (user_id -> userId), regardless of an unrelated REST endpoint; stdout:\n%s", stdout) + + require.Len(t, result.Encoded, 1, "stdout:\n%s", stdout) + require.Len(t, result.Encoded[0], 1, "the encoded message must contain exactly one item; stdout:\n%s", stdout) + assert.Equal(t, "y", result.Encoded[0][0]["user_id"], + "each item of an array-of-$ref WS message must encode (userId -> user_id), regardless of an unrelated REST endpoint; stdout:\n%s", stdout) + }) + } +} diff --git a/internal/client/generators/typescript/codecs.go b/internal/client/generators/typescript/codecs.go index a085e163..cdc20183 100644 --- a/internal/client/generators/typescript/codecs.go +++ b/internal/client/generators/typescript/codecs.go @@ -205,6 +205,54 @@ func registerEndpointArrayBodyCodecs(table *codecTable, spec *client.APISpec) { } } } + + // WebSocket/SSE/WebTransport message, event, and datagram schemas are + // endpoint-boundary shapes exactly like the REST bodies/responses above -- + // not walked by CodecGenerator.Generate's spec.Schemas loop either -- so + // without this, an array-of-$ref WS/SSE/WebTransport schema (e.g. + // `{type: array, items: $ref User}`) leaves messageCodecRef + // (websocket.go), sseEventCodecRef (sse.go), and wtCodecRef + // (webtransport.go) resolving "[]User" via schemaCodecRef -- which + // legitimately recognises the shape -- against a codec table that never + // registered it. That produced no warning at all (schemaCodecRef isn't + // wrong, only silent about what's actually in the table), and + // decode()/encode() found nothing under "[]User" and passed the payload + // through completely unrenamed. Registering here, the same narrow shape + // as the REST loop above, is what makes rest.go's schemaCodecRef and this + // table agree for streaming endpoints too -- the alternative (rejecting + // the array shape in messageCodecRef/sseEventCodecRef/wtCodecRef and + // warning instead) would leave every such WS/SSE/WebTransport endpoint + // silently un-renamed at runtime, which is a strictly worse outcome for a + // shape this common ("list of X" over a stream) than simply registering + // it like every other array-of-$ref boundary already is. + for i := range spec.WebSockets { + ws := &spec.WebSockets[i] + register(ws.SendSchema) + register(ws.ReceiveSchema) + } + + for i := range spec.SSEs { + sse := &spec.SSEs[i] + for _, name := range sortedKeys(sse.EventSchemas) { + register(sse.EventSchemas[name]) + } + } + + for i := range spec.WebTransports { + wt := &spec.WebTransports[i] + + if wt.BiStreamSchema != nil { + register(wt.BiStreamSchema.SendSchema) + register(wt.BiStreamSchema.ReceiveSchema) + } + + if wt.UniStreamSchema != nil { + register(wt.UniStreamSchema.SendSchema) + register(wt.UniStreamSchema.ReceiveSchema) + } + + register(wt.DatagramSchema) + } } // additionalPropertiesSegment is the synthetic path segment used for an From 0cc62ab3e3bc742bf6ce7df372f0c0fb7d3624cc Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 22:05:15 -0500 Subject: [PATCH 64/71] test(client/typescript): add a NamingPreserve x WS/SSE gate fixture Nothing in the tsc gate corpus previously ran a real compile against websocket.ts/sse.ts under NamingPreserve: the existing "preserve" fixture has no streaming endpoints at all, and "ws-sse"/"no-auth-ws-sse" both use the default NamingCamel. That left the codecsNeeded gating those two generators rely on (no ./codecs import, no encode()/decode() call sites, under preserve) covered only by string assertions on the generated output, never by an actual tsc run proving the un-gated fallback path still type-checks on its own. Adds "preserve-ws-sse" (wsSSESpec() under preserveConfig()), crossing the naming axis with the streaming axis the same way "no-auth-ws-sse" already crosses the auth axis. Fixture count moves 10 -> 11. --- .../generators/typescript/fixtures_test.go | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index c6590c41..ae87ed50 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -10,7 +10,7 @@ import ( ) func TestGateFixturesCoverKnownDefects(t *testing.T) { - want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse", "no-auth-ws-sse", "allof", "preserve"} + want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse", "no-auth-ws-sse", "allof", "preserve", "preserve-ws-sse"} fixtures := gateFixtures() @@ -30,11 +30,12 @@ func TestGateFixturesCoverKnownDefects(t *testing.T) { // name in `want` still happens to be present -- e.g. a duplicate name // masking a missing one, or an unrelated future edit deleting an // unnamed fixture this list never tracked. Task 7 grew the corpus from - // 9 fixtures to 10 (the "preserve" fixture above); this number must - // move in lockstep with `want` and with gateFixtures' own fixture + // 9 fixtures to 10 (the "preserve" fixture); task 5c (finding 2 review) + // grows it to 11 (the "preserve-ws-sse" fixture below) -- this number + // must move in lockstep with `want` and with gateFixtures' own fixture // literal. - if len(fixtures) != 10 { - t.Errorf("expected exactly 10 gate fixtures, got %d: %v", len(fixtures), fixtureNames(fixtures)) + if len(fixtures) != 11 { + t.Errorf("expected exactly 11 gate fixtures, got %d: %v", len(fixtures), fixtureNames(fixtures)) } } @@ -235,6 +236,19 @@ func gateFixtures() []gateFixture { // Task 7: NamingPreserve must type-check with no codecs.ts emitted // at all -- see preserveOddKeys' own doc comment above. {Name: "preserve", Spec: preserveOddKeys, Config: preserveConfig()}, + // Task 5c (finding 2 review): crosses NamingPreserve with WS/SSE, the + // same way "no-auth-ws-sse" crosses the auth axis with WS/SSE above. + // Before this fixture, nothing in the corpus ever ran tsc against + // websocket.ts/sse.ts under NamingPreserve -- "preserve" (above) has + // no streaming endpoints at all, and "ws-sse"/"no-auth-ws-sse" both + // use baseConfig()'s default NamingCamel. That gap meant the + // codecsNeeded gating streaming_codec_test.go added for websocket.ts + // and sse.ts (no './codecs' import, no encode()/decode() call sites, + // under preserve) was covered by unit assertions on the generated + // string, but never by an actual tsc run proving the un-gated + // fallback path (the raw `JSON.parse(data)`/`JSON.stringify(message)` + // casts) still type-checks on its own. + {Name: "preserve-ws-sse", Spec: wsSSESpec(), Config: preserveConfig()}, } } From 033537f9cf9c22a910bd3640fe463aa19af27207 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 22:32:35 -0500 Subject: [PATCH 65/71] fix(client/typescript): emit WebTransport stream classes unconditionally A WebTransport endpoint declaring ONLY a DatagramSchema -- arguably the most idiomatic WebTransport shape, since unreliable datagrams are the transport's headline feature -- generated a client with a dangling reference: src/webtransport.ts(393,45): error TS2304: Cannot find name 'DataWTClientBiDiStream'. src/webtransport.ts(460,42): error TS2304: Cannot find name 'DataWTClientBiDiStream'. handleIncomingBidiStreams and onIncomingBidiStream instantiate/type against `BiDiStream` unconditionally (an incoming server-initiated bidi stream can arrive regardless of this endpoint's own outgoing BiStreamSchema), but the class itself was only emitted when wt.BiStreamSchema != nil. A two-endpoint spec mixing a streaming endpoint with a datagram-only one reproduced the same defect against the wrong class name (TS2552, "did you mean"). Fixed by splitting generateBiStreamMethods/generateUniStreamMethods into a class generator (generateBiDiStreamClass/generateUniStreamClass, now called unconditionally, rendering send/receive as `any` when no schema is declared) and an open-method generator (generateOpenBidiStreamMethod/generateOpenUniStreamMethod, still gated on the schema being present -- opening a stream is this endpoint's own declared capability, not connection-level infrastructure every endpoint gets for free). Adding a real tsc check to TestTypeScriptGeneratorWebTransport (moved from generator_test.go, which cannot reach the unexported typeCheck/ writeTree helpers) surfaced a second, unrelated dangling reference: WebTransportClientConfig.auth?: types.AuthConfig was emitted unconditionally, while every other generator in this package (websocket.go, sse.go, rooms.go, presence.go, typing.go, channels.go, streaming_client.go, testing.go) gates the same field on config.IncludeAuth, because types.ts only emits AuthConfig when auth is enabled. Fixed to match. --- .../generators/typescript/generator_test.go | 128 +------- .../generators/typescript/webtransport.go | 185 ++++++++--- .../typescript/webtransport_codec_test.go | 293 ++++++++++++++++++ 3 files changed, 434 insertions(+), 172 deletions(-) diff --git a/internal/client/generators/typescript/generator_test.go b/internal/client/generators/typescript/generator_test.go index 1bc9a9c2..523b7783 100644 --- a/internal/client/generators/typescript/generator_test.go +++ b/internal/client/generators/typescript/generator_test.go @@ -534,128 +534,6 @@ func TestTypeScriptGeneratorValidation(t *testing.T) { } } -func TestTypeScriptGeneratorWebTransport(t *testing.T) { - spec := &client.APISpec{ - Info: client.APIInfo{ - Title: "WebTransport API", - Version: "1.0.0", - }, - WebTransports: []client.WebTransportEndpoint{ - { - ID: "data", - Path: "/wt/data", - Description: "Data WebTransport", - BiStreamSchema: &client.StreamSchema{ - SendSchema: &client.Schema{ - Type: "object", - Properties: map[string]*client.Schema{ - "action": {Type: "string"}, - "data": {Type: "string"}, - }, - }, - ReceiveSchema: &client.Schema{ - Type: "object", - Properties: map[string]*client.Schema{ - "result": {Type: "string"}, - "status": {Type: "string"}, - }, - }, - }, - UniStreamSchema: &client.StreamSchema{ - SendSchema: &client.Schema{ - Type: "object", - Properties: map[string]*client.Schema{ - "message": {Type: "string"}, - }, - }, - }, - DatagramSchema: &client.Schema{ - Type: "object", - Properties: map[string]*client.Schema{ - "ping": {Type: "string"}, - }, - }, - }, - }, - } - - config := client.GeneratorConfig{ - Language: "typescript", - OutputDir: "./wtclient", - PackageName: "@example/wtclient", - APIName: "WTClient", - BaseURL: "https://api.example.com", - Version: "1.0.0", - IncludeStreaming: true, - Features: client.Features{ - Reconnection: true, - StateManagement: true, - }, - } - - gen := typescript.NewGenerator() - - result, err := gen.Generate(context.Background(), spec, config) - if err != nil { - t.Fatalf("Generate failed: %v", err) - } - - // Check WebTransport file - wtCode, ok := result.Files["src/webtransport.ts"] - if !ok { - t.Fatal("webtransport.ts not found") - } - - // Check for expected content - expectedStrings := []string{ - "WebTransport", - "class", - "connect", - "openBidiStream", - "openUniStream", - "sendDatagram", - "receiveDatagram", - "close", - "WebTransportState", - "EventEmitter", - } - - for _, expected := range expectedStrings { - if !strings.Contains(wtCode, expected) { - t.Errorf("webtransport.ts should contain '%s'", expected) - } - } - - // Check for reconnection logic - if config.Features.Reconnection { - if !strings.Contains(wtCode, "reconnect") { - t.Error("webtransport.ts should contain reconnection logic") - } - } - - // Check for state management - if config.Features.StateManagement { - if !strings.Contains(wtCode, "onStateChange") { - t.Error("webtransport.ts should contain state management") - } - } - - // Verify BiDiStream and UniStream classes are generated. Each is a - // standalone, top-level `class` declaration named after this endpoint - // (className + "BiDiStream"/"UniStream"), not a bare "class BiDiStream"/ - // "class UniStream" -- generateWebTransportClient can no longer emit - // those as unqualified names spliced inside the outer client class body - // (a `class` statement is not a legal class-body member in TypeScript/ - // JavaScript, which made every previously-generated WebTransport client - // with a BiStreamSchema or UniStreamSchema a parse error, not just a - // type error -- neither tsc nor esbuild could ever have compiled or - // bundled it). Qualifying by endpoint name also avoids a redeclaration - // if a spec has more than one WebTransport endpoint. - if !strings.Contains(wtCode, "class DataWTClientBiDiStream") { - t.Error("webtransport.ts should contain the endpoint-qualified BiDiStream class") - } - - if !strings.Contains(wtCode, "class DataWTClientUniStream") { - t.Error("webtransport.ts should contain the endpoint-qualified UniStream class") - } -} +// TestTypeScriptGeneratorWebTransport moved to webtransport_codec_test.go +// (package typescript, not typescript_test): it now needs typeCheck/writeTree, +// which are unexported and unreachable from this external test package. diff --git a/internal/client/generators/typescript/webtransport.go b/internal/client/generators/typescript/webtransport.go index 1b1edd0e..ff668356 100644 --- a/internal/client/generators/typescript/webtransport.go +++ b/internal/client/generators/typescript/webtransport.go @@ -179,8 +179,25 @@ func (w *WebTransportGenerator) generateTypes(config client.GeneratorConfig) str buf.WriteString("export interface WebTransportClientConfig {\n") buf.WriteString(" /** Base URL for WebTransport connection */\n") buf.WriteString(" baseURL: string;\n") - buf.WriteString(" /** Authentication configuration */\n") - buf.WriteString(" auth?: types.AuthConfig;\n") + + // Gated on config.IncludeAuth exactly like every other generator in this + // package (websocket.go, sse.go, rooms.go, presence.go, typing.go, + // channels.go, streaming_client.go, testing.go, and generator.go's own + // ClientConfig.auth) -- generator.go's generateTypes (types.ts) only ever + // emits `export interface AuthConfig` when config.IncludeAuth is true + // (see its doc comment at generator.go:17-25), so an unconditional + // `auth?: types.AuthConfig;` reference here would dangle whenever a + // caller disables auth (e.g. the "no-auth-streaming"/"no-auth-ws-sse" + // fixtures' own config, or any hand-built GeneratorConfig that never sets + // IncludeAuth at all -- its zero value is false). Was unconditional + // before this fix; caught only once a real tsc run exercised a + // WebTransport client with auth disabled, which nothing in the corpus + // had ever done. + if config.IncludeAuth { + buf.WriteString(" /** Authentication configuration */\n") + buf.WriteString(" auth?: types.AuthConfig;\n") + } + buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n") buf.WriteString(" connectionTimeout?: number;\n") buf.WriteString(" /** Request timeout in ms (default: 10000) */\n") @@ -374,12 +391,18 @@ func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTranspor buf.WriteString(fmt.Sprintf(" let wtURL = this.config.baseURL.replace(/^http/, 'https') + '%s';\n\n", wt.Path)) - // Add auth to URL - buf.WriteString(" // Add auth to URL if provided\n") - buf.WriteString(" if (this.config.auth?.bearerToken) {\n") - buf.WriteString(" const separator = wtURL.includes('?') ? '&' : '?';\n") - buf.WriteString(" wtURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") - buf.WriteString(" }\n\n") + // Add auth to URL. Gated on config.IncludeAuth for the same reason the + // WebTransportClientConfig.auth field declaration itself is: referencing + // this.config.auth when the interface never declares an `auth` property + // (config.IncludeAuth false) is a dangling property access, not merely + // stylistic -- see generateTypes' own IncludeAuth gate. + if config.IncludeAuth { + buf.WriteString(" // Add auth to URL if provided\n") + buf.WriteString(" if (this.config.auth?.bearerToken) {\n") + buf.WriteString(" const separator = wtURL.includes('?') ? '&' : '?';\n") + buf.WriteString(" wtURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n") + buf.WriteString(" }\n\n") + } // Create connection with timeout buf.WriteString(" // Create transport with timeout\n") @@ -476,18 +499,41 @@ func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTranspor // otherwise redeclare a single top-level `class BiDiStream` twice). var auxClasses strings.Builder - // Bidirectional stream methods + // The BiDiStream and UniStream wrapper classes are ALWAYS emitted, for + // EVERY WebTransport endpoint, regardless of whether wt.BiStreamSchema/ + // wt.UniStreamSchema is nil. handleIncomingBidiStreams/ + // handleIncomingUniStreams below run unconditionally too (a connection + // can receive a bidi/uni stream the server opened, independent of + // whether THIS endpoint's own spec declares an outgoing schema for + // opening one itself -- see biDiStreamName's doc comment above), so they + // always need a class to instantiate. Before this, the class was only + // emitted when the corresponding schema was non-nil, which left every + // WebTransport endpoint that declares ONLY a DatagramSchema (the single + // most idiomatic WebTransport shape -- unreliable datagrams are the + // transport's headline feature) with a dangling reference to an + // undeclared class: `new DataWTClientBiDiStream(value)` with no + // `class DataWTClientBiDiStream` anywhere in the file (TS2304 "Cannot + // find name"). generateBiDiStreamClass/generateUniStreamClass accept a + // nil schema and render send/receive as `any` in that case -- the + // wrapper still WORKS at runtime (JSON.parse/JSON.stringify with no + // codec, exactly like any other unresolved schema), it just makes no + // renamed-shape promise the type checker has to honor. + // + // openBidiStream()/openUniStream() -- the methods that let THIS + // endpoint's own client CREATE a new outgoing stream -- remain gated on + // wt.BiStreamSchema/wt.UniStreamSchema != nil: unlike the incoming-stream + // handlers, opening a stream is this endpoint's own declared capability, + // not connection-level infrastructure every endpoint gets for free. + auxClasses.WriteString(w.generateBiDiStreamClass(wt.BiStreamSchema, spec, biSendCodecID, biReceiveCodecID, className)) + if wt.BiStreamSchema != nil { - methodCode, classCode := w.generateBiStreamMethods(wt.BiStreamSchema, spec, config, biSendCodecID, biReceiveCodecID, className) - buf.WriteString(methodCode) - auxClasses.WriteString(classCode) + buf.WriteString(w.generateOpenBidiStreamMethod(className)) } - // Unidirectional stream methods + auxClasses.WriteString(w.generateUniStreamClass(wt.UniStreamSchema, spec, uniSendCodecID, className)) + if wt.UniStreamSchema != nil { - methodCode, classCode := w.generateUniStreamMethods(wt.UniStreamSchema, spec, config, uniSendCodecID, className) - buf.WriteString(methodCode) - auxClasses.WriteString(classCode) + buf.WriteString(w.generateOpenUniStreamMethod(className)) } // Datagram methods with queue @@ -567,20 +613,17 @@ func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTranspor return buf.String() } -// generateBiStreamMethods generates bidirectional stream methods. The first -// return value is the openBidiStream() method body, meant to be embedded -// inside the outer client class; the second is the standalone -// `class BiDiStream { ... }` declaration, meant to be emitted -// AFTER the outer class's closing brace (see generateWebTransportClient's -// auxClasses doc comment for why these can no longer be spliced together the -// way they were before). -func (w *WebTransportGenerator) generateBiStreamMethods(schema *client.StreamSchema, spec *client.APISpec, config client.GeneratorConfig, sendCodecID, receiveCodecID, className string) (string, string) { - var buf strings.Builder - - sendType := w.getSchemaTypeName(schema.SendSchema, spec) - receiveType := w.getSchemaTypeName(schema.ReceiveSchema, spec) +// generateOpenBidiStreamMethod generates the openBidiStream() method body, +// meant to be embedded inside the outer client class. Only called when +// wt.BiStreamSchema != nil (see generateWebTransportClient) -- opening a new +// outgoing bidirectional stream is this endpoint's own declared capability, +// unlike the BiDiStream class itself (generateBiDiStreamClass), which is +// always emitted because incoming bidi streams are connection-level. +func (w *WebTransportGenerator) generateOpenBidiStreamMethod(className string) string { biDiStreamName := className + "BiDiStream" + var buf strings.Builder + buf.WriteString(" /**\n") buf.WriteString(" * Open a new bidirectional stream.\n") buf.WriteString(fmt.Sprintf(" * @returns Promise resolving to a %s instance\n", biDiStreamName)) @@ -601,9 +644,37 @@ func (w *WebTransportGenerator) generateBiStreamMethods(schema *client.StreamSch buf.WriteString(fmt.Sprintf(" return new %s(stream);\n", biDiStreamName)) buf.WriteString(" }\n\n") - // BiDiStream class -- a standalone top-level declaration (see this - // function's doc comment), not embedded in the caller's class body. - classCode := fmt.Sprintf(`/** + return buf.String() +} + +// generateBiDiStreamClass generates the standalone top-level +// `class BiDiStream { ... }` declaration, meant to be emitted +// AFTER the outer client class's closing brace (see generateWebTransportClient's +// auxClasses doc comment for why this can no longer be spliced inside it). +// +// Called UNCONDITIONALLY for every WebTransport endpoint, even when schema is +// nil: handleIncomingBidiStreams (generateIncomingStreamHandler) instantiates +// this class whenever the connection receives a server-initiated +// bidirectional stream, which can happen regardless of whether THIS +// endpoint's own spec declares an outgoing BiStreamSchema. A nil schema +// renders send/receive as "any" (getSchemaTypeName's own nil-schema +// behavior) and sendCodecID/receiveCodecID are "" in that case too (see +// generateWebTransportClient: they are only resolved when +// wt.BiStreamSchema != nil), so wireEncodeExpr/wireDecodeExpr degrade to the +// plain, un-decoded JSON.stringify/JSON.parse the "any" type makes no +// promise to rename anyway. +func (w *WebTransportGenerator) generateBiDiStreamClass(schema *client.StreamSchema, spec *client.APISpec, sendCodecID, receiveCodecID, className string) string { + biDiStreamName := className + "BiDiStream" + + sendType := "any" + receiveType := "any" + + if schema != nil { + sendType = w.getSchemaTypeName(schema.SendSchema, spec) + receiveType = w.getSchemaTypeName(schema.ReceiveSchema, spec) + } + + return fmt.Sprintf(`/** * Bidirectional stream wrapper for typed send/receive operations. */ class %s { @@ -692,21 +763,17 @@ class %s { } `, biDiStreamName, sendType, wireEncodeExpr(sendCodecID, "msg"), receiveType, wireDecodeExpr(receiveCodecID, "JSON.parse(result)"), receiveType, wireDecodeExpr(receiveCodecID, "JSON.parse(line)")) - - return buf.String(), classCode } -// generateUniStreamMethods generates unidirectional stream methods. Return -// values mirror generateBiStreamMethods': the openUniStream() method body -// (embedded in the outer client class) and the standalone -// `class UniStream { ... }` declaration (emitted after the outer -// class's closing brace). -func (w *WebTransportGenerator) generateUniStreamMethods(schema *client.StreamSchema, spec *client.APISpec, config client.GeneratorConfig, sendCodecID, className string) (string, string) { - var buf strings.Builder - - sendType := w.getSchemaTypeName(schema.SendSchema, spec) +// generateOpenUniStreamMethod generates the openUniStream() method body, +// meant to be embedded inside the outer client class. Only called when +// wt.UniStreamSchema != nil (see generateWebTransportClient) -- mirrors +// generateOpenBidiStreamMethod's own reasoning. +func (w *WebTransportGenerator) generateOpenUniStreamMethod(className string) string { uniStreamName := className + "UniStream" + var buf strings.Builder + buf.WriteString(" /**\n") buf.WriteString(" * Open a new unidirectional stream for sending.\n") buf.WriteString(fmt.Sprintf(" * @returns Promise resolving to a %s instance\n", uniStreamName)) @@ -727,9 +794,35 @@ func (w *WebTransportGenerator) generateUniStreamMethods(schema *client.StreamSc buf.WriteString(fmt.Sprintf(" return new %s(stream);\n", uniStreamName)) buf.WriteString(" }\n\n") - // UniStream class -- a standalone top-level declaration (see this - // function's doc comment), not embedded in the caller's class body. - classCode := fmt.Sprintf(`/** + return buf.String() +} + +// generateUniStreamClass generates the standalone top-level +// `class UniStream { ... }` declaration, meant to be emitted +// AFTER the outer client class's closing brace. +// +// Unlike generateBiDiStreamClass, UniStream has no unconditionally-emitted +// caller today: incoming unidirectional streams are handled directly as raw +// ReadableStream bytes by processIncomingUniStream +// (generateIncomingStreamHandler), which never constructs a UniStream +// instance -- only openUniStream() does, and that stays gated on +// wt.UniStreamSchema != nil. This is still called UNCONDITIONALLY, for +// symmetry with generateBiDiStreamClass and to keep +// generateWebTransportClient's aux-classes wiring uniform between the two +// wrapper kinds, rather than because a dangling reference has been observed +// here the way it was for BiDiStream. An unreferenced, un-exported top-level +// class is inert (dead code, not a compile error -- this package's +// tsconfig.json does not set noUnusedLocals), so this costs nothing when +// wt.UniStreamSchema is nil. +func (w *WebTransportGenerator) generateUniStreamClass(schema *client.StreamSchema, spec *client.APISpec, sendCodecID, className string) string { + uniStreamName := className + "UniStream" + + sendType := "any" + if schema != nil { + sendType = w.getSchemaTypeName(schema.SendSchema, spec) + } + + return fmt.Sprintf(`/** * Unidirectional stream wrapper for typed send operations. */ class %s { @@ -765,8 +858,6 @@ class %s { } `, uniStreamName, sendType, wireEncodeExpr(sendCodecID, "msg")) - - return buf.String(), classCode } // generateDatagramMethods generates datagram methods with offline queue. diff --git a/internal/client/generators/typescript/webtransport_codec_test.go b/internal/client/generators/typescript/webtransport_codec_test.go index 4131fba6..f2ece197 100644 --- a/internal/client/generators/typescript/webtransport_codec_test.go +++ b/internal/client/generators/typescript/webtransport_codec_test.go @@ -404,3 +404,296 @@ func TestHardcodedStreamingTypesUntouchedByWebTransportCodecFix(t *testing.T) { assert.Contains(t, types, want, "hardcoded streaming type field must be byte-identical to the pre-fix generator output, even with a WebTransport endpoint present") } } + +// wtDatagramOnlySpec returns a spec with a single WebTransport endpoint that +// declares ONLY a DatagramSchema -- no BiStreamSchema, no UniStreamSchema. +// This is arguably the single most idiomatic WebTransport shape: unreliable, +// low-latency datagrams are the transport's headline feature over +// bidirectional/unidirectional streams (which HTTP/2 or a plain WebSocket +// already cover). +func wtDatagramOnlySpec() *client.APISpec { + spec := baseSpec() + spec.WebTransports = []client.WebTransportEndpoint{ + { + ID: "data", + Path: "/wt/data", + Summary: "Datagram-only WebTransport", + DatagramSchema: &client.Schema{Ref: "#/components/schemas/User"}, + }, + } + + return spec +} + +// wtMixedEndpointsSpec returns a spec with TWO WebTransport endpoints: one +// declaring a BiStreamSchema (so its BiDiStream class has real content), and +// one declaring ONLY a DatagramSchema, sharing the same spec so both +// generated client classes coexist in a single webtransport.ts. +func wtMixedEndpointsSpec() *client.APISpec { + spec := baseSpec() + spec.WebTransports = []client.WebTransportEndpoint{ + { + ID: "data", + Path: "/wt/data", + Summary: "Bidirectional-stream WebTransport", + BiStreamSchema: &client.StreamSchema{ + SendSchema: &client.Schema{Ref: "#/components/schemas/User"}, + ReceiveSchema: &client.Schema{Ref: "#/components/schemas/User"}, + }, + }, + { + ID: "other", + Path: "/wt/other", + Summary: "Datagram-only WebTransport", + DatagramSchema: &client.Schema{Ref: "#/components/schemas/User"}, + }, + } + + return spec +} + +// TestWebTransportDatagramOnlyEndpointTypeChecks is the regression test for +// the coordinator's Important-1 finding on fix round 1: a WebTransport +// endpoint declaring ONLY a DatagramSchema previously left +// handleIncomingBidiStreams (generateIncomingStreamHandler, unconditionally +// emitted for every endpoint) referencing `new BiDiStream(value)` +// with NO `class BiDiStream` ever declared -- generateBiStreamMethods +// (now generateBiDiStreamClass) was only called when wt.BiStreamSchema != nil. +// +// Measured BEFORE this fix, via tsc against exactly this spec's generated +// output: +// +// src/webtransport.ts(393,45): error TS2304: Cannot find name 'DataWTClientBiDiStream'. +// src/webtransport.ts(460,42): error TS2304: Cannot find name 'DataWTClientBiDiStream'. +// +// This is not a type error a caller could reasonably work around -- it is a +// dangling reference to a class that plain doesn't exist anywhere in the +// file, for the single most idiomatic WebTransport shape (unreliable +// datagrams, the transport's headline feature). +func TestWebTransportDatagramOnlyEndpointTypeChecks(t *testing.T) { + spec := wtDatagramOnlySpec() + config := baseConfig() + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + wt := out.Files["src/webtransport.ts"] + + // The BiDiStream class must now be declared even though this endpoint's + // spec never mentions BiStreamSchema at all -- typed `any` since there is + // no schema to derive a real type from. + assert.Contains(t, wt, "class DataWTClientBiDiStream {") + assert.Contains(t, wt, "async send(msg: any): Promise {") + assert.NotContains(t, wt, "openBidiStream", "an endpoint with no BiStreamSchema must not expose openBidiStream() -- opening a bidi stream is a declared capability, not connection-level infrastructure") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("datagram-only webtransport client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } +} + +// TestWebTransportMixedEndpointsTypeCheck covers the coordinator's second +// Important-1 reproduction: a spec with one WebTransport endpoint declaring +// a BiStreamSchema and a SECOND declaring only a DatagramSchema. Both +// generated client classes coexist in the same webtransport.ts, so this also +// proves generateBiDiStreamClass's per-endpoint naming +// (`BiDiStream`) actually disambiguates rather than colliding. +// +// Measured BEFORE this fix: +// +// src/webtransport.ts(874,45): error TS2552: Cannot find name 'OtherWTClientBiDiStream'. Did you mean 'DataWTClientBiDiStream'? +// src/webtransport.ts(941,42): error TS2552: Cannot find name 'OtherWTClientBiDiStream'. Did you mean 'DataWTClientBiDiStream'? +func TestWebTransportMixedEndpointsTypeCheck(t *testing.T) { + spec := wtMixedEndpointsSpec() + config := baseConfig() + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + wt := out.Files["src/webtransport.ts"] + + assert.Contains(t, wt, "class DataWTClientBiDiStream {") + assert.Contains(t, wt, "class OtherWTClientBiDiStream {") + assert.Contains(t, wt, "async openBidiStream(): Promise") + assert.NotContains(t, wt, "openBidiStream(): Promise", "the datagram-only endpoint must not expose openBidiStream()") + + dir := t.TempDir() + writeTree(t, dir, out.Files) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("mixed-endpoint webtransport client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } +} + +// TestTypeScriptGeneratorWebTransport is a runtime-shape assertion PLUS a +// real tsc gate for the full bi+uni+datagram WebTransport client, moved here +// from generator_test.go (package typescript_test) because it now needs +// typeCheck/writeTree, both unexported. Before this task's fix-round-1, this +// test could ONLY ever have caught the codec/camelCase regression via string +// assertions -- it never actually ran tsc, so the pre-existing nested-class +// parse bug (Deviations, task-5c-report.md) shipped invisibly through every +// run of this exact test, on this exact spec, for as long as the generator +// has emitted a BiStreamSchema/UniStreamSchema. This is the "one typeCheck +// call that gives it teeth" the coordinator asked for. +// +// The spec's schemas are all INLINE objects (no $ref), so codecsNeeded(config) +// (true here -- Language: "typescript", no FieldNaming override) means +// wtCodecRef cannot resolve any of them to a codec-table id: it warns once +// per bidirectional-stream send/receive, once for the unidirectional-stream +// send, and once for the datagram -- four warnings total. Previously nothing +// asserted on out.Warnings at all. +func TestTypeScriptGeneratorWebTransport(t *testing.T) { + spec := &client.APISpec{ + Info: client.APIInfo{ + Title: "WebTransport API", + Version: "1.0.0", + }, + WebTransports: []client.WebTransportEndpoint{ + { + ID: "data", + Path: "/wt/data", + Description: "Data WebTransport", + BiStreamSchema: &client.StreamSchema{ + SendSchema: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "action": {Type: "string"}, + "data": {Type: "string"}, + }, + }, + ReceiveSchema: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "result": {Type: "string"}, + "status": {Type: "string"}, + }, + }, + }, + UniStreamSchema: &client.StreamSchema{ + SendSchema: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "message": {Type: "string"}, + }, + }, + }, + DatagramSchema: &client.Schema{ + Type: "object", + Properties: map[string]*client.Schema{ + "ping": {Type: "string"}, + }, + }, + }, + }, + } + + config := client.GeneratorConfig{ + Language: "typescript", + OutputDir: "./wtclient", + PackageName: "@example/wtclient", + APIName: "WTClient", + BaseURL: "https://api.example.com", + Version: "1.0.0", + IncludeStreaming: true, + Features: client.Features{ + Reconnection: true, + StateManagement: true, + }, + } + + out, err := NewGenerator().Generate(context.Background(), spec, config) + require.NoError(t, err) + + // Check WebTransport file + wtCode, ok := out.Files["src/webtransport.ts"] + if !ok { + t.Fatal("webtransport.ts not found") + } + + // Check for expected content + expectedStrings := []string{ + "WebTransport", + "class", + "connect", + "openBidiStream", + "openUniStream", + "sendDatagram", + "receiveDatagram", + "close", + "WebTransportState", + "EventEmitter", + } + + for _, expected := range expectedStrings { + if !strings.Contains(wtCode, expected) { + t.Errorf("webtransport.ts should contain '%s'", expected) + } + } + + // Check for reconnection logic + if config.Features.Reconnection { + if !strings.Contains(wtCode, "reconnect") { + t.Error("webtransport.ts should contain reconnection logic") + } + } + + // Check for state management + if config.Features.StateManagement { + if !strings.Contains(wtCode, "onStateChange") { + t.Error("webtransport.ts should contain state management") + } + } + + // Verify BiDiStream and UniStream classes are generated. Each is a + // standalone, top-level `class` declaration named after this endpoint + // (className + "BiDiStream"/"UniStream"), not a bare "class BiDiStream"/ + // "class UniStream" -- generateWebTransportClient can no longer emit + // those as unqualified names spliced inside the outer client class body + // (a `class` statement is not a legal class-body member in TypeScript/ + // JavaScript, which made every previously-generated WebTransport client + // with a BiStreamSchema or UniStreamSchema a parse error, not just a + // type error -- neither tsc nor esbuild could ever have compiled or + // bundled it). Qualifying by endpoint name also avoids a redeclaration + // if a spec has more than one WebTransport endpoint. + if !strings.Contains(wtCode, "class DataWTClientBiDiStream") { + t.Error("webtransport.ts should contain the endpoint-qualified BiDiStream class") + } + + if !strings.Contains(wtCode, "class DataWTClientUniStream") { + t.Error("webtransport.ts should contain the endpoint-qualified UniStream class") + } + + // Every schema here is an inline object (no $ref), so none of them can + // resolve to a codec-table id: one warning each for the bidirectional + // stream's send and receive schemas, the unidirectional stream's send + // schema, and the datagram schema. + assert.Len(t, out.Warnings, 4, "expected exactly one warning per unresolvable inline schema; got: %v", out.Warnings) + for _, want := range []string{ + `webtransport endpoint "data": bidirectional-stream send message schema is not a direct $ref`, + `webtransport endpoint "data": bidirectional-stream receive message schema is not a direct $ref`, + `webtransport endpoint "data": unidirectional-stream send message schema is not a direct $ref`, + `webtransport endpoint "data": datagram schema is not a direct $ref`, + } { + found := false + for _, w := range out.Warnings { + if strings.Contains(w, want) { + found = true + break + } + } + assert.True(t, found, "expected a warning containing %q, got: %v", want, out.Warnings) + } + + // This is the "one typeCheck call that gives it teeth": every previous + // run of this test only ever inspected the generated STRING, so the + // pre-existing nested-class parse bug (fixed in this task) shipped + // invisibly through this exact test, on this exact spec, indefinitely. + dir := t.TempDir() + writeTree(t, dir, out.Files) + + if errs := typeCheck(t, dir); len(errs) != 0 { + t.Fatalf("full bi+uni+datagram webtransport client must type-check with zero errors, got:\n%s", strings.Join(errs, "\n")) + } +} From ab530a385088604ff90ff8bd0ee78e10502c6899 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 22:32:43 -0500 Subject: [PATCH 66/71] test(client/typescript): add a WebTransport gate fixture TestGeneratedClientsTypeCheck and TestGenerationIsDeterministic both derive from gateFixtures(), and no fixture in the corpus had ever set spec.WebTransports -- WebTransport's only tsc coverage lived in webtransport_codec_test.go's own bespoke tests, all built from a single spec shape (bidirectional + unidirectional + datagram, all $ref). That was exactly the shape that hid the dangling-class-reference defect fixed in the preceding commit: a datagram-only endpoint was never exercised by the systematic matrix. Adds "webtransport" (wtMixedEndpointsSpec(): one endpoint with a BiStreamSchema, one with only a DatagramSchema, coexisting in one generated client) to gateFixtures(). Fixture count moves 11 -> 12. --- .../generators/typescript/fixtures_test.go | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go index ae87ed50..c7b3630e 100644 --- a/internal/client/generators/typescript/fixtures_test.go +++ b/internal/client/generators/typescript/fixtures_test.go @@ -10,7 +10,7 @@ import ( ) func TestGateFixturesCoverKnownDefects(t *testing.T) { - want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse", "no-auth-ws-sse", "allof", "preserve", "preserve-ws-sse"} + want := []string{"default", "apiname", "odd-keys", "with-auth", "no-streaming", "no-auth-streaming", "ws-sse", "no-auth-ws-sse", "allof", "preserve", "preserve-ws-sse", "webtransport"} fixtures := gateFixtures() @@ -31,11 +31,12 @@ func TestGateFixturesCoverKnownDefects(t *testing.T) { // masking a missing one, or an unrelated future edit deleting an // unnamed fixture this list never tracked. Task 7 grew the corpus from // 9 fixtures to 10 (the "preserve" fixture); task 5c (finding 2 review) - // grows it to 11 (the "preserve-ws-sse" fixture below) -- this number - // must move in lockstep with `want` and with gateFixtures' own fixture - // literal. - if len(fixtures) != 11 { - t.Errorf("expected exactly 11 gate fixtures, got %d: %v", len(fixtures), fixtureNames(fixtures)) + // grew it to 11 (the "preserve-ws-sse" fixture); task 5c fix round 1 + // (coordinator's Important-2 finding) grows it to 12 (the "webtransport" + // fixture below) -- this number must move in lockstep with `want` and + // with gateFixtures' own fixture literal. + if len(fixtures) != 12 { + t.Errorf("expected exactly 12 gate fixtures, got %d: %v", len(fixtures), fixtureNames(fixtures)) } } @@ -249,6 +250,20 @@ func gateFixtures() []gateFixture { // fallback path (the raw `JSON.parse(data)`/`JSON.stringify(message)` // casts) still type-checks on its own. {Name: "preserve-ws-sse", Spec: wsSSESpec(), Config: preserveConfig()}, + // Task 5c fix round 1 (coordinator's Important-2 finding): the tsc gate + // matrix had ZERO WebTransport coverage until now -- TestGeneratedClientsTypeCheck + // and TestGenerationIsDeterministic both derive from gateFixtures(), and + // no prior fixture set spec.WebTransports at all. WebTransport's own + // tsc coverage lived only in webtransport_codec_test.go's bespoke + // tests, every one of which used a single spec shape (bidirectional + + // unidirectional + datagram, all $ref) -- precisely the shape that + // hid the coordinator's Important-1 finding (a WebTransport endpoint + // declaring ONLY a DatagramSchema left a dangling reference to an + // undeclared BiDiStream wrapper class). wtMixedEndpointsSpec() + // (webtransport_codec_test.go) covers exactly that gap: one endpoint + // with a BiStreamSchema, one with ONLY a DatagramSchema, coexisting in + // the same generated webtransport.ts. + {Name: "webtransport", Spec: wtMixedEndpointsSpec(), Config: baseConfig()}, } } From b6a3da60ec5583c9f66c0fd5f8aba4e7f7163fbb Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 22:49:09 -0500 Subject: [PATCH 67/71] test(client/typescript): pin warning-order determinism; fix stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc comments in webtransport.go referenced generateBiStreamMethods and generateUniStreamMethods, which do not exist — the emitters are generateBiDiStreamClass and generateUniStreamClass. Worse, the comment on generateIncomingStreamHandler stated the inverse of the invariant the preceding commit established: it said the stream class "only runs when wt.BiStreamSchema != nil", when making that emission unconditional is precisely what fixed the datagram-only TS2304. Both now describe what the code does, and record why. Adds a warning-order determinism test. The Warnings comparison added to TestGenerationIsDeterministic is nearly vacuous on its own: the whole 12-fixture corpus produces exactly one warning, so it compares empty slices eleven times and a single-element slice once. It is kept for future fixtures; TestWarningOrderIsDeterministic is the real guard, using a spec that warns from all five generators that have a warnings channel. Its doc comment is deliberately explicit about what it does not catch. Order is currently deterministic twice over — every append site already walks its map through sortedKeys, and each generator sorts again before returning — so removing one sort does not make it flap. Verified by replacing sse.go's sort with a no-op comparator and watching eight consecutive runs stay green. What it guards is a future generator appending from a bare map range, which the existing sorts make easy to assume is already handled. --- .../generators/typescript/determinism_test.go | 110 ++++++++++++++++++ .../generators/typescript/webtransport.go | 31 +++-- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/internal/client/generators/typescript/determinism_test.go b/internal/client/generators/typescript/determinism_test.go index ea5151b0..f829069b 100644 --- a/internal/client/generators/typescript/determinism_test.go +++ b/internal/client/generators/typescript/determinism_test.go @@ -2,7 +2,10 @@ package typescript import ( "context" + "slices" "testing" + + "github.com/xraph/forge/internal/client" ) func TestGenerationIsDeterministic(t *testing.T) { @@ -28,7 +31,114 @@ func TestGenerationIsDeterministic(t *testing.T) { t.Fatalf("run %d: %s differs from run 0", i, name) } } + + // Warnings are gathered from map-keyed walks across several + // generators and concatenated, so their order is only stable + // because each generator sorts before returning. Comparing + // Files alone would let a dropped sort regress silently -- + // the output would stay byte-identical while the warnings a + // user sees reordered between runs. + if !slices.Equal(next.Warnings, first.Warnings) { + t.Fatalf("run %d: warnings differ from run 0:\n got: %v\nwant: %v", i, next.Warnings, first.Warnings) + } } }) } } + +// TestWarningOrderIsDeterministic is the real guard for warning ordering. +// +// The Warnings check inside TestGenerationIsDeterministic is nearly vacuous +// on its own: across the whole 12-fixture corpus exactly ONE warning is +// produced (by `allof`), so that loop compares empty slices eleven times and +// a single-element slice once -- and a one-element slice has no order to get +// wrong. It is kept there to cover future fixtures, not because it proves +// anything today. +// +// This test builds a spec that deliberately warns from every generator that +// has a warnings channel -- REST, codecs, WebSocket, SSE and WebTransport -- +// so the concatenated slice is long enough for an ordering regression to +// show. +// +// Be precise about what it does and does not catch. Warning order is +// currently deterministic TWICE OVER: every site that appends a warning +// already walks its map through sortedKeys (e.g. sse.go's +// sortedKeys(sse.EventSchemas)), and each generator then sorts again before +// returning. So removing one of those sort.Strings calls does NOT make this +// test flap -- verified by replacing sse.go's with a no-op comparator and +// watching 8 consecutive runs stay green. +// +// What it does guard is a FUTURE generator that appends warnings from a raw +// `for k := range someMap` without sorting. That is the realistic +// regression, since the existing sorts make it easy to assume ordering is +// handled and reach for a bare range. Without this test the generated files +// would stay byte-identical and nothing else in the suite would notice. +func TestWarningOrderIsDeterministic(t *testing.T) { + inline := func() *client.Schema { + // Inline (non-$ref) schemas cannot resolve to a codec id, which is + // what makes each generator emit its "will not be renamed" warning. + return &client.Schema{Type: "object", Properties: map[string]*client.Schema{ + "some_field": {Type: "string"}, + }} + } + + spec := &client.APISpec{ + Info: client.APIInfo{Title: "WarnAPI", Version: "1"}, + Schemas: map[string]*client.Schema{ + // Undiscriminated union -> codec-table warning. + "Zeta": {OneOf: []*client.Schema{{Ref: "#/components/schemas/Leaf"}}}, + "Alpha": {OneOf: []*client.Schema{{Ref: "#/components/schemas/Leaf"}}}, + "Mid": {OneOf: []*client.Schema{{Ref: "#/components/schemas/Leaf"}}}, + "Leaf": {Type: "object", Properties: map[string]*client.Schema{"leaf_value": {Type: "string"}}}, + }, + Endpoints: []client.Endpoint{{ + Method: "POST", Path: "/inline", OperationID: "inline.create", + RequestBody: &client.RequestBody{Required: true, Content: map[string]*client.MediaType{ + "application/json": {Schema: inline()}, + }}, + Responses: map[int]*client.Response{200: {Description: "ok", Content: map[string]*client.MediaType{ + "application/json": {Schema: inline()}, + }}}, + }}, + WebSockets: []client.WebSocketEndpoint{{ + ID: "ws.chat", Path: "/ws", + SendSchema: inline(), ReceiveSchema: inline(), + }}, + SSEs: []client.SSEEndpoint{{ + ID: "sse.feed", Path: "/sse", + EventSchemas: map[string]*client.Schema{"zulu": inline(), "alfa": inline(), "mike": inline()}, + }}, + WebTransports: []client.WebTransportEndpoint{ + {ID: "wt.zulu", Path: "/wt-z", DatagramSchema: inline()}, + {ID: "wt.alfa", Path: "/wt-a", DatagramSchema: inline()}, + }, + } + + cfg := baseConfig() + cfg.IncludeStreaming = true + + first, err := NewGenerator().Generate(context.Background(), spec, cfg) + if err != nil { + t.Fatal(err) + } + + // Guard against this test silently becoming vacuous the way the corpus + // check is: if a refactor stops emitting these warnings, fail loudly + // rather than comparing two empty slices forever. + if len(first.Warnings) < 6 { + t.Fatalf("expected this spec to produce several warnings across generators, got %d: %v", + len(first.Warnings), first.Warnings) + } + + for i := 1; i < 12; i++ { + next, err := NewGenerator().Generate(context.Background(), spec, cfg) + if err != nil { + t.Fatal(err) + } + + if !slices.Equal(next.Warnings, first.Warnings) { + t.Fatalf("run %d: warning order is not deterministic:\n got: %v\nwant: %v", + i, next.Warnings, first.Warnings) + } + } +} diff --git a/internal/client/generators/typescript/webtransport.go b/internal/client/generators/typescript/webtransport.go index ff668356..45ab35e0 100644 --- a/internal/client/generators/typescript/webtransport.go +++ b/internal/client/generators/typescript/webtransport.go @@ -238,13 +238,14 @@ func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTranspor className := w.generateClassName(wt) - // Shared with generateBiStreamMethods/generateUniStreamMethods (which - // derive the SAME two names for their own standalone class - // declarations): handleIncomingBidiStreams below instantiates - // `${className}BiDiStream` regardless of whether this endpoint declares - // an outgoing BiStreamSchema of its own, since an incoming bidirectional - // stream is a connection-level event, not something gated on this - // endpoint's own send/receive schema. + // Shared with generateBiDiStreamClass/generateUniStreamClass, which + // derive the SAME two names for their own standalone class declarations: + // handleIncomingBidiStreams below instantiates `${className}BiDiStream` + // regardless of whether this endpoint declares an outgoing BiStreamSchema + // of its own, since an incoming bidirectional stream is a connection-level + // event, not something gated on this endpoint's own send/receive schema. + // That is exactly why both classes are emitted unconditionally — see + // generateIncomingStreamHandler. biDiStreamName := className + "BiDiStream" // Codec ids for every stream/datagram schema this endpoint declares, @@ -1034,12 +1035,18 @@ func (w *WebTransportGenerator) generateQueueMethods() string { } // generateIncomingStreamHandler generates handler for incoming streams. -// biDiStreamName is the endpoint-specific `class` name generateBiStreamMethods +// biDiStreamName is the endpoint-specific `class` name generateBiDiStreamClass // declares (className + "BiDiStream") -- handleIncomingBidiStreams -// instantiates it by name, so the two must agree even though this function -// runs unconditionally (unlike generateBiStreamMethods, which only runs when -// wt.BiStreamSchema != nil; see generateWebTransportClient's biDiStreamName -// comment). +// instantiates it by name, so the two must agree. +// +// Both this function AND generateBiDiStreamClass run unconditionally. They +// used to disagree: this handler always referenced the class while the class +// itself was only emitted when wt.BiStreamSchema != nil, so a datagram-only +// endpoint -- the most idiomatic WebTransport shape -- generated +// `TS2304: Cannot find name 'BiDiStream'` and did not compile. Emitting +// the class unconditionally (typed `any` when no schema is declared) is the +// fix, because an incoming bidirectional stream is a connection-level event +// that arrives whether or not this endpoint declares an outgoing schema. func (w *WebTransportGenerator) generateIncomingStreamHandler(uniReceiveCodecID, biDiStreamName string) string { return fmt.Sprintf(` private async handleIncomingStreams(): Promise { if (!this.transport) return; From 310f9bf7c8bf88e96d9a8fc57143377c1551ec46 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 22:58:39 -0500 Subject: [PATCH 68/71] docs(cli): correct the client command flags and document the rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client section documented flags that do not exist. `--openapi`, `--grpc` and `--asyncapi` were never registered — the real flags are `--from-spec`/`-s` and `--from-url`/`-u`, and one spec source handles both OpenAPI and AsyncAPI. `--language` was listed as accepting python; it accepts go and typescript. There is no gRPC client generator at all, so the "For gRPC" generated-code example was fiction. Replaced with a TypeScript example, since that is the generator this repo actually ships and the one the rename affects. `forge client list` was documented as listing previously generated clients, with an invented table of names, languages and dates. It lists the endpoints declared by a spec, grouped by transport with a statistics footer. Replaced the example with real output, and added its `--type` filter. `forge client init` was undocumented; it is an interactive wizard, so the entry says so and points scripts at the config file instead. Also documents `--field-naming`/`--field-overrides` and the breaking change to generated TypeScript property names, with `--field-naming preserve` as the escape hatch — previously reachable only by reading the source. Every flag, output sample and behavioural claim here was checked against a binary built from this commit, not against the flag declarations: the help output, `client list` on a real spec, camel vs preserve generation (and that preserve emits no codecs.ts), and the collision error together with the `--field-overrides` key it prints actually resolving it. --- docs/content/docs/forge/(cli)/system.mdx | 146 ++++++++++++++++------- 1 file changed, 100 insertions(+), 46 deletions(-) diff --git a/docs/content/docs/forge/(cli)/system.mdx b/docs/content/docs/forge/(cli)/system.mdx index 9b1712e3..0e689832 100644 --- a/docs/content/docs/forge/(cli)/system.mdx +++ b/docs/content/docs/forge/(cli)/system.mdx @@ -182,29 +182,60 @@ Built: 2024-01-15 10:30:00 Client generation and management tools. ```bash -forge client generate --openapi spec.yaml -forge client generate --grpc proto/service.proto -forge client list +forge client generate --from-spec spec.yaml +forge client list --from-spec spec.yaml ``` **Subcommands:** #### `forge client generate` -Generate client code from API specifications. +Generate client code from an OpenAPI or AsyncAPI specification. One spec +source is required — either a local file or a URL. ```bash -forge client generate --openapi spec.yaml -forge client generate --grpc proto/service.proto -forge client generate --asyncapi events.yaml +forge client generate --from-spec spec.yaml --language typescript +forge client generate --from-url https://api.example.com/openapi.json -l go ``` -**Flags:** -- `--openapi` - OpenAPI specification file -- `--grpc` - gRPC proto file -- `--asyncapi` - AsyncAPI specification file -- `--output` - Output directory -- `--language` - Target language (go, typescript, python) +**Spec source and target:** +- `--from-spec`, `-s` - Path to an OpenAPI/AsyncAPI spec file +- `--from-url`, `-u` - URL to fetch an OpenAPI/AsyncAPI spec from +- `--language`, `-l` - Target language: `go` or `typescript` +- `--output`, `-o` - Output directory +- `--package`, `-p` - Package/module name +- `--base-url`, `-b` - API base URL +- `--module`, `-m` - Go module path (Go only) + +**Field naming (TypeScript):** +- `--field-naming` - Client-side property naming: `camel`, `pascal`, `snake`, or `preserve` +- `--field-overrides` - Comma-separated overrides, e.g. `User.user_id=userIdentifier,api_key=apiKey` + +**Features:** +- `--auth` / `--no-auth` - Include authentication +- `--streaming` / `--no-streaming` - Include WebSocket/SSE clients +- `--reconnection`, `--heartbeat`, `--state-management` - Streaming behaviour +- `--rooms`, `--presence`, `--typing`, `--channels`, `--history` - Streaming extensions +- `--all-streaming` - Enable rooms, presence, typing and channels together +- `--use-fetch`, `--dual-package`, `--error-taxonomy`, `--interceptors`, `--pagination` +- `--generate-tests`, `--generate-linting`, `--generate-ci` +- `--client-only` - Emit only client sources (no `package.json`, `tsconfig.json`, etc.) + +Defaults can also be supplied from a `.forge-client.yml` file; an explicit +flag always wins over the config file. + + + **Breaking change to generated TypeScript.** Schema property names are now + derived as camelCase by default, so a client that previously exposed + `user.user_id` exposes `user.userId`. The wire format is unchanged — a + generated `src/codecs.ts` translates between the two at the request and + response boundary. + + To keep the previous output, generate with `--field-naming preserve`. In + that mode no `codecs.ts` is emitted at all. See the + [client generator README](https://github.com/xraph/forge/blob/main/internal/client/README.md) + for the full naming rules, override key format, and collision behaviour. + **Generated Clients:** @@ -232,49 +263,65 @@ func (c *APIClient) GetUsers() ([]User, error) { } ``` -For gRPC: -```go -// Generated gRPC client -package client +For TypeScript: +```ts +// Generated TypeScript client +import { RESTClient } from './rest'; -import ( - "context" - "google.golang.org/grpc" -) +const client = new RESTClient('https://api.example.com'); -type UserServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient { - return &UserServiceClient{cc} -} - -func (c *UserServiceClient) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) { - // Generated method implementation -} +// Property names are camelCase; the wire stays snake_case. +const user = await client.users.get('u_123'); +console.log(user.userId, user.createdAt); ``` #### `forge client list` -List generated clients. +List the endpoints declared by a specification, grouped by transport. This +inspects a spec — it does not track previously generated clients. ```bash -forge client list +forge client list --from-spec spec.yaml +forge client list --from-spec spec.yaml --type ws ``` +**Flags:** +- `--from-spec`, `-s` - Path to an OpenAPI/AsyncAPI spec file +- `--from-url`, `-u` - URL to fetch a spec from +- `--type`, `-t` - Filter by transport: `rest`, `ws`, or `sse` + **Example Output:** ``` -Generated Clients: +API: Example API v1.0.0 + +┌──────┬────────┬─────────────┬──────┬─────────┐ +│ Type │ Method │ Path │ Auth │ Summary │ +├──────┼────────┼─────────────┼──────┼─────────┤ +│ REST │ GET │ /users │ No │ │ +│ REST │ POST │ /users │ No │ │ +│ REST │ GET │ /users/{id} │ No │ │ +└──────┴────────┴─────────────┴──────┴─────────┘ + +Statistics: + Total endpoints: 3 + REST: 3, WebSocket: 0, SSE: 0 + Secured: 0 +``` - Name Type Language Generated +#### `forge client init` - api-client OpenAPI Go 2024-01-15 - user-client gRPC Go 2024-01-14 - events-client AsyncAPI TypeScript 2024-01-13 +Interactive wizard that writes a `.forge-client.yml` into the current +directory, so generation options do not have to be passed as flags each +time. It prompts for the spec source, target language, output directory and +package name. +```bash +forge client init ``` +Because it prompts, it needs a TTY — in scripts and CI, write +`.forge-client.yml` directly or pass flags to `forge client generate`. + ## Project Templates ### Available Templates @@ -445,22 +492,29 @@ forge doctor --verbose If client generation fails: ```bash -forge client generate --openapi spec.yaml -# Error: invalid OpenAPI specification +forge client generate --from-spec spec.yaml +# Error: parse spec: ... ``` **Solution:** ```bash -# Validate OpenAPI spec -# Use online validator or tools +# Inspect what the generator actually sees in the spec +forge client list --from-spec spec.yaml -# Check file format +# Check file format and content file spec.yaml - -# Verify file content head -20 spec.yaml ``` +If generation aborts with a field-name collision, two property names in the +same schema derive to the same client-side name. The error names both wire +names and the exact `--field-overrides` key that resolves it: + +```bash +forge client generate --from-spec spec.yaml \ + --field-overrides 'User.user_id=userIdentifier' +``` + For more information about project development, see the [Development Commands](/docs/forge/cli/dev) documentation. From 8a1d5cfeb674737a3a5cb139ebba1bcc41f7624b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 28 Jul 2026 23:42:04 -0500 Subject: [PATCH 69/71] fix(ci): install esbuild and skip runtime tests when it is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated-client runtime tests bundle an emitted client with esbuild and execute it under Node. CI never installed esbuild — only typescript — so all three Test jobs failed with: npm error npx canceled due to missing packages and no YES option: ["esbuild@0.28.1"] Two independent defects, both fixed here. findESBuild inferred esbuild's availability from npx being on PATH, which is a proxy, not the capability. `npx --no-install esbuild` exits non-zero at RUN time when the package is absent, so the helper handed back a command that failed during bundling instead of skipping — turning every runtime test into a failure on any machine with npx but no esbuild. It now probes `npx --no-install esbuild --version` and skips when that does not succeed. Verified both directions: with esbuild present the tests still run and pass; under a shimmed PATH containing node and npx but no esbuild, they SKIP rather than FAIL, which is the exact CI condition. Skipping alone would have been the wrong fix on its own — it would have made CI green while silently dropping the execution coverage that caught most of the real defects in this branch (a union body encoding camelCase onto the wire, a datagram-only WebTransport client that did not compile, empty response bodies resolving to Blob instead of undefined). None of those are visible to the tsc gate. So the workflow now installs esbuild@0.28.1 alongside typescript@5.8.2, and the skip exists only for contributors who lack it locally. --- .github/workflows/go.yml | 11 +++++++++-- .../client/generators/typescript/runtime_test.go | 13 +++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9308ed9e..40b8a8ae 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -58,8 +58,15 @@ jobs: with: node-version: '20' - - name: Install TypeScript - run: npm install -g typescript@5.8.2 + - name: Install TypeScript and esbuild + # esbuild is required by the generated-client runtime tests, which + # bundle an emitted client and execute it under Node. Without it the + # harness falls back to `npx esbuild`, which cannot auto-install + # non-interactively in CI ("npx canceled due to missing packages and + # no YES option") -- those tests then fail rather than skip, and the + # execution coverage they provide is exactly the coverage that catches + # runtime/type mismatches the tsc gate cannot see. + run: npm install -g typescript@5.8.2 esbuild@0.28.1 - name: Run tests shell: bash diff --git a/internal/client/generators/typescript/runtime_test.go b/internal/client/generators/typescript/runtime_test.go index f2b69edd..945bd094 100644 --- a/internal/client/generators/typescript/runtime_test.go +++ b/internal/client/generators/typescript/runtime_test.go @@ -28,11 +28,20 @@ func findESBuild(t *testing.T) []string { return []string{path} } + // npx being on PATH does NOT mean esbuild is reachable through it. + // `npx --no-install esbuild` exits non-zero at RUN time with "npx canceled + // due to missing packages and no YES option" when the package is absent, + // which turned every runtime test into a failure rather than a skip on any + // machine (including CI) that had npx but no esbuild. Probe the capability + // instead of inferring it from npx's existence. if path, err := exec.LookPath("npx"); err == nil { - return []string{path, "--no-install", "esbuild"} + probe := exec.CommandContext(context.Background(), path, "--no-install", "esbuild", "--version") + if probe.Run() == nil { + return []string{path, "--no-install", "esbuild"} + } } - t.Skip("neither esbuild nor npx found on PATH; skipping generated-client runtime test") + t.Skip("esbuild not available (not on PATH, and not reachable via npx --no-install); skipping generated-client runtime test") return nil } From 0ee3373e611121a738d8e5b33556bc293dd1965b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 29 Jul 2026 00:02:48 -0500 Subject: [PATCH 70/71] test(client/typescript): pin codec-id escaping against hostile schema names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL raised a critical go/unsafe-quoting alert on webtransport.go's `this.emit('incomingUniStream', %s)` — "if this JSON value contains a single quote, it could break out of the enclosing quotes." It is a false positive, and this test is the evidence rather than an assertion that it is. The single quotes in that template belong to a constant event name; the interpolated value comes from json.Marshal, which is double-quoted and escapes everything relevant. A single quote inside a double-quoted JS string is inert. Verified by parsing, not by reasoning: eight hostile schema names — an apostrophe, a double quote, an attempted statement injection, a raw newline, , a backslash, U+2028, and an attempted break-out of the double-quoted form — all produce JavaScript that `node --check` accepts, through both wireEncodeExpr and wireDecodeExpr. Counting quotes is deliberately not the assertion. That heuristic reports `"it's"` as unbalanced when it is perfectly valid, which is what a first attempt at this test did before being corrected. Pinned because the failure mode is real and this repo has hit it once already: an earlier task had to replace fmt.Sprintf("'%v'", v) with json.Marshal in enumTSType after a schema value containing an apostrophe produced an unterminated literal that broke the whole generated file. Mutation-checked — reverting wireDecodeExpr to manual single-quoting fails this test on the apostrophe, injection and newline cases. --- .../generators/typescript/runtime_test.go | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/internal/client/generators/typescript/runtime_test.go b/internal/client/generators/typescript/runtime_test.go index 945bd094..8ae41e71 100644 --- a/internal/client/generators/typescript/runtime_test.go +++ b/internal/client/generators/typescript/runtime_test.go @@ -3,6 +3,7 @@ package typescript import ( "context" "encoding/json" + "os" "os/exec" "path/filepath" "strings" @@ -461,3 +462,60 @@ main().catch((err) => { assert.Equal(t, "server-added, unknown to Customer", result.Decoded.CustomerInternalNote, "an UNKNOWN nested key (not declared anywhere on Customer) must survive decode verbatim") } + +// TestWireCodecExprEscapesHostileSchemaNames pins that the codec id embedded +// by wireEncodeExpr/wireDecodeExpr cannot break out of its own literal. +// +// CodeQL flags webtransport.go's `this.emit('incomingUniStream', %s)` under +// go/unsafe-quoting: "if this JSON value contains a single quote, it could +// break out of the enclosing quotes." That alert is a FALSE POSITIVE, and +// this test is the evidence. The single quotes in that template belong to a +// constant event name; the interpolated value is a json.Marshal result, which +// is double-quoted and escapes everything that matters. A single quote inside +// a double-quoted JS string is inert. +// +// It is still worth pinning, because the failure mode is real and this repo +// has already hit it once: an earlier task had to replace +// fmt.Sprintf("'%v'", v) with json.Marshal in enumTSType after a schema value +// containing an apostrophe produced an unterminated literal that broke the +// whole generated file. If anyone "simplifies" these helpers back to manual +// quoting, this fails. +// +// Counting quotes is deliberately NOT the assertion — that heuristic reports +// `"it's"` as unbalanced when it is perfectly valid. Parsing is the assertion. +func TestWireCodecExprEscapesHostileSchemaNames(t *testing.T) { + node := findNode(t) + + hostile := []string{ + `it's`, // apostrophe -- the enumTSType regression + `a"b`, // double quote + `a'; alert(1); '`, // attempted statement injection + "line\nbreak", // raw newline + ``, // HTML context escape + `back\slash`, // backslash + "
sep", // JS line separator, valid in JSON but not in JS source + `"; process.exit(1); //`, // attempted break-out of the double-quoted form + } + + for _, id := range hostile { + for name, expr := range map[string]string{ + "decode": wireDecodeExpr(id, "JSON.parse(data)"), + "encode": wireEncodeExpr(id, "payload"), + } { + dir := t.TempDir() + file := filepath.Join(dir, "probe.mjs") + src := "const decode=(v,c)=>({v,c});\nconst encode=(v,c)=>({v,c});\nconst payload={};\n" + + "function f(data){ return " + expr + "; }\n" + + if err := os.WriteFile(file, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + + out, err := exec.CommandContext(context.Background(), node, "--check", file).CombinedOutput() + if err != nil { + t.Errorf("%s: schema id %q produced unparseable JavaScript: %v\n%s\nemitted: %s", + name, id, err, out, expr) + } + } + } +} From 232cbbf4a740e1c29554224d709837f8936f0630 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 29 Jul 2026 00:14:20 -0500 Subject: [PATCH 71/71] refactor(router): remove unreachable ValidateQueryParams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exported but never called anywhere in the repository, and its required-field logic disagreed with the rest of the stack: it treated a query parameter as required only when tagged `required:"true"`, while the OpenAPI parameter generator infers required from the absence of `omitempty` (honouring `optional:"true"` and `required:"true"` first). So a spec could advertise a parameter as required that this helper would never enforce. Since nothing calls it, that divergence was latent rather than a live bug — but it is public API, so anyone who did reach for it would inherit the mismatch. Removing it is preferable to aligning it: the generator's inference is the behaviour the rest of the stack already agrees on, and a second, subtly different implementation of "is this parameter required" is the kind of thing that silently drifts. convertQueryValue, which sat directly below it, is untouched and still used. --- internal/router/validation.go | 90 ----------------------------------- 1 file changed, 90 deletions(-) diff --git a/internal/router/validation.go b/internal/router/validation.go index 0a564914..4502a3a7 100644 --- a/internal/router/validation.go +++ b/internal/router/validation.go @@ -510,96 +510,6 @@ func FormatValidationError(err error) (int, []byte) { return 400, data } - -// ValidateQueryParams validates query parameters against a struct schema. -func ValidateQueryParams(r *http.Request, schemaType any) error { - // Extract query params - queryParams := make(map[string]any) - - for key, values := range r.URL.Query() { - if len(values) == 1 { - queryParams[key] = values[0] - } else { - queryParams[key] = values - } - } - - // Generate schema from struct type - rt := reflect.TypeOf(schemaType) - if rt.Kind() == reflect.Ptr { - rt = rt.Elem() - } - - validator := NewSchemaValidator() - errors := NewValidationErrors() - - // Validate each field - for i := range rt.NumField() { - field := rt.Field(i) - - queryTag := field.Tag.Get("query") - if queryTag == "" || queryTag == "-" { - continue - } - - paramName, _ := parseTagWithOmitempty(queryTag) - if paramName == "" { - paramName = field.Name - } - - // Check if required - required := field.Tag.Get("required") == "true" - value, exists := queryParams[paramName] - - if required && !exists { - errors.AddWithCode(paramName, "Query parameter is required", ErrCodeRequired, nil) - - continue - } - - if exists { - // Validate the value - // Convert string value to appropriate type based on field type - convertedValue := convertQueryValue(value, field.Type) - - // Create a simple schema for validation - schema := &Schema{ - Type: getSchemaTypeFromReflectType(field.Type), - } - - // Apply validation tags - if minStr := field.Tag.Get("minimum"); minStr != "" { - if minVal, err := strconv.ParseFloat(minStr, 64); err == nil { - schema.Minimum = minVal - } - } - - if maxStr := field.Tag.Get("maximum"); maxStr != "" { - if maxVal, err := strconv.ParseFloat(maxStr, 64); err == nil { - schema.Maximum = maxVal - } - } - - if enumStr := field.Tag.Get("enum"); enumStr != "" { - enumValues := strings.Split(enumStr, ",") - - schema.Enum = make([]any, len(enumValues)) - for i, v := range enumValues { - schema.Enum[i] = strings.TrimSpace(v) - } - } - - validator.validateValue(schema, convertedValue, paramName, errors) - } - } - - if errors.HasErrors() { - return errors - } - - return nil -} - func convertQueryValue(value any, targetType reflect.Type) any { str, ok := value.(string) if !ok {