From 2a5e8dfde52b27362717e4a79590343a032c273c Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 27 Jul 2026 16:29:38 +0200 Subject: [PATCH] feat: generated-service layer (GenericService) + grpc_tools_ruby_protoc codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the service-codegen surface of the grpc gem on top of the existing pure-Go runtime. GenericService ports GRPC::GenericService — the mixin a generated *_services_pb.rb Service base class includes: NewGenericService(name) + RPC(...) (the `rpc :Name, In, Out` macro), BuildService pairing declarations with handlers into a runtime Service to Handle, and StubClass deriving the client GenericStub (the gem's rpc_stub_class). The generated stub carries each rpc's marshal/unmarshal, so a caller supplies only the request(s); all four cardinalities are covered and driven end-to-end over the in-memory transport, plus a wire-interop oracle where a GenericStub calls a stock google.golang.org/grpc server with real protobuf messages. GenerateRubyServices is the grpc_tools_ruby_protoc equivalent: given a .proto's service block it emits the exact *_services_pb.rb source — byte-for-byte as the gem's generator — covering unary + all streaming shapes (stream(...)), multiple services per file, dotted/underscored packages, nested and cross-package message types, and package-less files. A differential oracle runs the real grpc_tools_ruby_protoc (grpc-tools gem) and asserts byte-equality, skip-gated when the gem is absent; inline goldens pin the format either way. CGO=0, gofmt + go vet clean, 100% coverage, -race clean, and green across the six 64-bit targets + js/wasm + wasip1/wasm. Co-Authored-By: Claude Opus 4.8 --- README.md | 62 +++++++ codegen.go | 248 +++++++++++++++++++++++++++ codegen_oracle_test.go | 126 ++++++++++++++ codegen_test.go | 359 ++++++++++++++++++++++++++++++++++++++++ doc.go | 16 ++ generic_service.go | 270 ++++++++++++++++++++++++++++++ generic_service_test.go | 284 +++++++++++++++++++++++++++++++ 7 files changed, 1365 insertions(+) create mode 100644 codegen.go create mode 100644 codegen_oracle_test.go create mode 100644 codegen_test.go create mode 100644 generic_service.go create mode 100644 generic_service_test.go diff --git a/README.md b/README.md index a2abcdd..9dfa1bf 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,64 @@ func main() { helpers; a streaming handler uses `ActiveCall.Read` / `EachRemoteRead` to consume requests and `ActiveCall.Send` to emit responses. +## Generated services & codegen + +Real gem usage rarely builds a `Service` by hand: a `.proto`'s `service` block is +compiled by `grpc_tools_ruby_protoc` into a `*_services_pb.rb` that declares a +`GRPC::GenericService` base class and its `Stub`. This package ports both halves. + +- **`GRPC::GenericService`** → `GenericService` — `NewGenericService(name)` then + `RPC(...)` per rpc (the gem's `rpc :Name, In, Out` macro). `BuildService` pairs + the declarations with handlers to yield a `Service` to `Handle`; `StubClass` + derives the client stub (the gem's `rpc_stub_class`). The generated stub + carries each rpc's marshal/unmarshal, so a caller supplies only the request: + + ```go + gs := grpc.NewGenericService("helloworld.Greeter"). + RPC(grpc.RpcDesc{Name: "SayHello", Type: grpc.Unary, + RequestMarshal: enc, RequestUnmarshal: dec, + ResponseMarshal: enc, ResponseUnmarshal: dec}) + + svc, _ := gs.BuildService(grpc.Handlers{ + "SayHello": func(req any, call *grpc.ActiveCall) (any, error) { + return "Hello " + req.(string), nil + }}) + srv.Handle(svc) + + stub := gs.StubClass(clientStub) + resp, _ := stub.RequestResponse("SayHello", "world", grpc.CallOptions{}) + ``` + +- **`grpc_tools_ruby_protoc`** → `GenerateRubyServices` — given a `.proto`'s + service block (`ServiceFile` / `ServiceGen` / `MethodGen`), it emits the exact + `*_services_pb.rb` source, **byte-for-byte** as the gem's generator. All four + cardinalities (`stream(...)` on the request and/or response), multiple services + per file, dotted and underscored packages, nested and cross-package message + types, and package-less files are reproduced. The generated Ruby loads + unchanged and binds this runtime through go-embedded-ruby. + + ```go + src, _ := grpc.GenerateRubyServices(grpc.ServiceFile{ + ProtoFile: "helloworld.proto", Package: "helloworld", + Services: []grpc.ServiceGen{{Name: "Greeter", Methods: []grpc.MethodGen{ + {Name: "SayHello", InputType: "helloworld.HelloRequest", + OutputType: "helloworld.HelloReply"}}}}, + }) + ``` + +The generator is checked against the real `grpc_tools_ruby_protoc` (the +`grpc-tools` gem) as a **differential oracle**: for each `.proto`, our output must +equal the binary's to the byte; the test skips only when the gem is not +installed, and inline goldens still pin the format in that case. + +**Residual (named, not silent):** message-`.proto` parsing and message codegen +stay in [go-ruby-protobuf](https://github.com/go-ruby-protobuf/protobuf) (which +also builds descriptors at runtime rather than parsing `.proto` text); a Ruby +constant for a message imported from another package with a *nested* type is +approximated (the common same-package and flat cross-package cases are +byte-exact); and TLS/`ChannelCredentials`, xDS, channelz and health/reflection +services remain follow-ups. + ## Status codes & errors ```go @@ -156,6 +214,10 @@ to `UNKNOWN`, exactly as the gem surfaces a bare exception. | `GRPC::Core::CallError` | `*CallError` | | metadata (a Hash) | `Metadata` (`map[string]string`) | | generated marshal/unmarshal procs | `Marshaler` / `Unmarshaler` per call | +| `GRPC::GenericService` | `*GenericService` | +| `rpc :Name, In, Out` | `(*GenericService).RPC` | +| `Service.rpc_stub_class` | `(*GenericService).StubClass` → `*GenericStub` | +| `grpc_tools_ruby_protoc` | `GenerateRubyServices` | ## Tests & coverage diff --git a/codegen.go b/codegen.go new file mode 100644 index 0000000..b24c644 --- /dev/null +++ b/codegen.go @@ -0,0 +1,248 @@ +// Copyright (c) the go-ruby-grpc/grpc authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package grpc + +import ( + "strings" +) + +// This file is the grpc_tools_ruby_protoc equivalent: given the service block +// of a .proto (its package, the services and their methods), +// [GenerateRubyServices] emits the exact *_services_pb.rb source the gem's code +// generator produces — a GRPC::GenericService base class plus its +// rpc_stub_class Stub, wrapped in the package's modules. The output is +// byte-faithful to grpc_tools_ruby_protoc (verified against it as the oracle), +// so the generated Ruby loads unchanged in a Ruby program that then binds the +// pure-Go runtime in this package via go-embedded-ruby. +// +// Parsing .proto text into these structs is the front-end's job (the protobuf +// descriptor layer); this file consumes the already-parsed service shape. + +// ServiceFile describes one .proto file's service block, the input to +// [GenerateRubyServices] — the subset of a FileDescriptorProto the Ruby gRPC +// generator reads. +type ServiceFile struct { + // ProtoFile is the source .proto path as it appears after -I, e.g. + // "helloworld.proto" or "grpc/testing/echo.proto". It sets the header's + // Source line and the require of the companion messages file. + ProtoFile string + // Package is the proto package, e.g. "helloworld" or "grpc.testing" (may be + // empty for a package-less file). + Package string + // Services are the services declared in the file, in declaration order. + Services []ServiceGen +} + +// ServiceGen describes one service block. +type ServiceGen struct { + // Name is the service name as declared, e.g. "Greeter". + Name string + // Methods are the RPCs of the service, in declaration order. + Methods []MethodGen +} + +// MethodGen describes one RPC in a service block. +type MethodGen struct { + // Name is the RPC name as declared, e.g. "SayHello". + Name string + // InputType is the fully-qualified proto name of the request message, e.g. + // "helloworld.HelloRequest" (a leading dot is tolerated). + InputType string + // OutputType is the fully-qualified proto name of the response message. + OutputType string + // ClientStreaming marks a streaming request (the input is wrapped in + // stream(...)). + ClientStreaming bool + // ServerStreaming marks a streaming response (the output is wrapped in + // stream(...)). + ServerStreaming bool +} + +// GenerateRubyServices emits the *_services_pb.rb Ruby source for f, byte-for-byte +// as grpc_tools_ruby_protoc would. A service with no methods is skipped (as the +// gem's generator skips it), while the package modules are still emitted. It +// errors if the file has no ProtoFile, or a non-empty service or one of its +// methods has an empty name. +func GenerateRubyServices(f ServiceFile) (string, error) { + if err := f.validate(); err != nil { + return "", err + } + + var b strings.Builder + b.WriteString("# Generated by the protocol buffer compiler. DO NOT EDIT!\n") + b.WriteString("# Source: " + f.ProtoFile + " for package '" + f.Package + "'\n") + b.WriteString("\n") + b.WriteString("require 'grpc'\n") + b.WriteString("require '" + requireName(f.ProtoFile) + "'\n") + b.WriteString("\n") + + pkgSegs := splitNonEmpty(f.Package) + indent := 0 + for _, seg := range pkgSegs { + writeLine(&b, indent, "module "+pascalModule(seg)) + indent += 2 + } + + for _, svc := range f.Services { + if len(svc.Methods) == 0 { + continue + } + writeLine(&b, indent, "module "+pascalModule(svc.Name)) + si := indent + 2 + writeLine(&b, si, "class Service") + b.WriteString("\n") + writeLine(&b, si+2, "include ::GRPC::GenericService") + b.WriteString("\n") + writeLine(&b, si+2, "self.marshal_class_method = :encode") + writeLine(&b, si+2, "self.unmarshal_class_method = :decode") + writeLine(&b, si+2, "self.service_name = '"+fullServiceName(f.Package, svc.Name)+"'") + b.WriteString("\n") + for _, m := range svc.Methods { + in := rubyType(m.InputType, f.Package) + if m.ClientStreaming { + in = "stream(" + in + ")" + } + out := rubyType(m.OutputType, f.Package) + if m.ServerStreaming { + out = "stream(" + out + ")" + } + writeLine(&b, si+2, "rpc :"+m.Name+", "+in+", "+out) + } + writeLine(&b, si, "end") + b.WriteString("\n") + writeLine(&b, si, "Stub = Service.rpc_stub_class") + writeLine(&b, indent, "end") + } + + for range pkgSegs { + indent -= 2 + writeLine(&b, indent, "end") + } + + return b.String(), nil +} + +// validate reports the first structural problem that would make the output +// meaningless. +func (f ServiceFile) validate() error { + if f.ProtoFile == "" { + return NewCallError("grpc: ServiceFile.ProtoFile is required") + } + for _, svc := range f.Services { + if len(svc.Methods) == 0 { + continue + } + if svc.Name == "" { + return NewCallError("grpc: a service in " + f.ProtoFile + " has no name") + } + for _, m := range svc.Methods { + if m.Name == "" { + return NewCallError("grpc: service " + svc.Name + " has an unnamed rpc") + } + if m.InputType == "" || m.OutputType == "" { + return NewCallError("grpc: rpc " + svc.Name + "." + m.Name + " is missing an input or output type") + } + } + } + return nil +} + +// writeLine writes indent spaces, text and a newline. +func writeLine(b *strings.Builder, indent int, text string) { + for i := 0; i < indent; i++ { + b.WriteByte(' ') + } + b.WriteString(text) + b.WriteByte('\n') +} + +// requireName turns a .proto path into the Ruby require of its messages file: +// the ".proto" suffix becomes "_pb", the directory path is kept. +func requireName(protoFile string) string { + return strings.TrimSuffix(protoFile, ".proto") + "_pb" +} + +// fullServiceName is the wire service name: ".", or just the +// service name for a package-less file. +func fullServiceName(pkg, service string) string { + if pkg == "" { + return service + } + return pkg + "." + service +} + +// splitNonEmpty splits a dotted proto package into its segments, returning nil +// for the empty package. +func splitNonEmpty(pkg string) []string { + if pkg == "" { + return nil + } + return strings.Split(pkg, ".") +} + +// pascalModule converts a proto identifier to its Ruby module spelling, as the +// gem's generator does for package segments and service names: each +// underscore-separated word is capitalized and the underscores are dropped +// (e.g. "foo_bar" -> "FooBar", "my_service" -> "MyService", "v1beta1" -> +// "V1beta1"). +func pascalModule(seg string) string { + parts := strings.Split(seg, "_") + for i, p := range parts { + parts[i] = capitalizeFirst(p) + } + return strings.Join(parts, "") +} + +// capitalizeFirst upper-cases the first rune of s, leaving the rest untouched. +func capitalizeFirst(s string) string { + if s == "" { + return s + } + r := []rune(s) + r[0] = upperRune(r[0]) + return string(r) +} + +// upperRune upper-cases an ASCII letter, leaving any other rune unchanged (proto +// identifiers are ASCII). +func upperRune(r rune) rune { + if r >= 'a' && r <= 'z' { + return r - ('a' - 'A') + } + return r +} + +// rubyType renders a fully-qualified proto message name as the Ruby constant the +// gem's generator emits, e.g. "helloworld.HelloRequest" -> "::Helloworld::HelloRequest" +// and "foo_bar.v1beta1.my_msg" -> "::FooBar::V1beta1::My_msg". The package +// portion is module-cased (underscores dropped) while the message name keeps its +// underscores with only its first letter capitalized — matching +// grpc_tools_ruby_protoc. +// +// A type in the file's own package strips that package prefix exactly; a type +// from another package (or a package-less file) is handled by treating its last +// dotted segment as the message name and the rest as its package. +func rubyType(fqn, pkg string) string { + fqn = strings.TrimPrefix(fqn, ".") + + var pkgParts, msgParts []string + if pkg != "" && strings.HasPrefix(fqn, pkg+".") { + pkgParts = strings.Split(pkg, ".") + msgParts = strings.Split(strings.TrimPrefix(fqn, pkg+"."), ".") + } else { + segs := strings.Split(fqn, ".") + pkgParts = segs[:len(segs)-1] + msgParts = segs[len(segs)-1:] + } + + out := make([]string, 0, len(pkgParts)+len(msgParts)) + for _, p := range pkgParts { + out = append(out, pascalModule(p)) + } + for _, m := range msgParts { + out = append(out, capitalizeFirst(m)) + } + return "::" + strings.Join(out, "::") +} diff --git a/codegen_oracle_test.go b/codegen_oracle_test.go new file mode 100644 index 0000000..a474bb7 --- /dev/null +++ b/codegen_oracle_test.go @@ -0,0 +1,126 @@ +// Copyright (c) the go-ruby-grpc/grpc authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package grpc + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// The differential oracle for the code generator: run the real +// grpc_tools_ruby_protoc on a .proto and assert that GenerateRubyServices +// produces byte-identical output for the equivalent ServiceFile. When the binary +// is not installed the test skips (the inline goldens in codegen_test.go still +// pin the format), so the suite stays green on a host without the Ruby gRPC +// toolchain. + +// findRubyProtoc locates grpc_tools_ruby_protoc on PATH or in the per-user gem +// bin directories, returning "" if it is not installed. +func findRubyProtoc() string { + if p, err := exec.LookPath("grpc_tools_ruby_protoc"); err == nil { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + matches, _ := filepath.Glob(filepath.Join(home, ".gem", "ruby", "*", "bin", "grpc_tools_ruby_protoc")) + for _, m := range matches { + if info, err := os.Stat(m); err == nil && !info.IsDir() { + return m + } + } + return "" +} + +// oracleProtos pairs .proto source with the ServiceFile our generator is fed for +// the same file; the two outputs must match to the byte. +var oracleProtos = []struct { + name string + proto string + file ServiceFile +}{ + { + name: "helloworld", + proto: `syntax = "proto3"; +package helloworld; +message HelloRequest { string name = 1; } +message HelloReply { string message = 1; } +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply) {} +} +`, + file: ServiceFile{ + ProtoFile: "helloworld.proto", Package: "helloworld", + Services: []ServiceGen{{Name: "Greeter", Methods: []MethodGen{ + {Name: "SayHello", InputType: "helloworld.HelloRequest", OutputType: "helloworld.HelloReply"}, + }}}, + }, + }, + { + name: "route_guide", + proto: `syntax = "proto3"; +package routeguide; +message Point { int32 latitude = 1; } +message Rectangle { Point lo = 1; } +message Feature { string name = 1; } +message RouteSummary { int32 point_count = 1; } +message RouteNote { string message = 1; } +service RouteGuide { + rpc GetFeature(Point) returns (Feature) {} + rpc ListFeatures(Rectangle) returns (stream Feature) {} + rpc RecordRoute(stream Point) returns (RouteSummary) {} + rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} +} +`, + file: ServiceFile{ + ProtoFile: "route_guide.proto", Package: "routeguide", + Services: []ServiceGen{{Name: "RouteGuide", Methods: []MethodGen{ + {Name: "GetFeature", InputType: "routeguide.Point", OutputType: "routeguide.Feature"}, + {Name: "ListFeatures", InputType: "routeguide.Rectangle", OutputType: "routeguide.Feature", ServerStreaming: true}, + {Name: "RecordRoute", InputType: "routeguide.Point", OutputType: "routeguide.RouteSummary", ClientStreaming: true}, + {Name: "RouteChat", InputType: "routeguide.RouteNote", OutputType: "routeguide.RouteNote", ClientStreaming: true, ServerStreaming: true}, + }}}, + }, + }, +} + +func TestGenerateRubyServicesAgainstRealProtoc(t *testing.T) { + bin := findRubyProtoc() + if bin == "" { + t.Skip("grpc_tools_ruby_protoc not installed; skipping the live differential oracle") + } + for _, tc := range oracleProtos { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + protoPath := filepath.Join(dir, tc.file.ProtoFile) + if err := os.WriteFile(protoPath, []byte(tc.proto), 0o644); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "out") + if err := os.Mkdir(out, 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(bin, "-I", dir, "--grpc_out="+out, "--ruby_out="+out, tc.file.ProtoFile) + if combined, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("grpc_tools_ruby_protoc failed: %v\n%s", err, combined) + } + base := tc.file.ProtoFile[:len(tc.file.ProtoFile)-len(".proto")] + refBytes, err := os.ReadFile(filepath.Join(out, base+"_services_pb.rb")) + if err != nil { + t.Fatal(err) + } + got, err := GenerateRubyServices(tc.file) + if err != nil { + t.Fatal(err) + } + if got != string(refBytes) { + t.Errorf("generator diverges from grpc_tools_ruby_protoc\n--- ours ---\n%s\n--- protoc ---\n%s", got, refBytes) + } + }) + } +} diff --git a/codegen_test.go b/codegen_test.go new file mode 100644 index 0000000..bae932f --- /dev/null +++ b/codegen_test.go @@ -0,0 +1,359 @@ +// Copyright (c) the go-ruby-grpc/grpc authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package grpc + +import "testing" + +// The golden strings below are the verbatim output of grpc_tools_ruby_protoc +// (grpc-tools 1.83) for the matching .proto; codegen_oracle_test.go re-derives +// them live from the binary when it is installed. Keeping them inline lets the +// generator's byte-fidelity be asserted in CI without the Ruby toolchain. + +const goldenHelloworld = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: helloworld.proto for package 'helloworld' + +require 'grpc' +require 'helloworld_pb' + +module Helloworld + module Greeter + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'helloworld.Greeter' + + rpc :SayHello, ::Helloworld::HelloRequest, ::Helloworld::HelloReply + end + + Stub = Service.rpc_stub_class + end +end +` + +const goldenRouteGuide = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: route_guide.proto for package 'routeguide' + +require 'grpc' +require 'route_guide_pb' + +module Routeguide + module RouteGuide + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'routeguide.RouteGuide' + + rpc :GetFeature, ::Routeguide::Point, ::Routeguide::Feature + rpc :ListFeatures, ::Routeguide::Rectangle, stream(::Routeguide::Feature) + rpc :RecordRoute, stream(::Routeguide::Point), ::Routeguide::RouteSummary + rpc :RouteChat, stream(::Routeguide::RouteNote), stream(::Routeguide::RouteNote) + end + + Stub = Service.rpc_stub_class + end +end +` + +const goldenMultiService = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: svc.proto for package 'grpc.testing.foo' + +require 'grpc' +require 'svc_pb' + +module Grpc + module Testing + module Foo + module EchoService + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'grpc.testing.foo.EchoService' + + rpc :Echo, ::Grpc::Testing::Foo::Req, ::Grpc::Testing::Foo::Resp + end + + Stub = Service.rpc_stub_class + end + module Other + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'grpc.testing.foo.Other' + + rpc :Ping, ::Grpc::Testing::Foo::Req, ::Grpc::Testing::Foo::Resp + end + + Stub = Service.rpc_stub_class + end + end + end +end +` + +const goldenNoPackage = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: nopkg.proto for package '' + +require 'grpc' +require 'nopkg_pb' + +module Bare + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'Bare' + + rpc :Do, ::A, ::A + end + + Stub = Service.rpc_stub_class +end +` + +const goldenNested = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: nested.proto for package 'a.b' + +require 'grpc' +require 'nested_pb' + +module A + module B + module S + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'a.b.S' + + rpc :M, ::A::B::Outer::Inner, ::A::B::Outer::Inner + end + + Stub = Service.rpc_stub_class + end + end +end +` + +const goldenUnderscore = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: und.proto for package 'foo_bar.v1beta1' + +require 'grpc' +require 'und_pb' + +module FooBar + module V1beta1 + module MyService + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'foo_bar.v1beta1.my_service' + + rpc :Do, ::FooBar::V1beta1::My_msg, ::FooBar::V1beta1::My_msg + end + + Stub = Service.rpc_stub_class + end + end +end +` + +const goldenEmptyService = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: empty.proto for package 'e' + +require 'grpc' +require 'empty_pb' + +module E +end +` + +const goldenMixed = `# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: mixed.proto for package 'm' + +require 'grpc' +require 'mixed_pb' + +module M + module Full + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'm.Full' + + rpc :Do, ::M::R, ::M::R + end + + Stub = Service.rpc_stub_class + end +end +` + +// codegenCases pairs a ServiceFile with the grpc_tools_ruby_protoc golden it must +// reproduce byte-for-byte. +var codegenCases = []struct { + name string + file ServiceFile + want string +}{ + {"helloworld", ServiceFile{ + ProtoFile: "helloworld.proto", Package: "helloworld", + Services: []ServiceGen{{Name: "Greeter", Methods: []MethodGen{ + {Name: "SayHello", InputType: "helloworld.HelloRequest", OutputType: "helloworld.HelloReply"}, + }}}, + }, goldenHelloworld}, + + {"route_guide", ServiceFile{ + ProtoFile: "route_guide.proto", Package: "routeguide", + Services: []ServiceGen{{Name: "RouteGuide", Methods: []MethodGen{ + {Name: "GetFeature", InputType: "routeguide.Point", OutputType: "routeguide.Feature"}, + {Name: "ListFeatures", InputType: "routeguide.Rectangle", OutputType: "routeguide.Feature", ServerStreaming: true}, + {Name: "RecordRoute", InputType: "routeguide.Point", OutputType: "routeguide.RouteSummary", ClientStreaming: true}, + {Name: "RouteChat", InputType: "routeguide.RouteNote", OutputType: "routeguide.RouteNote", ClientStreaming: true, ServerStreaming: true}, + }}}, + }, goldenRouteGuide}, + + {"multi_service", ServiceFile{ + ProtoFile: "svc.proto", Package: "grpc.testing.foo", + Services: []ServiceGen{ + {Name: "EchoService", Methods: []MethodGen{{Name: "Echo", InputType: "grpc.testing.foo.Req", OutputType: "grpc.testing.foo.Resp"}}}, + {Name: "Other", Methods: []MethodGen{{Name: "Ping", InputType: "grpc.testing.foo.Req", OutputType: "grpc.testing.foo.Resp"}}}, + }, + }, goldenMultiService}, + + {"no_package", ServiceFile{ + ProtoFile: "nopkg.proto", Package: "", + Services: []ServiceGen{{Name: "Bare", Methods: []MethodGen{{Name: "Do", InputType: "A", OutputType: "A"}}}}, + }, goldenNoPackage}, + + {"nested_type", ServiceFile{ + ProtoFile: "nested.proto", Package: "a.b", + Services: []ServiceGen{{Name: "S", Methods: []MethodGen{{Name: "M", InputType: "a.b.Outer.Inner", OutputType: "a.b.Outer.Inner"}}}}, + }, goldenNested}, + + {"underscore_package", ServiceFile{ + ProtoFile: "und.proto", Package: "foo_bar.v1beta1", + Services: []ServiceGen{{Name: "my_service", Methods: []MethodGen{ + {Name: "Do", InputType: "foo_bar.v1beta1.my_msg", OutputType: "foo_bar.v1beta1.my_msg"}, + }}}, + }, goldenUnderscore}, + + {"empty_service", ServiceFile{ + ProtoFile: "empty.proto", Package: "e", + Services: []ServiceGen{{Name: "Empty"}}, + }, goldenEmptyService}, + + {"mixed", ServiceFile{ + ProtoFile: "mixed.proto", Package: "m", + Services: []ServiceGen{ + {Name: "Empty"}, + {Name: "Full", Methods: []MethodGen{{Name: "Do", InputType: "m.R", OutputType: "m.R"}}}, + }, + }, goldenMixed}, +} + +func TestGenerateRubyServicesGolden(t *testing.T) { + for _, tc := range codegenCases { + t.Run(tc.name, func(t *testing.T) { + got, err := GenerateRubyServices(tc.file) + if err != nil { + t.Fatalf("GenerateRubyServices: %v", err) + } + if got != tc.want { + t.Errorf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, tc.want) + } + }) + } +} + +// TestGenerateRubyServicesLeadingDotType covers a fully-qualified type written +// with the leading dot a FileDescriptorProto uses (".helloworld.HelloRequest"). +func TestGenerateRubyServicesLeadingDotType(t *testing.T) { + got, err := GenerateRubyServices(ServiceFile{ + ProtoFile: "helloworld.proto", Package: "helloworld", + Services: []ServiceGen{{Name: "Greeter", Methods: []MethodGen{ + {Name: "SayHello", InputType: ".helloworld.HelloRequest", OutputType: ".helloworld.HelloReply"}, + }}}, + }) + if err != nil { + t.Fatal(err) + } + if got != goldenHelloworld { + t.Errorf("leading-dot type not normalized:\n%s", got) + } +} + +// TestGenerateRubyServicesErrors covers the validation branches. +func TestGenerateRubyServicesErrors(t *testing.T) { + cases := []struct { + name string + file ServiceFile + }{ + {"no_proto_file", ServiceFile{Package: "x", Services: []ServiceGen{{Name: "S", Methods: []MethodGen{{Name: "M", InputType: "x.A", OutputType: "x.A"}}}}}}, + {"unnamed_service", ServiceFile{ProtoFile: "x.proto", Services: []ServiceGen{{Name: "", Methods: []MethodGen{{Name: "M", InputType: "A", OutputType: "A"}}}}}}, + {"unnamed_rpc", ServiceFile{ProtoFile: "x.proto", Services: []ServiceGen{{Name: "S", Methods: []MethodGen{{Name: "", InputType: "A", OutputType: "A"}}}}}}, + {"missing_input", ServiceFile{ProtoFile: "x.proto", Services: []ServiceGen{{Name: "S", Methods: []MethodGen{{Name: "M", InputType: "", OutputType: "A"}}}}}}, + {"missing_output", ServiceFile{ProtoFile: "x.proto", Services: []ServiceGen{{Name: "S", Methods: []MethodGen{{Name: "M", InputType: "A", OutputType: ""}}}}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := GenerateRubyServices(tc.file); err == nil { + t.Fatal("expected an error, got nil") + } + }) + } +} + +// TestRubyTypeCrossPackage covers the fallback path for a type from another +// package (or a package-less file): the last dotted segment is the message name +// and the rest is its module-cased package. +func TestRubyTypeCrossPackage(t *testing.T) { + cases := map[string]struct { + fqn, pkg, want string + }{ + "foreign_wkt": {"google.protobuf.Empty", "helloworld", "::Google::Protobuf::Empty"}, + "foreign_leading": {".google.protobuf.Timestamp", "helloworld", "::Google::Protobuf::Timestamp"}, + "bare_no_package": {"Thing", "", "::Thing"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := rubyType(tc.fqn, tc.pkg); got != tc.want { + t.Errorf("rubyType(%q, %q) = %q, want %q", tc.fqn, tc.pkg, got, tc.want) + } + }) + } +} + +// TestPascalModuleEmptySegment covers the empty-subword branch of pascalModule +// (a package written with a trailing/leading underscore). +func TestPascalModuleEmptySegment(t *testing.T) { + if got := pascalModule("a_"); got != "A" { + t.Errorf(`pascalModule("a_") = %q, want "A"`, got) + } + if got := capitalizeFirst(""); got != "" { + t.Errorf("capitalizeFirst(empty) = %q", got) + } +} diff --git a/doc.go b/doc.go index b533ba3..e8bd0dd 100644 --- a/doc.go +++ b/doc.go @@ -43,6 +43,22 @@ // GRPC::BadStatus *BadStatus // GRPC::Core::CallError *CallError // metadata (a Hash) Metadata (map[string]string) +// GRPC::GenericService *GenericService +// rpc :Name, In, Out (*GenericService).RPC +// service.rpc_stub_class (*GenericService).StubClass → *GenericStub +// grpc_tools_ruby_protoc GenerateRubyServices +// +// # Generated services +// +// The generated-service layer mirrors what grpc_tools_ruby_protoc emits: a +// [GenericService] is the GRPC::GenericService a generated *_services_pb.rb +// Service base class includes — it names the service and collects the rpc +// declarations, then derives the server-side [Service] to register +// ([GenericService.BuildService]) and the client-side stub +// ([GenericService.StubClass], the gem's rpc_stub_class). [GenerateRubyServices] +// is the code generator itself: given a .proto's service block it emits the exact +// *_services_pb.rb source, byte-faithful to grpc_tools_ruby_protoc (asserted +// against the real binary as the oracle). // // # Messages // diff --git a/generic_service.go b/generic_service.go new file mode 100644 index 0000000..7875bac --- /dev/null +++ b/generic_service.go @@ -0,0 +1,270 @@ +// Copyright (c) the go-ruby-grpc/grpc authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package grpc + +// This file ports the generated-service layer of the grpc gem: the +// GRPC::GenericService mixin that a protoc-generated *_services_pb.rb Service +// base class includes, and the Stub class that GenericService.rpc_stub_class +// derives from it. Together with [GenerateRubyServices] (the +// grpc_tools_ruby_protoc equivalent, in codegen.go) this closes the service +// codegen surface: a .proto's service block yields a GenericService that binds +// straight onto [RpcServer] (server) and [ClientStub] (client), exactly as the +// gem's generated code does. + +// RpcDesc mirrors GRPC::RpcDesc: one declared RPC of a service — its wire name, +// cardinality, and the marshal/unmarshal functions for the request and response +// messages. In the gem a RpcDesc carries the marshal/unmarshal *procs* a message +// class' marshal_class_method / unmarshal_class_method yield; here, staying +// message-agnostic like the gem, it carries the equivalent [Marshaler] / +// [Unmarshaler] functions. Messages from github.com/go-ruby-protobuf/protobuf +// drop straight in via its Encode / Decode. +type RpcDesc struct { + // Name is the RPC method name as declared and as it appears on the wire, + // e.g. "SayHello". + Name string + // Type is the cardinality of the RPC. + Type MethodType + // RequestMarshal encodes a request message (used by the client stub). + RequestMarshal Marshaler + // RequestUnmarshal decodes a request message (used by the server). + RequestUnmarshal Unmarshaler + // ResponseMarshal encodes a response message (used by the server). + ResponseMarshal Marshaler + // ResponseUnmarshal decodes a response message (used by the client stub). + ResponseUnmarshal Unmarshaler +} + +// GenericService mirrors the GRPC::GenericService mixin a generated +// *_services_pb.rb Service base class includes: it names the service (the gem's +// self.service_name) and collects the RPC declarations (the gem's `rpc` +// class-macro calls). From it, [GenericService.BuildService] derives the +// server-side [Service] to register on an [RpcServer], and +// [GenericService.StubClass] derives the client-side stub — the gem's +// rpc_stub_class. +type GenericService struct { + serviceName string + descs []RpcDesc + index map[string]RpcDesc +} + +// NewGenericService builds a GenericService for the fully-qualified service name +// (e.g. "helloworld.Greeter"), mirroring a generated Service base whose +// self.service_name is set to that name. +func NewGenericService(serviceName string) *GenericService { + return &GenericService{serviceName: serviceName, index: map[string]RpcDesc{}} +} + +// ServiceName returns the fully-qualified service name, mirroring the gem's +// GenericService.service_name. +func (g *GenericService) ServiceName() string { return g.serviceName } + +// RPC declares one RPC on the service, mirroring the gem's +// `rpc :Name, Input, Output` class macro. It is chainable so a generated +// service reads as a sequence of RPC declarations. A duplicate name replaces the +// earlier declaration, as re-declaring an rpc does in the gem. +func (g *GenericService) RPC(d RpcDesc) *GenericService { + if _, dup := g.index[d.Name]; !dup { + g.descs = append(g.descs, d) + } else { + for i := range g.descs { + if g.descs[i].Name == d.Name { + g.descs[i] = d + break + } + } + } + g.index[d.Name] = d + return g +} + +// RpcDescs returns the declared RPCs in declaration order, mirroring the gem's +// GenericService.rpc_descs. +func (g *GenericService) RpcDescs() []RpcDesc { + out := make([]RpcDesc, len(g.descs)) + copy(out, g.descs) + return out +} + +// LookupRPC returns the RpcDesc for name and whether it was declared. +func (g *GenericService) LookupRPC(name string) (RpcDesc, bool) { + d, ok := g.index[name] + return d, ok +} + +// methodPath returns the wire path "//" for an RPC. +func (g *GenericService) methodPath(rpc string) string { + return "/" + g.serviceName + "/" + rpc +} + +// Handlers binds each declared RPC name to its handler implementation. Each +// value must be the handler func matching the RPC's cardinality — the same four +// shapes [Method] accepts: +// +// Unary func(req any, call *ActiveCall) (any, error) +// ClientStream func(call *ActiveCall) (any, error) +// ServerStream func(req any, call *ActiveCall) error +// BidiStream func(call *ActiveCall) error +// +// This mirrors defining the instance methods of a generated Service subclass. +type Handlers map[string]any + +// BuildService pairs each declared RpcDesc with its handler from h and returns +// the runtime [Service] to register on an [RpcServer] with Handle. It mirrors +// implementing a generated Service subclass and passing an instance to +// GRPC::RpcServer#handle. It errors if the service declares no RPCs, if a +// declared RPC has no handler, or if a handler's Go type does not match the +// RPC's cardinality. +func (g *GenericService) BuildService(h Handlers) (Service, error) { + if len(g.descs) == 0 { + return Service{}, NewCallError("grpc: service " + g.serviceName + " declares no rpcs") + } + svc := Service{Name: g.serviceName} + for _, d := range g.descs { + handler, ok := h[d.Name] + if !ok { + return Service{}, NewCallError("grpc: no handler for rpc " + d.Name) + } + m := Method{ + Name: d.Name, + Type: d.Type, + RequestUnmarshal: d.RequestUnmarshal, + ResponseMarshal: d.ResponseMarshal, + } + switch d.Type { + case Unary: + fn, ok := handler.(func(req any, call *ActiveCall) (any, error)) + if !ok { + return Service{}, wrongHandler(d) + } + m.UnaryHandler = fn + case ClientStream: + fn, ok := handler.(func(call *ActiveCall) (any, error)) + if !ok { + return Service{}, wrongHandler(d) + } + m.ClientStreamHandler = fn + case ServerStream: + fn, ok := handler.(func(req any, call *ActiveCall) error) + if !ok { + return Service{}, wrongHandler(d) + } + m.ServerStreamHandler = fn + case BidiStream: + fn, ok := handler.(func(call *ActiveCall) error) + if !ok { + return Service{}, wrongHandler(d) + } + m.BidiStreamHandler = fn + default: + return Service{}, NewCallError("grpc: rpc " + d.Name + " has an unknown cardinality") + } + svc.Methods = append(svc.Methods, m) + } + return svc, nil +} + +// wrongHandler builds the error returned when a handler's type does not match +// the RPC's declared cardinality. +func wrongHandler(d RpcDesc) error { + return NewCallError("grpc: handler for rpc " + d.Name + " has the wrong shape for its cardinality") +} + +// GenericStub mirrors the Stub class GenericService.rpc_stub_class generates: it +// wraps a [ClientStub] and, for each declared RPC, issues the call with the +// right cardinality and the descriptor's own marshal/unmarshal already applied, +// so the caller supplies only the request(s) and optional metadata/deadline — +// exactly the ergonomics of a generated Stub#say_hello(req). +type GenericStub struct { + stub *ClientStub + svc *GenericService +} + +// StubClass derives the client stub over an existing [ClientStub], mirroring +// `Stub = Service.rpc_stub_class` followed by `Stub.new(host, creds)`. +func (g *GenericService) StubClass(stub *ClientStub) *GenericStub { + return &GenericStub{stub: stub, svc: g} +} + +// descFor looks up rpc and verifies it has the wanted cardinality, returning the +// descriptor with its codec ready to apply to a [CallOptions]. +func (s *GenericStub) descFor(rpc string, want MethodType) (RpcDesc, error) { + d, ok := s.svc.LookupRPC(rpc) + if !ok { + return RpcDesc{}, NewCallError("grpc: unknown rpc " + rpc) + } + if d.Type != want { + return RpcDesc{}, NewCallError("grpc: rpc " + rpc + " is not " + methodTypeName(want)) + } + return d, nil +} + +// withCodec fills opts with the descriptor's request-marshal / response-unmarshal +// (the client-side codec direction), preserving the caller's metadata and +// deadline. The generated stub carries the codec so the caller never repeats it. +func withCodec(d RpcDesc, opts CallOptions) CallOptions { + opts.Marshal = d.RequestMarshal + opts.Unmarshal = d.ResponseUnmarshal + return opts +} + +// RequestResponse issues the unary RPC named rpc, mirroring a generated unary +// stub method. It errors if rpc is unknown or is not a Unary RPC. +func (s *GenericStub) RequestResponse(rpc string, req any, opts CallOptions) (any, error) { + d, err := s.descFor(rpc, Unary) + if err != nil { + return nil, err + } + return s.stub.RequestResponse(s.svc.methodPath(rpc), req, withCodec(d, opts)) +} + +// ClientStreamer issues the client-streaming RPC named rpc, mirroring a +// generated client-streaming stub method. It errors if rpc is unknown or is not +// a ClientStream RPC. +func (s *GenericStub) ClientStreamer(rpc string, requests []any, opts CallOptions) (any, error) { + d, err := s.descFor(rpc, ClientStream) + if err != nil { + return nil, err + } + return s.stub.ClientStreamer(s.svc.methodPath(rpc), requests, withCodec(d, opts)) +} + +// ServerStreamer issues the server-streaming RPC named rpc, mirroring a +// generated server-streaming stub method. It errors if rpc is unknown or is not +// a ServerStream RPC. +func (s *GenericStub) ServerStreamer(rpc string, req any, opts CallOptions) ([]any, error) { + d, err := s.descFor(rpc, ServerStream) + if err != nil { + return nil, err + } + return s.stub.ServerStreamer(s.svc.methodPath(rpc), req, withCodec(d, opts)) +} + +// BidiStreamer issues the bidirectional-streaming RPC named rpc, mirroring a +// generated bidi stub method. It errors if rpc is unknown or is not a BidiStream +// RPC. +func (s *GenericStub) BidiStreamer(rpc string, requests []any, opts CallOptions) ([]any, error) { + d, err := s.descFor(rpc, BidiStream) + if err != nil { + return nil, err + } + return s.stub.BidiStreamer(s.svc.methodPath(rpc), requests, withCodec(d, opts)) +} + +// methodTypeName renders a cardinality as the gem's stub-method name, used in +// mismatch errors. +func methodTypeName(t MethodType) string { + switch t { + case Unary: + return "request_response" + case ClientStream: + return "client_streamer" + case ServerStream: + return "server_streamer" + case BidiStream: + return "bidi_streamer" + default: + return "unknown" + } +} diff --git a/generic_service_test.go b/generic_service_test.go new file mode 100644 index 0000000..858fff4 --- /dev/null +++ b/generic_service_test.go @@ -0,0 +1,284 @@ +// Copyright (c) the go-ruby-grpc/grpc authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package grpc + +import ( + "reflect" + "strings" + "testing" + + ggrpc "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// genericEchoService builds a GenericService exercising every cardinality, the +// way a generated *_services_pb.rb Service base declares its rpcs. The string +// codec stands in for a message class' encode/decode. +func genericEchoService() *GenericService { + strDesc := func(name string, t MethodType) RpcDesc { + return RpcDesc{ + Name: name, Type: t, + RequestMarshal: strMarshal, RequestUnmarshal: strUnmarshal, + ResponseMarshal: strMarshal, ResponseUnmarshal: strUnmarshal, + } + } + return NewGenericService("test.Svc"). + RPC(strDesc("Echo", Unary)). + RPC(strDesc("Sum", ClientStream)). + RPC(strDesc("Split", ServerStream)). + RPC(strDesc("Chat", BidiStream)) +} + +func genericEchoHandlers() Handlers { + return Handlers{ + "Echo": func(req any, call *ActiveCall) (any, error) { + return "echo:" + req.(string), nil + }, + "Sum": func(call *ActiveCall) (any, error) { + var parts []string + if err := call.EachRemoteRead(func(m any) error { + parts = append(parts, m.(string)) + return nil + }); err != nil { + return nil, err + } + return strings.Join(parts, ""), nil + }, + "Split": func(req any, call *ActiveCall) error { + if err := call.Send(req.(string) + "!"); err != nil { + return err + } + return call.Send(req.(string) + "?") + }, + "Chat": func(call *ActiveCall) error { + return call.EachRemoteRead(func(m any) error { + return call.Send("re:" + m.(string)) + }) + }, + } +} + +// startGenericEcho registers a GenericService-built Service on an RpcServer over +// a MemTransport and returns a GenericStub over a connected ClientStub. +func startGenericEcho(t *testing.T) *GenericStub { + t.Helper() + gs := genericEchoService() + svc, err := gs.BuildService(genericEchoHandlers()) + if err != nil { + t.Fatalf("BuildService: %v", err) + } + tr := NewMemTransport() + srv := NewRpcServer(WithTransport(tr)) + srv.AddHTTP2Port("generic:1", ":this_port_is_insecure") + srv.Handle(svc) + go func() { _ = srv.Run() }() + waitRunning(t, srv) + + stub, err := NewClientStub("generic:1", ":this_channel_is_insecure", WithStubTransport(tr)) + if err != nil { + t.Fatalf("NewClientStub: %v", err) + } + t.Cleanup(func() { + _ = stub.Close() + srv.Stop() + }) + return gs.StubClass(stub) +} + +// TestGenericStubAllCardinalities drives every RPC shape end-to-end through the +// generated-service layer. Crucially the CallOptions carry no Marshal/Unmarshal: +// the generated stub supplies the descriptor's codec, exactly as the gem's +// rpc_stub_class does. +func TestGenericStubAllCardinalities(t *testing.T) { + stub := startGenericEcho(t) + + resp, err := stub.RequestResponse("Echo", "hi", CallOptions{Metadata: Metadata{"x-trace": "1"}}) + if err != nil { + t.Fatalf("RequestResponse: %v", err) + } + if resp != "echo:hi" { + t.Errorf("unary = %q, want %q", resp, "echo:hi") + } + + sum, err := stub.ClientStreamer("Sum", []any{"a", "b", "c"}, CallOptions{}) + if err != nil { + t.Fatalf("ClientStreamer: %v", err) + } + if sum != "abc" { + t.Errorf("client-stream = %q, want %q", sum, "abc") + } + + split, err := stub.ServerStreamer("Split", "x", CallOptions{}) + if err != nil { + t.Fatalf("ServerStreamer: %v", err) + } + if !reflect.DeepEqual(split, []any{"x!", "x?"}) { + t.Errorf("server-stream = %v, want [x! x?]", split) + } + + chat, err := stub.BidiStreamer("Chat", []any{"1", "2"}, CallOptions{}) + if err != nil { + t.Fatalf("BidiStreamer: %v", err) + } + if !reflect.DeepEqual(chat, []any{"re:1", "re:2"}) { + t.Errorf("bidi-stream = %v, want [re:1 re:2]", chat) + } +} + +// TestGenericServiceDeclarations covers ServiceName, RpcDescs (a copy in +// declaration order), LookupRPC and the duplicate-replace path of RPC. +func TestGenericServiceDeclarations(t *testing.T) { + gs := NewGenericService("a.B"). + RPC(RpcDesc{Name: "One", Type: Unary}). + RPC(RpcDesc{Name: "Two", Type: ServerStream}). + RPC(RpcDesc{Name: "One", Type: BidiStream}) // redeclare One + + if gs.ServiceName() != "a.B" { + t.Errorf("ServiceName = %q", gs.ServiceName()) + } + descs := gs.RpcDescs() + if len(descs) != 2 { + t.Fatalf("RpcDescs len = %d, want 2 (dup replaced in place)", len(descs)) + } + if descs[0].Name != "One" || descs[0].Type != BidiStream { + t.Errorf("redeclared One = %+v, want name One type BidiStream", descs[0]) + } + if descs[1].Name != "Two" { + t.Errorf("descs[1] = %q, want Two", descs[1].Name) + } + // Mutating the returned slice must not affect the service. + descs[0].Name = "mutated" + if again := gs.RpcDescs(); again[0].Name != "One" { + t.Errorf("RpcDescs did not return a copy: %q", again[0].Name) + } + if _, ok := gs.LookupRPC("nope"); ok { + t.Error("LookupRPC found a nonexistent rpc") + } +} + +// TestBuildServiceErrors covers every BuildService failure branch. +func TestBuildServiceErrors(t *testing.T) { + unary := func(req any, call *ActiveCall) (any, error) { return nil, nil } + clientStream := func(call *ActiveCall) (any, error) { return nil, nil } + serverStream := func(req any, call *ActiveCall) error { return nil } + bidi := func(call *ActiveCall) error { return nil } + + t.Run("no_rpcs", func(t *testing.T) { + if _, err := NewGenericService("x").BuildService(nil); err == nil { + t.Fatal("want error for a service with no rpcs") + } + }) + t.Run("missing_handler", func(t *testing.T) { + gs := NewGenericService("x").RPC(RpcDesc{Name: "M", Type: Unary}) + if _, err := gs.BuildService(Handlers{}); err == nil { + t.Fatal("want error for a missing handler") + } + }) + + wrongShape := []struct { + name string + typ MethodType + good any // a correctly shaped handler, to prove the good path builds + bad any // a mis-shaped handler + }{ + {"unary", Unary, unary, clientStream}, + {"client_stream", ClientStream, clientStream, unary}, + {"server_stream", ServerStream, serverStream, bidi}, + {"bidi_stream", BidiStream, bidi, serverStream}, + } + for _, ws := range wrongShape { + t.Run("wrong_shape_"+ws.name, func(t *testing.T) { + gs := NewGenericService("x").RPC(RpcDesc{Name: "M", Type: ws.typ}) + if _, err := gs.BuildService(Handlers{"M": ws.bad}); err == nil { + t.Fatal("want error for a mis-shaped handler") + } + if _, err := gs.BuildService(Handlers{"M": ws.good}); err != nil { + t.Fatalf("well-shaped handler rejected: %v", err) + } + }) + } + + t.Run("unknown_cardinality", func(t *testing.T) { + gs := NewGenericService("x").RPC(RpcDesc{Name: "M", Type: MethodType(99)}) + if _, err := gs.BuildService(Handlers{"M": unary}); err == nil { + t.Fatal("want error for an unknown cardinality") + } + }) +} + +// TestGenericStubMismatch covers the unknown-rpc and wrong-cardinality guards on +// each stub method. +func TestGenericStubMismatch(t *testing.T) { + stub := startGenericEcho(t) + + if _, err := stub.RequestResponse("Nope", "x", CallOptions{}); err == nil { + t.Error("want error for an unknown rpc") + } + if _, err := stub.RequestResponse("Sum", "x", CallOptions{}); err == nil { + t.Error("want error calling a client-stream rpc as unary") + } + if _, err := stub.ClientStreamer("Echo", nil, CallOptions{}); err == nil { + t.Error("want error calling a unary rpc as client-stream") + } + if _, err := stub.ServerStreamer("Echo", "x", CallOptions{}); err == nil { + t.Error("want error calling a unary rpc as server-stream") + } + if _, err := stub.BidiStreamer("Echo", nil, CallOptions{}); err == nil { + t.Error("want error calling a unary rpc as bidi-stream") + } +} + +// TestMethodTypeName covers the cardinality-name mapping including its fallback. +func TestMethodTypeName(t *testing.T) { + cases := map[MethodType]string{ + Unary: "request_response", + ClientStream: "client_streamer", + ServerStream: "server_streamer", + BidiStream: "bidi_streamer", + MethodType(99): "unknown", + } + for typ, want := range cases { + if got := methodTypeName(typ); got != want { + t.Errorf("methodTypeName(%d) = %q, want %q", typ, got, want) + } + } +} + +// TestGenericStubToRealServer is the wire-interop oracle for the generated-stub +// path: a GenericStub calls a stock google.golang.org/grpc server over the +// in-memory transport, with real protobuf messages. It proves the generated stub +// speaks the gRPC wire faithfully, not just to our own server. +func TestGenericStubToRealServer(t *testing.T) { + tr := NewMemTransport() + lis, err := tr.Listen("genericoracle:1") + if err != nil { + t.Fatal(err) + } + real := ggrpc.NewServer() + real.RegisterService(&realEchoDesc, nil) + go func() { _ = real.Serve(lis) }() + defer real.Stop() + + stub, err := NewClientStub("genericoracle:1", ":insecure", WithStubTransport(tr)) + if err != nil { + t.Fatal(err) + } + defer stub.Close() + + gs := NewGenericService("oracle.Echo").RPC(RpcDesc{ + Name: "Unary", Type: Unary, + RequestMarshal: pbMarshal, RequestUnmarshal: pbUnmarshal, + ResponseMarshal: pbMarshal, ResponseUnmarshal: pbUnmarshal, + }) + gstub := gs.StubClass(stub) + + resp, err := gstub.RequestResponse("Unary", wrapperspb.String("world"), CallOptions{}) + if err != nil { + t.Fatalf("RequestResponse: %v", err) + } + if got := resp.(*wrapperspb.StringValue).Value; got != "echo:world" { + t.Errorf("got %q, want %q", got, "echo:world") + } +}