diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index 7ccaacb..2a5a9cf 100644 --- a/.github/.release-please-config.json +++ b/.github/.release-please-config.json @@ -24,6 +24,9 @@ "packages": { "cmd/protoc-gen-connect-go-servicestruct": { "component": "protoc-gen-connect-go-servicestruct" + }, + "cmd/protoc-gen-elixir-grpc": { + "component": "protoc-gen-elixir-grpc" } }, "plugins": [ diff --git a/.gitignore b/.gitignore index 3614830..11843d6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,6 @@ go.work.sum # .idea/ # .vscode/ -# Generated build files -protoc-gen-connect-go-servicestruct -protoc-gen-connect-go-servicestruct.wasm +# Generated protoc plugin binaries (root directory only) +/protoc-gen-* +/protoc-gen-*.wasm diff --git a/Taskfile.yml b/Taskfile.yml index 0b62bda..ebd6e97 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -69,10 +69,23 @@ tasks: - go build ./... build-plugin: - desc: Build the protoc plugin binary + desc: Build all protoc plugin binaries cmds: - - echo "Building protoc plugin binary..." + - echo "Building protoc plugin binaries..." - go build -o protoc-gen-connect-go-servicestruct ./cmd/protoc-gen-connect-go-servicestruct + - go build -o protoc-gen-elixir-grpc ./cmd/protoc-gen-elixir-grpc + + build-plugin-go: + desc: Build the Go Connect protoc plugin binary + cmds: + - echo "Building Go Connect protoc plugin binary..." + - go build -o protoc-gen-connect-go-servicestruct ./cmd/protoc-gen-connect-go-servicestruct + + build-plugin-elixir: + desc: Build the Elixir gRPC protoc plugin binary + cmds: + - echo "Building Elixir gRPC protoc plugin binary..." + - go build -o protoc-gen-elixir-grpc ./cmd/protoc-gen-elixir-grpc dev-test: desc: Development workflow - fmt, vet, and generate coverage HTML diff --git a/cmd/protoc-gen-elixir-grpc/README.md b/cmd/protoc-gen-elixir-grpc/README.md new file mode 100644 index 0000000..c5581ca --- /dev/null +++ b/cmd/protoc-gen-elixir-grpc/README.md @@ -0,0 +1,86 @@ +# protoc-gen-elixir-grpc + +A protobuf compiler plugin that generates Elixir gRPC server modules with `defdelegate` patterns +for clean handler organization. **Requires `protoc-gen-elixir` as a dependency.** + +## How-tos + +### Installation + +```bash +go install github.com/TrogonStack/protoc-gen/cmd/protoc-gen-elixir-grpc@latest +``` + +### Basic Usage + +**Required**: This plugin must be used alongside `protoc-gen-elixir` as it generates server modules that reference +the protobuf message and service definitions: + +```bash +protoc --elixir_out=lib --elixir-grpc_out=lib path/to/greeter.proto +``` + +### With buf + +**Required**: Add to your `buf.gen.yaml` alongside the required `protoc-gen-elixir` plugin: + +```yaml +version: v2 +plugins: + - local: protoc-gen-elixir # Required dependency + out: lib + - local: protoc-gen-elixir-grpc + out: lib +``` + +Server modules are generated into the same directory structure as the protobuf definitions, with `.server.pb.ex` suffix. + +### Basic Service Implementation + +For more realistic applications that require dependencies like database connections, implement handlers +with proper dependency injection: + +```elixir +# lib/helloworld/greeter/server/say_hello_handler.ex +defmodule Helloworld.Greeter.Server.SayHelloHandler do + def handle_message(request, _stream) do + # Your business logic with dependencies + case MyApp.Users.get_user_by_name(request.name) do + {:ok, user} -> + reply = %Helloworld.HelloReply{message: "Hello #{user.display_name}!"} + {:ok, reply} + {:error, :not_found} -> + raise GRPC.RPCError, status: :not_found, message: "User not found" + end + end +end + +# lib/helloworld/greeter/server/say_goodbye_handler.ex +defmodule Helloworld.Greeter.Server.SayGoodbyeHandler do + def handle_message(request, _stream) do + case MyApp.Users.log_goodbye(request.name) do + :ok -> + reply = %Helloworld.GoodbyeReply{message: "Goodbye #{request.name}!"} + {:ok, reply} + {:error, reason} -> + raise GRPC.RPCError, status: :internal, message: "Failed to log goodbye: #{reason}" + end + end +end +``` + +## Explanations + +### Overview + +This plugin generates gRPC server modules that provide a convenient way to organize handler functions using +Elixir's `defdelegate` pattern. **This plugin requires `protoc-gen-elixir` to function** - it generates +server modules that reference the protobuf message and service definitions created by the standard Elixir +protobuf plugin. + +### Features + +- **Handler Delegation**: Uses `defdelegate` to separate transport concerns from business logic +- **Clean Organization**: Each RPC method gets its own dedicated handler module +- **Streaming Support**: Handles all gRPC method types automatically +- **Package Structure**: Maintains protobuf package hierarchy in generated modules diff --git a/cmd/protoc-gen-elixir-grpc/main.go b/cmd/protoc-gen-elixir-grpc/main.go new file mode 100644 index 0000000..038f286 --- /dev/null +++ b/cmd/protoc-gen-elixir-grpc/main.go @@ -0,0 +1,348 @@ +// protoc-gen-elixir-grpc is a plugin for the Protobuf compiler that generates +// Elixir gRPC server modules with defdelegate patterns. To use it, build this program and make +// it available on your PATH as protoc-gen-elixir-grpc. +// +// The 'elixir-grpc' suffix becomes part of the arguments for the Protobuf +// compiler. To generate server modules using protoc: +// +// protoc --elixir_out=lib --elixir-grpc_out=lib path/to/file.proto +// +// With [buf], your buf.gen.yaml will look like this: +// +// version: v2 +// plugins: +// - local: protoc-gen-elixir +// out: lib +// - local: protoc-gen-elixir-grpc +// out: lib +// +// This generates server module definitions for the Protobuf services +// defined by file.proto. If file.proto defines the Greeter service, the +// invocations above will write output to: +// +// lib/greeter.pb.ex +// lib/greeter.server.pb.ex +// +// [buf]: https://buf.build +package main + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +const ( + filenameSuffix = ".ex" + serverSuffix = "Server" + defaultPackagePrefix = "" + packagePrefixFlag = "package_prefix" + handlerModulePrefixFlag = "handler_module_prefix" + + usage = "\n\nFlags:\n -h, --help\tPrint this help and exit.\n --version\tPrint the version and exit.\n --handler_module_prefix\tCustom Elixir module prefix for handler modules instead of protobuf package." +) + +func parsePluginParameters(paramStr string, flagSet *flag.FlagSet) error { + if paramStr == "" { + return nil + } + + params := strings.Split(paramStr, ",") + for _, param := range params { + param = strings.TrimSpace(param) + if param == "" { + continue + } + + parts := strings.SplitN(param, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid parameter format: %q, expected key=value", param) + } + + if err := flagSet.Set(parts[0], parts[1]); err != nil { + return err + } + } + + return nil +} + +func main() { + if len(os.Args) == 2 && os.Args[1] == "--version" { + if _, err := fmt.Fprintln(os.Stdout, "1.0.0"); 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) + } + + var flagSet flag.FlagSet + packagePrefix := flagSet.String( + packagePrefixFlag, + defaultPackagePrefix, + "Generate files with a package prefix.", + ) + handlerModulePrefix := flagSet.String( + handlerModulePrefixFlag, + "", + "Custom Elixir module prefix for handler modules instead of protobuf package (e.g., 'MyApp.Handlers').", + ) + + input, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to read input: %v\n", err) + os.Exit(1) + } + + var req pluginpb.CodeGeneratorRequest + if err := proto.Unmarshal(input, &req); err != nil { + fmt.Fprintf(os.Stderr, "failed to unmarshal request: %v\n", err) + os.Exit(1) + } + + if req.Parameter != nil { + if err := parsePluginParameters(*req.Parameter, &flagSet); err != nil { + resp := &pluginpb.CodeGeneratorResponse{ + Error: proto.String(fmt.Sprintf("failed to parse parameters: %v", err)), + } + output, marshalErr := proto.Marshal(resp) + if marshalErr != nil { + fmt.Fprintf(os.Stderr, "failed to marshal error response: %v\n", marshalErr) + os.Exit(1) + } + if _, writeErr := os.Stdout.Write(output); writeErr != nil { + fmt.Fprintf(os.Stderr, "failed to write error response: %v\n", writeErr) + os.Exit(1) + } + return + } + } + + resp := &pluginpb.CodeGeneratorResponse{ + SupportedFeatures: proto.Uint64(uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)), + } + + for _, fileName := range req.FileToGenerate { + var protoFile *descriptorpb.FileDescriptorProto + for _, file := range req.ProtoFile { + if file.GetName() == fileName { + protoFile = file + break + } + } + if protoFile == nil { + continue + } + + if len(protoFile.Service) == 0 { + continue + } + + generateElixirFile(resp, protoFile, *packagePrefix, *handlerModulePrefix) + } + + output, err := proto.Marshal(resp) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal response: %v\n", err) + os.Exit(1) + } + + if _, err := os.Stdout.Write(output); err != nil { + fmt.Fprintf(os.Stderr, "failed to write output: %v\n", err) + os.Exit(1) + } +} + +func generateElixirFile(resp *pluginpb.CodeGeneratorResponse, file *descriptorpb.FileDescriptorProto, packagePrefix, handlerModulePrefix string) { + if len(file.Service) == 0 { + return + } + + fileName := generateFilePath(file, packagePrefix) + + var content strings.Builder + content.WriteString("# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT.\n") + content.WriteString("#\n") + content.WriteString("# Source: " + file.GetName() + "\n") + content.WriteString("\n") + + for _, service := range file.Service { + generateServiceModule(&content, file, service, handlerModulePrefix) + content.WriteString("\n") + } + + resp.File = append(resp.File, &pluginpb.CodeGeneratorResponse_File{ + Name: proto.String(fileName), + Content: proto.String(content.String()), + }) +} + +func generateServiceModule(content *strings.Builder, file *descriptorpb.FileDescriptorProto, service *descriptorpb.ServiceDescriptorProto, handlerModulePrefix string) { + serverModuleName := generateServerModuleName(file, service) + serviceModuleName := generateServiceModuleName(file, service) + + content.WriteString("defmodule " + serverModuleName + " do\n") + content.WriteString(" use GRPC.Server, service: " + serviceModuleName + "\n") + content.WriteString("\n") + + for _, method := range service.Method { + generateMethodDelegate(content, file, service, method, handlerModulePrefix) + } + + content.WriteString("end") +} + +func generateMethodDelegate(content *strings.Builder, file *descriptorpb.FileDescriptorProto, service *descriptorpb.ServiceDescriptorProto, method *descriptorpb.MethodDescriptorProto, handlerModulePrefix string) { + methodName := toSnakeCase(method.GetName()) + handlerModuleName := generateHandlerModuleName(file, service, method, handlerModulePrefix) + + isStreamingClient := method.GetClientStreaming() + isStreamingServer := method.GetServerStreaming() + + var signature string + switch { + case isStreamingClient && isStreamingServer: + signature = fmt.Sprintf("%s(request_stream, response_stream)", methodName) + case isStreamingClient && !isStreamingServer: + signature = fmt.Sprintf("%s(request_stream, stream)", methodName) + case !isStreamingClient && isStreamingServer: + signature = fmt.Sprintf("%s(request, response_stream)", methodName) + default: + signature = fmt.Sprintf("%s(request, stream)", methodName) + } + + content.WriteString(" defdelegate " + signature + ", to: " + handlerModuleName + ", as: :handle_message\n") +} + +func generateServiceModuleName(file *descriptorpb.FileDescriptorProto, service *descriptorpb.ServiceDescriptorProto) string { + serviceName := service.GetName() + pkg := file.GetPackage() + + if pkg == "" { + return toPascalCase(serviceName) + ".Service" + } + + parts := strings.Split(pkg, ".") + var elixirParts []string + for _, part := range parts { + elixirParts = append(elixirParts, toPascalCase(part)) + } + + elixirParts = append(elixirParts, toPascalCase(serviceName), "Service") + + return strings.Join(elixirParts, ".") +} + +func generateServerModuleName(file *descriptorpb.FileDescriptorProto, service *descriptorpb.ServiceDescriptorProto) string { + serviceName := service.GetName() + pkg := file.GetPackage() + + if pkg == "" { + return toPascalCase(serviceName) + "." + serverSuffix + } + + parts := strings.Split(pkg, ".") + var elixirParts []string + for _, part := range parts { + elixirParts = append(elixirParts, toPascalCase(part)) + } + + elixirParts = append(elixirParts, toPascalCase(serviceName), serverSuffix) + + return strings.Join(elixirParts, ".") +} + +func generateHandlerModuleName(file *descriptorpb.FileDescriptorProto, service *descriptorpb.ServiceDescriptorProto, method *descriptorpb.MethodDescriptorProto, handlerModulePrefix string) string { + serviceName := service.GetName() + methodName := method.GetName() + pkg := file.GetPackage() + + if handlerModulePrefix != "" { + if pkg == "" { + return fmt.Sprintf("%s.%s.Server.%sHandler", handlerModulePrefix, toPascalCase(serviceName), toPascalCase(methodName)) + } + + parts := strings.Split(pkg, ".") + var packageParts []string + for _, part := range parts { + packageParts = append(packageParts, toPascalCase(part)) + } + + return fmt.Sprintf("%s.%s.%s.Server.%sHandler", handlerModulePrefix, strings.Join(packageParts, "."), toPascalCase(serviceName), toPascalCase(methodName)) + } + + if pkg == "" { + return fmt.Sprintf("%s.Server.%sHandler", toPascalCase(serviceName), toPascalCase(methodName)) + } + + parts := strings.Split(pkg, ".") + var elixirParts []string + for _, part := range parts { + elixirParts = append(elixirParts, toPascalCase(part)) + } + + elixirParts = append(elixirParts, toPascalCase(serviceName), "Server", toPascalCase(methodName)+"Handler") + + return strings.Join(elixirParts, ".") +} + +func generateFilePath(file *descriptorpb.FileDescriptorProto, packagePrefix string) string { + pkg := file.GetPackage() + fileName := file.GetName() + + var pathParts []string + + if packagePrefix != "" { + pathParts = append(pathParts, packagePrefix) + } + + if pkg != "" { + parts := strings.Split(pkg, ".") + for _, part := range parts { + pathParts = append(pathParts, strings.ToLower(part)) + } + } + + baseFileName := filepath.Base(fileName) + if idx := strings.LastIndex(baseFileName, "."); idx > 0 { + baseFileName = baseFileName[:idx] + } + + return strings.Join(pathParts, "/") + "/" + baseFileName + ".server.pb" + filenameSuffix +} + +func toSnakeCase(s string) string { + re := regexp.MustCompile("([A-Z]+)([A-Z][a-z])") + s = re.ReplaceAllString(s, "${1}_${2}") + + re = regexp.MustCompile("([a-z])([A-Z])") + s = re.ReplaceAllString(s, "${1}_${2}") + + return strings.ToLower(s) +} + +func toPascalCase(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/cmd/protoc-gen-elixir-grpc/main_test.go b/cmd/protoc-gen-elixir-grpc/main_test.go new file mode 100644 index 0000000..dadc68b --- /dev/null +++ b/cmd/protoc-gen-elixir-grpc/main_test.go @@ -0,0 +1,542 @@ +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 := testRunProtocGenElixirGrpc(t, nil, "--version") + assert.Equal(t, "", stderr.String()) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "1.0.0\n", stdout.String()) +} + +func TestHelp(t *testing.T) { + t.Parallel() + stdout, stderr, exitCode := testRunProtocGenElixirGrpc(t, nil, "--help") + assert.Equal(t, "", stderr.String()) + assert.Equal(t, 0, exitCode) + assert.Contains(t, stdout.String(), "Flags:") + assert.Contains(t, stdout.String(), "--version") + assert.Contains(t, stdout.String(), "--handler_module_prefix") +} + +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("greeter/v1/greeter.proto"), + Package: ptr("greeter.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("HelloRequest")}, + {Name: ptr("HelloResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("GreeterService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("SayHello"), + InputType: ptr(".greeter.v1.HelloRequest"), + OutputType: ptr(".greeter.v1.HelloResponse"), + }, + { + Name: ptr("SayHelloStream"), + InputType: ptr(".greeter.v1.HelloRequest"), + OutputType: ptr(".greeter.v1.HelloResponse"), + ClientStreaming: ptr(false), + ServerStreaming: ptr(true), + }, + { + Name: ptr("SayHelloClientStream"), + InputType: ptr(".greeter.v1.HelloRequest"), + OutputType: ptr(".greeter.v1.HelloResponse"), + ClientStreaming: ptr(true), + ServerStreaming: ptr(false), + }, + { + Name: ptr("SayHelloBidiStream"), + InputType: ptr(".greeter.v1.HelloRequest"), + OutputType: ptr(".greeter.v1.HelloResponse"), + ClientStreaming: ptr(true), + ServerStreaming: ptr(true), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"greeter/v1/greeter.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.Equal(t, "greeter/v1/greeter.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: greeter/v1/greeter.proto + +defmodule Greeter.V1.GreeterService.Server do + use GRPC.Server, service: Greeter.V1.GreeterService.Service + + defdelegate say_hello(request, stream), to: Greeter.V1.GreeterService.Server.SayHelloHandler, as: :handle_message + defdelegate say_hello_stream(request, response_stream), to: Greeter.V1.GreeterService.Server.SayHelloStreamHandler, as: :handle_message + defdelegate say_hello_client_stream(request_stream, stream), to: Greeter.V1.GreeterService.Server.SayHelloClientStreamHandler, as: :handle_message + defdelegate say_hello_bidi_stream(request_stream, response_stream), to: Greeter.V1.GreeterService.Server.SayHelloBidiStreamHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + + t.Run("with custom handler module prefix", func(t *testing.T) { + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("auth/v1/auth.proto"), + Package: ptr("auth.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("LoginRequest")}, + {Name: ptr("LoginResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("AuthService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("Login"), + InputType: ptr(".auth.v1.LoginRequest"), + OutputType: ptr(".auth.v1.LoginResponse"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"auth/v1/auth.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + Parameter: ptr("handler_module_prefix=MyApp.Handlers"), + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + assert.Equal(t, "auth/v1/auth.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: auth/v1/auth.proto + +defmodule Auth.V1.AuthService.Server do + use GRPC.Server, service: Auth.V1.AuthService.Service + + defdelegate login(request, stream), to: MyApp.Handlers.Auth.V1.AuthService.Server.LoginHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + + t.Run("with package prefix", func(t *testing.T) { + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("user.proto"), + Package: ptr("user.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("GetUserRequest")}, + {Name: ptr("GetUserResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("UserService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("GetUser"), + InputType: ptr(".user.v1.GetUserRequest"), + OutputType: ptr(".user.v1.GetUserResponse"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"user.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + Parameter: ptr("package_prefix=lib/proto"), + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + assert.Equal(t, "lib/proto/user/v1/user.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: user.proto + +defmodule User.V1.UserService.Server do + use GRPC.Server, service: User.V1.UserService.Service + + defdelegate get_user(request, stream), to: User.V1.UserService.Server.GetUserHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + + t.Run("with multiple parameters", func(t *testing.T) { + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("billing/v1/billing.proto"), + Package: ptr("billing.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("CreateInvoiceRequest")}, + {Name: ptr("CreateInvoiceResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("BillingService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("CreateInvoice"), + InputType: ptr(".billing.v1.CreateInvoiceRequest"), + OutputType: ptr(".billing.v1.CreateInvoiceResponse"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"billing/v1/billing.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + Parameter: ptr("package_prefix=lib/grpc,handler_module_prefix=MyApp.BusinessLogic"), + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + assert.Equal(t, "lib/grpc/billing/v1/billing.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: billing/v1/billing.proto + +defmodule Billing.V1.BillingService.Server do + use GRPC.Server, service: Billing.V1.BillingService.Service + + defdelegate create_invoice(request, stream), to: MyApp.BusinessLogic.Billing.V1.BillingService.Server.CreateInvoiceHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + }) + + t.Run("no service", func(t *testing.T) { + t.Parallel() + + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("messages.proto"), + Package: ptr("test.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("User")}, + {Name: ptr("Product")}, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"messages.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)) + }) + + t.Run("multiple services in same file", func(t *testing.T) { + t.Parallel() + + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("services.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("CreateUser"), + InputType: ptr(".test.v1.UserRequest"), + OutputType: ptr(".test.v1.UserResponse"), + }, + }, + }, + { + Name: ptr("ProductService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("CreateProduct"), + InputType: ptr(".test.v1.ProductRequest"), + OutputType: ptr(".test.v1.ProductResponse"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"services.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.Equal(t, "test/v1/services.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: services.proto + +defmodule Test.V1.UserService.Server do + use GRPC.Server, service: Test.V1.UserService.Service + + defdelegate create_user(request, stream), to: Test.V1.UserService.Server.CreateUserHandler, as: :handle_message +end +defmodule Test.V1.ProductService.Server do + use GRPC.Server, service: Test.V1.ProductService.Service + + defdelegate create_product(request, stream), to: Test.V1.ProductService.Server.CreateProductHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + + t.Run("no package", func(t *testing.T) { + t.Parallel() + + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("simple.proto"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("Request")}, + {Name: ptr("Response")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("SimpleService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("DoSomething"), + InputType: ptr(".Request"), + OutputType: ptr(".Response"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"simple.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.Equal(t, "/simple.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: simple.proto + +defmodule SimpleService.Server do + use GRPC.Server, service: SimpleService.Service + + defdelegate do_something(request, stream), to: SimpleService.Server.DoSomethingHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + + t.Run("invalid parameters", func(t *testing.T) { + t.Parallel() + + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("test.proto"), + Package: ptr("test.v1"), + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("TestService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("Test"), + InputType: ptr(".test.v1.Request"), + OutputType: ptr(".test.v1.Response"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"test.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + Parameter: ptr("invalid_flag=value"), + } + + rsp := testGenerate(t, req) + assert.NotNil(t, rsp.Error) + assert.Contains(t, rsp.GetError(), "no such flag") + assert.Equal(t, 0, len(rsp.File)) + }) +} + +func TestToSnakeCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"SayHello", "say_hello"}, + {"SayGoodbye", "say_goodbye"}, + {"HTTPClient", "http_client"}, + {"XMLParser", "xml_parser"}, + {"ID", "id"}, + {"UserID", "user_id"}, + {"GetUserProfile", "get_user_profile"}, + {"", ""}, + {"A", "a"}, + {"AB", "ab"}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := toSnakeCase(test.input) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestToPascalCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"sayHello", "SayHello"}, + {"sayGoodbye", "SayGoodbye"}, + {"httpClient", "HttpClient"}, + {"id", "Id"}, + {"", ""}, + {"a", "A"}, + {"ABC", "ABC"}, + {"SayHello", "SayHello"}, // Already PascalCase + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := toPascalCase(test.input) + assert.Equal(t, test.expected, result) + }) + } +} + +func testGenerate(t *testing.T, req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorResponse { + t.Helper() + + inputBytes, err := proto.Marshal(req) + require.NoError(t, err) + + stdout, stderr, exitCode := testRunProtocGenElixirGrpc(t, bytes.NewReader(inputBytes)) + if exitCode != 0 { + t.Errorf("Plugin failed with exit code %d, stderr: %s", exitCode, stderr.String()) + return nil + } + 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 testRunProtocGenElixirGrpc(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 + _ = cmd.Run() // Don't use require.NoError since we want to capture exit codes + exitCode = cmd.ProcessState.ExitCode() + return stdout, stderr, exitCode +} + +func ptr[T any](v T) *T { + return &v +}