diff --git a/cmd/protoc-gen-ts-client/main.go b/cmd/protoc-gen-ts-client/main.go index a8554c41..c8e183cc 100644 --- a/cmd/protoc-gen-ts-client/main.go +++ b/cmd/protoc-gen-ts-client/main.go @@ -5,6 +5,7 @@ import ( "google.golang.org/protobuf/types/pluginpb" "github.com/SebastienMelki/sebuf/internal/tsclientgen" + "github.com/SebastienMelki/sebuf/internal/tscommon" ) func main() { @@ -12,7 +13,11 @@ func main() { options.Run(func(plugin *protogen.Plugin) error { plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) - gen := tsclientgen.New(plugin) + opts, err := tscommon.ParseOptions(plugin.Request.GetParameter()) + if err != nil { + return err + } + gen := tsclientgen.New(plugin, opts) return gen.Generate() }) } diff --git a/cmd/protoc-gen-ts-server/main.go b/cmd/protoc-gen-ts-server/main.go index 14cdc021..60f47e55 100644 --- a/cmd/protoc-gen-ts-server/main.go +++ b/cmd/protoc-gen-ts-server/main.go @@ -5,6 +5,7 @@ import ( "google.golang.org/protobuf/types/pluginpb" "github.com/SebastienMelki/sebuf/internal/tsservergen" + "github.com/SebastienMelki/sebuf/internal/tscommon" ) func main() { @@ -12,7 +13,11 @@ func main() { options.Run(func(plugin *protogen.Plugin) error { plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) - gen := tsservergen.New(plugin) + opts, err := tscommon.ParseOptions(plugin.Request.GetParameter()) + if err != nil { + return err + } + gen := tsservergen.New(plugin, opts) return gen.Generate() }) } diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index 6cd98886..9466b327 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -13,17 +13,26 @@ import ( // Generator handles TypeScript HTTP client code generation for protobuf services. type Generator struct { plugin *protogen.Plugin + opts tscommon.Options + // ctx carries modules-mode emission state for the file currently being + // written. It is nil in inline mode (and during type-module emission), which + // makes every type reference resolve to its bare name with no import. + ctx *tscommon.EmitContext } // New creates a new TypeScript client generator. -func New(plugin *protogen.Plugin) *Generator { +func New(plugin *protogen.Plugin, opts tscommon.Options) *Generator { return &Generator{ plugin: plugin, + opts: opts, } } // Generate processes all files and generates TypeScript clients. func (g *Generator) Generate() error { + if g.opts.ImportStyle == tscommon.ImportStyleModules { + return g.generateModules() + } for _, file := range g.plugin.Files { if !file.Generate { continue @@ -47,6 +56,12 @@ func (g *Generator) generateClientFile(file *protogen.File) error { filename := file.GeneratedFilenamePrefix + "_client.ts" gf := g.plugin.NewGeneratedFile(filename, "") + // Inline-mode context: no imports (bare names), but carries oneof_style so + // oneof_style=discriminated works without import_style=modules. With the + // default flatten style this is byte-identical to the historical output. + g.ctx = &tscommon.EmitContext{Options: g.opts} + defer func() { g.ctx = nil }() + // Collect all referenced messages and enums ms := collectServiceMessages(file) @@ -64,7 +79,7 @@ func (g *Generator) generateClientFile(file *protogen.File) error { // 2. Message interfaces for _, msg := range ms.OrderedMessages() { - generateInterface(p, msg) + tscommon.GenerateInterfaceCtx(g.ctx, tscommon.Printer(p), msg) } // 3. Enum types @@ -284,7 +299,7 @@ func (g *Generator) generateRPCMethod(p printer, service *protogen.Service, meth return } - inputType := string(method.Input.Desc.Name()) + inputType := g.ctx.RefMessage(method.Input) outputType := g.resolveOutputType(method) tsMethodName := annotations.LowerFirst(cfg.methodName) @@ -316,7 +331,7 @@ func (g *Generator) generateSSERPCMethod( method *protogen.Method, cfg *rpcMethodConfig, ) { - inputType := string(method.Input.Desc.Name()) + inputType := g.ctx.RefMessage(method.Input) outputType := g.resolveOutputType(method) tsMethodName := annotations.LowerFirst(cfg.methodName) @@ -420,9 +435,9 @@ func (g *Generator) generateSSEStreamParsing(p printer, outputType string) { func (g *Generator) resolveOutputType(method *protogen.Method) string { msg := method.Output if annotations.IsRootUnwrap(msg) { - return rootUnwrapTSType(msg) + return tscommon.RootUnwrapTSTypeCtx(g.ctx, msg) } - return string(msg.Desc.Name()) + return g.ctx.RefMessage(msg) } // generateURLBuilding generates URL construction with path and query params. diff --git a/internal/tsclientgen/golden_test.go b/internal/tsclientgen/golden_test.go index 0520b32d..0bd2ae9f 100644 --- a/internal/tsclientgen/golden_test.go +++ b/internal/tsclientgen/golden_test.go @@ -24,6 +24,8 @@ func TestTSClientGenGoldenFiles(t *testing.T) { name string protoFile string expectedFiles []string + opts []string // extra --ts-client_opt values (beyond paths=source_relative) + subdir string // golden subdirectory for opt variants (avoids filename clashes) }{ { name: "comprehensive HTTP verbs", @@ -130,6 +132,26 @@ func TestTSClientGenGoldenFiles(t *testing.T) { "empty_request_body_client.ts", }, }, + { + name: "import_style=modules", + protoFile: "complex_features.proto", + opts: []string{"import_style=modules"}, + subdir: "modules", + expectedFiles: []string{ + "complex_features.ts", + "complex_features_client.ts", + "errors.ts", + }, + }, + { + name: "oneof_style=discriminated", + protoFile: "oneof_discriminator.proto", + opts: []string{"oneof_style=discriminated"}, + subdir: "oneof_discriminated", + expectedFiles: []string{ + "oneof_discriminator_client.ts", + }, + }, } baseDir, err := os.Getwd() @@ -171,14 +193,20 @@ func TestTSClientGenGoldenFiles(t *testing.T) { } // Run protoc with ts-client plugin - cmd := exec.Command("protoc", - "--plugin=protoc-gen-ts-client="+pluginPath, - "--ts-client_out="+tempDir, + args := []string{ + "--plugin=protoc-gen-ts-client=" + pluginPath, + "--ts-client_out=" + tempDir, "--ts-client_opt=paths=source_relative", + } + for _, opt := range tc.opts { + args = append(args, "--ts-client_opt="+opt) + } + args = append(args, "--proto_path="+protoDir, "--proto_path="+filepath.Join(projectRoot, "proto"), tc.protoFile, ) + cmd := exec.Command("protoc", args...) cmd.Dir = protoDir var stderr bytes.Buffer @@ -191,7 +219,7 @@ func TestTSClientGenGoldenFiles(t *testing.T) { for _, expectedFile := range tc.expectedFiles { generatedPath := filepath.Join(tempDir, expectedFile) - goldenPath := filepath.Join(goldenDir, expectedFile) + goldenPath := filepath.Join(goldenDir, tc.subdir, expectedFile) generatedContent, readErr := os.ReadFile(generatedPath) if readErr != nil { @@ -210,6 +238,9 @@ func TestTSClientGenGoldenFiles(t *testing.T) { func updateGoldenFile(t *testing.T, goldenPath string, content []byte) { t.Helper() + if mkErr := os.MkdirAll(filepath.Dir(goldenPath), 0o755); mkErr != nil { + t.Fatalf("Failed to create golden dir for %s: %v", goldenPath, mkErr) + } writeErr := os.WriteFile(goldenPath, content, 0o644) if writeErr != nil { t.Fatalf("Failed to write golden file %s: %v", goldenPath, writeErr) diff --git a/internal/tsclientgen/modules.go b/internal/tsclientgen/modules.go new file mode 100644 index 00000000..306a6de0 --- /dev/null +++ b/internal/tsclientgen/modules.go @@ -0,0 +1,51 @@ +package tsclientgen + +import ( + "google.golang.org/protobuf/compiler/protogen" + + "github.com/SebastienMelki/sebuf/internal/tscommon" +) + +// generateModules implements import_style=modules: shared canonical type modules +// and an errors module (via tscommon), plus one slimmed client module per +// service file that imports its request/response types and the error helpers. +func (g *Generator) generateModules() error { + if err := tscommon.EmitSharedModules(g.plugin, g.opts); err != nil { + return err + } + for _, file := range g.plugin.Files { + if !file.Generate || len(file.Services) == 0 { + continue + } + g.emitClientModule(file) + } + return nil +} + +// emitClientModule writes a service file's client class(es), importing the +// request/response types from their canonical modules and the shared error +// helpers. +func (g *Generator) emitClientModule(file *protogen.File) { + module := file.GeneratedFilenamePrefix + "_client" + gf := g.plugin.NewGeneratedFile(module+".ts", "") + tracker := tscommon.NewImportTracker() + g.ctx = &tscommon.EmitContext{Options: g.opts, SelfModule: module, Imports: tracker} + defer func() { g.ctx = nil }() + + var body []string + bp := printer(tscommon.BufferedPrinter(&body)) + for _, service := range file.Services { + _ = g.generateServiceClient(bp, service) + } + // Import only the error helpers actually referenced in the body. + g.ctx.NeedErrors(tscommon.UsedErrorSymbols(body)...) + + dp := tscommon.DirectPrinter(gf) + dp("// Code generated by protoc-gen-ts-client. DO NOT EDIT.") + dp("// source: %s", file.Desc.Path()) + dp("") + tracker.Render(dp) + for _, line := range body { + gf.P(line) + } +} diff --git a/internal/tsclientgen/testdata/golden/modules/complex_features.ts b/internal/tsclientgen/testdata/golden/modules/complex_features.ts new file mode 100644 index 00000000..8a68b94b --- /dev/null +++ b/internal/tsclientgen/testdata/golden/modules/complex_features.ts @@ -0,0 +1,105 @@ +// Code generated by sebuf. DO NOT EDIT. +// source: complex_features.proto + +export interface ListNotesRequest { + page: number; + pageSize: number; + filter: string; +} + +export interface ListNotesResponse { + notes: Note[]; + totalCount: number; +} + +export interface Note { + id: string; + title: string; + content: string; + priority: Priority; + status: Status; + tags: Tag[]; + metadata: Record; + dueDate?: string; + address?: Address; +} + +export interface Tag { + name: string; + color: string; +} + +export interface Address { + street: string; + city: string; + zipCode: string; +} + +export interface GetNoteRequest { + noteId: string; +} + +export interface CreateNoteRequest { + title: string; + content: string; + priority: Priority; + status: Status; + tags: Tag[]; + metadata: Record; + dueDate?: string; + address?: Address; +} + +export interface UpdateNoteRequest { + noteId: string; + title: string; + content: string; + priority: Priority; +} + +export interface GetNoteListRequest { + category: string; +} + +export interface NoteList { + notes: Note[]; +} + +export interface GetNoteMapRequest { + ownerId: string; +} + +export interface NoteMap { + notes: Record; +} + +export interface GetBarsBySymbolRequest { + symbols: string[]; +} + +export interface BarsBySymbol { + data: Record; +} + +export interface BarWrapper { + bars: Bar[]; +} + +export interface Bar { + symbol: string; + price: number; + volume: string; +} + +export interface GetCombinedUnwrapRequest { + market: string; +} + +export interface CombinedUnwrap { + data: Record; +} + +export type Priority = "PRIORITY_UNSPECIFIED" | "PRIORITY_LOW" | "PRIORITY_MEDIUM" | "PRIORITY_HIGH" | "PRIORITY_URGENT"; + +export type Status = "STATUS_UNSPECIFIED" | "STATUS_PENDING" | "STATUS_IN_PROGRESS" | "STATUS_DONE" | "STATUS_ARCHIVED"; + diff --git a/internal/tsclientgen/testdata/golden/modules/complex_features_client.ts b/internal/tsclientgen/testdata/golden/modules/complex_features_client.ts new file mode 100644 index 00000000..95aba6c8 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/modules/complex_features_client.ts @@ -0,0 +1,269 @@ +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: complex_features.proto + +import { ApiError, ValidationError } from "./errors"; +import type { Bar, BarWrapper, BarsBySymbol, CreateNoteRequest, GetBarsBySymbolRequest, GetCombinedUnwrapRequest, GetNoteListRequest, GetNoteMapRequest, GetNoteRequest, ListNotesRequest, ListNotesResponse, Note, UpdateNoteRequest } from "./complex_features"; + +export interface FeatureServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; + apiKey?: string; + tenantId?: string; +} + +export interface FeatureServiceCallOptions { + headers?: Record; + signal?: AbortSignal; + apiKey?: string; + tenantId?: string; + requestId?: string; + idempotencyKey?: string; +} + +export class FeatureServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: FeatureServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + if (options?.apiKey) { + this.defaultHeaders["X-API-Key"] = options.apiKey; + } + if (options?.tenantId) { + this.defaultHeaders["X-Tenant-ID"] = options.tenantId; + } + } + + async listNotes(req: ListNotesRequest, options?: FeatureServiceCallOptions): Promise { + let path = "/api/v1/notes"; + const params = new URLSearchParams(); + if (req.page != null && req.page !== 0) params.set("page", String(req.page)); + if (req.pageSize != null && req.pageSize !== 0) params.set("page_size", String(req.pageSize)); + if (req.filter != null && req.filter !== "") params.set("filter", String(req.filter)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as ListNotesResponse; + } + + async getNote(req: GetNoteRequest, options?: FeatureServiceCallOptions): Promise { + let path = "/api/v1/notes/{note_id}"; + path = path.replace("{note_id}", encodeURIComponent(String(req.noteId))); + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as Note; + } + + async createNote(req: CreateNoteRequest, options?: FeatureServiceCallOptions): Promise { + let path = "/api/v1/notes"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + if (options?.requestId) headers["X-Request-ID"] = options.requestId; + + 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 Note; + } + + async updateNote(req: UpdateNoteRequest, options?: FeatureServiceCallOptions): Promise { + let path = "/api/v1/notes/{note_id}"; + path = path.replace("{note_id}", encodeURIComponent(String(req.noteId))); + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + if (options?.idempotencyKey) headers["X-Idempotency-Key"] = options.idempotencyKey; + + const resp = await this.fetchFn(url, { + method: "PUT", + headers, + body: JSON.stringify(req), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as Note; + } + + async getNoteList(req: GetNoteListRequest, options?: FeatureServiceCallOptions): Promise { + let path = "/api/v1/notes/list"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + + 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 Note[]; + } + + async getNoteMap(req: GetNoteMapRequest, options?: FeatureServiceCallOptions): Promise> { + let path = "/api/v1/notes/map"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + + 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 Record; + } + + async getBarsBySymbol(req: GetBarsBySymbolRequest, options?: FeatureServiceCallOptions): Promise { + let path = "/api/v1/bars"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + + 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 BarsBySymbol; + } + + async getCombinedUnwrap(req: GetCombinedUnwrapRequest, options?: FeatureServiceCallOptions): Promise> { + let path = "/api/v1/bars/combined"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + if (options?.apiKey) headers["X-API-Key"] = options.apiKey; + if (options?.tenantId) headers["X-Tenant-ID"] = options.tenantId; + + 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 Record; + } + + 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/modules/errors.ts b/internal/tsclientgen/testdata/golden/modules/errors.ts new file mode 100644 index 00000000..991abd2c --- /dev/null +++ b/internal/tsclientgen/testdata/golden/modules/errors.ts @@ -0,0 +1,29 @@ +// Code generated by sebuf. DO NOT EDIT. + +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; + } +} + diff --git a/internal/tsclientgen/testdata/golden/oneof_discriminated/oneof_discriminator_client.ts b/internal/tsclientgen/testdata/golden/oneof_discriminated/oneof_discriminator_client.ts new file mode 100644 index 00000000..1cf80ea9 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/oneof_discriminated/oneof_discriminator_client.ts @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: oneof_discriminator.proto + +export type FlattenedEventContent = + | { type: "text"; body: string } + | { type: "img"; url: string; width: number; height: number }; + +export interface FlattenedEventBase { + id: string; +} + +export type FlattenedEvent = FlattenedEventBase & FlattenedEventContent; + +export interface TextContent { + body: string; +} + +export interface ImageContent { + url: string; + width: number; + height: number; +} + +export type NestedEventContent = + | { kind: "text"; text?: TextContent } + | { kind: "image"; image?: ImageContent } + | { kind: "vid"; video?: VideoContent }; + +export interface NestedEvent { + id: string; + content?: NestedEventContent; +} + +export interface VideoContent { + url: string; + duration: number; +} + +export type PlainEventContent = + | { $case: "text"; text?: TextContent } + | { $case: "image"; image?: ImageContent }; + +export interface PlainEvent { + id: string; + content?: PlainEventContent; +} + +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 OneofDiscriminatorServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface OneofDiscriminatorServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class OneofDiscriminatorServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: OneofDiscriminatorServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async testFlattenedEvent(req: FlattenedEvent, options?: OneofDiscriminatorServiceCallOptions): Promise { + let path = "/api/v1/events/flattened"; + 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 FlattenedEvent; + } + + async testNestedEvent(req: NestedEvent, options?: OneofDiscriminatorServiceCallOptions): Promise { + let path = "/api/v1/events/nested"; + 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 NestedEvent; + } + + async testPlainEvent(req: PlainEvent, options?: OneofDiscriminatorServiceCallOptions): Promise { + let path = "/api/v1/events/plain"; + 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 PlainEvent; + } + + 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/tscommon/config.go b/internal/tscommon/config.go new file mode 100644 index 00000000..4fa374bd --- /dev/null +++ b/internal/tscommon/config.go @@ -0,0 +1,89 @@ +package tscommon + +import ( + "fmt" + "strings" +) + +// ImportStyle controls how generated TypeScript references types that are +// defined in other proto files. +type ImportStyle int + +const ( + // ImportStyleInline (default) inlines every referenced message/enum into the + // generated file, with no cross-file imports. This is the historical + // behavior and the zero value. + ImportStyleInline ImportStyle = iota + // ImportStyleModules emits one canonical type module per proto file and + // imports referenced types across files. + ImportStyleModules +) + +// OneofStyle controls how oneof fields WITHOUT an explicit sebuf.http +// oneof_config annotation are rendered. Annotated oneofs always follow their +// annotation regardless of this option. +type OneofStyle int + +const ( + // OneofStyleFlatten (default) renders each oneof variant as an independent + // optional field. This is the historical behavior and the zero value. + OneofStyleFlatten OneofStyle = iota + // OneofStyleDiscriminated renders un-annotated oneofs as discriminated + // unions keyed by a synthesized "$case" discriminator. + OneofStyleDiscriminated +) + +// Options holds the parsed plugin options shared by the TypeScript generators +// (protoc-gen-ts-client and protoc-gen-ts-server). +type Options struct { + ImportStyle ImportStyle + OneofStyle OneofStyle +} + +// ParseOptions parses a protoc plugin parameter string of the form +// "key=value,key2=value2" into Options. +// +// Unknown keys are ignored on purpose: protoc concatenates every "--_opt" +// flag into a single comma-separated parameter, so framework keys such as +// "paths=source_relative" reach us here too. A KNOWN key with an unrecognized +// value is a hard error — a silent fallback to the default would mask typos. +func ParseOptions(parameter string) (Options, error) { + opts := Options{} + if parameter == "" { + return opts, nil + } + + const kvParts = 2 + for _, pair := range strings.Split(parameter, ",") { + kv := strings.SplitN(pair, "=", kvParts) + if len(kv) != kvParts { + continue + } + key := strings.TrimSpace(kv[0]) + value := strings.TrimSpace(kv[1]) + + switch key { + case "import_style": + switch value { + case "modules": + opts.ImportStyle = ImportStyleModules + case "inline": + opts.ImportStyle = ImportStyleInline + default: + return opts, fmt.Errorf("invalid import_style %q (want \"modules\" or \"inline\")", value) + } + case "oneof_style": + switch value { + case "discriminated": + opts.OneofStyle = OneofStyleDiscriminated + case "flatten": + opts.OneofStyle = OneofStyleFlatten + default: + return opts, fmt.Errorf("invalid oneof_style %q (want \"discriminated\" or \"flatten\")", value) + } + default: + // Ignore unknown keys (e.g. paths=source_relative). + } + } + return opts, nil +} diff --git a/internal/tscommon/config_test.go b/internal/tscommon/config_test.go new file mode 100644 index 00000000..4e99587c --- /dev/null +++ b/internal/tscommon/config_test.go @@ -0,0 +1,71 @@ +package tscommon + +import "testing" + +func TestParseOptions(t *testing.T) { + tests := []struct { + name string + param string + want Options + wantErr bool + }{ + { + name: "empty defaults to inline+flatten", + param: "", + want: Options{ImportStyle: ImportStyleInline, OneofStyle: OneofStyleFlatten}, + }, + { + name: "import_style=modules", + param: "import_style=modules", + want: Options{ImportStyle: ImportStyleModules, OneofStyle: OneofStyleFlatten}, + }, + { + name: "oneof_style=discriminated", + param: "oneof_style=discriminated", + want: Options{ImportStyle: ImportStyleInline, OneofStyle: OneofStyleDiscriminated}, + }, + { + name: "unknown key ignored (paths=source_relative)", + param: "paths=source_relative,import_style=modules", + want: Options{ImportStyle: ImportStyleModules, OneofStyle: OneofStyleFlatten}, + }, + { + name: "combined flags in any order", + param: "oneof_style=discriminated,paths=source_relative,import_style=modules", + want: Options{ImportStyle: ImportStyleModules, OneofStyle: OneofStyleDiscriminated}, + }, + { + name: "explicit inline+flatten", + param: "import_style=inline,oneof_style=flatten", + want: Options{ImportStyle: ImportStyleInline, OneofStyle: OneofStyleFlatten}, + }, + { + name: "invalid import_style value errors", + param: "import_style=bogus", + wantErr: true, + }, + { + name: "invalid oneof_style value errors", + param: "oneof_style=bogus", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseOptions(tt.param) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseOptions(%q) = %+v, want error", tt.param, got) + } + return + } + if err != nil { + t.Fatalf("ParseOptions(%q) unexpected error: %v", tt.param, err) + } + if got != tt.want { + t.Errorf("ParseOptions(%q) = %+v, want %+v", tt.param, got, tt.want) + } + }) + } +} diff --git a/internal/tscommon/imports.go b/internal/tscommon/imports.go new file mode 100644 index 00000000..91ee7ada --- /dev/null +++ b/internal/tscommon/imports.go @@ -0,0 +1,227 @@ +package tscommon + +import ( + "fmt" + "path" + "sort" + "strings" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// errorsModule is the extensionless module path of the shared error-helpers +// file emitted at the output root in modules mode. +const errorsModule = "errors" + +// ModuleForFile returns the canonical extensionless TypeScript module path for a +// proto file path, e.g. "anghamna/core/v1/identifiers.proto" -> +// "anghamna/core/v1/identifiers". +func ModuleForFile(protoPath string) string { + return strings.TrimSuffix(protoPath, ".proto") +} + +// RelativeImportSpecifier returns the extensionless, POSIX, "./"-prefixed import +// specifier needed to reach toModule from the file at fromModule. Both arguments +// are extensionless module paths (e.g. "album/v1/service_client", +// "core/v1/identifiers"). +func RelativeImportSpecifier(fromModule, toModule string) string { + fromDir := path.Dir(fromModule) + var rel string + switch { + case fromDir == "." || fromDir == "": + rel = toModule + default: + baseParts := strings.Split(fromDir, "/") + targetParts := strings.Split(toModule, "/") + i := 0 + for i < len(baseParts) && i < len(targetParts) && baseParts[i] == targetParts[i] { + i++ + } + var out []string + for j := i; j < len(baseParts); j++ { + out = append(out, "..") + } + out = append(out, targetParts[i:]...) + rel = strings.Join(out, "/") + } + if !strings.HasPrefix(rel, ".") { + rel = "./" + rel + } + return rel +} + +// importedSymbol records a single imported name and its (possibly aliased) local +// binding within the importing module. +type importedSymbol struct { + symbol string + alias string // equals symbol when there is no collision +} + +// ImportTracker collects the cross-module imports a single generated file needs +// and renders the import block. It is only used in modules mode. +type ImportTracker struct { + typeImports map[string][]importedSymbol // specifier -> imported type symbols + aliasOf map[string]string // "spec\x00symbol" -> local alias + usedAlias map[string]string // local alias -> owning "spec\x00symbol" + errorSyms map[string]bool // error helpers referenced (value import) + errorsSpec string +} + +// NewImportTracker returns an empty tracker. +func NewImportTracker() *ImportTracker { + return &ImportTracker{ + typeImports: map[string][]importedSymbol{}, + aliasOf: map[string]string{}, + usedAlias: map[string]string{}, + errorSyms: map[string]bool{}, + } +} + +// NeedType records that `symbol` from module specifier `spec` is referenced and +// returns the local name to use (aliased deterministically on collision). +func (t *ImportTracker) NeedType(spec, symbol string) string { + key := spec + "\x00" + symbol + if a, ok := t.aliasOf[key]; ok { + return a + } + alias := symbol + if owner, taken := t.usedAlias[alias]; taken && owner != key { + for i := 1; ; i++ { + cand := fmt.Sprintf("%s_%d", symbol, i) + if _, used := t.usedAlias[cand]; !used { + alias = cand + break + } + } + } + t.usedAlias[alias] = key + t.aliasOf[key] = alias + t.typeImports[spec] = append(t.typeImports[spec], importedSymbol{symbol: symbol, alias: alias}) + return alias +} + +// NeedErrors records that the given shared error helpers are referenced, +// importing them (as a value import) relative to the given module specifier. +func (t *ImportTracker) NeedErrors(spec string, symbols ...string) { + t.errorsSpec = spec + for _, s := range symbols { + t.errorSyms[s] = true + } +} + +// Empty reports whether no imports were recorded. +func (t *ImportTracker) Empty() bool { + return len(t.errorSyms) == 0 && len(t.typeImports) == 0 +} + +// Render writes the import block (value import for error helpers, then sorted +// type-only imports). It emits a trailing blank line when anything was written. +func (t *ImportTracker) Render(p Printer) { + if t.Empty() { + return + } + if len(t.errorSyms) > 0 { + syms := make([]string, 0, len(t.errorSyms)) + for s := range t.errorSyms { + syms = append(syms, s) + } + sort.Strings(syms) + p(`import { %s } from "%s";`, strings.Join(syms, ", "), t.errorsSpec) + } + specs := make([]string, 0, len(t.typeImports)) + for s := range t.typeImports { + specs = append(specs, s) + } + sort.Strings(specs) + for _, spec := range specs { + syms := append([]importedSymbol(nil), t.typeImports[spec]...) + sort.Slice(syms, func(i, j int) bool { return syms[i].symbol < syms[j].symbol }) + parts := make([]string, 0, len(syms)) + for _, is := range syms { + if is.alias == is.symbol { + parts = append(parts, is.symbol) + } else { + parts = append(parts, fmt.Sprintf("%s as %s", is.symbol, is.alias)) + } + } + p(`import type { %s } from "%s";`, strings.Join(parts, ", "), spec) + } + p("") +} + +// EmitContext threads modules-mode state through the (otherwise stateless) type +// emitters. A nil context — or one whose ImportStyle is inline — makes every +// type reference resolve to its bare name with no import recorded, preserving +// the historical output byte-for-byte. +type EmitContext struct { + Options Options + SelfModule string // extensionless module path of the file being written + Imports *ImportTracker +} + +func (c *EmitContext) modules() bool { + return c != nil && c.Options.ImportStyle == ImportStyleModules && c.Imports != nil +} + +// oneofDiscriminated reports whether un-annotated oneofs should render as +// discriminated unions. Orthogonal to import style (works in inline mode too). +func (c *EmitContext) oneofDiscriminated() bool { + return c != nil && c.Options.OneofStyle == OneofStyleDiscriminated +} + +// RefMessage returns the local TypeScript name for a message reference, +// recording a cross-module import when needed. +func (c *EmitContext) RefMessage(msg *protogen.Message) string { + if msg == nil { + return "" + } + return c.ref(string(msg.Desc.Name()), msg.Desc.ParentFile()) +} + +// RefEnum returns the local TypeScript name for an enum reference, recording a +// cross-module import when needed. +func (c *EmitContext) RefEnum(enum *protogen.Enum) string { + if enum == nil { + return "" + } + return c.ref(string(enum.Desc.Name()), enum.Desc.ParentFile()) +} + +func (c *EmitContext) ref(symbol string, file protoreflect.FileDescriptor) string { + if !c.modules() { + return symbol + } + mod := ModuleForFile(file.Path()) + if mod == c.SelfModule { + return symbol + } + return c.Imports.NeedType(RelativeImportSpecifier(c.SelfModule, mod), symbol) +} + +// NeedErrors records that this file references the given shared error helpers. +func (c *EmitContext) NeedErrors(symbols ...string) { + if !c.modules() || len(symbols) == 0 { + return + } + c.Imports.NeedErrors(RelativeImportSpecifier(c.SelfModule, errorsModule), symbols...) +} + +// errorHelperSymbols are the value exports of the shared errors module. +var errorHelperSymbols = []string{"ApiError", "FieldViolation", "ValidationError"} + +// UsedErrorSymbols returns the error-helper symbols referenced anywhere in the +// given body lines, so a generated file imports only what it uses (these +// symbols are distinct, none a substring of another). +func UsedErrorSymbols(lines []string) []string { + var used []string + for _, sym := range errorHelperSymbols { + for _, line := range lines { + if strings.Contains(line, sym) { + used = append(used, sym) + break + } + } + } + return used +} diff --git a/internal/tscommon/imports_test.go b/internal/tscommon/imports_test.go new file mode 100644 index 00000000..83813141 --- /dev/null +++ b/internal/tscommon/imports_test.go @@ -0,0 +1,100 @@ +package tscommon + +import ( + "fmt" + "strings" + "testing" +) + +func TestRelativeImportSpecifier(t *testing.T) { + tests := []struct { + from string + to string + want string + }{ + {"album/v1/service_client", "core/v1/identifiers", "../../core/v1/identifiers"}, + {"album/v1/service_client", "album/v1/album", "./album"}, + {"album/v1/service_client", "errors", "../../errors"}, + {"service_client", "errors", "./errors"}, + {"service_client", "album", "./album"}, + {"a/b/c_client", "a/b/c", "./c"}, + {"a/b/c/deep_client", "x/y", "../../../x/y"}, + } + for _, tt := range tests { + if got := RelativeImportSpecifier(tt.from, tt.to); got != tt.want { + t.Errorf("RelativeImportSpecifier(%q,%q) = %q, want %q", tt.from, tt.to, got, tt.want) + } + } +} + +func TestModuleForFile(t *testing.T) { + if got := ModuleForFile("anghamna/core/v1/identifiers.proto"); got != "anghamna/core/v1/identifiers" { + t.Errorf("ModuleForFile = %q", got) + } +} + +func TestImportTracker_SameSymbolMemoized(t *testing.T) { + tr := NewImportTracker() + a1 := tr.NeedType("./album", "Album") + a2 := tr.NeedType("./album", "Album") + if a1 != "Album" || a2 != "Album" { + t.Fatalf("expected stable alias Album, got %q/%q", a1, a2) + } + if got := len(tr.typeImports["./album"]); got != 1 { + t.Errorf("expected 1 recorded symbol, got %d", got) + } +} + +func TestImportTracker_CollisionAliasing(t *testing.T) { + tr := NewImportTracker() + first := tr.NeedType("../a/meta", "Metadata") + second := tr.NeedType("../b/meta", "Metadata") + if first != "Metadata" { + t.Errorf("first reference should keep bare name, got %q", first) + } + if second != "Metadata_1" { + t.Errorf("colliding reference should alias to Metadata_1, got %q", second) + } + // memoized on repeat + if again := tr.NeedType("../b/meta", "Metadata"); again != "Metadata_1" { + t.Errorf("repeat should return Metadata_1, got %q", again) + } +} + +func TestImportTracker_RenderOrdering(t *testing.T) { + tr := NewImportTracker() + tr.NeedErrors("./errors", "ApiError", "ValidationError") + tr.NeedType("../../core/v1/identifiers", "ArtistID") + tr.NeedType("../../core/v1/identifiers", "AlbumID") + tr.NeedType("./album", "Album") + + var b strings.Builder + p := Printer(func(format string, args ...interface{}) { + b.WriteString(fmt.Sprintf(format, args...)) + b.WriteString("\n") + }) + tr.Render(p) + got := b.String() + + want := strings.Join([]string{ + `import { ApiError, ValidationError } from "./errors";`, + `import type { AlbumID, ArtistID } from "../../core/v1/identifiers";`, + `import type { Album } from "./album";`, + ``, + ``, + }, "\n") + if got != want { + t.Errorf("Render mismatch:\n got:\n%s\nwant:\n%s", got, want) + } +} + +func TestImportTracker_Empty(t *testing.T) { + tr := NewImportTracker() + if !tr.Empty() { + t.Error("new tracker should be empty") + } + tr.NeedType("./x", "Y") + if tr.Empty() { + t.Error("tracker should be non-empty after NeedType") + } +} diff --git a/internal/tscommon/modules.go b/internal/tscommon/modules.go new file mode 100644 index 00000000..4e6d9e9a --- /dev/null +++ b/internal/tscommon/modules.go @@ -0,0 +1,158 @@ +package tscommon + +import ( + "fmt" + "sort" + "strings" + + "google.golang.org/protobuf/compiler/protogen" +) + +// reservedTypeNames are the symbols emitted by the shared errors module. A proto +// message with one of these names would shadow the helpers in modules mode. +var reservedTypeNames = map[string]bool{ + "ValidationError": true, + "ApiError": true, + "FieldViolation": true, +} + +// BufferedPrinter returns a Printer that accumulates formatted lines into *lines. +func BufferedPrinter(lines *[]string) Printer { + return func(format string, args ...interface{}) { + if len(args) == 0 { + *lines = append(*lines, format) + } else { + *lines = append(*lines, fmt.Sprintf(format, args...)) + } + } +} + +// DirectPrinter returns a Printer that writes formatted lines straight to gf. +func DirectPrinter(gf *protogen.GeneratedFile) Printer { + return func(format string, args ...interface{}) { + if len(args) == 0 { + gf.P(format) + } else { + gf.P(fmt.Sprintf(format, args...)) + } + } +} + +// CollectAllServiceMessages builds the transitive closure of every message/enum +// referenced by any service across all generated files, plus proto-defined +// "*Error" messages (matching CollectServiceMessages). +func CollectAllServiceMessages(plugin *protogen.Plugin) *MessageSet { + ms := NewMessageSet() + for _, file := range plugin.Files { + if !file.Generate { + continue + } + for _, service := range file.Services { + for _, method := range service.Methods { + ms.AddMessage(method.Input) + ms.AddMessage(method.Output) + } + } + for _, msg := range file.Messages { + if strings.HasSuffix(string(msg.Desc.Name()), "Error") { + ms.AddMessage(msg) + } + } + } + return ms +} + +// CheckReservedNames returns an error if any collected message would shadow the +// shared error helpers in modules mode. +func CheckReservedNames(ms *MessageSet) error { + for _, msg := range ms.OrderedMessages() { + if reservedTypeNames[string(msg.Desc.Name())] { + return fmt.Errorf( + "message %q collides with a reserved error-helper name in import_style=modules; rename it", + msg.Desc.FullName(), + ) + } + } + return nil +} + +// EmitSharedModules emits, for import_style=modules, one canonical type module +// per proto file (.ts) plus a single shared errors module (errors.ts). +// Both TS generators call this; the emitted files are byte-identical between +// them (neutral header), so running client and server against the same output +// directory yields consistent type modules. +func EmitSharedModules(plugin *protogen.Plugin, opts Options) error { + global := CollectAllServiceMessages(plugin) + if err := CheckReservedNames(global); err != nil { + return err + } + + msgsBySrc := global.MessagesBySourceFile() + enumsBySrc := global.EnumsBySourceFile() + for _, src := range sortedSourceFiles(msgsBySrc, enumsBySrc) { + emitTypeModule(plugin, opts, src, msgsBySrc[src], enumsBySrc[src]) + } + emitErrorsModule(plugin) + return nil +} + +func sortedSourceFiles(msgs map[string][]*protogen.Message, enums map[string][]*protogen.Enum) []string { + seen := map[string]bool{} + var srcs []string + for src := range msgs { + if !seen[src] { + seen[src] = true + srcs = append(srcs, src) + } + } + for src := range enums { + if !seen[src] { + seen[src] = true + srcs = append(srcs, src) + } + } + sort.Strings(srcs) + return srcs +} + +func emitTypeModule( + plugin *protogen.Plugin, + opts Options, + srcPath string, + msgs []*protogen.Message, + enums []*protogen.Enum, +) { + if len(msgs) == 0 && len(enums) == 0 { + return + } + module := ModuleForFile(srcPath) + gf := plugin.NewGeneratedFile(module+".ts", "") + tracker := NewImportTracker() + ctx := &EmitContext{Options: opts, SelfModule: module, Imports: tracker} + + var body []string + bp := BufferedPrinter(&body) + for _, msg := range msgs { + GenerateInterfaceCtx(ctx, bp, msg) + } + for _, enum := range enums { + GenerateEnumType(bp, enum) + } + + dp := DirectPrinter(gf) + dp("// Code generated by sebuf. DO NOT EDIT.") + dp("// source: %s", srcPath) + dp("") + tracker.Render(dp) + for _, line := range body { + gf.P(line) + } +} + +func emitErrorsModule(plugin *protogen.Plugin) { + gf := plugin.NewGeneratedFile("errors.ts", "") + dp := DirectPrinter(gf) + dp("// Code generated by sebuf. DO NOT EDIT.") + dp("") + WriteErrorTypes(dp) +} diff --git a/internal/tscommon/types.go b/internal/tscommon/types.go index 4bdac418..f2028254 100644 --- a/internal/tscommon/types.go +++ b/internal/tscommon/types.go @@ -230,6 +230,28 @@ func (ms *MessageSet) OrderedEnums() []*protogen.Enum { return result } +// MessagesBySourceFile groups the collected messages by their source proto file +// path, preserving discovery order within each group. Used by modules mode to +// emit one type module per proto file. +func (ms *MessageSet) MessagesBySourceFile() map[string][]*protogen.Message { + out := make(map[string][]*protogen.Message) + for _, msg := range ms.OrderedMessages() { + src := msg.Desc.ParentFile().Path() + out[src] = append(out[src], msg) + } + return out +} + +// EnumsBySourceFile groups the collected enums by their source proto file path. +func (ms *MessageSet) EnumsBySourceFile() map[string][]*protogen.Enum { + out := make(map[string][]*protogen.Enum) + for _, enum := range ms.OrderedEnums() { + src := enum.Desc.ParentFile().Path() + out[src] = append(out[src], enum) + } + return out +} + // CollectServiceMessages collects all messages transitively referenced by services in a file. // It also includes messages whose names end with "Error" (convention for proto-defined custom errors). func CollectServiceMessages(file *protogen.File) *MessageSet { @@ -251,20 +273,27 @@ func CollectServiceMessages(file *protogen.File) *MessageSet { return ms } -// TSFieldType returns the TypeScript type string for a protobuf field. +// TSFieldType returns the TypeScript type string for a protobuf field +// (inline mode — bare names, no imports). func TSFieldType(field *protogen.Field) string { + return TSFieldTypeCtx(nil, field) +} + +// TSFieldTypeCtx is the import-aware variant of TSFieldType. A nil ctx (or a ctx +// in inline mode) returns bare type names byte-identical to the historical +// output; in modules mode it records cross-module imports via ctx. +func TSFieldTypeCtx(ctx *EmitContext, field *protogen.Field) string { // Handle map fields if field.Desc.IsMap() { valueField := field.Message.Fields[1] // map value is always second field of map entry - valueType := TSFieldType(valueField) + valueType := TSFieldTypeCtx(ctx, valueField) // Check if the map value is a message with unwrap annotation if valueField.Desc.Kind() == protoreflect.MessageKind && valueField.Message != nil { unwrapField := annotations.FindUnwrapField(valueField.Message) if unwrapField != nil && !unwrapField.Desc.IsMap() { // Map-value unwrap: collapse wrapper to inner type array - // Use TSElementType since unwrapField is always repeated - valueType = TSElementType(unwrapField) + "[]" + valueType = TSElementTypeCtx(ctx, unwrapField) + "[]" } } @@ -273,8 +302,7 @@ func TSFieldType(field *protogen.Field) string { // Handle repeated fields if field.Desc.IsList() { - elemType := TSElementType(field) - return elemType + "[]" + return TSElementTypeCtx(ctx, field) + "[]" } // Handle google.protobuf.Timestamp fields (serialized as primitive, not as nested object) @@ -284,7 +312,7 @@ func TSFieldType(field *protogen.Field) string { // Handle message fields if field.Desc.Kind() == protoreflect.MessageKind && field.Message != nil { - return string(field.Message.Desc.Name()) + return ctx.RefMessage(field.Message) } // Handle enum fields @@ -294,7 +322,7 @@ func TSFieldType(field *protogen.Field) string { if encoding == http.EnumEncoding_ENUM_ENCODING_NUMBER { return TSNumber } - return string(field.Enum.Desc.Name()) + return ctx.RefEnum(field.Enum) } // Scalar types (use field-aware function for encoding annotations) @@ -303,12 +331,17 @@ func TSFieldType(field *protogen.Field) string { // TSElementType returns the TypeScript type for the element of a repeated field. func TSElementType(field *protogen.Field) string { + return TSElementTypeCtx(nil, field) +} + +// TSElementTypeCtx is the import-aware variant of TSElementType. +func TSElementTypeCtx(ctx *EmitContext, field *protogen.Field) string { // Handle google.protobuf.Timestamp (serialized as primitive, not as nested object) if annotations.IsTimestampField(field) { return TSTimestampType(field) } if field.Desc.Kind() == protoreflect.MessageKind && field.Message != nil { - return string(field.Message.Desc.Name()) + return ctx.RefMessage(field.Message) } if field.Desc.Kind() == protoreflect.EnumKind && field.Enum != nil { // Check for NUMBER encoding - return number type instead of enum name @@ -316,7 +349,7 @@ func TSElementType(field *protogen.Field) string { if encoding == http.EnumEncoding_ENUM_ENCODING_NUMBER { return TSNumber } - return string(field.Enum.Desc.Name()) + return ctx.RefEnum(field.Enum) } // Use field-aware function for encoding annotations return TSScalarTypeForField(field) @@ -324,18 +357,22 @@ func TSElementType(field *protogen.Field) string { // RootUnwrapTSType returns the TypeScript type for a root-unwrapped message. func RootUnwrapTSType(msg *protogen.Message) string { + return RootUnwrapTSTypeCtx(nil, msg) +} + +// RootUnwrapTSTypeCtx is the import-aware variant of RootUnwrapTSType. +func RootUnwrapTSTypeCtx(ctx *EmitContext, msg *protogen.Message) string { field := msg.Fields[0] if field.Desc.IsMap() { valueField := field.Message.Fields[1] - valueType := TSFieldType(valueField) + valueType := TSFieldTypeCtx(ctx, valueField) // Check for combined unwrap: root map + value unwrap if valueField.Desc.Kind() == protoreflect.MessageKind && valueField.Message != nil { unwrapField := annotations.FindUnwrapField(valueField.Message) if unwrapField != nil { - // Use TSElementType since unwrapField is always repeated - valueType = TSElementType(unwrapField) + "[]" + valueType = TSElementTypeCtx(ctx, unwrapField) + "[]" } } @@ -343,10 +380,10 @@ func RootUnwrapTSType(msg *protogen.Message) string { } if field.Desc.IsList() { - return TSElementType(field) + "[]" + return TSElementTypeCtx(ctx, field) + "[]" } - return TSFieldType(field) + return TSFieldTypeCtx(ctx, field) } // GenerateEnumType writes a TypeScript string union type for a protobuf enum. @@ -376,15 +413,28 @@ func GenerateEnumType(p Printer, enum *protogen.Enum) { p("") } -// GenerateInterface writes a TypeScript interface for a protobuf message. -// If the message has discriminated oneofs, it generates appropriate union types. +// GenerateInterface writes a TypeScript interface for a protobuf message +// (inline mode). func GenerateInterface(p Printer, msg *protogen.Message) { + GenerateInterfaceCtx(nil, p, msg) +} + +// GenerateInterfaceCtx is the import-aware variant of GenerateInterface. In +// modules mode it records cross-module imports via ctx; when ctx requests the +// discriminated oneof style, un-annotated (non-synthetic) oneofs are rendered as +// discriminated unions instead of flattened optional fields. +func GenerateInterfaceCtx(ctx *EmitContext, p Printer, msg *protogen.Message) { name := string(msg.Desc.Name()) // Collect discriminated oneof info var discriminatedOneofs []*annotations.OneofDiscriminatorInfo for _, oneof := range msg.Oneofs { info := annotations.GetOneofDiscriminatorInfo(oneof) + // oneof_style=discriminated: synthesize info for real oneofs that lack an + // explicit annotation. Synthetic oneofs (proto3 `optional`) are left alone. + if info == nil && ctx.oneofDiscriminated() && !oneof.Desc.IsSynthetic() { + info = SynthesizeOneofInfo(oneof) + } if info != nil { discriminatedOneofs = append(discriminatedOneofs, info) } @@ -401,18 +451,48 @@ func GenerateInterface(p Printer, msg *protogen.Message) { // Generate discriminated union types before the message for _, info := range discriminatedOneofs { - GenerateOneofDiscriminatedUnionType(p, name, info) + GenerateOneofDiscriminatedUnionTypeCtx(ctx, p, name, info) } if hasFlattenedOneof { - GenerateFlattenedOneofInterface(p, msg, name, discriminatedOneofs) + GenerateFlattenedOneofInterfaceCtx(ctx, p, msg, name, discriminatedOneofs) } else { - GenerateStandardInterface(p, msg, name, discriminatedOneofs) + GenerateStandardInterfaceCtx(ctx, p, msg, name, discriminatedOneofs) } } -// GenerateOneofDiscriminatedUnionType generates a TypeScript discriminated union type for a oneof. +// SynthesizeOneofInfo builds discriminator info for a oneof that has no explicit +// sebuf.http oneof_config annotation, using "$case" as the discriminator and +// each field's JSON name as the discriminator value. Used by oneof_style=discriminated. +func SynthesizeOneofInfo(oneof *protogen.Oneof) *annotations.OneofDiscriminatorInfo { + info := &annotations.OneofDiscriminatorInfo{ + Oneof: oneof, + Discriminator: "$case", + Flatten: false, + } + for _, field := range oneof.Fields { + info.Variants = append(info.Variants, annotations.OneofVariant{ + Field: field, + DiscriminatorVal: field.Desc.JSONName(), + IsMessage: field.Message != nil, + }) + } + return info +} + +// GenerateOneofDiscriminatedUnionType generates a TypeScript discriminated union type for a oneof +// (inline mode). func GenerateOneofDiscriminatedUnionType(p Printer, msgName string, info *annotations.OneofDiscriminatorInfo) { + GenerateOneofDiscriminatedUnionTypeCtx(nil, p, msgName, info) +} + +// GenerateOneofDiscriminatedUnionTypeCtx is the import-aware variant. +func GenerateOneofDiscriminatedUnionTypeCtx( + ctx *EmitContext, + p Printer, + msgName string, + info *annotations.OneofDiscriminatorInfo, +) { unionName := msgName + SnakeToUpperCamel(string(info.Oneof.Desc.Name())) var branches []string @@ -425,7 +505,7 @@ func GenerateOneofDiscriminatedUnionType(p Printer, msgName string, info *annota var sb strings.Builder for _, childField := range variant.Field.Message.Fields { jsonName := childField.Desc.JSONName() - tsType := TSFieldType(childField) + tsType := TSFieldTypeCtx(ctx, childField) fmt.Fprintf(&sb, "; %s: %s", jsonName, tsType) } branch += sb.String() @@ -433,7 +513,7 @@ func GenerateOneofDiscriminatedUnionType(p Printer, msgName string, info *annota case variant.IsMessage: // Non-flattened message: { discriminator: "value", fieldName?: MessageType } fieldJSONName := variant.Field.Desc.JSONName() - msgType := string(variant.Field.Message.Desc.Name()) + msgType := ctx.RefMessage(variant.Field.Message) branch = fmt.Sprintf( "{ %s: \"%s\"; %s?: %s }", info.Discriminator, @@ -468,12 +548,23 @@ func GenerateOneofDiscriminatedUnionType(p Printer, msgName string, info *annota } // GenerateFlattenedOneofInterface generates a type alias with intersection for messages -// with flattened discriminated oneofs. +// with flattened discriminated oneofs (inline mode). func GenerateFlattenedOneofInterface( p Printer, msg *protogen.Message, name string, discriminatedOneofs []*annotations.OneofDiscriminatorInfo, +) { + GenerateFlattenedOneofInterfaceCtx(nil, p, msg, name, discriminatedOneofs) +} + +// GenerateFlattenedOneofInterfaceCtx is the import-aware variant. +func GenerateFlattenedOneofInterfaceCtx( + ctx *EmitContext, + p Printer, + msg *protogen.Message, + name string, + discriminatedOneofs []*annotations.OneofDiscriminatorInfo, ) { // Build set of fields that belong to discriminated oneofs oneofFields := BuildOneofFieldSet(discriminatedOneofs) @@ -486,10 +577,10 @@ func GenerateFlattenedOneofInterface( } if annotations.IsFlattenField(field) && field.Message != nil { prefix := annotations.GetFlattenPrefix(field) - GenerateFlattenedFields(p, field.Message, prefix) + GenerateFlattenedFieldsCtx(ctx, p, field.Message, prefix) continue } - GenerateFieldDeclaration(p, field) + GenerateFieldDeclarationCtx(ctx, p, field) } p("}") p("") @@ -505,12 +596,23 @@ func GenerateFlattenedOneofInterface( } // GenerateStandardInterface generates a standard interface, handling non-flattened -// discriminated oneofs as optional union properties. +// discriminated oneofs as optional union properties (inline mode). func GenerateStandardInterface( p Printer, msg *protogen.Message, name string, discriminatedOneofs []*annotations.OneofDiscriminatorInfo, +) { + GenerateStandardInterfaceCtx(nil, p, msg, name, discriminatedOneofs) +} + +// GenerateStandardInterfaceCtx is the import-aware variant. +func GenerateStandardInterfaceCtx( + ctx *EmitContext, + p Printer, + msg *protogen.Message, + name string, + discriminatedOneofs []*annotations.OneofDiscriminatorInfo, ) { // Build set of fields that belong to discriminated oneofs oneofFields := BuildOneofFieldSet(discriminatedOneofs) @@ -541,11 +643,11 @@ func GenerateStandardInterface( if annotations.IsFlattenField(field) && field.Message != nil { prefix := annotations.GetFlattenPrefix(field) - GenerateFlattenedFields(p, field.Message, prefix) + GenerateFlattenedFieldsCtx(ctx, p, field.Message, prefix) continue } - GenerateFieldDeclaration(p, field) + GenerateFieldDeclarationCtx(ctx, p, field) } p("}") p("") @@ -562,10 +664,16 @@ func BuildOneofFieldSet(discriminatedOneofs []*annotations.OneofDiscriminatorInf return oneofFields } -// GenerateFieldDeclaration generates a single TypeScript field declaration line. +// GenerateFieldDeclaration generates a single TypeScript field declaration line +// (inline mode). func GenerateFieldDeclaration(p Printer, field *protogen.Field) { + GenerateFieldDeclarationCtx(nil, p, field) +} + +// GenerateFieldDeclarationCtx is the import-aware variant. +func GenerateFieldDeclarationCtx(ctx *EmitContext, p Printer, field *protogen.Field) { jsonName := field.Desc.JSONName() - tsType := TSFieldType(field) + tsType := TSFieldTypeCtx(ctx, field) //nolint:gocritic // if-else chain is clearer than switch for distinct boolean checks if annotations.IsNullableField(field) { @@ -588,11 +696,17 @@ func SnakeToUpperCamel(s string) string { return strings.Join(parts, "") } -// GenerateFlattenedFields inlines child message fields at the parent level with optional prefix. +// GenerateFlattenedFields inlines child message fields at the parent level with optional prefix +// (inline mode). func GenerateFlattenedFields(p Printer, childMsg *protogen.Message, prefix string) { + GenerateFlattenedFieldsCtx(nil, p, childMsg, prefix) +} + +// GenerateFlattenedFieldsCtx is the import-aware variant. +func GenerateFlattenedFieldsCtx(ctx *EmitContext, p Printer, childMsg *protogen.Message, prefix string) { for _, childField := range childMsg.Fields { jsonName := prefix + childField.Desc.JSONName() - tsType := TSFieldType(childField) + tsType := TSFieldTypeCtx(ctx, childField) //nolint:gocritic // if-else chain is clearer than switch for distinct boolean checks if annotations.IsNullableField(childField) { diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index a0e5e483..43f839bc 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -16,17 +16,25 @@ import ( // Generator handles TypeScript server code generation for protobuf services. type Generator struct { plugin *protogen.Plugin + opts tscommon.Options + // ctx carries modules-mode emission state for the file currently being + // written; nil in inline mode (bare names, no imports). + ctx *tscommon.EmitContext } // New creates a new TypeScript server generator. -func New(plugin *protogen.Plugin) *Generator { +func New(plugin *protogen.Plugin, opts tscommon.Options) *Generator { return &Generator{ plugin: plugin, + opts: opts, } } // Generate processes all files and generates TypeScript server files. func (g *Generator) Generate() error { + if g.opts.ImportStyle == tscommon.ImportStyleModules { + return g.generateModules() + } for _, file := range g.plugin.Files { if !file.Generate { continue @@ -49,6 +57,12 @@ func (g *Generator) generateServerFile(file *protogen.File) error { filename := file.GeneratedFilenamePrefix + "_server.ts" gf := g.plugin.NewGeneratedFile(filename, "") + // Inline-mode context: no imports (bare names), but carries oneof_style so + // oneof_style=discriminated works without import_style=modules. With the + // default flatten style this is byte-identical to the historical output. + g.ctx = &tscommon.EmitContext{Options: g.opts} + defer func() { g.ctx = nil }() + // Collect all referenced messages and enums ms := tscommon.CollectServiceMessages(file) @@ -65,7 +79,7 @@ func (g *Generator) generateServerFile(file *protogen.File) error { // 2. Message interfaces (shared types via tscommon) for _, msg := range ms.OrderedMessages() { - tscommon.GenerateInterface(tscommon.Printer(p), msg) + tscommon.GenerateInterfaceCtx(g.ctx, tscommon.Printer(p), msg) } // 3. Enum types (shared via tscommon) @@ -260,7 +274,7 @@ func (g *Generator) generateHandlerInterface(p tscommon.Printer, service *protog p("export interface %sHandler {", serviceName) for _, method := range service.Methods { methodName := annotations.LowerFirst(method.GoName) - inputType := string(method.Input.Desc.Name()) + inputType := g.ctx.RefMessage(method.Input) outputType := g.resolveOutputType(method) if g.isSSEMethod(method) { p(" %s(ctx: ServerContext, req: %s): ReadableStream<%s>;", methodName, inputType, outputType) @@ -276,9 +290,9 @@ func (g *Generator) generateHandlerInterface(p tscommon.Printer, service *protog func (g *Generator) resolveOutputType(method *protogen.Method) string { msg := method.Output if annotations.IsRootUnwrap(msg) { - return tscommon.RootUnwrapTSType(msg) + return tscommon.RootUnwrapTSTypeCtx(g.ctx, msg) } - return string(msg.Desc.Name()) + return g.ctx.RefMessage(msg) } // pathParamField maps a URL path parameter to its corresponding request message field. @@ -612,14 +626,14 @@ func (g *Generator) generateSSERouteEntry( } // emitPathParamAssignment emits a single path param assignment with enum casting if needed. -func emitPathParamAssignment( +func (g *Generator) emitPathParamAssignment( p tscommon.Printer, ppf pathParamField, prefix string, suffix string, ) { if ppf.field != nil && ppf.field.Desc.Kind() == protoreflect.EnumKind && ppf.field.Enum != nil { - enumName := string(ppf.field.Enum.Desc.Name()) + enumName := g.ctx.RefEnum(ppf.field.Enum) p( "%s%s: pathParams[\"%s\"] as %s%s", prefix, ppf.jsonName, ppf.protoName, enumName, suffix, @@ -637,7 +651,7 @@ func (g *Generator) generatePathParamMerge(p tscommon.Printer, cfg *rpcRouteConf } for _, ppf := range cfg.pathParamFields { if ppf.field != nil && ppf.field.Desc.Kind() == protoreflect.EnumKind && ppf.field.Enum != nil { - enumName := string(ppf.field.Enum.Desc.Name()) + enumName := g.ctx.RefEnum(ppf.field.Enum) p( " body.%s = pathParams[\"%s\"] as %s;", ppf.jsonName, ppf.protoName, enumName, @@ -710,7 +724,7 @@ func (g *Generator) generatePathParamExtraction(p tscommon.Printer, cfg *rpcRout // generateBodyParsing generates code to parse JSON request body. func (g *Generator) generateBodyParsing(p tscommon.Printer, method *protogen.Method, tsMethodName string) { - inputType := string(method.Input.Desc.Name()) + inputType := g.ctx.RefMessage(method.Input) p(" const body = await req.json() as %s;", inputType) // Optional validation hook @@ -730,13 +744,13 @@ func (g *Generator) generateQueryParamParsing( method *protogen.Method, tsMethodName string, ) { - inputType := string(method.Input.Desc.Name()) + inputType := g.ctx.RefMessage(method.Input) if len(cfg.queryParams) == 0 { if len(cfg.pathParamFields) > 0 { p(" const body: %s = {", inputType) for _, ppf := range cfg.pathParamFields { - emitPathParamAssignment(p, ppf, " ", ",") + g.emitPathParamAssignment(p, ppf, " ", ",") } p(" };") } else { @@ -756,7 +770,7 @@ func (g *Generator) generateQueryParamParsing( p(" const body: %s = {", inputType) // Include path param fields in the literal so TS sees all required properties for _, ppf := range cfg.pathParamFields { - emitPathParamAssignment(p, ppf, " ", ",") + g.emitPathParamAssignment(p, ppf, " ", ",") } for _, qp := range cfg.queryParams { g.generateQueryParamField(p, qp) @@ -781,7 +795,7 @@ func (g *Generator) generateQueryParamField(p tscommon.Printer, qp annotations.Q // Handle repeated fields: use getAll() for multi-value params if qp.Field != nil && qp.Field.Desc.IsList() { if qp.Field.Desc.Kind() == protoreflect.EnumKind && qp.Field.Enum != nil { - p(` %s: params.getAll("%s") as %s[],`, jsonName, paramName, string(qp.Field.Enum.Desc.Name())) + p(` %s: params.getAll("%s") as %s[],`, jsonName, paramName, g.ctx.RefEnum(qp.Field.Enum)) } else { p(` %s: params.getAll("%s"),`, jsonName, paramName) } @@ -797,7 +811,7 @@ func (g *Generator) generateQueryParamField(p tscommon.Printer, qp annotations.Q jsonName, paramName, unspecified, - string(qp.Field.Enum.Desc.Name()), + g.ctx.RefEnum(qp.Field.Enum), ) return } diff --git a/internal/tsservergen/golden_test.go b/internal/tsservergen/golden_test.go index 27cc0260..9910f2f0 100644 --- a/internal/tsservergen/golden_test.go +++ b/internal/tsservergen/golden_test.go @@ -24,6 +24,8 @@ func TestTSServerGenGoldenFiles(t *testing.T) { name string protoFile string expectedFiles []string + opts []string // extra --ts-server_opt values (beyond paths=source_relative) + subdir string // golden subdirectory for opt variants }{ { name: "comprehensive HTTP verbs", @@ -130,6 +132,26 @@ func TestTSServerGenGoldenFiles(t *testing.T) { "sse_server.ts", }, }, + { + name: "import_style=modules", + protoFile: "complex_features.proto", + opts: []string{"import_style=modules"}, + subdir: "modules", + expectedFiles: []string{ + "complex_features.ts", + "complex_features_server.ts", + "errors.ts", + }, + }, + { + name: "oneof_style=discriminated", + protoFile: "oneof_discriminator.proto", + opts: []string{"oneof_style=discriminated"}, + subdir: "oneof_discriminated", + expectedFiles: []string{ + "oneof_discriminator_server.ts", + }, + }, } baseDir, err := os.Getwd() @@ -171,14 +193,20 @@ func TestTSServerGenGoldenFiles(t *testing.T) { } // Run protoc with ts-server plugin - cmd := exec.Command("protoc", - "--plugin=protoc-gen-ts-server="+pluginPath, - "--ts-server_out="+tempDir, + args := []string{ + "--plugin=protoc-gen-ts-server=" + pluginPath, + "--ts-server_out=" + tempDir, "--ts-server_opt=paths=source_relative", + } + for _, opt := range tc.opts { + args = append(args, "--ts-server_opt="+opt) + } + args = append(args, "--proto_path="+protoDir, "--proto_path="+filepath.Join(projectRoot, "proto"), tc.protoFile, ) + cmd := exec.Command("protoc", args...) cmd.Dir = protoDir var stderr bytes.Buffer @@ -191,7 +219,7 @@ func TestTSServerGenGoldenFiles(t *testing.T) { for _, expectedFile := range tc.expectedFiles { generatedPath := filepath.Join(tempDir, expectedFile) - goldenPath := filepath.Join(goldenDir, expectedFile) + goldenPath := filepath.Join(goldenDir, tc.subdir, expectedFile) generatedContent, readErr := os.ReadFile(generatedPath) if readErr != nil { @@ -210,6 +238,9 @@ func TestTSServerGenGoldenFiles(t *testing.T) { func updateGoldenFile(t *testing.T, goldenPath string, content []byte) { t.Helper() + if mkErr := os.MkdirAll(filepath.Dir(goldenPath), 0o755); mkErr != nil { + t.Fatalf("Failed to create golden dir for %s: %v", goldenPath, mkErr) + } writeErr := os.WriteFile(goldenPath, content, 0o644) if writeErr != nil { t.Fatalf("Failed to write golden file %s: %v", goldenPath, writeErr) diff --git a/internal/tsservergen/modules.go b/internal/tsservergen/modules.go new file mode 100644 index 00000000..b0723d33 --- /dev/null +++ b/internal/tsservergen/modules.go @@ -0,0 +1,56 @@ +package tsservergen + +import ( + "google.golang.org/protobuf/compiler/protogen" + + "github.com/SebastienMelki/sebuf/internal/tscommon" +) + +// generateModules implements import_style=modules: shared canonical type modules +// and an errors module (via tscommon), plus one slimmed server module per +// service file that imports its request/response types and the error helpers. +func (g *Generator) generateModules() error { + if err := tscommon.EmitSharedModules(g.plugin, g.opts); err != nil { + return err + } + for _, file := range g.plugin.Files { + if !file.Generate || len(file.Services) == 0 { + continue + } + g.emitServerModule(file) + } + return nil +} + +// emitServerModule writes a service file's server framework types + handlers, +// importing the request/response types from their canonical modules and the +// shared error helpers. +func (g *Generator) emitServerModule(file *protogen.File) { + module := file.GeneratedFilenamePrefix + "_server" + gf := g.plugin.NewGeneratedFile(module+".ts", "") + tracker := tscommon.NewImportTracker() + g.ctx = &tscommon.EmitContext{Options: g.opts, SelfModule: module, Imports: tracker} + defer func() { g.ctx = nil }() + + var body []string + bp := tscommon.BufferedPrinter(&body) + + g.writeServerTypes(bp) + if g.fileUsesHeaders(file) { + g.writeHeaderValidationHelpers(bp) + } + for _, service := range file.Services { + _ = g.generateService(bp, service) + } + // Import only the error helpers actually referenced in the body. + g.ctx.NeedErrors(tscommon.UsedErrorSymbols(body)...) + + dp := tscommon.DirectPrinter(gf) + dp("// Code generated by protoc-gen-ts-server. DO NOT EDIT.") + dp("// source: %s", file.Desc.Path()) + dp("") + tracker.Render(dp) + for _, line := range body { + gf.P(line) + } +} diff --git a/internal/tsservergen/testdata/golden/modules/complex_features.ts b/internal/tsservergen/testdata/golden/modules/complex_features.ts new file mode 100644 index 00000000..8a68b94b --- /dev/null +++ b/internal/tsservergen/testdata/golden/modules/complex_features.ts @@ -0,0 +1,105 @@ +// Code generated by sebuf. DO NOT EDIT. +// source: complex_features.proto + +export interface ListNotesRequest { + page: number; + pageSize: number; + filter: string; +} + +export interface ListNotesResponse { + notes: Note[]; + totalCount: number; +} + +export interface Note { + id: string; + title: string; + content: string; + priority: Priority; + status: Status; + tags: Tag[]; + metadata: Record; + dueDate?: string; + address?: Address; +} + +export interface Tag { + name: string; + color: string; +} + +export interface Address { + street: string; + city: string; + zipCode: string; +} + +export interface GetNoteRequest { + noteId: string; +} + +export interface CreateNoteRequest { + title: string; + content: string; + priority: Priority; + status: Status; + tags: Tag[]; + metadata: Record; + dueDate?: string; + address?: Address; +} + +export interface UpdateNoteRequest { + noteId: string; + title: string; + content: string; + priority: Priority; +} + +export interface GetNoteListRequest { + category: string; +} + +export interface NoteList { + notes: Note[]; +} + +export interface GetNoteMapRequest { + ownerId: string; +} + +export interface NoteMap { + notes: Record; +} + +export interface GetBarsBySymbolRequest { + symbols: string[]; +} + +export interface BarsBySymbol { + data: Record; +} + +export interface BarWrapper { + bars: Bar[]; +} + +export interface Bar { + symbol: string; + price: number; + volume: string; +} + +export interface GetCombinedUnwrapRequest { + market: string; +} + +export interface CombinedUnwrap { + data: Record; +} + +export type Priority = "PRIORITY_UNSPECIFIED" | "PRIORITY_LOW" | "PRIORITY_MEDIUM" | "PRIORITY_HIGH" | "PRIORITY_URGENT"; + +export type Status = "STATUS_UNSPECIFIED" | "STATUS_PENDING" | "STATUS_IN_PROGRESS" | "STATUS_DONE" | "STATUS_ARCHIVED"; + diff --git a/internal/tsservergen/testdata/golden/modules/complex_features_server.ts b/internal/tsservergen/testdata/golden/modules/complex_features_server.ts new file mode 100644 index 00000000..d8cc1a22 --- /dev/null +++ b/internal/tsservergen/testdata/golden/modules/complex_features_server.ts @@ -0,0 +1,551 @@ +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: complex_features.proto + +import { FieldViolation, ValidationError } from "./errors"; +import type { Bar, BarWrapper, BarsBySymbol, CreateNoteRequest, GetBarsBySymbolRequest, GetCombinedUnwrapRequest, GetNoteListRequest, GetNoteMapRequest, GetNoteRequest, ListNotesRequest, ListNotesResponse, Note, UpdateNoteRequest } from "./complex_features"; + +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; +} + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.[\d]+)?(Z|[+-]\d{2}:\d{2})$/; + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +const TIME_REGEX = /^\d{2}:\d{2}:\d{2}(\.[\d]+)?$/; + +interface HeaderConfig { + name: string; + type: string; + required: boolean; + format?: string; +} + +function validateHeaderValue(value: string, config: HeaderConfig): string | undefined { + switch (config.type) { + case "integer": + if (!/^-?\d+$/.test(value)) return "must be an integer"; + break; + case "number": + if (isNaN(Number(value))) return "must be a number"; + break; + case "boolean": + if (value !== "true" && value !== "false" && value !== "1" && value !== "0") + return "must be a boolean"; + break; + } + if (config.format) { + switch (config.format) { + case "uuid": + if (!UUID_REGEX.test(value)) return "must be a valid UUID"; + break; + case "email": + if (!EMAIL_REGEX.test(value)) return "must be a valid email"; + break; + case "date-time": + if (!DATETIME_REGEX.test(value)) return "must be a valid date-time"; + break; + case "date": + if (!DATE_REGEX.test(value)) return "must be a valid date"; + break; + case "time": + if (!TIME_REGEX.test(value)) return "must be a valid time"; + break; + } + } + return undefined; +} + +function validateHeaders( + req: Request, + configs: HeaderConfig[], +): FieldViolation[] | undefined { + const violations: FieldViolation[] = []; + for (const config of configs) { + const value = req.headers.get(config.name); + if (value == null) { + if (config.required) { + violations.push({ + field: config.name, + description: "required header is missing", + }); + } + continue; + } + const err = validateHeaderValue(value, config); + if (err) { + violations.push({ + field: config.name, + description: `header ${config.name}: ${err}`, + }); + } + } + return violations.length > 0 ? violations : undefined; +} + +export interface FeatureServiceHandler { + listNotes(ctx: ServerContext, req: ListNotesRequest): Promise; + getNote(ctx: ServerContext, req: GetNoteRequest): Promise; + createNote(ctx: ServerContext, req: CreateNoteRequest): Promise; + updateNote(ctx: ServerContext, req: UpdateNoteRequest): Promise; + getNoteList(ctx: ServerContext, req: GetNoteListRequest): Promise; + getNoteMap(ctx: ServerContext, req: GetNoteMapRequest): Promise>; + getBarsBySymbol(ctx: ServerContext, req: GetBarsBySymbolRequest): Promise; + getCombinedUnwrap(ctx: ServerContext, req: GetCombinedUnwrapRequest): Promise>; +} + +export function createFeatureServiceRoutes( + handler: FeatureServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "GET", + path: "/api/v1/notes", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: ListNotesRequest = { + page: Number(params.get("page") ?? "0"), + pageSize: Number(params.get("page_size") ?? "0"), + filter: params.get("filter") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("listNotes", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.listNotes(ctx, body); + return new Response(JSON.stringify(result as ListNotesResponse), { + 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" }, + }); + } + }, + }, + { + method: "GET", + path: "/api/v1/notes/{note_id}", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const pathSegments = url.pathname.split("/"); + pathParams["note_id"] = decodeURIComponent(pathSegments[4] ?? ""); + + const body: GetNoteRequest = { + noteId: pathParams["note_id"], + }; + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getNote(ctx, body); + return new Response(JSON.stringify(result as Note), { + 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" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/notes", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + { name: "X-Request-ID", type: "string", required: true, format: "uuid" }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const body = await req.json() as CreateNoteRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("createNote", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.createNote(ctx, body); + return new Response(JSON.stringify(result as Note), { + 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" }, + }); + } + }, + }, + { + method: "PUT", + path: "/api/v1/notes/{note_id}", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + { name: "X-Idempotency-Key", type: "string", required: true, format: "uuid" }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const pathSegments = url.pathname.split("/"); + pathParams["note_id"] = decodeURIComponent(pathSegments[4] ?? ""); + + const body = await req.json() as UpdateNoteRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("updateNote", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + body.noteId = pathParams["note_id"]; + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.updateNote(ctx, body); + return new Response(JSON.stringify(result as Note), { + 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" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/notes/list", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const body = await req.json() as GetNoteListRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getNoteList", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getNoteList(ctx, body); + return new Response(JSON.stringify(result as Note[]), { + 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" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/notes/map", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const body = await req.json() as GetNoteMapRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getNoteMap", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getNoteMap(ctx, body); + return new Response(JSON.stringify(result as Record), { + 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" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/bars", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const body = await req.json() as GetBarsBySymbolRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getBarsBySymbol", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getBarsBySymbol(ctx, body); + return new Response(JSON.stringify(result as BarsBySymbol), { + 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" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/bars/combined", + handler: async (req: Request): Promise => { + try { + const headerConfigs: HeaderConfig[] = [ + { name: "X-API-Key", type: "string", required: true, format: "uuid" }, + { name: "X-Tenant-ID", type: "integer", required: true }, + ]; + const headerViolations = validateHeaders(req, headerConfigs); + if (headerViolations) { + throw new ValidationError(headerViolations); + } + + const pathParams: Record = {}; + const body = await req.json() as GetCombinedUnwrapRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getCombinedUnwrap", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getCombinedUnwrap(ctx, body); + return new Response(JSON.stringify(result as Record), { + 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/modules/errors.ts b/internal/tsservergen/testdata/golden/modules/errors.ts new file mode 100644 index 00000000..991abd2c --- /dev/null +++ b/internal/tsservergen/testdata/golden/modules/errors.ts @@ -0,0 +1,29 @@ +// Code generated by sebuf. DO NOT EDIT. + +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; + } +} + diff --git a/internal/tsservergen/testdata/golden/oneof_discriminated/oneof_discriminator_server.ts b/internal/tsservergen/testdata/golden/oneof_discriminated/oneof_discriminator_server.ts new file mode 100644 index 00000000..1677842a --- /dev/null +++ b/internal/tsservergen/testdata/golden/oneof_discriminated/oneof_discriminator_server.ts @@ -0,0 +1,234 @@ +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: oneof_discriminator.proto + +export type FlattenedEventContent = + | { type: "text"; body: string } + | { type: "img"; url: string; width: number; height: number }; + +export interface FlattenedEventBase { + id: string; +} + +export type FlattenedEvent = FlattenedEventBase & FlattenedEventContent; + +export interface TextContent { + body: string; +} + +export interface ImageContent { + url: string; + width: number; + height: number; +} + +export type NestedEventContent = + | { kind: "text"; text?: TextContent } + | { kind: "image"; image?: ImageContent } + | { kind: "vid"; video?: VideoContent }; + +export interface NestedEvent { + id: string; + content?: NestedEventContent; +} + +export interface VideoContent { + url: string; + duration: number; +} + +export type PlainEventContent = + | { $case: "text"; text?: TextContent } + | { $case: "image"; image?: ImageContent }; + +export interface PlainEvent { + id: string; + content?: PlainEventContent; +} + +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 OneofDiscriminatorServiceHandler { + testFlattenedEvent(ctx: ServerContext, req: FlattenedEvent): Promise; + testNestedEvent(ctx: ServerContext, req: NestedEvent): Promise; + testPlainEvent(ctx: ServerContext, req: PlainEvent): Promise; +} + +export function createOneofDiscriminatorServiceRoutes( + handler: OneofDiscriminatorServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "POST", + path: "/api/v1/events/flattened", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as FlattenedEvent; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("testFlattenedEvent", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.testFlattenedEvent(ctx, body); + return new Response(JSON.stringify(result as FlattenedEvent), { + 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" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/events/nested", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as NestedEvent; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("testNestedEvent", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.testNestedEvent(ctx, body); + return new Response(JSON.stringify(result as NestedEvent), { + 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" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/events/plain", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as PlainEvent; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("testPlainEvent", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.testPlainEvent(ctx, body); + return new Response(JSON.stringify(result as PlainEvent), { + 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" }, + }); + } + }, + }, + ]; +} +