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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/

# Generated build files
protoc-gen-connect-go-servicestruct
108 changes: 108 additions & 0 deletions cmd/protoc-gen-connect-go-servicestruct/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# protoc-gen-connect-go-servicestruct

A protobuf compiler plugin that generates Go service structs with handler
functions for Connect RPC services. **Requires `protoc-gen-connect-go` as a dependency.**

## How-tos

### Installation

```bash
go install github.com/TrogonStack/protoc-gen/cmd/protoc-gen-connect-go-servicestruct@latest
```

### Basic Usage

**Required**: This plugin must be used alongside `protoc-gen-connect-go` as it generates structs that implement the
Connect handler interfaces:

```bash
protoc --go_out=gen --connect-go_out=gen --connect-go-servicestruct_out=gen path/to/service.proto
```

### With buf

**Required**: Add to your `buf.gen.yaml` alongside the required `buf.build/connectrpc/go` plugin:

```yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
- remote: buf.build/connectrpc/go:v1.18.1 # Required dependency
out: gen
opt: paths=source_relative
- local: protoc-gen-connect-go-servicestruct
out: gen
opt:
- paths=source_relative
```

Files are generated into `{packagename}connect` subdirectories alongside the connect plugin output.

### Basic Service Implementation

For more realistic applications that require dependencies like database connections, use constructor functions to
create handlers:

```go
package main

import (
"context"
"fmt"
"log"
"net/http"

"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgxpool"
greeterv1 "example.com/greeter/gen/greeter/v1"
"example.com/greeter/gen/greeter/v1/greeterv1connect"
)

func main() {
// Initialize database connection
dbPool, err := pgxpool.New(context.Background(), "postgres://user:pass@localhost/db")
if err != nil {
log.Fatal("Failed to create connection pool:", err)
}
defer dbPool.Close()

mux := http.NewServeMux()
path, handler := greeterv1connect.NewGreeterServiceHandler(&greeterv1connect.GreeterServiceStruct{
GreetFunc: newGreetHandler(dbPool),
GreetStreamFunc: newGreetStreamHandler(dbPool),
})
mux.Handle(path, handler)

log.Println("Greeter server starting on :8080")
http.ListenAndServe(":8080", mux)
}

func newGreetHandler(db *pgxpool.Pool) greeterv1.GreeterServiceGreetHandlerFunc {
return func(ctx context.Context, req *connect.Request[greeterv1.GreetRequest]) (*connect.Response[greeterv1.GreetResponse], error) {
// ...
}
}

func newGreetStreamHandler(db *pgxpool.Pool) greeterv1.GreeterServiceGreetStreamHandlerFunc {
return func(ctx context.Context, stream *connect.BidiStream[greeterv1.GreetRequest, greeterv1.GreetResponse]) error {
// ...
}
}
```

## Explanations

### Overview

This plugin generates service structs that provide a convenient way to organize and access handler functions for
Connect RPC services. **This plugin requires `protoc-gen-connect-go` (or `buf.build/connectrpc/go`) to function** - it
generates structs that implement the Connect handler interfaces created by the standard Connect plugin.

### Features

- **Service Structs**: Generates public structs with handler function fields.
- **Type Safety**: Uses strongly-typed handler function types.
- **Interface Compatibility**: Generated structs implement the standard handler interfaces for backwards compatibility.
267 changes: 267 additions & 0 deletions cmd/protoc-gen-connect-go-servicestruct/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// protoc-gen-connect-go-servicestruct is a plugin for the Protobuf compiler that generates
// Go service structs with handler functions. To use it, build this program and make
// it available on your PATH as protoc-gen-connect-go-servicestruct.
//
// The 'connect-go-servicestruct' suffix becomes part of the arguments for the Protobuf
// compiler. To generate service structs using protoc:
//
// protoc --go_out=gen --connect-go-servicestruct_out=gen path/to/file.proto
//
// With [buf], your buf.gen.yaml will look like this:
//
// version: v2
// plugins:
// - local: protoc-gen-go
// out: gen
// - local: protoc-gen-connect-go-servicestruct
// out: gen
//
// This generates service struct definitions for the Protobuf services
// defined by file.proto. If file.proto defines the foov1 Protobuf package, the
// invocations above will write output to:
//
// gen/path/to/file.pb.go
// gen/path/to/foov1connect/file.servicestruct.connect.go
//
// [buf]: https://buf.build
package main

import (
"fmt"
"os"
"path"
"strings"

connect "connectrpc.com/connect"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/pluginpb"
)

const (
contextPackage = protogen.GoImportPath("context")
connectPackage = protogen.GoImportPath("connectrpc.com/connect")

filenameSuffix = ".servicestruct.connect.go"
handlerFuncSuffix = "HandlerFunc"
serviceSuffix = "Struct"

usage = "\n\nFlags:\n -h, --help\tPrint this help and exit.\n --version\tPrint the version and exit."
)

func main() {
if len(os.Args) == 2 && os.Args[1] == "--version" {
if _, err := fmt.Fprintln(os.Stdout, connect.Version); err != nil {
os.Exit(1)
}
os.Exit(0)
}
if len(os.Args) == 2 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
if _, err := fmt.Fprintln(os.Stdout, usage); err != nil {
os.Exit(1)
}
os.Exit(0)
}
if len(os.Args) != 1 {
if _, err := fmt.Fprintln(os.Stderr, usage); err != nil {
os.Exit(1)
}
os.Exit(1)
}
protogen.Options{}.Run(
func(plugin *protogen.Plugin) error {
plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) | uint64(pluginpb.CodeGeneratorResponse_FEATURE_SUPPORTS_EDITIONS)
plugin.SupportedEditionsMinimum = descriptorpb.Edition_EDITION_PROTO2
plugin.SupportedEditionsMaximum = descriptorpb.Edition_EDITION_2023

for _, file := range plugin.Files {
if file.Generate && len(file.Services) > 0 {
generateFile(plugin, file)
}
}

return nil
},
)
}

func generateFile(plugin *protogen.Plugin, file *protogen.File) {
// Modify package name and paths to match connect plugin behavior
connectSuffix := "connect"
connectPackageName := file.GoPackageName + protogen.GoPackageName(connectSuffix)
dir := path.Dir(file.GeneratedFilenamePrefix)
base := path.Base(file.GeneratedFilenamePrefix)
connectDir := string(file.GoPackageName) + connectSuffix
filename := path.Join(dir, connectDir, base+filenameSuffix)
goImportPath := protogen.GoImportPath(path.Join(
string(file.GoImportPath),
connectDir,
))
generatedFile := plugin.NewGeneratedFile(filename, goImportPath)

// Import the base package for message types
generatedFile.Import(file.GoImportPath)

generatedFile.P("// Code generated by protoc-gen-connect-go-servicestruct. DO NOT EDIT.")
generatedFile.P("//")
generatedFile.P("// Source: ", file.Desc.Path())
generatedFile.P()
generatedFile.P("package ", connectPackageName)
generatedFile.P()

generateHandlerFuncTypes(generatedFile, []*protogen.File{file})

for _, service := range file.Services {
generateServiceStruct(generatedFile, service)
}
}

func generateHandlerFuncTypes(g *protogen.GeneratedFile, files []*protogen.File) {
// Generate specific handler function types for each method
for _, file := range files {
for _, service := range file.Services {
for _, method := range service.Methods {
generateMethodHandlerType(g, service, method)
}
}
}
}

func generateMethodHandlerType(g *protogen.GeneratedFile, service *protogen.Service, method *protogen.Method) {
typeName := service.GoName + method.GoName + handlerFuncSuffix

if isDeprecatedMethod(method) {
g.P("//")
deprecated(g)
}

// Generate the appropriate function signature based on streaming type
isStreamingClient := method.Desc.IsStreamingClient()
isStreamingServer := method.Desc.IsStreamingServer()

switch {
case isStreamingClient && isStreamingServer:
// Bidirectional streaming
g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("BidiStream"), "[", g.QualifiedGoIdent(method.Input.GoIdent), ", ", g.QualifiedGoIdent(method.Output.GoIdent), "]) error")
case isStreamingClient && !isStreamingServer:
// Client streaming
g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("ClientStream"), "[", g.QualifiedGoIdent(method.Input.GoIdent), "]) (*", connectPackage.Ident("Response"), "[", g.QualifiedGoIdent(method.Output.GoIdent), "], error)")
case !isStreamingClient && isStreamingServer:
// Server streaming
g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("Request"), "[", g.QualifiedGoIdent(method.Input.GoIdent), "], *", connectPackage.Ident("ServerStream"), "[", g.QualifiedGoIdent(method.Output.GoIdent), "]) error")
default:
// Unary
g.P("type ", typeName, " func(", contextPackage.Ident("Context"), ", *", connectPackage.Ident("Request"), "[", g.QualifiedGoIdent(method.Input.GoIdent), "]) (*", connectPackage.Ident("Response"), "[", g.QualifiedGoIdent(method.Output.GoIdent), "], error)")
}
g.P()
}

func generateServiceStruct(g *protogen.GeneratedFile, service *protogen.Service) {
serviceName := fmt.Sprintf("%s%s", service.GoName, serviceSuffix)

if isDeprecatedService(service) {
g.P("//")
deprecated(g)
}
g.AnnotateSymbol(serviceName, protogen.Annotation{Location: service.Location})
g.P("type ", serviceName, " struct {")
for _, method := range service.Methods {
fieldName := method.GoName + "Func"
g.AnnotateSymbol(serviceName+"."+fieldName, protogen.Annotation{Location: method.Location})
leadingComments(
g,
method.Comments.Leading,
isDeprecatedMethod(method),
)
handlerTypeName := service.GoName + method.GoName + handlerFuncSuffix
g.P(fieldName, " ", handlerTypeName)
}
g.P("}")
g.P()

for _, method := range service.Methods {
fieldName := method.GoName + "Func"
if isDeprecatedMethod(method) {
g.P("//")
deprecated(g)
}
methodSig := method.GoName + serverSignatureParams(g, method, true)
g.P("func (s *", serviceName, ") ", methodSig, " {")

isStreamingClient := method.Desc.IsStreamingClient()
isStreamingServer := method.Desc.IsStreamingServer()

switch {
case isStreamingClient && isStreamingServer:
g.P("return s.", fieldName, "(ctx, stream)")
case isStreamingClient && !isStreamingServer:
g.P("return s.", fieldName, "(ctx, stream)")
case !isStreamingClient && isStreamingServer:
g.P("return s.", fieldName, "(ctx, req, stream)")
default:
g.P("return s.", fieldName, "(ctx, req)")
}
g.P("}")
g.P()
}
}

func serverSignatureParams(g *protogen.GeneratedFile, method *protogen.Method, named bool) string {
ctxName := "ctx "
reqName := "req "
streamName := "stream "
if !named {
ctxName, reqName, streamName = "", "", ""
}
if method.Desc.IsStreamingClient() && method.Desc.IsStreamingServer() {
return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ", " +
streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("BidiStream")) +
"[" + g.QualifiedGoIdent(method.Input.GoIdent) + ", " + g.QualifiedGoIdent(method.Output.GoIdent) + "]" +
") error"
}
if method.Desc.IsStreamingClient() {
return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ", " +
streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ClientStream")) +
"[" + g.QualifiedGoIdent(method.Input.GoIdent) + "]" +
") (*" + g.QualifiedGoIdent(connectPackage.Ident("Response")) + "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "], error)"
}
if method.Desc.IsStreamingServer() {
return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) +
", " + reqName + "*" + g.QualifiedGoIdent(connectPackage.Ident("Request")) + "[" +
g.QualifiedGoIdent(method.Input.GoIdent) + "], " +
streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ServerStream")) +
"[" + g.QualifiedGoIdent(method.Output.GoIdent) + "]" +
") error"
}
return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) +
", " + reqName + "*" + g.QualifiedGoIdent(connectPackage.Ident("Request")) + "[" +
g.QualifiedGoIdent(method.Input.GoIdent) + "]) " +
"(*" + g.QualifiedGoIdent(connectPackage.Ident("Response")) + "[" +
g.QualifiedGoIdent(method.Output.GoIdent) + "], error)"
}

func isDeprecatedService(service *protogen.Service) bool {
serviceOptions, ok := service.Desc.Options().(*descriptorpb.ServiceOptions)
return ok && serviceOptions.GetDeprecated()
}

func isDeprecatedMethod(method *protogen.Method) bool {
methodOptions, ok := method.Desc.Options().(*descriptorpb.MethodOptions)
return ok && methodOptions.GetDeprecated()
}

func leadingComments(g *protogen.GeneratedFile, comments protogen.Comments, isDeprecated bool) {
if comments.String() != "" {
g.P(strings.TrimSpace(comments.String()))
}
if isDeprecated {
if comments.String() != "" {
g.P("//")
}
deprecated(g)
}
}

func deprecated(g *protogen.GeneratedFile) {
g.P("// Deprecated: do not use.")
}
Loading