From 30b6c46d4f732c57435406f1e875c9f88befc53e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 16 Sep 2025 22:33:11 -0400 Subject: [PATCH] feat: add protoc plugin Signed-off-by: Yordis Prieto --- .gitignore | 3 + .../README.md | 108 ++++++ .../main.go | 267 +++++++++++++++ .../main_test.go | 307 ++++++++++++++++++ go.mod | 9 +- go.sum | 22 +- 6 files changed, 709 insertions(+), 7 deletions(-) create mode 100644 cmd/protoc-gen-connect-go-servicestruct/README.md create mode 100644 cmd/protoc-gen-connect-go-servicestruct/main.go create mode 100644 cmd/protoc-gen-connect-go-servicestruct/main_test.go diff --git a/.gitignore b/.gitignore index aaadf73..19792b9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Generated build files +protoc-gen-connect-go-servicestruct diff --git a/cmd/protoc-gen-connect-go-servicestruct/README.md b/cmd/protoc-gen-connect-go-servicestruct/README.md new file mode 100644 index 0000000..5c05dec --- /dev/null +++ b/cmd/protoc-gen-connect-go-servicestruct/README.md @@ -0,0 +1,108 @@ +# protoc-gen-connect-go-servicestruct + +A protobuf compiler plugin that generates Go service structs with handler +functions for Connect RPC services. **Requires `protoc-gen-connect-go` as a dependency.** + +## How-tos + +### Installation + +```bash +go install github.com/TrogonStack/protoc-gen/cmd/protoc-gen-connect-go-servicestruct@latest +``` + +### Basic Usage + +**Required**: This plugin must be used alongside `protoc-gen-connect-go` as it generates structs that implement the +Connect handler interfaces: + +```bash +protoc --go_out=gen --connect-go_out=gen --connect-go-servicestruct_out=gen path/to/service.proto +``` + +### With buf + +**Required**: Add to your `buf.gen.yaml` alongside the required `buf.build/connectrpc/go` plugin: + +```yaml +version: v2 +plugins: + - remote: buf.build/protocolbuffers/go + out: gen + opt: paths=source_relative + - remote: buf.build/connectrpc/go:v1.18.1 # Required dependency + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go-servicestruct + out: gen + opt: + - paths=source_relative +``` + +Files are generated into `{packagename}connect` subdirectories alongside the connect plugin output. + +### Basic Service Implementation + +For more realistic applications that require dependencies like database connections, use constructor functions to +create handlers: + +```go +package main + +import ( + "context" + "fmt" + "log" + "net/http" + + "connectrpc.com/connect" + "github.com/jackc/pgx/v5/pgxpool" + greeterv1 "example.com/greeter/gen/greeter/v1" + "example.com/greeter/gen/greeter/v1/greeterv1connect" +) + +func main() { + // Initialize database connection + dbPool, err := pgxpool.New(context.Background(), "postgres://user:pass@localhost/db") + if err != nil { + log.Fatal("Failed to create connection pool:", err) + } + defer dbPool.Close() + + mux := http.NewServeMux() + path, handler := greeterv1connect.NewGreeterServiceHandler(&greeterv1connect.GreeterServiceStruct{ + GreetFunc: newGreetHandler(dbPool), + GreetStreamFunc: newGreetStreamHandler(dbPool), + }) + mux.Handle(path, handler) + + log.Println("Greeter server starting on :8080") + http.ListenAndServe(":8080", mux) +} + +func newGreetHandler(db *pgxpool.Pool) greeterv1.GreeterServiceGreetHandlerFunc { + return func(ctx context.Context, req *connect.Request[greeterv1.GreetRequest]) (*connect.Response[greeterv1.GreetResponse], error) { + // ... + } +} + +func newGreetStreamHandler(db *pgxpool.Pool) greeterv1.GreeterServiceGreetStreamHandlerFunc { + return func(ctx context.Context, stream *connect.BidiStream[greeterv1.GreetRequest, greeterv1.GreetResponse]) error { + // ... + } +} +``` + +## Explanations + +### Overview + +This plugin generates service structs that provide a convenient way to organize and access handler functions for +Connect RPC services. **This plugin requires `protoc-gen-connect-go` (or `buf.build/connectrpc/go`) to function** - it +generates structs that implement the Connect handler interfaces created by the standard Connect plugin. + +### Features + +- **Service Structs**: Generates public structs with handler function fields. +- **Type Safety**: Uses strongly-typed handler function types. +- **Interface Compatibility**: Generated structs implement the standard handler interfaces for backwards compatibility. diff --git a/cmd/protoc-gen-connect-go-servicestruct/main.go b/cmd/protoc-gen-connect-go-servicestruct/main.go new file mode 100644 index 0000000..f1870a6 --- /dev/null +++ b/cmd/protoc-gen-connect-go-servicestruct/main.go @@ -0,0 +1,267 @@ +// protoc-gen-connect-go-servicestruct is a plugin for the Protobuf compiler that generates +// Go service structs with handler functions. To use it, build this program and make +// it available on your PATH as protoc-gen-connect-go-servicestruct. +// +// The 'connect-go-servicestruct' suffix becomes part of the arguments for the Protobuf +// compiler. To generate service structs using protoc: +// +// protoc --go_out=gen --connect-go-servicestruct_out=gen path/to/file.proto +// +// With [buf], your buf.gen.yaml will look like this: +// +// version: v2 +// plugins: +// - local: protoc-gen-go +// out: gen +// - local: protoc-gen-connect-go-servicestruct +// out: gen +// +// This generates service struct definitions for the Protobuf services +// defined by file.proto. If file.proto defines the foov1 Protobuf package, the +// invocations above will write output to: +// +// gen/path/to/file.pb.go +// gen/path/to/foov1connect/file.servicestruct.connect.go +// +// [buf]: https://buf.build +package main + +import ( + "fmt" + "os" + "path" + "strings" + + connect "connectrpc.com/connect" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +const ( + contextPackage = protogen.GoImportPath("context") + connectPackage = protogen.GoImportPath("connectrpc.com/connect") + + filenameSuffix = ".servicestruct.connect.go" + handlerFuncSuffix = "HandlerFunc" + serviceSuffix = "Struct" + + usage = "\n\nFlags:\n -h, --help\tPrint this help and exit.\n --version\tPrint the version and exit." +) + +func main() { + if len(os.Args) == 2 && os.Args[1] == "--version" { + if _, err := fmt.Fprintln(os.Stdout, connect.Version); err != nil { + os.Exit(1) + } + os.Exit(0) + } + if len(os.Args) == 2 && (os.Args[1] == "-h" || os.Args[1] == "--help") { + if _, err := fmt.Fprintln(os.Stdout, usage); err != nil { + os.Exit(1) + } + os.Exit(0) + } + if len(os.Args) != 1 { + if _, err := fmt.Fprintln(os.Stderr, usage); err != nil { + os.Exit(1) + } + os.Exit(1) + } + protogen.Options{}.Run( + func(plugin *protogen.Plugin) error { + plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) | uint64(pluginpb.CodeGeneratorResponse_FEATURE_SUPPORTS_EDITIONS) + plugin.SupportedEditionsMinimum = descriptorpb.Edition_EDITION_PROTO2 + plugin.SupportedEditionsMaximum = descriptorpb.Edition_EDITION_2023 + + for _, file := range plugin.Files { + if file.Generate && len(file.Services) > 0 { + generateFile(plugin, file) + } + } + + return nil + }, + ) +} + +func generateFile(plugin *protogen.Plugin, file *protogen.File) { + // Modify package name and paths to match connect plugin behavior + connectSuffix := "connect" + connectPackageName := file.GoPackageName + protogen.GoPackageName(connectSuffix) + dir := path.Dir(file.GeneratedFilenamePrefix) + base := path.Base(file.GeneratedFilenamePrefix) + connectDir := string(file.GoPackageName) + connectSuffix + filename := path.Join(dir, connectDir, base+filenameSuffix) + goImportPath := protogen.GoImportPath(path.Join( + string(file.GoImportPath), + connectDir, + )) + generatedFile := plugin.NewGeneratedFile(filename, goImportPath) + + // Import the base package for message types + generatedFile.Import(file.GoImportPath) + + generatedFile.P("// Code generated by protoc-gen-connect-go-servicestruct. DO NOT EDIT.") + generatedFile.P("//") + generatedFile.P("// Source: ", file.Desc.Path()) + generatedFile.P() + generatedFile.P("package ", connectPackageName) + generatedFile.P() + + generateHandlerFuncTypes(generatedFile, []*protogen.File{file}) + + for _, service := range file.Services { + generateServiceStruct(generatedFile, service) + } +} + +func generateHandlerFuncTypes(g *protogen.GeneratedFile, files []*protogen.File) { + // Generate specific handler function types for each method + for _, file := range files { + for _, service := range file.Services { + for _, method := range service.Methods { + generateMethodHandlerType(g, service, method) + } + } + } +} + +func generateMethodHandlerType(g *protogen.GeneratedFile, service *protogen.Service, method *protogen.Method) { + typeName := service.GoName + method.GoName + handlerFuncSuffix + + if isDeprecatedMethod(method) { + g.P("//") + deprecated(g) + } + + // Generate the appropriate function signature based on streaming type + isStreamingClient := method.Desc.IsStreamingClient() + isStreamingServer := method.Desc.IsStreamingServer() + + switch { + case isStreamingClient && isStreamingServer: + // Bidirectional streaming + g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("BidiStream"), "[", g.QualifiedGoIdent(method.Input.GoIdent), ", ", g.QualifiedGoIdent(method.Output.GoIdent), "]) error") + case isStreamingClient && !isStreamingServer: + // Client streaming + g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("ClientStream"), "[", g.QualifiedGoIdent(method.Input.GoIdent), "]) (*", connectPackage.Ident("Response"), "[", g.QualifiedGoIdent(method.Output.GoIdent), "], error)") + case !isStreamingClient && isStreamingServer: + // Server streaming + g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("Request"), "[", g.QualifiedGoIdent(method.Input.GoIdent), "], *", connectPackage.Ident("ServerStream"), "[", g.QualifiedGoIdent(method.Output.GoIdent), "]) error") + default: + // Unary + g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("Request"), "[", g.QualifiedGoIdent(method.Input.GoIdent), "]) (*", connectPackage.Ident("Response"), "[", g.QualifiedGoIdent(method.Output.GoIdent), "], error)") + } + g.P() +} + +func generateServiceStruct(g *protogen.GeneratedFile, service *protogen.Service) { + serviceName := fmt.Sprintf("%s%s", service.GoName, serviceSuffix) + + if isDeprecatedService(service) { + g.P("//") + deprecated(g) + } + g.AnnotateSymbol(serviceName, protogen.Annotation{Location: service.Location}) + g.P("type ", serviceName, " struct {") + for _, method := range service.Methods { + fieldName := method.GoName + "Func" + g.AnnotateSymbol(serviceName+"."+fieldName, protogen.Annotation{Location: method.Location}) + leadingComments( + g, + method.Comments.Leading, + isDeprecatedMethod(method), + ) + handlerTypeName := service.GoName + method.GoName + handlerFuncSuffix + g.P(fieldName, " ", handlerTypeName) + } + g.P("}") + g.P() + + for _, method := range service.Methods { + fieldName := method.GoName + "Func" + if isDeprecatedMethod(method) { + g.P("//") + deprecated(g) + } + methodSig := method.GoName + serverSignatureParams(g, method, true) + g.P("func (s *", serviceName, ") ", methodSig, " {") + + isStreamingClient := method.Desc.IsStreamingClient() + isStreamingServer := method.Desc.IsStreamingServer() + + switch { + case isStreamingClient && isStreamingServer: + g.P("return s.", fieldName, "(ctx, stream)") + case isStreamingClient && !isStreamingServer: + g.P("return s.", fieldName, "(ctx, stream)") + case !isStreamingClient && isStreamingServer: + g.P("return s.", fieldName, "(ctx, req, stream)") + default: + g.P("return s.", fieldName, "(ctx, req)") + } + g.P("}") + g.P() + } +} + +func serverSignatureParams(g *protogen.GeneratedFile, method *protogen.Method, named bool) string { + ctxName := "ctx " + reqName := "req " + streamName := "stream " + if !named { + ctxName, reqName, streamName = "", "", "" + } + if method.Desc.IsStreamingClient() && method.Desc.IsStreamingServer() { + return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ", " + + streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("BidiStream")) + + "[" + g.QualifiedGoIdent(method.Input.GoIdent) + ", " + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + + ") error" + } + if method.Desc.IsStreamingClient() { + return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ", " + + streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ClientStream")) + + "[" + g.QualifiedGoIdent(method.Input.GoIdent) + "]" + + ") (*" + g.QualifiedGoIdent(connectPackage.Ident("Response")) + "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "], error)" + } + if method.Desc.IsStreamingServer() { + return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + + ", " + reqName + "*" + g.QualifiedGoIdent(connectPackage.Ident("Request")) + "[" + + g.QualifiedGoIdent(method.Input.GoIdent) + "], " + + streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ServerStream")) + + "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + + ") error" + } + return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + + ", " + reqName + "*" + g.QualifiedGoIdent(connectPackage.Ident("Request")) + "[" + + g.QualifiedGoIdent(method.Input.GoIdent) + "]) " + + "(*" + g.QualifiedGoIdent(connectPackage.Ident("Response")) + "[" + + g.QualifiedGoIdent(method.Output.GoIdent) + "], error)" +} + +func isDeprecatedService(service *protogen.Service) bool { + serviceOptions, ok := service.Desc.Options().(*descriptorpb.ServiceOptions) + return ok && serviceOptions.GetDeprecated() +} + +func isDeprecatedMethod(method *protogen.Method) bool { + methodOptions, ok := method.Desc.Options().(*descriptorpb.MethodOptions) + return ok && methodOptions.GetDeprecated() +} + +func leadingComments(g *protogen.GeneratedFile, comments protogen.Comments, isDeprecated bool) { + if comments.String() != "" { + g.P(strings.TrimSpace(comments.String())) + } + if isDeprecated { + if comments.String() != "" { + g.P("//") + } + deprecated(g) + } +} + +func deprecated(g *protogen.GeneratedFile) { + g.P("// Deprecated: do not use.") +} diff --git a/cmd/protoc-gen-connect-go-servicestruct/main_test.go b/cmd/protoc-gen-connect-go-servicestruct/main_test.go new file mode 100644 index 0000000..03b8c29 --- /dev/null +++ b/cmd/protoc-gen-connect-go-servicestruct/main_test.go @@ -0,0 +1,307 @@ +package main + +import ( + "bytes" + "io" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +func TestVersion(t *testing.T) { + t.Parallel() + stdout, stderr, exitCode := testRunProtocGenServiceStruct(t, nil, "--version") + assert.Equal(t, "", stderr.String()) + assert.Equal(t, 0, exitCode) + assert.NotEmpty(t, stdout.String()) +} + +func TestGenerate(t *testing.T) { + t.Parallel() + + compilerVersion := &pluginpb.Version{ + Major: ptr(int32(0)), + Minor: ptr(int32(0)), + Patch: ptr(int32(1)), + Suffix: ptr("test"), + } + + t.Run("basic generation", func(t *testing.T) { + t.Parallel() + + t.Run("with unary and streaming methods", func(t *testing.T) { + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("test.proto"), + Package: ptr("test.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("Request")}, + {Name: ptr("Response")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("TestService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("UnaryMethod"), + InputType: ptr(".test.v1.Request"), + OutputType: ptr(".test.v1.Response"), + }, + { + Name: ptr("ClientStream"), + InputType: ptr(".test.v1.Request"), + OutputType: ptr(".test.v1.Response"), + ClientStreaming: ptr(true), + ServerStreaming: ptr(false), + }, + { + Name: ptr("ServerStream"), + InputType: ptr(".test.v1.Request"), + OutputType: ptr(".test.v1.Response"), + ClientStreaming: ptr(false), + ServerStreaming: ptr(true), + }, + { + Name: ptr("BidiStream"), + InputType: ptr(".test.v1.Request"), + OutputType: ptr(".test.v1.Response"), + ClientStreaming: ptr(true), + ServerStreaming: ptr(true), + }, + }, + }, + }, + Options: &descriptorpb.FileOptions{ + GoPackage: ptr("example.com/test/gen"), + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"test.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + assert.Contains(t, file.GetName(), "example.com/test/gen/genconnect/test.servicestruct.connect.go") + + content := file.GetContent() + + assert.Equal(t, `// Code generated by protoc-gen-connect-go-servicestruct. DO NOT EDIT. +// +// Source: test.proto + +package genconnect + +import ( + connect "connectrpc.com/connect" + context "context" + gen "example.com/test/gen" +) + +type TestServiceUnaryMethodHandlerFunc func(context.Context, *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) + +type TestServiceClientStreamHandlerFunc func(context.Context, *connect.ClientStream[gen.Request]) (*connect.Response[gen.Response], error) + +type TestServiceServerStreamHandlerFunc func(context.Context, *connect.Request[gen.Request], *connect.ServerStream[gen.Response]) error + +type TestServiceBidiStreamHandlerFunc func(context.Context, *connect.BidiStream[gen.Request, gen.Response]) error + +type TestServiceStruct struct { + UnaryMethodFunc TestServiceUnaryMethodHandlerFunc + ClientStreamFunc TestServiceClientStreamHandlerFunc + ServerStreamFunc TestServiceServerStreamHandlerFunc + BidiStreamFunc TestServiceBidiStreamHandlerFunc +} + +func (s *TestServiceStruct) UnaryMethod(ctx context.Context, req *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) { + return s.UnaryMethodFunc(ctx, req) +} + +func (s *TestServiceStruct) ClientStream(ctx context.Context, stream *connect.ClientStream[gen.Request]) (*connect.Response[gen.Response], error) { + return s.ClientStreamFunc(ctx, stream) +} + +func (s *TestServiceStruct) ServerStream(ctx context.Context, req *connect.Request[gen.Request], stream *connect.ServerStream[gen.Response]) error { + return s.ServerStreamFunc(ctx, req, stream) +} + +func (s *TestServiceStruct) BidiStream(ctx context.Context, stream *connect.BidiStream[gen.Request, gen.Response]) error { + return s.BidiStreamFunc(ctx, stream) +} +`, content) + }) + + }) + + t.Run("no service", func(t *testing.T) { + t.Parallel() + + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("no_service.proto"), + Package: ptr("test.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("Request")}, + {Name: ptr("Response")}, + }, + Options: &descriptorpb.FileOptions{ + GoPackage: ptr("example.com/test/gen"), + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"no_service.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + } + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 0, len(rsp.File)) + }) +} + +func TestGeneratedCodeStructure(t *testing.T) { + t.Parallel() + + t.Run("two services with same method name", func(t *testing.T) { + t.Parallel() + + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("multi_service.proto"), + Package: ptr("test.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("UserRequest")}, + {Name: ptr("UserResponse")}, + {Name: ptr("ProductRequest")}, + {Name: ptr("ProductResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("UserService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("Create"), + InputType: ptr(".test.v1.UserRequest"), + OutputType: ptr(".test.v1.UserResponse"), + }, + }, + }, + { + Name: ptr("ProductService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("Create"), + InputType: ptr(".test.v1.ProductRequest"), + OutputType: ptr(".test.v1.ProductResponse"), + }, + }, + }, + }, + Options: &descriptorpb.FileOptions{ + GoPackage: ptr("example.com/test/gen"), + }, + } + + compilerVersion := &pluginpb.Version{ + Major: ptr(int32(0)), + Minor: ptr(int32(0)), + Patch: ptr(int32(1)), + Suffix: ptr("test"), + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"multi_service.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + content := file.GetContent() + + assert.Equal(t, `// Code generated by protoc-gen-connect-go-servicestruct. DO NOT EDIT. +// +// Source: multi_service.proto + +package genconnect + +import ( + connect "connectrpc.com/connect" + context "context" + gen "example.com/test/gen" +) + +type UserServiceCreateHandlerFunc func(context.Context, *connect.Request[gen.UserRequest]) (*connect.Response[gen.UserResponse], error) + +type ProductServiceCreateHandlerFunc func(context.Context, *connect.Request[gen.ProductRequest]) (*connect.Response[gen.ProductResponse], error) + +type UserServiceStruct struct { + CreateFunc UserServiceCreateHandlerFunc +} + +func (s *UserServiceStruct) Create(ctx context.Context, req *connect.Request[gen.UserRequest]) (*connect.Response[gen.UserResponse], error) { + return s.CreateFunc(ctx, req) +} + +type ProductServiceStruct struct { + CreateFunc ProductServiceCreateHandlerFunc +} + +func (s *ProductServiceStruct) Create(ctx context.Context, req *connect.Request[gen.ProductRequest]) (*connect.Response[gen.ProductResponse], error) { + return s.CreateFunc(ctx, req) +} +`, content) + }) +} + +func testGenerate(t *testing.T, req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorResponse { + t.Helper() + + inputBytes, err := proto.Marshal(req) + require.NoError(t, err) + + stdout, stderr, exitCode := testRunProtocGenServiceStruct(t, bytes.NewReader(inputBytes)) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "", stderr.String()) + assert.Greater(t, len(stdout.Bytes()), 0) + + var output pluginpb.CodeGeneratorResponse + assert.NoError(t, proto.Unmarshal(stdout.Bytes(), &output)) + return &output +} + +func testRunProtocGenServiceStruct(t *testing.T, stdin io.Reader, args ...string) (stdout, stderr *bytes.Buffer, exitCode int) { + t.Helper() + + stdout = &bytes.Buffer{} + stderr = &bytes.Buffer{} + args = append([]string{"run", "main.go"}, args...) + + cmd := exec.Command("go", args...) + cmd.Env = os.Environ() + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + require.NoError(t, cmd.Run(), "Run go %v", args) + exitCode = cmd.ProcessState.ExitCode() + return stdout, stderr, exitCode +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/go.mod b/go.mod index 163a500..2d53ef9 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,14 @@ go 1.24.2 require ( connectrpc.com/connect v1.18.1 + github.com/stretchr/testify v1.11.1 google.golang.org/protobuf v1.36.9 ) -require github.com/google/go-cmp v0.5.9 // indirect +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.25.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 838eeaf..4873c0e 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,20 @@ connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=