Skip to content

Commit c093e06

Browse files
committed
add support for grpc metadata headers
add -H arrayString flag
1 parent a9c9ca3 commit c093e06

5 files changed

Lines changed: 67 additions & 4 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@
1212

1313
![Streaming call demo](demos/streaming.gif)
1414

15+
### Request metadata
16+
17+
Use `-H` or `--header` multiple times to send request metadata. Headers are
18+
included in both server reflection and RPC requests.
19+
20+
```bash
21+
grpcexp --addr api.example.com:443 --tls \
22+
-H "authorization: Bearer ${TOKEN}" \
23+
-H "x-tenant-id: acme"
24+
```
25+
1526
## Installation
1627

1728
### Linux or MacOS

internal/cli/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
port int
2121
addr string
2222
protoset string
23+
headers []string
2324
useTLS bool
2425
timeout time.Duration
2526
)
@@ -54,6 +55,7 @@ func run(cmd *cobra.Command, args []string) error {
5455
Creds: creds,
5556
UserAgent: "grpcexp/" + strings.TrimSpace(version),
5657
Protoset: protoset,
58+
Headers: headers,
5759
})
5860
if err != nil {
5961
return err
@@ -83,6 +85,7 @@ func init() {
8385
rootCmd.Flags().IntVarP(&port, "port", "p", 50051, "grpc server port")
8486
rootCmd.Flags().StringVarP(&addr, "addr", "a", "", "grpc server address")
8587
rootCmd.Flags().StringVar(&protoset, "protoset", "", "path to protoset file (uses server reflection if not specified)")
88+
rootCmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "additional header in 'name: value' format (may be repeated)")
8689
rootCmd.Flags().BoolVar(&useTLS, "tls", false, "use TLS to connect to the server")
8790
rootCmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "connection timeout")
8891
}

internal/cli/version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.0.13
1+
0.0.14

internal/grpc/client.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"google.golang.org/grpc"
1818
"google.golang.org/grpc/codes"
1919
"google.golang.org/grpc/credentials"
20+
"google.golang.org/grpc/metadata"
2021
"google.golang.org/protobuf/reflect/protoreflect"
2122
)
2223

@@ -25,6 +26,7 @@ type Config struct {
2526
Creds credentials.TransportCredentials
2627
UserAgent string
2728
Protoset string
29+
Headers []string
2830
}
2931

3032
type Client struct {
@@ -34,6 +36,10 @@ type Client struct {
3436
}
3537

3638
func NewClient(ctx context.Context, config Config) (*Client, error) {
39+
if err := validateHeaders(config.Headers); err != nil {
40+
return nil, err
41+
}
42+
3743
opts := []grpc.DialOption{
3844
grpc.WithUserAgent(config.UserAgent),
3945
}
@@ -50,6 +56,9 @@ func NewClient(ctx context.Context, config Config) (*Client, error) {
5056
}
5157
} else {
5258
refCtx := context.Background()
59+
if len(config.Headers) > 0 {
60+
refCtx = metadata.NewOutgoingContext(refCtx, grpcurl.MetadataFromHeaders(config.Headers))
61+
}
5362
refClient := grpcreflect.NewClientAuto(refCtx, cc)
5463
refClient.AllowMissingFileDescriptors()
5564
source = grpcurl.DescriptorSourceFromServer(refCtx, refClient)
@@ -58,6 +67,16 @@ func NewClient(ctx context.Context, config Config) (*Client, error) {
5867
return &Client{source: source, conn: cc, config: config}, nil
5968
}
6069

70+
func validateHeaders(headers []string) error {
71+
for _, header := range headers {
72+
name, _, ok := strings.Cut(header, ":")
73+
if !ok || strings.TrimSpace(name) == "" {
74+
return fmt.Errorf("invalid header %q: expected 'name: value'", header)
75+
}
76+
}
77+
return nil
78+
}
79+
6180
func (c *Client) InvokeRPC(ctx context.Context, methodFullName string, request map[string]any) (string, error) {
6281
jsonData, err := json.Marshal(request)
6382
if err != nil {
@@ -78,7 +97,7 @@ func (c *Client) InvokeRPC(ctx context.Context, methodFullName string, request m
7897
VerbosityLevel: 0,
7998
}
8099

81-
err = grpcurl.InvokeRPC(ctx, c.source, c.conn, methodFullName, nil, handler, rf.Next)
100+
err = grpcurl.InvokeRPC(ctx, c.source, c.conn, methodFullName, c.config.Headers, handler, rf.Next)
82101
if err != nil {
83102
return "", fmt.Errorf("RPC invocation failed: %w", err)
84103
}
@@ -120,7 +139,7 @@ func (c *Client) InvokeStreaming(ctx context.Context, methodFullName string, req
120139
return rf.Next(msg)
121140
}
122141

123-
if err := grpcurl.InvokeRPC(ctx, c.source, c.conn, methodFullName, nil, handler, requestSupplier); err != nil {
142+
if err := grpcurl.InvokeRPC(ctx, c.source, c.conn, methodFullName, c.config.Headers, handler, requestSupplier); err != nil {
124143
events <- StreamEvent{Kind: StreamEventError, Err: fmt.Errorf("RPC invocation failed: %w", err)}
125144
return err
126145
}
@@ -152,6 +171,9 @@ func (c *Client) GRPCURLCommand(methodFullName string, request map[string]any) (
152171
if c.config.UserAgent != "" {
153172
args = append(args, "-user-agent", c.config.UserAgent)
154173
}
174+
for _, header := range c.config.Headers {
175+
args = append(args, "-H", header)
176+
}
155177
args = append(args, "-d", string(jsonData), c.config.Target, methodFullName)
156178

157179
for i, arg := range args {

internal/grpc/client_test.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,40 @@ import (
66
"google.golang.org/grpc/credentials/insecure"
77
)
88

9+
func TestValidateHeaders(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
headers []string
13+
wantErr bool
14+
}{
15+
{name: "none"},
16+
{name: "multiple", headers: []string{"authorization: Bearer token", "x-label: value:with:colons"}},
17+
{name: "empty value", headers: []string{"x-debug:"}},
18+
{name: "missing colon", headers: []string{"authorization"}, wantErr: true},
19+
{name: "empty name", headers: []string{" : value"}, wantErr: true},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
err := validateHeaders(tt.headers)
25+
if (err != nil) != tt.wantErr {
26+
t.Fatalf("validateHeaders() error = %v, wantErr %v", err, tt.wantErr)
27+
}
28+
})
29+
}
30+
}
31+
932
func TestGRPCURLCommand(t *testing.T) {
1033
client := &Client{
1134
config: Config{
1235
Target: "localhost:50051",
1336
Creds: insecure.NewCredentials(),
1437
UserAgent: "grpcexp/test",
1538
Protoset: "api fixtures/echo.protoset",
39+
Headers: []string{
40+
"authorization: Bearer test-token",
41+
"x-request-label: it's a test",
42+
},
1643
},
1744
}
1845

@@ -23,7 +50,7 @@ func TestGRPCURLCommand(t *testing.T) {
2350
t.Fatalf("GRPCURLCommand returned error: %v", err)
2451
}
2552

26-
want := `grpcurl -plaintext -protoset 'api fixtures/echo.protoset' -user-agent grpcexp/test -d '{"message":"it'"'"'s here"}' localhost:50051 echo.v1.EchoService.Echo`
53+
want := `grpcurl -plaintext -protoset 'api fixtures/echo.protoset' -user-agent grpcexp/test -H 'authorization: Bearer test-token' -H 'x-request-label: it'"'"'s a test' -d '{"message":"it'"'"'s here"}' localhost:50051 echo.v1.EchoService.Echo`
2754
if got != want {
2855
t.Fatalf("GRPCURLCommand = %q, want %q", got, want)
2956
}

0 commit comments

Comments
 (0)