From 0f863124ce2fbd78927d372c6a666332139cca61 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:50:07 +0400 Subject: [PATCH 1/2] fix(ts): flatten annotated non-flatten oneofs to match the wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TS generators rendered annotated non-flatten oneofs as a wrapper property named after the oneof: export interface NestedEvent { id: string; content?: { kind: "text"; text?: TextContent } | ...; } but no such property exists on the wire. The generated MarshalJSON writes the discriminator directly onto the parent object (raw["kind"] = "text") and leaves the variant key where protojson put it — also on the parent — so the actual JSON is {"id": ..., "kind": "text", "text": {...}}. The OpenAPI generator already documents this flat shape; TypeScript was the only generator disagreeing with the Go serialization. Non-flatten annotated oneofs now render flat, intersected with the base fields like flattened ones: export type NestedEventContent = | { kind: "text"; text: TextContent; image?: never; video?: never } | { kind: "image"; image: ImageContent; text?: never; video?: never } | { kind: "vid"; video: VideoContent; text?: never; image?: never } | { kind?: never; text?: never; image?: never; video?: never }; export type NestedEvent = NestedEventBase & NestedEventContent; Payloads are non-optional because oneof fields have explicit presence — a set member is always emitted. Sibling variant keys are `?: never` so narrowing on the discriminator also types the payload keys correctly, and the all-never arm models the unset oneof, which MarshalJSON emits with no discriminator at all. Flattened oneofs gain the same unset arm ({ type?: never }) for that reason. With every oneof now rendering flat on the parent, the wrapper-property branch in GenerateStandardInterface is unreachable and removed — which also removes the bug where the wrapper property kept the oneof's raw snake_case name (e.g. `super_title_image?:`) instead of a camelCase one. Co-Authored-By: Claude Fable 5 --- .../oneof_discriminator_consistency_test.go | 13 ++ .../golden/oneof_discriminator_client.ts | 15 ++- internal/tscommon/types.go | 124 ++++++++++-------- .../golden/oneof_discriminator_server.ts | 15 ++- 4 files changed, 103 insertions(+), 64 deletions(-) diff --git a/internal/httpgen/oneof_discriminator_consistency_test.go b/internal/httpgen/oneof_discriminator_consistency_test.go index 105bd83..aa18d77 100644 --- a/internal/httpgen/oneof_discriminator_consistency_test.go +++ b/internal/httpgen/oneof_discriminator_consistency_test.go @@ -75,6 +75,19 @@ func TestOneofDiscriminatorTypeScriptTypes(t *testing.T) { t.Error("TypeScript NestedEvent should have vid variant with kind: \"vid\" (custom oneof_value)") } + // NestedEvent variants sit flat on the parent, matching the generated + // MarshalJSON: discriminator and the set variant's key are both emitted + // directly on the parent object, with no wrapper property for the oneof. + if !strings.Contains(tsContent, "export type NestedEvent = NestedEventBase & NestedEventContent") { + t.Error("TypeScript NestedEvent should intersect its base fields with the oneof union") + } + if !strings.Contains(tsContent, `{ kind: "text"; text: TextContent; image?: never; video?: never }`) { + t.Error("TypeScript NestedEvent text variant should carry a non-optional payload with never guards") + } + if !strings.Contains(tsContent, `{ kind?: never; text?: never; image?: never; video?: never }`) { + t.Error("TypeScript NestedEventContent should include an all-never arm for the unset oneof") + } + // PlainEvent: un-annotated oneof renders as a presence-discriminated flat // union intersected with the base fields, matching plain protojson (the set // member's key appears directly on the parent; no discriminator on the wire). diff --git a/internal/tsclientgen/testdata/golden/oneof_discriminator_client.ts b/internal/tsclientgen/testdata/golden/oneof_discriminator_client.ts index 3831e6a..7d84f2c 100644 --- a/internal/tsclientgen/testdata/golden/oneof_discriminator_client.ts +++ b/internal/tsclientgen/testdata/golden/oneof_discriminator_client.ts @@ -3,7 +3,8 @@ export type FlattenedEventContent = | { type: "text"; body: string } - | { type: "img"; url: string; width: number; height: number }; + | { type: "img"; url: string; width: number; height: number } + | { type?: never }; export interface FlattenedEventBase { id: string; @@ -22,15 +23,17 @@ export interface ImageContent { } export type NestedEventContent = - | { kind: "text"; text?: TextContent } - | { kind: "image"; image?: ImageContent } - | { kind: "vid"; video?: VideoContent }; + | { kind: "text"; text: TextContent; image?: never; video?: never } + | { kind: "image"; image: ImageContent; text?: never; video?: never } + | { kind: "vid"; video: VideoContent; text?: never; image?: never } + | { kind?: never; text?: never; image?: never; video?: never }; -export interface NestedEvent { +export interface NestedEventBase { id: string; - content?: NestedEventContent; } +export type NestedEvent = NestedEventBase & NestedEventContent; + export interface VideoContent { url: string; duration: number; diff --git a/internal/tscommon/types.go b/internal/tscommon/types.go index dac078b..19c35a0 100644 --- a/internal/tscommon/types.go +++ b/internal/tscommon/types.go @@ -395,23 +395,16 @@ func GenerateInterface(p Printer, msg *protogen.Message) { } } - // Flattened and presence-discriminated (un-annotated) oneofs render their - // members flat on the parent object, which requires the intersection type - // alias instead of a wrapper property. - hasIntersectionOneof := false - for _, info := range discriminatedOneofs { - if info.Flatten || info.Discriminator == "" { - hasIntersectionOneof = true - break - } - } - // Generate discriminated union types before the message for _, info := range discriminatedOneofs { GenerateOneofDiscriminatedUnionType(p, name, info) } - if hasIntersectionOneof { + // Every oneof union renders its members flat on the parent object — that is + // where the wire puts them (protojson for un-annotated oneofs, the generated + // MarshalJSON for annotated ones) — so any discriminated oneof requires the + // intersection type alias instead of a wrapper property. + if len(discriminatedOneofs) > 0 { GenerateFlattenedOneofInterface(p, msg, name, discriminatedOneofs) } else { GenerateStandardInterface(p, msg, name, discriminatedOneofs) @@ -449,8 +442,13 @@ func GenerateOneofDiscriminatedUnionType(p Printer, msgName string, info *annota return } + jsonNames := make([]string, len(info.Variants)) + for i, variant := range info.Variants { + jsonNames[i] = variant.Field.Desc.JSONName() + } + var branches []string - for _, variant := range info.Variants { + for i, variant := range info.Variants { var branch string switch { case info.Flatten && variant.IsMessage: @@ -465,31 +463,46 @@ func GenerateOneofDiscriminatedUnionType(p Printer, msgName string, info *annota branch += sb.String() branch += " }" case variant.IsMessage: - // Non-flattened message: { discriminator: "value", fieldName?: MessageType } - fieldJSONName := variant.Field.Desc.JSONName() - msgType := string(variant.Field.Message.Desc.Name()) - branch = fmt.Sprintf( - "{ %s: \"%s\"; %s?: %s }", - info.Discriminator, - variant.DiscriminatorVal, - fieldJSONName, - msgType, + // Non-flattened message. The generated MarshalJSON keeps the variant's + // JSON key where protojson put it — directly on the parent object, + // next to the discriminator — so the arm carries both, with sibling + // variant keys typed `?: never` for presence narrowing. + branch = nonFlattenedOneofBranch( + info, variant.DiscriminatorVal, jsonNames, i, + string(variant.Field.Message.Desc.Name()), ) default: - // Non-flattened scalar: { discriminator: "value", fieldName?: scalarType } - fieldJSONName := variant.Field.Desc.JSONName() - tsType := TSScalarTypeForField(variant.Field) - branch = fmt.Sprintf( - "{ %s: \"%s\"; %s?: %s }", - info.Discriminator, - variant.DiscriminatorVal, - fieldJSONName, - tsType, - ) + // Non-flattened scalar: same flat shape with the scalar's TS type. + if info.Flatten { + branch = fmt.Sprintf( + "{ %s: \"%s\"; %s?: %s }", + info.Discriminator, + variant.DiscriminatorVal, + jsonNames[i], + TSScalarTypeForField(variant.Field), + ) + } else { + branch = nonFlattenedOneofBranch( + info, variant.DiscriminatorVal, jsonNames, i, + TSScalarTypeForField(variant.Field), + ) + } } branches = append(branches, branch) } + // Unset arm: proto3 oneofs may have no member set, in which case the + // generated MarshalJSON omits the discriminator entirely. + var unset strings.Builder + fmt.Fprintf(&unset, "{ %s?: never", info.Discriminator) + if !info.Flatten { + for _, jsonName := range jsonNames { + fmt.Fprintf(&unset, "; %s?: never", jsonName) + } + } + unset.WriteString(" }") + branches = append(branches, unset.String()) + p("export type %s =", unionName) for i, branch := range branches { if i < len(branches)-1 { @@ -501,6 +514,29 @@ func GenerateOneofDiscriminatedUnionType(p Printer, msgName string, info *annota p("") } +// nonFlattenedOneofBranch renders one arm of a non-flattened annotated oneof +// union: discriminator, the set variant's key with a non-optional payload +// (oneof fields have explicit presence, so a set member is always emitted), +// and `?: never` guards for the sibling variant keys. +func nonFlattenedOneofBranch( + info *annotations.OneofDiscriminatorInfo, + discriminatorVal string, + jsonNames []string, + index int, + tsType string, +) string { + var sb strings.Builder + fmt.Fprintf(&sb, "{ %s: \"%s\"; %s: %s", info.Discriminator, discriminatorVal, jsonNames[index], tsType) + for j := range jsonNames { + if j == index { + continue + } + fmt.Fprintf(&sb, "; %s?: never", jsonNames[j]) + } + sb.WriteString(" }") + return sb.String() +} + // generatePresenceOneofUnionType renders a oneof that has no wire discriminator // (no oneof_config annotation) as a union discriminated by key presence, matching // protojson serialization: exactly the set member's JSON key appears on the parent @@ -605,8 +641,10 @@ func GenerateFlattenedOneofInterface( p("") } -// GenerateStandardInterface generates a standard interface, handling non-flattened -// discriminated oneofs as optional union properties. +// GenerateStandardInterface generates a standard interface for messages without +// discriminated oneofs. Fields belonging to discriminated oneofs are skipped; +// messages that have any render through GenerateFlattenedOneofInterface instead, +// since every oneof union sits flat on the parent object. func GenerateStandardInterface( p Printer, msg *protogen.Message, @@ -616,27 +654,9 @@ func GenerateStandardInterface( // Build set of fields that belong to discriminated oneofs oneofFields := BuildOneofFieldSet(discriminatedOneofs) - // Build map of oneof -> union type name for non-flattened - oneofUnionNames := make(map[*protogen.Oneof]string) - for _, info := range discriminatedOneofs { - unionName := name + SnakeToUpperCamel(string(info.Oneof.Desc.Name())) - oneofUnionNames[info.Oneof] = unionName - } - p("export interface %s {", name) - // Track which oneofs we've already emitted - emittedOneofs := make(map[*protogen.Oneof]bool) - for _, field := range msg.Fields { if oneofFields[field] { - // For discriminated oneof fields, emit the union type once for the oneof - if field.Oneof != nil && !emittedOneofs[field.Oneof] { - if unionName, ok := oneofUnionNames[field.Oneof]; ok { - oneofJSONName := string(field.Oneof.Desc.Name()) - p(" %s?: %s;", oneofJSONName, unionName) - emittedOneofs[field.Oneof] = true - } - } continue } diff --git a/internal/tsservergen/testdata/golden/oneof_discriminator_server.ts b/internal/tsservergen/testdata/golden/oneof_discriminator_server.ts index 62bf8a8..7de9ed7 100644 --- a/internal/tsservergen/testdata/golden/oneof_discriminator_server.ts +++ b/internal/tsservergen/testdata/golden/oneof_discriminator_server.ts @@ -3,7 +3,8 @@ export type FlattenedEventContent = | { type: "text"; body: string } - | { type: "img"; url: string; width: number; height: number }; + | { type: "img"; url: string; width: number; height: number } + | { type?: never }; export interface FlattenedEventBase { id: string; @@ -22,15 +23,17 @@ export interface ImageContent { } export type NestedEventContent = - | { kind: "text"; text?: TextContent } - | { kind: "image"; image?: ImageContent } - | { kind: "vid"; video?: VideoContent }; + | { kind: "text"; text: TextContent; image?: never; video?: never } + | { kind: "image"; image: ImageContent; text?: never; video?: never } + | { kind: "vid"; video: VideoContent; text?: never; image?: never } + | { kind?: never; text?: never; image?: never; video?: never }; -export interface NestedEvent { +export interface NestedEventBase { id: string; - content?: NestedEventContent; } +export type NestedEvent = NestedEventBase & NestedEventContent; + export interface VideoContent { url: string; duration: number; From de440bf6eb07a953bcb4e4f79206e93e6bb9fdd0 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:54 +0400 Subject: [PATCH 2/2] test(ts): cover multi-word oneof names in TS oneof emission Add a TS-only fixture (multi_word_oneof.proto) whose un-annotated oneof has a multi-word name (super_title_image), exercising the presence-union path in tscommon. This locks in the regression fixed on this branch: the oneof name must surface only as the PascalCase union type name (MultiWordEventSuperTitleImage) and never leak into the generated TS as a raw snake_case wrapper property. The fixture is scoped to the ts-client and ts-server generators only, leaving the shared oneof_discriminator.proto byte-identical across all six generators so the Go/OpenAPI/Python goldens and cross-generator consistency tests are untouched. - New golden test cases in tsclientgen/tsservergen; the new proto is added to the tsservergen cross-generator consistency proto list. - Explicit regression assertion (TestMultiWordOneofNameDoesNotLeak): generated TS contains MultiWordEventSuperTitleImage and not super_title_image. - Unit tests for nonFlattenedOneofBranch in tscommon (message, middle, and scalar variants), asserting discriminator/value/payload/never-guards. Co-Authored-By: Claude Fable 5 --- internal/tsclientgen/golden_test.go | 37 ++++++ .../golden/multi_word_oneof_client.ts | 112 ++++++++++++++++ .../testdata/proto/multi_word_oneof.proto | 46 +++++++ internal/tscommon/types_test.go | 35 +++++ internal/tsservergen/consistency_test.go | 1 + internal/tsservergen/golden_test.go | 7 + .../golden/multi_word_oneof_server.ts | 123 ++++++++++++++++++ .../testdata/proto/multi_word_oneof.proto | 46 +++++++ 8 files changed, 407 insertions(+) create mode 100644 internal/tsclientgen/testdata/golden/multi_word_oneof_client.ts create mode 100644 internal/tsclientgen/testdata/proto/multi_word_oneof.proto create mode 100644 internal/tsservergen/testdata/golden/multi_word_oneof_server.ts create mode 100644 internal/tsservergen/testdata/proto/multi_word_oneof.proto diff --git a/internal/tsclientgen/golden_test.go b/internal/tsclientgen/golden_test.go index 0520b32..ed8370f 100644 --- a/internal/tsclientgen/golden_test.go +++ b/internal/tsclientgen/golden_test.go @@ -116,6 +116,13 @@ func TestTSClientGenGoldenFiles(t *testing.T) { "oneof_discriminator_client.ts", }, }, + { + name: "multi-word oneof name", + protoFile: "multi_word_oneof.proto", + expectedFiles: []string{ + "multi_word_oneof_client.ts", + }, + }, { name: "SSE streaming", protoFile: "sse.proto", @@ -208,6 +215,36 @@ func TestTSClientGenGoldenFiles(t *testing.T) { } } +// TestMultiWordOneofNameDoesNotLeak asserts the regression fixed on this branch: +// a multi-word oneof name (super_title_image) must surface only as the PascalCase +// union type name and never leak into the generated TypeScript as a raw +// snake_case wrapper property. See internal/tscommon/types.go +// (GenerateOneofDiscriminatedUnionType / GenerateStandardInterface). +func TestMultiWordOneofNameDoesNotLeak(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + goldenPath := filepath.Join(wd, "testdata", "golden", "multi_word_oneof_client.ts") + + content, readErr := os.ReadFile(goldenPath) + if readErr != nil { + t.Fatalf("Failed to read golden file %s: %v", goldenPath, readErr) + } + ts := string(content) + + // The oneof name renders as the PascalCase discriminated-union type name. + if !strings.Contains(ts, "MultiWordEventSuperTitleImage") { + t.Error("expected generated TS to contain the PascalCase union type MultiWordEventSuperTitleImage") + } + + // The raw snake_case oneof name must never appear: no wrapper property such + // as `super_title_image?:` leaks onto the message interface. + if strings.Contains(ts, "super_title_image") { + t.Error("generated TS must not contain the raw snake_case oneof name super_title_image") + } +} + func updateGoldenFile(t *testing.T, goldenPath string, content []byte) { t.Helper() writeErr := os.WriteFile(goldenPath, content, 0o644) diff --git a/internal/tsclientgen/testdata/golden/multi_word_oneof_client.ts b/internal/tsclientgen/testdata/golden/multi_word_oneof_client.ts new file mode 100644 index 0000000..b62d656 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/multi_word_oneof_client.ts @@ -0,0 +1,112 @@ +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: multi_word_oneof.proto + +export type MultiWordEventSuperTitleImage = + | { bigText: TextContent; bigImage?: never } + | { bigImage: ImageContent; bigText?: never } + | { bigText?: never; bigImage?: never }; + +export interface MultiWordEventBase { + id: string; +} + +export type MultiWordEvent = MultiWordEventBase & MultiWordEventSuperTitleImage; + +export interface TextContent { + body: string; +} + +export interface ImageContent { + url: string; + width: number; + height: number; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface MultiWordOneofServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface MultiWordOneofServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class MultiWordOneofServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: MultiWordOneofServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async testMultiWordEvent(req: MultiWordEvent, options?: MultiWordOneofServiceCallOptions): Promise { + let path = "/api/v1/events/multi-word"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(req), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as MultiWordEvent; + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/internal/tsclientgen/testdata/proto/multi_word_oneof.proto b/internal/tsclientgen/testdata/proto/multi_word_oneof.proto new file mode 100644 index 0000000..0675197 --- /dev/null +++ b/internal/tsclientgen/testdata/proto/multi_word_oneof.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +package testdata.multi_word_oneof; + +option go_package = "github.com/SebastienMelki/sebuf/internal/tsclientgen/testdata/multiwordoneof;multiwordoneof"; + +import "sebuf/http/annotations.proto"; + +// Variant message types +message TextContent { + string body = 1; +} + +message ImageContent { + string url = 1; + int32 width = 2; + int32 height = 3; +} + +// Message with a multi-word oneof name and no oneof_config annotation. +// +// The oneof name (super_title_image) is deliberately multi-word: it must +// surface in the generated TypeScript only as the PascalCase union type name +// (MultiWordEventSuperTitleImage) and must NEVER leak as a raw snake_case +// wrapper property. Because the oneof is un-annotated it takes the +// presence-discriminated flat-union path, matching plain protojson. +message MultiWordEvent { + string id = 1; + oneof super_title_image { + TextContent big_text = 2; + ImageContent big_image = 3; + } +} + +service MultiWordOneofService { + option (sebuf.http.service_config) = { + base_path: "/api/v1" + }; + + rpc TestMultiWordEvent(MultiWordEvent) returns (MultiWordEvent) { + option (sebuf.http.config) = { + path: "/events/multi-word" + method: HTTP_METHOD_POST + }; + } +} diff --git a/internal/tscommon/types_test.go b/internal/tscommon/types_test.go index f8ac95e..5bdb34e 100644 --- a/internal/tscommon/types_test.go +++ b/internal/tscommon/types_test.go @@ -7,6 +7,8 @@ import ( "testing" "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/SebastienMelki/sebuf/internal/annotations" ) func TestTSScalarType(t *testing.T) { @@ -77,6 +79,39 @@ func TestTSZeroCheck(t *testing.T) { } } +// TestNonFlattenedOneofBranch verifies the arm rendered for a single variant of a +// non-flattened annotated oneof: the discriminator with its value, the set +// variant's key carrying a non-optional payload, and `?: never` guards for every +// sibling variant key. nonFlattenedOneofBranch reads only info.Discriminator, so +// it can be exercised directly without a *protogen.Oneof. +func TestNonFlattenedOneofBranch(t *testing.T) { + // Three message variants keyed by their JSON names; render the "text" arm. + info := &annotations.OneofDiscriminatorInfo{Discriminator: "kind"} + jsonNames := []string{"text", "image", "video"} + + got := nonFlattenedOneofBranch(info, "text", jsonNames, 0, "TextContent") + want := `{ kind: "text"; text: TextContent; image?: never; video?: never }` + if got != want { + t.Errorf("nonFlattenedOneofBranch message variant:\n got: %s\n want: %s", got, want) + } + + // A middle variant guards both its siblings (before and after its index). + gotMid := nonFlattenedOneofBranch(info, "image", jsonNames, 1, "ImageContent") + wantMid := `{ kind: "image"; image: ImageContent; text?: never; video?: never }` + if gotMid != wantMid { + t.Errorf("nonFlattenedOneofBranch middle variant:\n got: %s\n want: %s", gotMid, wantMid) + } + + // A scalar variant: the discriminator value, the field name, and the scalar + // TS type land correctly, with the sibling key guarded. + scalarInfo := &annotations.OneofDiscriminatorInfo{Discriminator: "type"} + gotScalar := nonFlattenedOneofBranch(scalarInfo, "count", []string{"count", "label"}, 0, "number") + wantScalar := `{ type: "count"; count: number; label?: never }` + if gotScalar != wantScalar { + t.Errorf("nonFlattenedOneofBranch scalar variant:\n got: %s\n want: %s", gotScalar, wantScalar) + } +} + // readGoldenFile reads a golden file relative to the project root and returns its content. func readGoldenFile(t *testing.T, projectRoot, relPath string) string { t.Helper() diff --git a/internal/tsservergen/consistency_test.go b/internal/tsservergen/consistency_test.go index 9340c18..b1184ef 100644 --- a/internal/tsservergen/consistency_test.go +++ b/internal/tsservergen/consistency_test.go @@ -30,6 +30,7 @@ func TestCrossGeneratorTypeConsistency(t *testing.T) { "bytes_encoding.proto", "flatten.proto", "oneof_discriminator.proto", + "multi_word_oneof.proto", } baseDir, err := os.Getwd() diff --git a/internal/tsservergen/golden_test.go b/internal/tsservergen/golden_test.go index 27cc026..1a7ce01 100644 --- a/internal/tsservergen/golden_test.go +++ b/internal/tsservergen/golden_test.go @@ -123,6 +123,13 @@ func TestTSServerGenGoldenFiles(t *testing.T) { "oneof_discriminator_server.ts", }, }, + { + name: "multi-word oneof name", + protoFile: "multi_word_oneof.proto", + expectedFiles: []string{ + "multi_word_oneof_server.ts", + }, + }, { name: "SSE streaming", protoFile: "sse.proto", diff --git a/internal/tsservergen/testdata/golden/multi_word_oneof_server.ts b/internal/tsservergen/testdata/golden/multi_word_oneof_server.ts new file mode 100644 index 0000000..9c15ec9 --- /dev/null +++ b/internal/tsservergen/testdata/golden/multi_word_oneof_server.ts @@ -0,0 +1,123 @@ +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: multi_word_oneof.proto + +export type MultiWordEventSuperTitleImage = + | { bigText: TextContent; bigImage?: never } + | { bigImage: ImageContent; bigText?: never } + | { bigText?: never; bigImage?: never }; + +export interface MultiWordEventBase { + id: string; +} + +export type MultiWordEvent = MultiWordEventBase & MultiWordEventSuperTitleImage; + +export interface TextContent { + body: string; +} + +export interface ImageContent { + url: string; + width: number; + height: number; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface MultiWordOneofServiceHandler { + testMultiWordEvent(ctx: ServerContext, req: MultiWordEvent): Promise; +} + +export function createMultiWordOneofServiceRoutes( + handler: MultiWordOneofServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "POST", + path: "/api/v1/events/multi-word", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as MultiWordEvent; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("testMultiWordEvent", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.testMultiWordEvent(ctx, body); + return new Response(JSON.stringify(result as MultiWordEvent), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + diff --git a/internal/tsservergen/testdata/proto/multi_word_oneof.proto b/internal/tsservergen/testdata/proto/multi_word_oneof.proto new file mode 100644 index 0000000..0675197 --- /dev/null +++ b/internal/tsservergen/testdata/proto/multi_word_oneof.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +package testdata.multi_word_oneof; + +option go_package = "github.com/SebastienMelki/sebuf/internal/tsclientgen/testdata/multiwordoneof;multiwordoneof"; + +import "sebuf/http/annotations.proto"; + +// Variant message types +message TextContent { + string body = 1; +} + +message ImageContent { + string url = 1; + int32 width = 2; + int32 height = 3; +} + +// Message with a multi-word oneof name and no oneof_config annotation. +// +// The oneof name (super_title_image) is deliberately multi-word: it must +// surface in the generated TypeScript only as the PascalCase union type name +// (MultiWordEventSuperTitleImage) and must NEVER leak as a raw snake_case +// wrapper property. Because the oneof is un-annotated it takes the +// presence-discriminated flat-union path, matching plain protojson. +message MultiWordEvent { + string id = 1; + oneof super_title_image { + TextContent big_text = 2; + ImageContent big_image = 3; + } +} + +service MultiWordOneofService { + option (sebuf.http.service_config) = { + base_path: "/api/v1" + }; + + rpc TestMultiWordEvent(MultiWordEvent) returns (MultiWordEvent) { + option (sebuf.http.config) = { + path: "/events/multi-word" + method: HTTP_METHOD_POST + }; + } +}