diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 95eeadcc..40b8a8ae 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -53,6 +53,21 @@ jobs:
- name: Build
run: go build -v ./...
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - 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
run: |
diff --git a/cmd/forge/plugins/client.go b/cmd/forge/plugins/client.go
index 568fc39a..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,
@@ -435,6 +475,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!")
@@ -777,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")
+ })
+}
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.
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:
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/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/channels.go b/internal/client/generators/typescript/channels.go
index 1a9b9b5f..35c1cb15 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")
@@ -94,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()
}
@@ -152,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")
@@ -285,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")
@@ -310,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/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/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/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
new file mode 100644
index 00000000..cdc20183
--- /dev/null
+++ b/internal/client/generators/typescript/codecs.go
@@ -0,0 +1,1395 @@
+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.
+//
+// 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.
+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 (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, 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
+ // 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"`
+}
+
+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
+
+ // 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
+ // 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.
+// 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)
+}
+
+// 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)
+ }
+ }
+ }
+
+ // 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
+// 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
+// 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 || 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
+ }
+
+ 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"}
+
+ 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)
+ 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",
+ 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: tsFieldName(id, prop, t.config),
+ Codec: t.codecIDFor(id, prop, schema.Properties[prop], spec),
+ }
+ }
+
+ 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+"."+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, additionalPropertiesSegment, values, spec)
+ }
+
+ t.entries[id] = entry
+
+ 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.
+ // 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
+ }
+
+ // Anything else (scalars, empty objects, unresolvable refs) stays the
+ // passthrough reserved above.
+}
+
+// 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 i, member := range members {
+ if member == nil {
+ continue
+ }
+
+ 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
+ }
+
+ // 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 == "" {
+ 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))
+
+ // 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]
+
+ // 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"
+
+ switch {
+ case t.building[memberID]:
+ kind = "unknown (cyclic reference back to a schema still being built)"
+ case 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}
+ }
+
+ 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
+ }
+ }
+
+ // 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{
+ Wire: schema.Discriminator.PropertyName,
+ Map: mapping,
+ },
+ Members: memberIDs,
+ }
+}
+
+// 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
+// (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
+// 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.
+//
+// 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
+// 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.
+//
+// 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 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
+ }
+
+ 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], name, spec, visited)
+ }
+
+ 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 {
+ // 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...)
+ }
+
+ if len(schema.Properties) > 0 {
+ layers = append(layers, allOfLayer{schema: schema, nsID: label})
+ }
+
+ return layers, polymorphicMembers
+}
+
+// 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 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
+// 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 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.
+//
+// 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.
+//
+// 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
+// `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.
+//
+// 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{})
+
+ 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. "+
+ "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, ", ")))
+ }
+
+ fields := map[string]codecField{}
+ fieldCodec := map[string]string{}
+ conflicts := map[string]bool{}
+ var required []string
+
+ for _, layer := range layers {
+ // 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(layerNSID, prop, t.config), Codec: codecID}
+ }
+
+ required = append(required, layer.schema.Required...)
+ }
+
+ if len(conflicts) > 0 {
+ names := make([]string, 0, len(conflicts))
+ for name := range conflicts {
+ names = append(names, name)
+ }
+
+ sort.Strings(names)
+
+ 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, ", ")))
+ }
+
+ if len(fields) == 0 {
+ return codecEntry{Kind: "passthrough"}
+ }
+
+ return codecEntry{Kind: "object", Fields: fields, Required: requiredWireFields(fields, required)}
+}
+
+// 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 := fields[r]; !ok || seen[r] {
+ continue
+ }
+
+ seen[r] = true
+ out = append(out, r)
+ }
+
+ sort.Strings(out)
+
+ return out
+}
+
+// Generate emits src/codecs.ts. The second return value lists
+// 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
+// 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{}, config: config}
+
+ for _, name := range sortedKeys(spec.Schemas) {
+ table.add(name, spec, spec.Schemas[name])
+ }
+
+ registerEndpointArrayBodyCodecs(table, spec)
+
+ sort.Strings(table.warnings)
+
+ 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` 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[]; 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")
+ 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
+ }
+
+ // 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")
+
+ buf.WriteString(codecRuntime)
+
+ return buf.String(), table.warnings
+}
+
+// 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, `,"`, `, "`)
+}
+
+// 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:
+//
+// - 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 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 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
+// 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) {
+ 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) {
+ 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.
+ setOwn(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)) {
+ setOwn(out, key, walk(val, codec.values, toTS));
+ }
+
+ return out;
+ }
+
+ case 'union': {
+ if (typeof value !== 'object' || Array.isArray(value)) {
+ return value;
+ }
+
+ const src = value as Record;
+
+ if (codec.discriminator) {
+ // 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, 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 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) {
+ addCandidate(declared.ts);
+ }
+ }
+ addCandidate(codec.discriminator.wire);
+
+ 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;
+ }
+ }
+
+ if (!memberID) {
+ return value;
+ }
+
+ return walk(value, memberID, toTS);
+ }
+
+ // No discriminator: try each member in the order it was declared,
+ // 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
+ // 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);
+ if (!member || member.kind !== 'object') {
+ continue;
+ }
+
+ const required = member.required;
+ if (!required || required.length === 0) {
+ continue;
+ }
+
+ // '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);
+ }
+ }
+
+ // No member matched: pass through verbatim rather than guess.
+ return value;
+ }
+
+ 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..3f555d2b
--- /dev/null
+++ b/internal/client/generators/typescript/codecs_test.go
@@ -0,0 +1,1488 @@
+package typescript
+
+import (
+ "context"
+ "slices"
+ "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 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{
+ 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",
+ },
+ },
+ }
+ // 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"`)
+ assert.Contains(t, code, `"wire": "kind"`)
+ assert.Empty(t, warnings, "a discriminated union must not warn")
+
+ // 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, 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
+// 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() {
+ code, _ := NewCodecGenerator().Generate(spec, baseConfig())
+ done <- code
+ }()
+
+ 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, firstWarnings := NewCodecGenerator().Generate(spec, baseConfig())
+
+ for i := range 12 {
+ 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)
+ }
+ }
+}
+
+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';")
+}
+
+// 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())
+
+ 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, "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
+// 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()
+ code, _ := NewCodecGenerator().Generate(spec, baseConfig())
+ writeTree(t, dir, map[string]string{
+ "src/codecs.ts": code,
+ })
+
+ 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. 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');
+
+// 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"`
+ 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)
+
+ 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.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")
+
+ 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")
+}
+
+// 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)
+}
+
+// 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")
+}
+
+// ========== 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)
+}
+
+// ========== 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")
+}
+
+// ========== 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")
+}
+
+// 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/determinism_test.go b/internal/client/generators/typescript/determinism_test.go
new file mode 100644
index 00000000..f829069b
--- /dev/null
+++ b/internal/client/generators/typescript/determinism_test.go
@@ -0,0 +1,144 @@
+package typescript
+
+import (
+ "context"
+ "slices"
+ "testing"
+
+ "github.com/xraph/forge/internal/client"
+)
+
+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)
+ }
+ }
+
+ // 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/fetch_client.go b/internal/client/generators/typescript/fetch_client.go
index 13f97ee0..363967aa 100644
--- a/internal/client/generators/typescript/fetch_client.go
+++ b/internal/client/generators/typescript/fetch_client.go
@@ -18,10 +18,60 @@ 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\n")
+ buf.WriteString("// Base HTTP client using native fetch\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")
+ 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("/**\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")
+
+ 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")
buf.WriteString("export interface RequestConfig {\n")
buf.WriteString(" method: string;\n")
buf.WriteString(" url: string;\n")
@@ -29,6 +79,36 @@ 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")
+
+ // 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
@@ -39,6 +119,44 @@ 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. 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. 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 { 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(" 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 { signal: merged.signal, dispose };\n")
+ buf.WriteString("};\n\n")
+
// Interceptor interfaces
if config.Interceptors {
buf.WriteString("export interface RequestInterceptor {\n")
@@ -74,9 +192,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
@@ -97,7 +220,49 @@ 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(" // 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")
+
buf.WriteString(" let lastError: Error | null = null;\n\n")
buf.WriteString(" for (let attempt = 0; attempt < (retryConfig.maxAttempts || 1); attempt++) {\n")
@@ -116,8 +281,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")
@@ -126,7 +320,23 @@ 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 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")
+ buf.WriteString(" retry: config.retry ? { ...config.retry } : config.retry,\n")
+ buf.WriteString(" };\n\n")
// Apply request interceptors
if config.Interceptors {
@@ -141,25 +351,115 @@ 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(" // 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 requestConfig.body === 'string') {\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(" // 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.\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")
+
+ 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")
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 combined = requestConfig.signal\n")
+ buf.WriteString(" ? combineSignals(requestConfig.signal, controller.signal)\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")
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")
@@ -171,27 +471,90 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c
buf.WriteString(" }\n\n")
}
- buf.WriteString(" clearTimeout(timeoutId);\n\n")
-
buf.WriteString(" // Handle non-OK responses\n")
buf.WriteString(" if (!response.ok) {\n")
buf.WriteString(" await this.handleErrorResponse(response);\n")
buf.WriteString(" }\n\n")
- buf.WriteString(" // Parse response\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(" // 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")
+
+ 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(" const blob = await response.blob();\n\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(" return await response.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(" const parsed = JSON.parse(await blob.text());\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(" // Return empty object for 204 No Content\n")
- buf.WriteString(" if (response.status === 204) {\n")
- buf.WriteString(" return {} as T;\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")
- buf.WriteString(" return await response.text() as any;\n")
+ buf.WriteString(" return blob as any;\n")
buf.WriteString(" } catch (error) {\n")
- buf.WriteString(" clearTimeout(timeoutId);\n\n")
// Apply error interceptors
if config.Interceptors {
@@ -207,6 +570,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")
@@ -228,13 +605,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/fetch_client_test.go b/internal/client/generators/typescript/fetch_client_test.go
new file mode 100644
index 00000000..e1460e07
--- /dev/null
+++ b/internal/client/generators/typescript/fetch_client_test.go
@@ -0,0 +1,1293 @@
+package typescript
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// 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,
+ "src/codecs.ts": codecCode,
+ })
+
+ 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")
+}
+
+// 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")
+}
+
+// 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)
+ }
+}
+
+// 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/fieldname.go b/internal/client/generators/typescript/fieldname.go
new file mode 100644
index 00000000..63776773
--- /dev/null
+++ b/internal/client/generators/typescript/fieldname.go
@@ -0,0 +1,562 @@
+package typescript
+
+import (
+ "fmt"
+ "strings"
+
+ "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
+}
+
+// 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
+// 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:
+// - 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 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 --
+// 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.
+//
+// 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
+// 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 !codecsNeeded(config) {
+ return nil
+ }
+
+ var messages []string
+
+ visited := make(map[string]bool, len(spec.Schemas))
+
+ for _, schemaName := range sortedKeys(spec.Schemas) {
+ messages = append(messages, checkSchemaFieldCollisions(schemaName, spec.Schemas[schemaName], spec, config, visited)...)
+ }
+
+ messages = dedupeMessages(messages)
+
+ 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"))
+}
+
+// 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
+// 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.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
+// 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 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), 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
+// 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.
+// 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 {
+ if schema == nil || visited[id] || strings.Count(id, ".") > maxFieldCollisionDepth {
+ 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) {
+ prop := schema.Properties[wireName]
+ clientName := tsFieldName(id, wireName, config)
+
+ 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
+ }
+
+ if prop != nil && prop.Ref == "" {
+ 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, spec, config, visited)...)
+ }
+
+ if values, ok := additionalPropsSchema(schema.AdditionalProperties); ok && values != nil && values.Ref == "" {
+ // 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 {
+ if member != nil && member.Ref == "" {
+ 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, 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, visited)...)
+ }
+
+ return messages
+}
+
+// checkFlattenedAllOfCollisions checks the namespace(s) allOfEntry
+// (codecs.go) actually builds for an allOf composition: every contributing
+// 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 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 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
+// 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 (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 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 {
+ // 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, layerNSID+"."+wireName))
+ }
+ // first == wireName: the identical wire name reachable
+ // through two different paths to the same layer (e.g. a
+ // 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(layerNSID+"."+wireName, prop, spec, config, visited)...)
+ }
+ }
+ }
+
+ return messages
+}
diff --git a/internal/client/generators/typescript/fieldname_test.go b/internal/client/generators/typescript/fieldname_test.go
new file mode 100644
index 00000000..63447049
--- /dev/null
+++ b/internal/client/generators/typescript/fieldname_test.go
@@ -0,0 +1,1029 @@
+package typescript
+
+import (
+ "context"
+ "strings"
+ "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)
+ }
+ })
+ }
+}
+
+// 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())
+ }
+}
+
+// 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
+// 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 "." + 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"},
+ 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.additionalProperties.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.
+//
+// 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"},
+ 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.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 -----------
+//
+// 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())
+ }
+}
+
+// --- 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())
+ }
+ }
+}
diff --git a/internal/client/generators/typescript/fixtures_test.go b/internal/client/generators/typescript/fixtures_test.go
new file mode 100644
index 00000000..c7b3630e
--- /dev/null
+++ b/internal/client/generators/typescript/fixtures_test.go
@@ -0,0 +1,361 @@
+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", "no-auth-streaming", "ws-sse", "no-auth-ws-sse", "allof", "preserve", "preserve-ws-sse", "webtransport"}
+
+ fixtures := gateFixtures()
+
+ got := make(map[string]bool)
+ for _, f := range fixtures {
+ got[f.Name] = true
+ }
+
+ for _, name := range want {
+ if !got[name] {
+ 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); task 5c (finding 2 review)
+ // 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))
+ }
+}
+
+// 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) {
+ 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"}}},
+ // 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",
+ 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"}}}}},
+ },
+ // 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"}}}}},
+ },
+ // 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},
+ }
+}
+
+func baseConfig() client.GeneratorConfig {
+ cfg := client.DefaultConfig()
+ cfg.Language = "typescript"
+ cfg.PackageName = "probe"
+
+ 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{
+ "content-type": {Type: "string"},
+ "3dtiles": {Type: "string"},
+ "it's": {Type: "string"},
+ "back\\slash": {Type: "string"},
+ }}
+
+ withAuth := baseSpec()
+ withAuth.Security = []client.SecurityScheme{{Type: "http", Name: "bearerAuth", Scheme: "bearer"}}
+
+ apiName := baseConfig()
+ apiName.APIName = "APIClient"
+
+ noStreaming := baseConfig()
+ noStreaming.IncludeStreaming = false
+
+ noAuthStreaming := baseConfig()
+ noAuthStreaming.IncludeAuth = false
+
+ 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},
+ {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},
+ // 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()},
+ // 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()},
+ // 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()},
+ }
+}
+
+// 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.
+func wsSSESpec() *client.APISpec {
+ spec := baseSpec()
+ spec.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",
+ },
+ },
+ }
+ spec.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 spec
+}
+
+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
+}
diff --git a/internal/client/generators/typescript/gate_test.go b/internal/client/generators/typescript/gate_test.go
new file mode 100644
index 00000000..ea8d9d00
--- /dev/null
+++ b/internal/client/generators/typescript/gate_test.go
@@ -0,0 +1,818 @@
+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"
+)
+
+// 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"))
+ }
+ })
+ }
+}
+
+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"))
+ }
+ }
+ })
+ }
+}
+
+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
+
+ 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"]
+
+ // "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)
+ }
+
+ 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))
+
+ // 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"))
+ }
+}
+
+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())
+
+ 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")
+ }
+
+ 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")
+ }
+
+ 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")
+ }
+
+ // 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")
+ }
+
+ 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")
+ }
+}
+
+// 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())
+
+ // 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, "ArrayBuffer.isView(") {
+ t.Error("expected ArrayBuffer.isView to cover TypedArray/DataView bodies, which have no single toStringTag")
+ }
+
+ // 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") {
+ 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()
+
+ // 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")
+ }
+}
+
+// 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"))
+ }
+ })
+ }
+}
+
+// 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 {")
+}
+
+// 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")
+}
+
+// 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")
+}
+
+// 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")
+}
+
+// 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")
+}
+
+// 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")
+}
+
+// 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()
+
+ // 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/generator.go b/internal/client/generators/typescript/generator.go
index a763162f..c5711574 100644
--- a/internal/client/generators/typescript/generator.go
+++ b/internal/client/generators/typescript/generator.go
@@ -2,8 +2,10 @@ package typescript
import (
"context"
+ "encoding/json"
"fmt"
"slices"
+ "sort"
"strings"
"github.com/xraph/forge/errors"
@@ -11,6 +13,165 @@ 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 {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+
+ sort.Strings(keys)
+
+ 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 ""
+}
+
+// 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
+// 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{}
@@ -69,6 +230,14 @@ 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
+ }
+
+ if err := checkFieldNameCollisions(spec, config); err != nil {
+ return nil, err
+ }
+
genClient := &generators.GeneratedClient{
Files: make(map[string]string),
Language: "typescript",
@@ -109,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
@@ -125,25 +295,40 @@ 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. 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 {
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
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
@@ -266,12 +451,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,14 +557,16 @@ 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, config)
buf.WriteString(typeCode)
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")
@@ -619,48 +806,269 @@ export class EventEmitter {
`
}
-// schemaToTypeScript converts a schema to TypeScript.
-func (g *Generator) schemaToTypeScript(name string, schema *client.Schema, spec *client.APISpec) string {
+// 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`).
+//
+// 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."+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
+// 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")
+
+ for _, propName := range sortedKeys(schema.Properties) {
+ prop := schema.Properties[propName]
+ required := contains(schema.Required, propName)
+
+ optional := ""
+ if !required {
+ optional = "?"
+ }
+
+ buf.WriteString(propertyJSDoc(prop, " "))
+
+ 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("}")
+
+ return buf.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 ""
}
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, ""))
+
+ // 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, name, config)))
+ return buf.String()
+ }
+
switch schema.Type {
case "object":
- buf.WriteString(fmt.Sprintf("export interface %s {\n", name))
+ 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, name+"."+additionalPropertiesSegment, config)
+ }
+
+ 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, name+"."+additionalPropertiesSegment, config)
+ }
- for propName, prop := range schema.Properties {
- required := contains(schema.Required, propName)
+ buf.WriteString(fmt.Sprintf("export type %s = %s & Record;\n", name, g.objectPropsLiteral(schema, spec, name, config), valueType))
- optional := ""
- if !required {
- optional = "?"
+ 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, " "))
+
+ 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))
}
- tsType := g.schemaToTSType(prop, spec)
- buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", propName, optional, tsType))
+ buf.WriteString("}\n")
}
- buf.WriteString("}\n")
-
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))
}
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 {
+//
+// 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"
}
@@ -680,8 +1088,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, " | ")
@@ -694,8 +1102,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, " | ")
@@ -709,7 +1117,29 @@ 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))
+ }
+
+ // 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, " & ")
@@ -721,22 +1151,31 @@ func (g *Generator) schemaToTSType(schema *client.Schema, spec *client.APISpec)
return result
}
- 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))
- }
+ // 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"
+ }
- result := strings.Join(values, " | ")
- if schema.Nullable {
- result += " | null"
- }
+ return et
+ }
- return result
+ if ft := formatTSType(schema); ft != "" {
+ if schema.Nullable {
+ return ft + " | null"
}
+ return ft
+ }
+
+ switch schema.Type {
+ case "string":
if schema.Nullable {
return "string | null"
}
@@ -756,7 +1195,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"
}
@@ -770,11 +1209,45 @@ 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, nsID+"."+additionalPropertiesSegment, config)
+ }
+
+ result = g.objectPropsLiteral(schema, spec, nsID, config) + " & Record"
+
+ case allowed:
+ valueType := "any"
+ if valueSchema != nil {
+ valueType = g.schemaToTSType(valueSchema, spec, nsID+"."+additionalPropertiesSegment, config)
+ }
+
+ 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, nsID, config)
+
+ default:
+ result = "Record"
+ }
+
if schema.Nullable {
- return "Record | null"
+ return "(" + result + ") | null"
}
- return "Record"
+ return result
case "null":
return "null"
}
@@ -791,32 +1264,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")
@@ -850,6 +1341,10 @@ func (g *Generator) generateIndex(spec *client.APISpec, config client.GeneratorC
buf.WriteString("export * from './types';\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/generator_test.go b/internal/client/generators/typescript/generator_test.go
index 59a74d18..523b7783 100644
--- a/internal/client/generators/typescript/generator_test.go
+++ b/internal/client/generators/typescript/generator_test.go
@@ -534,118 +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
- 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")
- }
-}
+// 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/naming.go b/internal/client/generators/typescript/naming.go
new file mode 100644
index 00000000..8d069d19
--- /dev/null
+++ b/internal/client/generators/typescript/naming.go
@@ -0,0 +1,165 @@
+package typescript
+
+import (
+ "strings"
+ "unicode"
+)
+
+// 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
+ }
+
+ r[0] = unicode.ToLower(r[0])
+
+ return string(r)
+}
+
+// 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
+ }
+
+ r[0] = unicode.ToUpper(r[0])
+
+ return string(r)
+}
+
+// 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 (
+ words []string
+ cur strings.Builder
+ )
+
+ flush := func() {
+ if cur.Len() > 0 {
+ words = append(words, cur.String())
+ cur.Reset()
+ }
+ }
+
+ 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 && 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:
+ 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(lowerFirst(words[0]))
+
+ for _, w := range words[1:] {
+ out.WriteString(upperFirst(w))
+ }
+
+ 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(upperFirst(w))
+ }
+
+ 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
new file mode 100644
index 00000000..883a2ecd
--- /dev/null
+++ b/internal/client/generators/typescript/naming_test.go
@@ -0,0 +1,122 @@
+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
+ {"id", "id"},
+ {"a", "a"},
+ {"", ""},
+ {"_", "_"}, // 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
+
+ // 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 {
+ if got := toCamel(c.in); got != c.want {
+ t.Errorf("toCamel(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// 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"},
+ {"", ""},
+ {"_", ""},
+ {"--", ""},
+ {"...", ""},
+ {"_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 {
+ if got := toPascal(c.in); got != c.want {
+ t.Errorf("toPascal(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// 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)
+ }
+ }
+}
diff --git a/internal/client/generators/typescript/pagination.go b/internal/client/generators/typescript/pagination.go
index f1c4e790..be694ab1 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 {
@@ -281,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/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"))
+}
diff --git a/internal/client/generators/typescript/presence.go b/internal/client/generators/typescript/presence.go
index affea7eb..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()
}
@@ -50,6 +51,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 +61,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")
@@ -135,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")
@@ -260,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")
@@ -284,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/rename_test.go b/internal/client/generators/typescript/rename_test.go
new file mode 100644
index 00000000..05977856
--- /dev/null
+++ b/internal/client/generators/typescript/rename_test.go
@@ -0,0 +1,968 @@
+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 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"}
+
+ 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")
+
+ // 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, `"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. 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.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.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
+// 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)
+}
+
+// --- 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)
+}
diff --git a/internal/client/generators/typescript/rest.go b/internal/client/generators/typescript/rest.go
index 13f6cf8e..23ec3ac0 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"
@@ -9,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 {
@@ -48,8 +60,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,
}
@@ -103,16 +132,32 @@ 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
+ 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)
@@ -122,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
@@ -147,12 +194,17 @@ 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 + "'"
+ // 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)
}
// generateTreeNode recursively generates TypeScript object literals.
@@ -222,7 +274,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
@@ -235,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 ',').
@@ -247,9 +299,15 @@ 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
+ // 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)
@@ -285,6 +343,31 @@ 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 (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.
+ //
+ // 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)
+ }
+ }
}
// Headers
@@ -305,10 +388,59 @@ 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)
+ }
+
+ // Only set when every JSON 2xx response in the union agrees on a single
+ // 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.
+ //
+ // 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)
- // Make request
- returnType := r.generateReturnType(*endpoint, spec)
+ // 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` — 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 {
@@ -316,140 +448,285 @@ 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")
+// 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 {
+ return r.requestBodyContentType(endpoint) != ""
+}
- if endpoint.Summary != "" {
- buf.WriteString(fmt.Sprintf(" * %s\n", endpoint.Summary))
+// requestBodyContentType selects the single content type an endpoint's
+// 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
+// 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"
+ }
+
+ for _, contentType := range sortedKeys(endpoint.RequestBody.Content) {
+ if strings.HasPrefix(contentType, "text/") {
+ return contentType
+ }
}
- if endpoint.Description != "" {
- buf.WriteString(fmt.Sprintf(" * %s\n", endpoint.Description))
+ // 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
+ }
}
- if endpoint.Deprecated {
- buf.WriteString(" * @deprecated\n")
+ // Schemaless application/json really was the only option.
+ if len(keys) == 0 {
+ return ""
}
- buf.WriteString(" */\n")
+ return keys[0]
+}
- // Method signature with optional options parameter
- params := r.generateParameters(endpoint, spec)
- if params != "" {
- params += ", "
+// 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 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:
+ return "Blob"
}
+}
- 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")
+// 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
}
- // 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")
+ return endpoint.Method + " " + endpoint.Path
+}
- // Add request body
- if endpoint.RequestBody != nil {
- buf.WriteString(" body,\n")
+// schemaCodecRef returns the codec table id (see codecs.go) for a JSON
+// body/response schema at an endpoint boundary, or "" when none applies.
+//
+// Two shapes resolve to something real:
+//
+// - 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 ""
}
- // Add custom headers
- if len(endpoint.HeaderParams) > 0 {
- buf.WriteString(" headers: {\n")
+ if name := refName(schema.Ref); name != "" {
+ return name
+ }
- 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))
- }
+ if schema.Type == "array" && schema.Items != nil {
+ if itemName := refName(schema.Items.Ref); itemName != "" {
+ return arrayRefCodecID(itemName)
}
-
- buf.WriteString(" },\n")
}
- // Add signal and retry from options
- buf.WriteString(" signal: options?.signal,\n")
- buf.WriteString(" retry: options?.retry,\n")
+ return ""
+}
- buf.WriteString(" };\n\n")
+// 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 "", ""
+ }
+
+ 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))
+}
- // 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")
+// 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
+// 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, 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 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) (id string, warning string) {
+ codes := make([]int, 0, len(endpoint.Responses))
+
+ for code := range endpoint.Responses {
+ if code >= 200 && code < 300 {
+ codes = append(codes, code)
+ }
}
- buf.WriteString(" }\n")
+ sort.Ints(codes)
- return buf.String()
-}
+ var ref string
+ sawJSON := false
-// generateMethodName generates a method name from an endpoint.
-func (r *RESTGenerator) generateMethodName(endpoint client.Endpoint) string {
- if endpoint.OperationID != "" {
- return r.toCamelCase(endpoint.OperationID)
- }
+ for _, code := range codes {
+ resp := endpoint.Responses[code]
+ if resp == nil || len(resp.Content) == 0 {
+ continue
+ }
- // 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, "-", "_")
+ media, ok := resp.Content["application/json"]
+ if !ok || media.Schema == nil {
+ continue
+ }
- method := strings.ToLower(endpoint.Method)
+ name := schemaCodecRef(media.Schema)
+ if name == "" {
+ 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)
+ }
- return r.toCamelCase(method + "_" + path)
-}
+ if !sawJSON {
+ sawJSON = true
+ ref = name
-// 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 {
- if endpoint.RequestBody == nil {
- return false
- }
+ continue
+ }
- media, ok := endpoint.RequestBody.Content["application/json"]
+ if ref != name {
+ 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 ok && media.Schema != nil
+ return ref, ""
}
// generateParameters generates method parameters. Query and header parameters
@@ -472,8 +749,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)
@@ -513,23 +789,99 @@ func (r *RESTGenerator) generateParameters(endpoint client.Endpoint, spec *clien
return strings.Join(append(required, optional...), ", ")
}
-// generateReturnType generates the return type for an endpoint.
-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)
- }
+// generateReturnType generates the return type for an endpoint by unioning
+// 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
+// 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, bool) {
+ 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 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
+
+ types = append(types, t)
+ }
+ }
+
+ if len(types) == 0 {
+ // 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
}
- // Check for 204 No Content
- if _, ok := endpoint.Responses[204]; ok {
+ return strings.Join(types, " | "), hasVoid
+}
+
+// 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"
}
- return "any"
+ 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.
@@ -540,7 +892,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)
@@ -576,17 +930,24 @@ func (r *RESTGenerator) schemaToTSType(schema *client.Schema, spec *client.APISp
return "types." + parts[len(parts)-1]
}
- 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))
- }
+ // 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 neither the enum branch below nor the format branch appends
+ // " | null".
+ if et := enumTSType(schema); et != "" {
+ return et
+ }
- return strings.Join(values, " | ")
- }
+ if ft := formatTSType(schema); ft != "" {
+ return ft
+ }
+ switch schema.Type {
+ case "string":
return "string"
case "integer", "number":
return "number"
@@ -612,25 +973,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 5a3c9802..592379a7 100644
--- a/internal/client/generators/typescript/rest_test.go
+++ b/internal/client/generators/typescript/rest_test.go
@@ -1,6 +1,8 @@
package typescript
import (
+ "context"
+ "encoding/json"
"strings"
"testing"
@@ -36,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 = {")
@@ -78,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 = {")
@@ -110,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 = ")
@@ -138,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 = {")
@@ -176,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 = {")
@@ -185,19 +187,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
- 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 = {}")
- 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) {
@@ -238,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 = {")
@@ -271,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")
@@ -295,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)
@@ -334,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 = {")
@@ -373,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
@@ -433,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'`).
@@ -442,3 +444,812 @@ func TestRESTGenerator_ReturnTypes(t *testing.T) {
assert.Contains(t, code, "return this.request(config)")
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 {
+ 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.
+ 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")
+}
+
+// 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 ""
+}
+
+// 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.
+ //
+ // "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"},
+ 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))}")
+}
+
+// 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 {
+ code, _ := 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())
+
+ return code
+ }
+
+ 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 {
+ code, _ := 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())
+
+ return code
+ }
+
+ // 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")
+
+ // 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
+// 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?")
+}
+
+// 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).
+//
+// 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"}}}
+ 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, 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
+// (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.
+//
+// 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{
+ 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, 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")
+}
+
+// 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
+// (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")
+}
diff --git a/internal/client/generators/typescript/rooms.go b/internal/client/generators/typescript/rooms.go
index 43d7fdb2..4902fcf3 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")
@@ -94,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()
}
@@ -139,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")
@@ -289,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")
@@ -315,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/runtime_test.go b/internal/client/generators/typescript/runtime_test.go
new file mode 100644
index 00000000..8ae41e71
--- /dev/null
+++ b/internal/client/generators/typescript/runtime_test.go
@@ -0,0 +1,521 @@
+package typescript
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "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
+// 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}
+ }
+
+ // 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 {
+ probe := exec.CommandContext(context.Background(), path, "--no-install", "esbuild", "--version")
+ if probe.Run() == nil {
+ return []string{path, "--no-install", "esbuild"}
+ }
+ }
+
+ t.Skip("esbuild not available (not on PATH, and not reachable via npx --no-install); 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)
+}
+
+// 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). 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
+// 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 },
+ ],
+ // 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',
+ };
+
+ 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 },
+ ],
+ // 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,
+ };
+
+ 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 billingRegion: string = created.metadata['billing_region'];
+ const expressZone: string = created.metadata['expressZone'];
+
+ 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,
+ 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.
+ 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"`
+ BillingRegion string `json:"billingRegion"`
+ ExpressZone string `json:"expressZone"`
+ 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, "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)
+ 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, "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")
+
+ // --- 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")
+}
+
+// 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)
+ }
+ }
+ }
+}
diff --git a/internal/client/generators/typescript/sse.go b/internal/client/generators/typescript/sse.go
index 76ccfeeb..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.
@@ -50,6 +118,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 +129,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")
@@ -114,8 +188,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")
@@ -136,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)
@@ -215,12 +293,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")
@@ -260,12 +341,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")
@@ -273,7 +357,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
@@ -307,9 +391,27 @@ 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)
+ // 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")
@@ -319,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")
@@ -349,7 +451,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)
@@ -514,17 +617,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/streaming_client.go b/internal/client/generators/typescript/streaming_client.go
index 66cd62c4..96e027fb 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")
@@ -168,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")
@@ -252,7 +260,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 +279,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 +298,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 +317,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/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/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")
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
+}
diff --git a/internal/client/generators/typescript/typing.go b/internal/client/generators/typescript/typing.go
index 56e564f6..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()
}
@@ -50,6 +51,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 +61,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")
@@ -126,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")
@@ -255,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")
@@ -279,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")
diff --git a/internal/client/generators/typescript/websocket.go b/internal/client/generators/typescript/websocket.go
index d01bb906..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.
@@ -50,6 +157,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 +168,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")
@@ -122,8 +235,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")
@@ -156,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))
@@ -262,12 +404,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")
@@ -275,13 +422,17 @@ 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")
- buf.WriteString(" this.ws = new (WS as any)(url, { headers });\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")
// Use standard event handlers that work in both browser and Node.js
@@ -305,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")
@@ -360,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")
@@ -394,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
@@ -436,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")
@@ -646,17 +809,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..45ab35e0 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.
@@ -103,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")
@@ -140,11 +233,86 @@ 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 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,
+ // 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))
@@ -224,12 +392,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")
@@ -305,29 +479,77 @@ func (w *WebTransportGenerator) generateWebTransportClient(wt client.WebTranspor
buf.WriteString(" }\n")
buf.WriteString(" }\n\n")
- // Bidirectional stream methods
+ // 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
+
+ // 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 {
- buf.WriteString(w.generateBiStreamMethods(wt.BiStreamSchema, spec, config))
+ buf.WriteString(w.generateOpenBidiStreamMethod(className))
}
- // Unidirectional stream methods
+ auxClasses.WriteString(w.generateUniStreamClass(wt.UniStreamSchema, spec, uniSendCodecID, className))
+
if wt.UniStreamSchema != nil {
- buf.WriteString(w.generateUniStreamMethods(wt.UniStreamSchema, spec, config))
+ buf.WriteString(w.generateOpenUniStreamMethod(className))
}
// 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 +608,29 @@ 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 {
- var buf strings.Builder
+// 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"
- sendType := w.getSchemaTypeName(schema.SendSchema, spec)
- receiveType := w.getSchemaTypeName(schema.ReceiveSchema, spec)
+ var buf strings.Builder
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 +642,43 @@ 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(`/**
+ 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 BiDiStream {
+class %s {
private stream: WebTransportBidirectionalStream;
private writer: WritableStreamDefaultWriter | null = null;
private reader: ReadableStreamDefaultReader | null = null;
@@ -440,7 +696,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 +717,7 @@ class BiDiStream {
result += decoder.decode(value, { stream: true });
}
- return JSON.parse(result);
+ return %s;
}
/**
@@ -477,16 +733,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 +763,24 @@ class BiDiStream {
}
}
-`, sendType, receiveType, receiveType))
-
- return buf.String()
+`, biDiStreamName, sendType, wireEncodeExpr(sendCodecID, "msg"), receiveType, wireDecodeExpr(receiveCodecID, "JSON.parse(result)"), receiveType, wireDecodeExpr(receiveCodecID, "JSON.parse(line)"))
}
-// generateUniStreamMethods generates unidirectional stream methods.
-func (w *WebTransportGenerator) generateUniStreamMethods(schema *client.StreamSchema, spec *client.APISpec, config client.GeneratorConfig) string {
- var buf strings.Builder
+// 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"
- sendType := w.getSchemaTypeName(schema.SendSchema, spec)
+ var buf strings.Builder
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 +792,41 @@ 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(`/**
+ 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 UniStream {
+class %s {
private stream: WritableStream;
private writer: WritableStreamDefaultWriter | null = null;
@@ -559,7 +843,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 +858,11 @@ class UniStream {
}
}
-`, sendType))
-
- return buf.String()
+`, uniStreamName, sendType, wireEncodeExpr(sendCodecID, "msg"))
}
// 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 +875,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 +917,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 +940,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 +962,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 +1035,20 @@ 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 generateBiDiStreamClass
+// declares (className + "BiDiStream") -- handleIncomingBidiStreams
+// 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;
// Handle incoming bidirectional streams
@@ -775,7 +1069,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 +1115,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 +1124,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 +1144,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 +1152,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 +1164,7 @@ func (w *WebTransportGenerator) generateStateManagement() string {
this.on('close', handler);
}
-`
+`, biDiStreamName, uniReceiveType)
}
// generateErrorHandling generates error handling methods.
@@ -947,17 +1244,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)
}
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..f2ece197
--- /dev/null
+++ b/internal/client/generators/typescript/webtransport_codec_test.go
@@ -0,0 +1,699 @@
+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")
+ }
+}
+
+// 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"))
+ }
+}
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/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
diff --git a/internal/client/output.go b/internal/client/output.go
index c45ecbea..3095900b 100644
--- a/internal/client/output.go
+++ b/internal/client/output.go
@@ -18,6 +18,18 @@ 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 {
// Create output directory with restrictive permissions
if err := os.MkdirAll(outputDir, 0750); err != nil {
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/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 {
diff --git a/internal/shared/asyncapi.go b/internal/shared/asyncapi.go
index ed02b8c0..c77169ea 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,36 +282,36 @@ 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"`
}
// AsyncAPIOAuthFlows defines OAuth 2.0 flows (compatible with OpenAPI OAuthFlows).
type AsyncAPIOAuthFlows struct {
- Implicit *OAuthFlow `json:"implicit,omitempty"`
- Password *OAuthFlow `json:"password,omitempty"`
- ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty"`
- AuthorizationCode *OAuthFlow `json:"authorizationCode,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"`
}
// 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 9d546065..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.
@@ -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,49 +241,59 @@ 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"`
// 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"`
- 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"`
}
@@ -292,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.
@@ -306,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..4e987de6
--- /dev/null
+++ b/internal/shared/yaml_tags_test.go
@@ -0,0 +1,769 @@
+package shared
+
+import (
+ "encoding/json"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+// 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
+ hasJSON bool
+ 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)
+ }
+
+ 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
+ }
+
+ 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")
+ 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,
+ hasJSON: hasJSON,
+ 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(fieldName, yamlTag string, hasYAML bool) (key string, skip bool) {
+ if !hasYAML {
+ return strings.ToLower(fieldName), false
+ }
+
+ parts := strings.Split(yamlTag, ",")
+ if parts[0] == "-" {
+ return "", true
+ }
+
+ if parts[0] == "" {
+ return strings.ToLower(fieldName), false
+ }
+
+ return parts[0], false
+}
+
+// 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
+}
+
+// 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.
+//
+// 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,
+// or without any tag at all.
+func TestJSONYAMLTagParity(t *testing.T) {
+ fields := parseStructFields(t, "openapi.go", "asyncapi.go")
+ if len(fields) == 0 {
+ t.Fatal("parseStructFields returned no fields; the AST walk is broken")
+ }
+
+ for _, f := range fields {
+ f := f
+
+ t.Run(f.structName+"."+f.fieldName, func(t *testing.T) {
+ runTagParityCheck(t, f)
+ })
+ }
+}
+
+// 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")
+ }
+}
+
+// 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...))
+}