Skip to content

Commit

Permalink
Allow setting protoc command via Meta (#3633)
Browse files Browse the repository at this point in the history
* Allow setting protoc command via Meta

* Expose DefaultProtoc for resetting protoc command and update documentation

* Add a test for passing alternate protoc commands

* Document const DefaultProtoc

---------

Co-authored-by: Raphael Simon <[email protected]>
  • Loading branch information
duckbrain and raphael authored Jan 28, 2025
1 parent d04a160 commit dccae52
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 5 deletions.
32 changes: 32 additions & 0 deletions dsl/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import (
"goa.design/goa/v3/expr"
)

// DefaultProtoc is the default command to be invoked for generating code from protobuf schemas.
// You may use this to prepend arguments/flags to the command or revert to the default command.
//
// See also [Meta]; this is useful with the "protoc:cmd" key.
const DefaultProtoc = expr.DefaultProtoc

// Meta defines a set of key/value pairs that can be assigned to an object. Each
// value consists of a slice of strings so that multiple invocation of the Meta
// function on the same target using the same key builds up the slice.
Expand Down Expand Up @@ -124,6 +130,32 @@ import (
// })
// })
//
// - "protoc:cmd" provides an alternate command to execute for protoc with
// optional arguments. Applicable to API and service definitions only. If used
// on an API definition the include paths are used for all services, unless
// specified otherwise for specific services. The first value will be used as
// the command, and the following values will be used as initial arguments to
// that command. The given command will have additional arguments appended and
// is expected to behave similar to protoc.
//
// Can be used to specify custom options or alternate implementations. The
// default command can be specified using DefaultProtoc.
//
// // Use Go run to run a drop-in replacement for protoc.
// var _ = API("myapi", func() {
// Meta("protoc:cmd", "go", "run", "github.com/duckbrain/goprotoc")
// })
//
// // Specify the full path to protoc and turn on fatal warnings.
// var _ = Service("service1", func() {
// Meta("protoc:cmd", "/usr/bin/protoc", "--fatal_warnings")
// })
//
// // Restore defaults for a specific service.
// var _ = Service("service2", func() {
// Meta("protoc:cmd", DefaultProtoc)
// })
//
// - "protoc:include" provides the list of import paths used to invoke protoc.
// Applicable to API and service definitions only. If used on an API definition
// the include paths are used for all services.
Expand Down
3 changes: 3 additions & 0 deletions expr/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (
// Root is the root object built by the DSL.
var Root = new(RootExpr)

// DefaultProtoc is the default command to be invoked for generating code from protobuf schemas.
const DefaultProtoc = "protoc"

type (
// RootExpr is the struct built by the DSL on process start.
RootExpr struct {
Expand Down
20 changes: 17 additions & 3 deletions grpc/codegen/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,19 @@ func protoFile(genpkg string, svc *expr.GRPCServiceExpr) *codegen.File {
runProtoc := func(path string) error {
includes := svc.ServiceExpr.Meta["protoc:include"]
includes = append(includes, expr.Root.API.Meta["protoc:include"]...)
return protoc(path, includes)

cmd := defaultProtocCmd
if c, ok := expr.Root.API.Meta["protoc:cmd"]; ok {
cmd = c
}
if c, ok := svc.ServiceExpr.Meta["protoc:cmd"]; ok {
cmd = c
}
if len(cmd) == 0 {
return fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`)
}

return protoc(cmd, path, includes)
}

return &codegen.File{
Expand All @@ -100,7 +112,9 @@ func pkgName(svc *expr.GRPCServiceExpr, svcName string) string {
return codegen.SnakeCase(svcName)
}

func protoc(path string, includes []string) error {
var defaultProtocCmd = []string{expr.DefaultProtoc}

func protoc(protocCmd []string, path string, includes []string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
Expand All @@ -117,7 +131,7 @@ func protoc(path string, includes []string) error {
for _, include := range includes {
args = append(args, "-I", include)
}
cmd := exec.Command("protoc", args...)
cmd := exec.Command(protocCmd[0], append(protocCmd[1:len(protocCmd):len(protocCmd)], args...)...)
cmd.Dir = filepath.Dir(path)

if output, err := cmd.CombinedOutput(); err != nil {
Expand Down
51 changes: 49 additions & 2 deletions grpc/codegen/proto_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package codegen

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
Expand Down Expand Up @@ -49,7 +52,7 @@ func TestProtoFiles(t *testing.T) {
}
assert.Equal(t, c.Code, code)
fpath := codegen.CreateTempFile(t, code)
assert.NoError(t, protoc(fpath, nil), "error occurred when compiling proto file %q", fpath)
assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath)
})
}
}
Expand Down Expand Up @@ -85,7 +88,51 @@ func TestMessageDefSection(t *testing.T) {
}
assert.Equal(t, c.Code, msgCode)
fpath := codegen.CreateTempFile(t, code+msgCode)
assert.NoError(t, protoc(fpath, nil), "error occurred when compiling proto file %q", fpath)
assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath)
})
}
}

func TestProtoc(t *testing.T) {
const code = testdata.UnaryRPCsProtoCode

fakeBin := filepath.Join(os.TempDir(), t.Name()+"-fakeprotoc")
if runtime.GOOS == "windows" {
fakeBin += ".exe"
}
out, err := exec.Command("go", "build", "-o", fakeBin, "./testdata/protoc").CombinedOutput()
t.Log("go build output: ", string(out))
require.NoError(t, err, "compile a fake protoc that requires a prefix")
t.Cleanup(func() { assert.NoError(t, os.Remove(fakeBin)) })

cases := []struct {
Name string
Cmd []string
}{
{"protoc", defaultProtocCmd},
{"fakepc", []string{fakeBin, "required-ignored-arg"}},
}

var firstOutput string

for _, c := range cases {
t.Run(c.Name, func(t *testing.T) {
dir, err := os.MkdirTemp("", strings.ReplaceAll(t.Name(), "/", "-"))
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) })
fpath := filepath.Join(dir, "schema")
require.NoError(t, os.WriteFile(fpath, []byte(code), 0o600), "error occured writing proto schema")
require.NoError(t, protoc(c.Cmd, fpath, nil), "error occurred when compiling proto file with the standard protoc %q", fpath)

fcontents, err := os.ReadFile(fpath + ".pb.go")
require.NoError(t, err)

if firstOutput == "" {
firstOutput = string(fcontents)
return
}

assert.Equal(t, firstOutput, string(fcontents))
})
}
}
21 changes: 21 additions & 0 deletions grpc/codegen/testdata/protoc/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package main

import (
"fmt"
"os"
"os/exec"
)

func main() {
if len(os.Args) < 2 {
fmt.Println("must pass a prefix arg to be ignored, to test it being passed")
os.Exit(1)
}
cmd := exec.Command("protoc", os.Args[2:]...)
fmt.Println(cmd)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
os.Exit(cmd.ProcessState.ExitCode())
}

0 comments on commit dccae52

Please sign in to comment.