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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/protoc-gen-ts-client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import (
"google.golang.org/protobuf/types/pluginpb"

"github.com/SebastienMelki/sebuf/internal/tsclientgen"
"github.com/SebastienMelki/sebuf/internal/tscommon"
)

func main() {
options := protogen.Options{}

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()
})
}
7 changes: 6 additions & 1 deletion cmd/protoc-gen-ts-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
"google.golang.org/protobuf/types/pluginpb"

"github.com/SebastienMelki/sebuf/internal/tsservergen"
"github.com/SebastienMelki/sebuf/internal/tscommon"

Check failure on line 8 in cmd/protoc-gen-ts-server/main.go

View workflow job for this annotation

GitHub Actions / Lint & Code Quality

File is not properly formatted (goimports)
)

func main() {
options := protogen.Options{}

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()
})
}
27 changes: 21 additions & 6 deletions internal/tsclientgen/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
39 changes: 35 additions & 4 deletions internal/tsclientgen/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions internal/tsclientgen/modules.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
105 changes: 105 additions & 0 deletions internal/tsclientgen/testdata/golden/modules/complex_features.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<string, string>;
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<string, Note>;
}

export interface GetBarsBySymbolRequest {
symbols: string[];
}

export interface BarsBySymbol {
data: Record<string, Bar[]>;
}

export interface BarWrapper {
bars: Bar[];
}

export interface Bar {
symbol: string;
price: number;
volume: string;
}

export interface GetCombinedUnwrapRequest {
market: string;
}

export interface CombinedUnwrap {
data: Record<string, Bar[]>;
}

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";

Loading
Loading