diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db9664b..59ddf7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Add `--list-lint-group` flag to the `lint` command to list a lint group's rules. - Add `--diff-lint-groups` flag to the `lint` command to print the diff - between two lint groups + between two lint groups. - Breaking change detector added as the `break check` command. - A Docker image is now provided on Docker Hub as [uber/prototool](https://hub.docker.com/r/uber/prototool) which provides an environment with commonly-used plugins. @@ -43,6 +43,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Add file locking around the `protoc` downloader to eliminate concurrency issues where multiple `prototool` invocations may be accessing the cache at the same time. +- Add `--details` flag to the `grpc` command to output headers, trailers, + and statuses as well as the responses. - Unix domain sockets can now be specified for the `--address` flag of the `grpc` command via the prefix `unix://`. diff --git a/docs/grpc.md b/docs/grpc.md index 231fea61..739c82c8 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -19,37 +19,24 @@ Either use `--data 'requestData'` as the the JSON data to input, or `--stdin` wh ```bash $ make example # make sure everything is built just in case +$ go run example/cmd/excited/main.go # run in another terminal $ prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/Exclamation \ + --method uber.foo.v1.ExcitedAPI/Exclamation \ --data '{"value":"hello"}' -{ - "value": "hello!" -} +{"value": "hello!"} $ prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/ExclamationServerStream \ + --method uber.foo.v1.ExcitedAPI/ExclamationServerStream \ --data '{"value":"hello"}' -{ - "value": "h" -} -{ - "value": "e" -} -{ - "value": "l" -} -{ - "value": "l" -} -{ - "value": "o" -} -{ - "value": "!" -} +{"value": "h"} +{"value": "e"} +{"value": "l"} +{"value": "l"} +{"value": "o"} +{"value": "!"} $ cat input.json {"value":"hello"} @@ -57,20 +44,27 @@ $ cat input.json $ cat input.json | prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/ExclamationClientStream \ + --method uber.foo.v1.ExcitedAPI/ExclamationClientStream \ --stdin -{ - "value": "hellosalutations!" -} +{"value": "hellosalutations!"} $ cat input.json | prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/ExclamationBidiStream \ + --method uber.foo.v1.ExcitedAPI/ExclamationBidiStream \ --stdin -{ - "value": "hello!" -} -{ - "value": "salutations!" -} +{"value": "hello!"} +{"value": "salutations!"} + +$ prototool grpc example \ + --address 0.0.0.0:8080 \ + --method uber.foo.v1.ExcitedAPI/ExclamationServerStream \ + --data '{"value":"hello"}' \ + --details +{"headers":{"content-type":["application/grpc"]}} +{"response":{"value":"h"}} +{"response":{"value":"e"}} +{"response":{"value":"l"}} +{"response":{"value":"l"}} +{"response":{"value":"o"}} +{"response":{"value":"!"}} ``` diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index ac0b4664..009cbe1f 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1116,22 +1116,14 @@ func TestGRPC(t *testing.T) { t.Parallel() assertGRPC(t, 0, - ` - { - "value": "hello!" - } - `, + `{"value":"hello!"}`, "testdata/grpc/grpc.proto", "grpc.ExcitedService/Exclamation", `{"value":"hello"}`, ) assertGRPC(t, 0, - ` - { - "value": "hellosalutations!" - } - `, + `{"value":"hellosalutations!"}`, "testdata/grpc/grpc.proto", "grpc.ExcitedService/ExclamationClientStream", `{"value":"hello"} @@ -1139,26 +1131,12 @@ func TestGRPC(t *testing.T) { ) assertGRPC(t, 0, - ` - { - "value": "h" - } - { - "value": "e" - } - { - "value": "l" - } - { - "value": "l" - } - { - "value": "o" - } - { - "value": "!" - } - `, + `{"value":"h"} + {"value":"e"} + {"value":"l"} + {"value":"l"} + {"value":"o"} + {"value":"!"}`, "testdata/grpc/grpc.proto", "grpc.ExcitedService/ExclamationServerStream", `{"value":"hello"}`, @@ -1166,18 +1144,28 @@ func TestGRPC(t *testing.T) { assertGRPC(t, 0, ` - { - "value": "hello!" - } - { - "value": "salutations!" - } + {"value":"hello!"} + {"value":"salutations!"} `, "testdata/grpc/grpc.proto", "grpc.ExcitedService/ExclamationBidiStream", `{"value":"hello"} {"value":"salutations"}`, ) + assertGRPC(t, + 0, + `{"headers":{"content-type":["application/grpc"]}} + {"response":{"value":"h"}} + {"response":{"value":"e"}} + {"response":{"value":"l"}} + {"response":{"value":"l"}} + {"response":{"value":"o"}} + {"response":{"value":"!"}}`, + "testdata/grpc/grpc.proto", + "grpc.ExcitedService/ExclamationServerStream", + `{"value":"hello"}`, + `--details`, + ) } func TestVersion(t *testing.T) { @@ -1381,10 +1369,10 @@ func assertGoldenFormat(t *testing.T, expectSuccess bool, fix bool, filePath str assert.Equal(t, strings.TrimSpace(string(golden)), output) } -func assertGRPC(t *testing.T, expectedExitCode int, expectedLinePrefixes string, filePath string, method string, jsonData string) { +func assertGRPC(t *testing.T, expectedExitCode int, expectedLinePrefixes string, filePath string, method string, jsonData string, extraFlags ...string) { excitedTestCase := startExcitedTestCase(t) defer excitedTestCase.Close() - assertDoStdin(t, strings.NewReader(jsonData), true, expectedExitCode, expectedLinePrefixes, "grpc", filePath, "--address", excitedTestCase.Address(), "--method", method, "--stdin") + assertDoStdin(t, strings.NewReader(jsonData), true, expectedExitCode, expectedLinePrefixes, append([]string{"grpc", filePath, "--address", excitedTestCase.Address(), "--method", method, "--stdin"}, extraFlags...)...) } func assertRegexp(t *testing.T, extraErrorFormat bool, expectedExitCode int, expectedRegexp string, args ...string) { diff --git a/internal/cmd/flags.go b/internal/cmd/flags.go index 133406c8..50bd8785 100644 --- a/internal/cmd/flags.go +++ b/internal/cmd/flags.go @@ -33,6 +33,7 @@ type flags struct { connectTimeout string data string debug bool + details bool diffLintGroups string diffMode bool disableFormat bool @@ -95,6 +96,10 @@ func (f *flags) bindDebug(flagSet *pflag.FlagSet) { flagSet.BoolVar(&f.debug, "debug", false, "Run in debug mode, which will print out debug logging.") } +func (f *flags) bindDetails(flagSet *pflag.FlagSet) { + flagSet.BoolVar(&f.details, "details", false, "Output headers, trailers, and status as well as the responses.") +} + func (f *flags) bindDiffLintGroups(flagSet *pflag.FlagSet) { flagSet.StringVar(&f.diffLintGroups, "diff-lint-groups", "", "Diff the two lint groups separated by '.', for example google,uber2.") } diff --git a/internal/cmd/templates.go b/internal/cmd/templates.go index 6b438ab0..ac98579c 100644 --- a/internal/cmd/templates.go +++ b/internal/cmd/templates.go @@ -286,37 +286,24 @@ prototool grpc [dirOrFile] \ Either use "--data 'requestData'" as the the JSON data to input, or "--stdin" which will result in the input being read from stdin as JSON. $ make example # make sure everything is built just in case +$ go run example/cmd/excited/main.go # run in another terminal $ prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/Exclamation \ + --method uber.foo.v1.ExcitedAPI/Exclamation \ --data '{"value":"hello"}' -{ - "value": "hello!" -} +{"value": "hello!"} $ prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/ExclamationServerStream \ + --method uber.foo.v1.ExcitedAPI/ExclamationServerStream \ --data '{"value":"hello"}' -{ - "value": "h" -} -{ - "value": "e" -} -{ - "value": "l" -} -{ - "value": "l" -} -{ - "value": "o" -} -{ - "value": "!" -} +{"value": "h"} +{"value": "e"} +{"value": "l"} +{"value": "l"} +{"value": "o"} +{"value": "!"} $ cat input.json {"value":"hello"} @@ -324,25 +311,32 @@ $ cat input.json $ cat input.json | prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/ExclamationClientStream \ + --method uber.foo.v1.ExcitedAPI/ExclamationClientStream \ --stdin -{ - "value": "hellosalutations!" -} +{"value": "hellosalutations!"} $ cat input.json | prototool grpc example \ --address 0.0.0.0:8080 \ - --method foo.ExcitedService/ExclamationBidiStream \ + --method uber.foo.v1.ExcitedAPI/ExclamationBidiStream \ --stdin -{ - "value": "hello!" -} -{ - "value": "salutations!" -}`, +{"value": "hello!"} +{"value": "salutations!"} + +$ prototool grpc example \ + --address 0.0.0.0:8080 \ + --method uber.foo.v1.ExcitedAPI/ExclamationServerStream \ + --data '{"value":"hello"}' \ + --details +{"headers":{"content-type":["application/grpc"]}} +{"response":{"value":"h"}} +{"response":{"value":"e"}} +{"response":{"value":"l"}} +{"response":{"value":"l"}} +{"response":{"value":"o"}} +{"response":{"value":"!"}}`, Args: cobra.MaximumNArgs(1), Run: func(runner exec.Runner, args []string, flags *flags) error { - return runner.GRPC(args, flags.headers, flags.address, flags.method, flags.data, flags.callTimeout, flags.connectTimeout, flags.keepaliveTime, flags.stdin) + return runner.GRPC(args, flags.headers, flags.address, flags.method, flags.data, flags.callTimeout, flags.connectTimeout, flags.keepaliveTime, flags.stdin, flags.details) }, BindFlags: func(flagSet *pflag.FlagSet, flags *flags) { flags.bindCachePath(flagSet) @@ -351,6 +345,7 @@ $ cat input.json | prototool grpc example \ flags.bindCallTimeout(flagSet) flags.bindConnectTimeout(flagSet) flags.bindData(flagSet) + flags.bindDetails(flagSet) flags.bindErrorFormat(flagSet) flags.bindHeaders(flagSet) flags.bindKeepaliveTime(flagSet) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 931dbe18..3fea6493 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -56,7 +56,7 @@ type Runner interface { Lint(args []string, listAllLinters bool, listLinters bool, listAllLintGroups bool, listLintGroup string, diffLintGroups string) error Format(args []string, overwrite, diffMode, lintMode, fix bool) error All(args []string, disableFormat, disableLint, fix bool) error - GRPC(args, headers []string, address, method, data, callTimeout, connectTimeout, keepaliveTime string, stdin bool) error + GRPC(args, headers []string, address, method, data, callTimeout, connectTimeout, keepaliveTime string, stdin bool, details bool) error InspectPackages(args []string) error InspectPackageDeps(args []string, name string) error InspectPackageImporters(args []string, name string) error diff --git a/internal/exec/runner.go b/internal/exec/runner.go index ebc8638c..a9ad8b93 100644 --- a/internal/exec/runner.go +++ b/internal/exec/runner.go @@ -498,7 +498,7 @@ func (r *runner) All(args []string, disableFormat, disableLint, fixFlag bool) er return nil } -func (r *runner) GRPC(args, headers []string, address, method, data, callTimeout, connectTimeout, keepaliveTime string, stdin bool) error { +func (r *runner) GRPC(args, headers []string, address, method, data, callTimeout, connectTimeout, keepaliveTime string, stdin bool, details bool) error { if address == "" { return newExitErrorf(255, "must set address") } @@ -561,6 +561,7 @@ func (r *runner) GRPC(args, headers []string, address, method, data, callTimeout parsedCallTimeout, parsedConnectTimeout, parsedKeepaliveTime, + details, ).Invoke(fileDescriptorSets.Unwrap(), address, method, reader, r.output) } @@ -843,6 +844,7 @@ func (r *runner) newGRPCHandler( callTimeout time.Duration, connectTimeout time.Duration, keepaliveTime time.Duration, + details bool, ) grpc.Handler { handlerOptions := []grpc.HandlerOption{ grpc.HandlerWithLogger(r.logger), @@ -859,6 +861,9 @@ func (r *runner) newGRPCHandler( if keepaliveTime != 0 { handlerOptions = append(handlerOptions, grpc.HandlerWithKeepaliveTime(keepaliveTime)) } + if details { + handlerOptions = append(handlerOptions, grpc.HandlerWithDetails()) + } return grpc.NewHandler(handlerOptions...) } diff --git a/internal/grpc/grpc.go b/internal/grpc/grpc.go index eae5fb0d..5ac0ebbf 100644 --- a/internal/grpc/grpc.go +++ b/internal/grpc/grpc.go @@ -53,6 +53,16 @@ func HandlerWithLogger(logger *zap.Logger) HandlerOption { } } +// HandlerWithDetails returns a HandlerOption that outputs responses +// in a structured JSON message that includes headers, trailers, and statuses. +// +// The default is to just print the responses. +func HandlerWithDetails() HandlerOption { + return func(handler *handler) { + handler.details = true + } +} + // HandlerWithCallTimeout returns a HandlerOption that has the given call timeout. // // Each invocation must be completed within this time. diff --git a/internal/grpc/handler.go b/internal/grpc/handler.go index 4a8d4919..0d4ff5b9 100644 --- a/internal/grpc/handler.go +++ b/internal/grpc/handler.go @@ -45,6 +45,7 @@ type handler struct { connectTimeout time.Duration keepaliveTime time.Duration headers []string + details bool } func newHandler(options ...HandlerOption) *handler { @@ -73,7 +74,7 @@ func (h *handler) Invoke(fileDescriptorSets []*descriptor.FileDescriptorSet, add return err } defer func() { _ = clientConn.Close() }() - invocationEventHandler := newInvocationEventHandler(outputWriter, h.logger) + invocationEventHandler := newInvocationEventHandler(outputWriter, h.logger, h.details) ctx, cancel := context.WithTimeout(context.Background(), h.callTimeout) defer cancel() if err := grpcurl.InvokeRPC( diff --git a/internal/grpc/invocation_event_handler.go b/internal/grpc/invocation_event_handler.go index 73f5c949..18268bee 100644 --- a/internal/grpc/invocation_event_handler.go +++ b/internal/grpc/invocation_event_handler.go @@ -21,6 +21,7 @@ package grpc import ( + "encoding/json" "io" "github.com/fullstorydev/grpcurl" @@ -32,20 +33,22 @@ import ( "google.golang.org/grpc/status" ) -var jsonpbMarshaler = &jsonpb.Marshaler{Indent: " "} +var jsonpbMarshaler = &jsonpb.Marshaler{} var _ grpcurl.InvocationEventHandler = &invocationEventHandler{} type invocationEventHandler struct { - output io.Writer - logger *zap.Logger - err error + output io.Writer + logger *zap.Logger + details bool + err error } -func newInvocationEventHandler(output io.Writer, logger *zap.Logger) *invocationEventHandler { +func newInvocationEventHandler(output io.Writer, logger *zap.Logger, details bool) *invocationEventHandler { return &invocationEventHandler{ - output: output, - logger: logger, + output: output, + logger: logger, + details: details, } } @@ -53,27 +56,66 @@ func (i *invocationEventHandler) OnResolveMethod(*desc.MethodDescriptor) {} func (i *invocationEventHandler) OnSendHeaders(metadata.MD) {} -func (i *invocationEventHandler) OnReceiveHeaders(metadata.MD) {} +func (i *invocationEventHandler) OnReceiveHeaders(headers metadata.MD) { + if !i.details { + return + } + i.printMetadata(headers, "headers") +} func (i *invocationEventHandler) OnReceiveResponse(message proto.Message) { - i.println(i.marshal(message)) + if !i.details { + i.printProtoMessage(message, "") + return + } + i.printProtoMessage(message, "response") } -func (i *invocationEventHandler) OnReceiveTrailers(s *status.Status, _ metadata.MD) { +func (i *invocationEventHandler) OnReceiveTrailers(s *status.Status, trailers metadata.MD) { if err := s.Err(); err != nil { i.err = err } + if !i.details { + return + } + i.printProtoMessage(s.Proto(), "status") + i.printMetadata(trailers, "trailers") } func (i *invocationEventHandler) Err() error { return i.err } -func (i *invocationEventHandler) marshal(message proto.Message) string { - s, err := jsonpbMarshaler.MarshalToString(message) +func (i *invocationEventHandler) printProtoMessage(input proto.Message, detailsKey string) { + if input == nil { + return + } + s, err := jsonpbMarshaler.MarshalToString(input) + if err != nil { + i.logger.Error("marshal error", zap.Error(err)) + return + } + i.println(i.marshalSanitize(s, detailsKey)) +} + +func (i *invocationEventHandler) printMetadata(input metadata.MD, detailsKey string) { + if len(input) == 0 { + return + } + data, err := json.Marshal(input) if err != nil { i.logger.Error("marshal error", zap.Error(err)) - return "" + return + } + i.println(i.marshalSanitize(string(data), detailsKey)) +} + +func (i *invocationEventHandler) marshalSanitize(s string, detailsKey string) string { + if s == "{}" { + s = "" + } + if i.details && detailsKey != "" && s != "" { + return `{"` + detailsKey + `":` + s + `}` } return s }