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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
248 changes: 248 additions & 0 deletions codegen.go
Original file line number Diff line number Diff line change
@@ -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: "<package>.<Service>", 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, "::")
}
Loading