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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/httpgen/oneof_discriminator_consistency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
37 changes: 37 additions & 0 deletions internal/tsclientgen/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
112 changes: 112 additions & 0 deletions internal/tsclientgen/testdata/golden/multi_word_oneof_client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

export interface MultiWordOneofServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}

export class MultiWordOneofServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;

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<MultiWordEvent> {
let path = "/api/v1/events/multi-word";
const url = this.baseURL + path;

const headers: Record<string, string> = {
"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<never> {
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);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions internal/tsclientgen/testdata/proto/multi_word_oneof.proto
Original file line number Diff line number Diff line change
@@ -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
};
}
}
Loading