diff --git a/internal/httpgen/oneof_discriminator_consistency_test.go b/internal/httpgen/oneof_discriminator_consistency_test.go index 105bd83a..aa18d774 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/golden_test.go b/internal/tsclientgen/golden_test.go index 0520b32d..ed8370fd 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 00000000..b62d656f --- /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/golden/oneof_discriminator_client.ts b/internal/tsclientgen/testdata/golden/oneof_discriminator_client.ts index 3831e6a3..7d84f2c4 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/tsclientgen/testdata/proto/multi_word_oneof.proto b/internal/tsclientgen/testdata/proto/multi_word_oneof.proto new file mode 100644 index 00000000..0675197d --- /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.go b/internal/tscommon/types.go index dac078b3..19c35a01 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/tscommon/types_test.go b/internal/tscommon/types_test.go index 94c8e781..482499ff 100644 --- a/internal/tscommon/types_test.go +++ b/internal/tscommon/types_test.go @@ -12,6 +12,8 @@ import ( "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/descriptorpb" "google.golang.org/protobuf/types/pluginpb" + + "github.com/SebastienMelki/sebuf/internal/annotations" ) func TestTSScalarType(t *testing.T) { @@ -82,6 +84,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 9340c186..b1184ef6 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 27cc0260..1a7ce015 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 00000000..9c15ec9c --- /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/golden/oneof_discriminator_server.ts b/internal/tsservergen/testdata/golden/oneof_discriminator_server.ts index 62bf8a8b..7de9ed7f 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; 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 00000000..0675197d --- /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 + }; + } +}