diff --git a/cmd/protoc-gen-elixir-grpc/README.md b/cmd/protoc-gen-elixir-grpc/README.md index d595d4c..0f08745 100644 --- a/cmd/protoc-gen-elixir-grpc/README.md +++ b/cmd/protoc-gen-elixir-grpc/README.md @@ -58,7 +58,9 @@ This generates: ```elixir defmodule Greeter.Server do - use GRPC.Server, service: Greeter.Service, http_transcode: true + use GRPC.Server, + service: Greeter.Service, + http_transcode: true # ... method delegates end @@ -79,6 +81,33 @@ plugins: - handler_module_prefix=MyApp.Handlers ``` +#### Custom Codecs + +Specify custom codec modules for your gRPC server: + +```yaml +version: v2 +plugins: + - local: protoc-gen-elixir + out: lib + - local: protoc-gen-elixir-grpc + out: lib + opt: + - codecs=GRPC.Codec.Proto,GRPC.Codec.WebText,GRPC.Codec.JSON +``` + +This generates: + +```elixir +defmodule Greeter.Server do + use GRPC.Server, + service: Greeter.Service, + codecs: [GRPC.Codec.Proto, GRPC.Codec.WebText, GRPC.Codec.JSON] + + # ... method delegates +end +``` + #### Combining Options You can combine multiple options: @@ -93,6 +122,7 @@ plugins: opt: - http_transcode=true - handler_module_prefix=MyApp.Handlers + - codecs=GRPC.Codec.Proto,GRPC.Codec.WebText,GRPC.Codec.JSON ``` ### Basic Service Implementation diff --git a/cmd/protoc-gen-elixir-grpc/main.go b/cmd/protoc-gen-elixir-grpc/main.go index 9262b86..f96c0fd 100644 --- a/cmd/protoc-gen-elixir-grpc/main.go +++ b/cmd/protoc-gen-elixir-grpc/main.go @@ -54,8 +54,9 @@ const ( packagePrefixFlag = "package_prefix" handlerModulePrefixFlag = "handler_module_prefix" httpTranscodeFlag = "http_transcode" + codecsFlag = "codecs" - 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.\n --http_transcode\tEnable HTTP transcoding support (adds http_transcode: true to use GRPC.Server)." + 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.\n --http_transcode\tEnable HTTP transcoding support (adds http_transcode: true to use GRPC.Server).\n --codecs\tComma-separated list of codec modules (e.g., 'GRPC.Codec.Proto,GRPC.Codec.WebText,GRPC.Codec.JSON')." ) func parsePluginParameters(paramStr string, flagSet *flag.FlagSet) error { @@ -63,24 +64,79 @@ func parsePluginParameters(paramStr string, flagSet *flag.FlagSet) error { return nil } - params := strings.Split(paramStr, ",") - for _, param := range params { - param = strings.TrimSpace(param) - if param == "" { + // Parse key=value pairs, handling values that may contain commas + params := parseKeyValuePairs(paramStr) + for key, value := range params { + if err := flagSet.Set(key, value); err != nil { + return err + } + } + + return nil +} + +// parseKeyValuePairs parses a comma-separated list of key=value pairs. +// It handles the special case where a value may contain commas (e.g., codecs=A,B,C). +// The parser works by splitting on commas, then checking if each segment is a valid key=value pair. +// If not, it's assumed to be a continuation of the previous value. +func parseKeyValuePairs(paramStr string) map[string]string { + result := make(map[string]string) + + segments := strings.Split(paramStr, ",") + var currentKey string + var currentValue strings.Builder + + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { continue } - parts := strings.SplitN(param, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid parameter format: %q, expected key=value", param) + // Check if this segment contains an '=' sign + if idx := strings.Index(segment, "="); idx > 0 { + // This is a new key=value pair + // Save the previous key-value if exists + if currentKey != "" { + result[currentKey] = currentValue.String() + } + + // Start new key-value + currentKey = strings.TrimSpace(segment[:idx]) + valueStart := strings.TrimSpace(segment[idx+1:]) + currentValue.Reset() + currentValue.WriteString(valueStart) + } else { + // This is a continuation of the current value + if currentKey != "" { + currentValue.WriteString(",") + currentValue.WriteString(segment) + } } + } - if err := flagSet.Set(parts[0], parts[1]); err != nil { - return err + // Save the final key-value pair if it exists + if currentKey != "" { + result[currentKey] = currentValue.String() + } + + return result +} + +func parseCodecs(codecsStr string) []string { + if codecsStr == "" { + return nil + } + + codecs := strings.Split(codecsStr, ",") + var result []string + for _, codec := range codecs { + codec = strings.TrimSpace(codec) + if codec != "" { + result = append(result, codec) } } - return nil + return result } func main() { @@ -117,6 +173,11 @@ func main() { false, "Enable HTTP transcoding support (adds http_transcode: true to use GRPC.Server).", ) + codecs := flagSet.String( + codecsFlag, + "", + "Comma-separated list of codec modules (e.g., 'GRPC.Codec.Proto,GRPC.Codec.WebText,GRPC.Codec.JSON').", + ) input, err := io.ReadAll(os.Stdin) if err != nil { @@ -152,6 +213,8 @@ func main() { SupportedFeatures: proto.Uint64(uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)), } + codecsList := parseCodecs(*codecs) + for _, fileName := range req.FileToGenerate { var protoFile *descriptorpb.FileDescriptorProto for _, file := range req.ProtoFile { @@ -168,7 +231,7 @@ func main() { continue } - generateElixirFile(resp, protoFile, *packagePrefix, *handlerModulePrefix, *httpTranscode) + generateElixirFile(resp, protoFile, *packagePrefix, *handlerModulePrefix, *httpTranscode, codecsList) } output, err := proto.Marshal(resp) @@ -183,7 +246,7 @@ func main() { } } -func generateElixirFile(resp *pluginpb.CodeGeneratorResponse, file *descriptorpb.FileDescriptorProto, packagePrefix, handlerModulePrefix string, httpTranscode bool) { +func generateElixirFile(resp *pluginpb.CodeGeneratorResponse, file *descriptorpb.FileDescriptorProto, packagePrefix, handlerModulePrefix string, httpTranscode bool, codecs []string) { if len(file.Service) == 0 { return } @@ -197,7 +260,7 @@ func generateElixirFile(resp *pluginpb.CodeGeneratorResponse, file *descriptorpb content.WriteString("\n") for _, service := range file.Service { - generateServiceModule(&content, file, service, handlerModulePrefix, httpTranscode) + generateServiceModule(&content, file, service, handlerModulePrefix, httpTranscode, codecs) content.WriteString("\n") } @@ -207,14 +270,25 @@ func generateElixirFile(resp *pluginpb.CodeGeneratorResponse, file *descriptorpb }) } -func generateServiceModule(content *strings.Builder, file *descriptorpb.FileDescriptorProto, service *descriptorpb.ServiceDescriptorProto, handlerModulePrefix string, httpTranscode bool) { +func generateServiceModule(content *strings.Builder, file *descriptorpb.FileDescriptorProto, service *descriptorpb.ServiceDescriptorProto, handlerModulePrefix string, httpTranscode bool, codecs []string) { serverModuleName := generateServerModuleName(file, service) serviceModuleName := generateServiceModuleName(file, service) content.WriteString("defmodule " + serverModuleName + " do\n") - content.WriteString(" use GRPC.Server, service: " + serviceModuleName) + content.WriteString(" use GRPC.Server,\n") + content.WriteString(" service: " + serviceModuleName) if httpTranscode { - content.WriteString(", http_transcode: true") + content.WriteString(",\n http_transcode: true") + } + if len(codecs) > 0 { + content.WriteString(",\n codecs: [") + for i, codec := range codecs { + if i > 0 { + content.WriteString(", ") + } + content.WriteString(codec) + } + content.WriteString("]") } content.WriteString("\n\n") diff --git a/cmd/protoc-gen-elixir-grpc/main_test.go b/cmd/protoc-gen-elixir-grpc/main_test.go index f924bae..b1f53b1 100644 --- a/cmd/protoc-gen-elixir-grpc/main_test.go +++ b/cmd/protoc-gen-elixir-grpc/main_test.go @@ -110,7 +110,8 @@ func TestGenerate(t *testing.T) { # Source: greeter/v1/greeter.proto defmodule Greeter.V1.GreeterService.Server do - use GRPC.Server, service: Greeter.V1.GreeterService.Service + 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 @@ -164,7 +165,8 @@ end # Source: auth/v1/auth.proto defmodule Auth.V1.AuthService.Server do - use GRPC.Server, service: Auth.V1.AuthService.Service + use GRPC.Server, + service: Auth.V1.AuthService.Service defdelegate login(request, stream), to: MyApp.Handlers.Auth.V1.AuthService.Server.LoginHandler, as: :handle_message end @@ -215,7 +217,9 @@ end # Source: api/v1/api.proto defmodule Api.V1.ApiService.Server do - use GRPC.Server, service: Api.V1.ApiService.Service, http_transcode: true + use GRPC.Server, + service: Api.V1.ApiService.Service, + http_transcode: true defdelegate get(request, stream), to: Api.V1.ApiService.Server.GetHandler, as: :handle_message end @@ -266,7 +270,8 @@ end # Source: user.proto defmodule User.V1.UserService.Server do - use GRPC.Server, service: User.V1.UserService.Service + use GRPC.Server, + service: User.V1.UserService.Service defdelegate get_user(request, stream), to: User.V1.UserService.Server.GetUserHandler, as: :handle_message end @@ -317,7 +322,8 @@ end # Source: billing/v1/billing.proto defmodule Billing.V1.BillingService.Server do - use GRPC.Server, service: Billing.V1.BillingService.Service + 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 @@ -368,10 +374,172 @@ end # Source: payment/v1/payment.proto defmodule Payment.V1.PaymentService.Server do - use GRPC.Server, service: Payment.V1.PaymentService.Service, http_transcode: true + use GRPC.Server, + service: Payment.V1.PaymentService.Service, + http_transcode: true defdelegate process_payment(request, stream), to: MyApp.Core.Payment.V1.PaymentService.Server.ProcessPaymentHandler, as: :handle_message end +` + assert.Equal(t, expected, content) + }) + + t.Run("with codecs option", func(t *testing.T) { + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("data/v1/data.proto"), + Package: ptr("data.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("DataRequest")}, + {Name: ptr("DataResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("DataService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("GetData"), + InputType: ptr(".data.v1.DataRequest"), + OutputType: ptr(".data.v1.DataResponse"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"data/v1/data.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + Parameter: ptr("codecs=GRPC.Codec.Proto,GRPC.Codec.WebText,GRPC.Codec.JSON"), + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + assert.Equal(t, "data/v1/data.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: data/v1/data.proto + +defmodule Data.V1.DataService.Server do + use GRPC.Server, + service: Data.V1.DataService.Service, + codecs: [GRPC.Codec.Proto, GRPC.Codec.WebText, GRPC.Codec.JSON] + + defdelegate get_data(request, stream), to: Data.V1.DataService.Server.GetDataHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + + t.Run("with codecs option with spaces", func(t *testing.T) { + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("content/v1/content.proto"), + Package: ptr("content.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("ContentRequest")}, + {Name: ptr("ContentResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("ContentService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("GetContent"), + InputType: ptr(".content.v1.ContentRequest"), + OutputType: ptr(".content.v1.ContentResponse"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"content/v1/content.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + Parameter: ptr("codecs=GRPC.Codec.Proto, GRPC.Codec.WebText, GRPC.Codec.JSON"), + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + assert.Equal(t, "content/v1/content.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: content/v1/content.proto + +defmodule Content.V1.ContentService.Server do + use GRPC.Server, + service: Content.V1.ContentService.Service, + codecs: [GRPC.Codec.Proto, GRPC.Codec.WebText, GRPC.Codec.JSON] + + defdelegate get_content(request, stream), to: Content.V1.ContentService.Server.GetContentHandler, as: :handle_message +end +` + assert.Equal(t, expected, content) + }) + + t.Run("with all options combined", func(t *testing.T) { + fileDesc := &descriptorpb.FileDescriptorProto{ + Name: ptr("store/v1/store.proto"), + Package: ptr("store.v1"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: ptr("StoreRequest")}, + {Name: ptr("StoreResponse")}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("StoreService"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("CreateStore"), + InputType: ptr(".store.v1.StoreRequest"), + OutputType: ptr(".store.v1.StoreResponse"), + }, + }, + }, + }, + } + + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{"store/v1/store.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{fileDesc}, + SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{fileDesc}, + CompilerVersion: compilerVersion, + Parameter: ptr("http_transcode=true,handler_module_prefix=MyApp.Business,codecs=GRPC.Codec.Proto,GRPC.Codec.JSON"), + } + + rsp := testGenerate(t, req) + assert.Nil(t, rsp.Error) + assert.Equal(t, 1, len(rsp.File)) + + file := rsp.File[0] + assert.Equal(t, "store/v1/store.server.pb.ex", file.GetName()) + + content := file.GetContent() + expected := `# Code generated by protoc-gen-elixir-grpc. DO NOT EDIT. +# +# Source: store/v1/store.proto + +defmodule Store.V1.StoreService.Server do + use GRPC.Server, + service: Store.V1.StoreService.Service, + http_transcode: true, + codecs: [GRPC.Codec.Proto, GRPC.Codec.JSON] + + defdelegate create_store(request, stream), to: MyApp.Business.Store.V1.StoreService.Server.CreateStoreHandler, as: :handle_message +end ` assert.Equal(t, expected, content) }) @@ -457,12 +625,14 @@ end # Source: services.proto defmodule Test.V1.UserService.Server do - use GRPC.Server, service: Test.V1.UserService.Service + 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 + use GRPC.Server, + service: Test.V1.ProductService.Service defdelegate create_product(request, stream), to: Test.V1.ProductService.Server.CreateProductHandler, as: :handle_message end @@ -513,7 +683,8 @@ end # Source: simple.proto defmodule SimpleService.Server do - use GRPC.Server, service: SimpleService.Service + use GRPC.Server, + service: SimpleService.Service defdelegate do_something(request, stream), to: SimpleService.Server.DoSomethingHandler, as: :handle_message end @@ -604,6 +775,123 @@ func TestToPascalCase(t *testing.T) { } } +func TestParseKeyValuePairs(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "empty string", + input: "", + expected: map[string]string{}, + }, + { + name: "single key-value", + input: "key1=value1", + expected: map[string]string{"key1": "value1"}, + }, + { + name: "single key-value with trailing comma", + input: "key1=value1,", + expected: map[string]string{"key1": "value1"}, + }, + { + name: "multiple key-values", + input: "key1=value1,key2=value2", + expected: map[string]string{"key1": "value1", "key2": "value2"}, + }, + { + name: "multiple key-values with trailing comma", + input: "key1=value1,key2=value2,", + expected: map[string]string{"key1": "value1", "key2": "value2"}, + }, + { + name: "value with commas (codecs)", + input: "codecs=A,B,C", + expected: map[string]string{"codecs": "A,B,C"}, + }, + { + name: "value with commas and trailing comma", + input: "codecs=A,B,C,", + expected: map[string]string{"codecs": "A,B,C"}, + }, + { + name: "mixed: regular key and value with commas", + input: "key1=value1,codecs=A,B,C", + expected: map[string]string{"key1": "value1", "codecs": "A,B,C"}, + }, + { + name: "mixed with trailing comma", + input: "key1=value1,codecs=A,B,C,", + expected: map[string]string{"key1": "value1", "codecs": "A,B,C"}, + }, + { + name: "with spaces", + input: "key1 = value1 , key2 = value2", + expected: map[string]string{"key1": "value1", "key2": "value2"}, + }, + { + name: "multiple trailing commas", + input: "key1=value1,,,", + expected: map[string]string{"key1": "value1"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := parseKeyValuePairs(test.input) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestParseCodecs(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "single codec", + input: "GRPC.Codec.Proto", + expected: []string{"GRPC.Codec.Proto"}, + }, + { + name: "multiple codecs", + input: "GRPC.Codec.Proto,GRPC.Codec.WebText,GRPC.Codec.JSON", + expected: []string{"GRPC.Codec.Proto", "GRPC.Codec.WebText", "GRPC.Codec.JSON"}, + }, + { + name: "multiple codecs with spaces", + input: "GRPC.Codec.Proto, GRPC.Codec.WebText, GRPC.Codec.JSON", + expected: []string{"GRPC.Codec.Proto", "GRPC.Codec.WebText", "GRPC.Codec.JSON"}, + }, + { + name: "with extra spaces", + input: " GRPC.Codec.Proto , GRPC.Codec.WebText ,GRPC.Codec.JSON ", + expected: []string{"GRPC.Codec.Proto", "GRPC.Codec.WebText", "GRPC.Codec.JSON"}, + }, + { + name: "with empty items", + input: "GRPC.Codec.Proto,,,GRPC.Codec.JSON", + expected: []string{"GRPC.Codec.Proto", "GRPC.Codec.JSON"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := parseCodecs(test.input) + assert.Equal(t, test.expected, result) + }) + } +} + func testGenerate(t *testing.T, req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorResponse { t.Helper()