Skip to content
Open
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
36 changes: 30 additions & 6 deletions grpc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,34 @@ const (
ClientTypeMetadataValue = "aggkit"
)

// VersionHeaderInterceptor adds the client version and type headers to all outgoing requests
func VersionHeaderInterceptor() grpc.UnaryClientInterceptor {
// mergeHeaders overlays the configured headers on top of the built-in defaults.
// Configured values win, so x-client-version / x-client-type can be overridden.
// Keys are normalized to lowercase to match gRPC metadata semantics, so a
// mixed-case override (e.g. "X-Client-Type") replaces the default instead of
// adding a duplicate entry.
func mergeHeaders(extra map[string]string) map[string]string {
merged := map[string]string{
ClientVersionMetadataKey: aggkit.Version,
ClientTypeMetadataKey: ClientTypeMetadataValue,
}
for k, v := range extra {
merged[strings.ToLower(k)] = v
}
return merged
}

// HeaderInterceptor returns a unary client interceptor that appends the given
// metadata headers to every outgoing request. Callers build the header set with
// mergeHeaders, which combines the built-in client version/type defaults with
// any per-client overrides.
func HeaderInterceptor(headers map[string]string) grpc.UnaryClientInterceptor {
return func(
ctx context.Context,
method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
// Add version and type headers to context
ctx = metadata.AppendToOutgoingContext(ctx, ClientVersionMetadataKey, aggkit.Version)
ctx = metadata.AppendToOutgoingContext(ctx, ClientTypeMetadataKey, ClientTypeMetadataValue)
for k, v := range headers {
ctx = metadata.AppendToOutgoingContext(ctx, k, v)
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
Expand All @@ -71,6 +90,11 @@ type ClientConfig struct {

// Retry represents the retry configuration
Retry *RetryConfig `mapstructure:"Retry"`

// Headers are static metadata headers appended to every outgoing request on
// this client. Configured per-client; empty means none. Configured keys
// x-client-version / x-client-type override the built-in defaults.
Headers map[string]string `mapstructure:"Headers"`
}

// WithURL returns a copy of the current ClientConfig with the URL field set to the given value.
Expand Down Expand Up @@ -265,7 +289,7 @@ func NewClient(cfg *ClientConfig) (*Client, error) {
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithConnectParams(connectParams),
grpc.WithUnaryInterceptor(VersionHeaderInterceptor()),
grpc.WithUnaryInterceptor(HeaderInterceptor(mergeHeaders(cfg.Headers))),
}

serviceCfgJSON, err := createServiceConfig(retryCfg)
Expand Down
110 changes: 104 additions & 6 deletions grpc/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ func TestValidateRequestTimeout(t *testing.T) {
}
}

func TestVersionHeaderInterceptor(t *testing.T) {
func TestHeaderInterceptor(t *testing.T) {
testMethod := "/test.Service/TestMethod"

t.Run("AddsVersionAndClientTypeHeadersToContext", func(t *testing.T) {
Expand All @@ -539,7 +539,7 @@ func TestVersionHeaderInterceptor(t *testing.T) {
}

// Create the interceptor
interceptor := VersionHeaderInterceptor()
interceptor := HeaderInterceptor(mergeHeaders(nil))

// Create a test context
ctx := context.Background()
Expand Down Expand Up @@ -579,7 +579,7 @@ func TestVersionHeaderInterceptor(t *testing.T) {
}

// Create the interceptor
interceptor := VersionHeaderInterceptor()
interceptor := HeaderInterceptor(mergeHeaders(nil))

// Create a test context with existing metadata
existingMD := metadata.New(map[string]string{
Expand Down Expand Up @@ -631,7 +631,7 @@ func TestVersionHeaderInterceptor(t *testing.T) {
}

// Create the interceptor
interceptor := VersionHeaderInterceptor()
interceptor := HeaderInterceptor(mergeHeaders(nil))

// Create a test context
ctx := context.Background()
Expand All @@ -655,7 +655,7 @@ func TestVersionHeaderInterceptor(t *testing.T) {
}

// Create the interceptor
interceptor := VersionHeaderInterceptor()
interceptor := HeaderInterceptor(mergeHeaders(nil))

// Create a test context
ctx := context.Background()
Expand Down Expand Up @@ -692,7 +692,7 @@ func TestVersionHeaderInterceptor(t *testing.T) {
}

// Create the interceptor
interceptor := VersionHeaderInterceptor()
interceptor := HeaderInterceptor(mergeHeaders(nil))

// Create a test context
ctx := context.Background()
Expand Down Expand Up @@ -721,4 +721,102 @@ func TestVersionHeaderInterceptor(t *testing.T) {
require.Len(t, clientTypeValues, 1, "Should have exactly one client type header")
require.Equal(t, ClientTypeMetadataValue, clientTypeValues[0], "Client type should match ClientTypeMetadataValue")
})

t.Run("AppliesOverriddenHeaderExactlyOnce", func(t *testing.T) {
var capturedCtx context.Context
mockInvoker := func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error {
capturedCtx = ctx
return nil
}

interceptor := HeaderInterceptor(mergeHeaders(map[string]string{ClientTypeMetadataKey: "aggkit-aggsender"}))

var req, reply interface{}
var cc *grpc.ClientConn
err := interceptor(context.Background(), testMethod, req, reply, cc, mockInvoker)
require.NoError(t, err)

md, ok := metadata.FromOutgoingContext(capturedCtx)
require.True(t, ok, "Context should contain outgoing metadata")

clientTypeValues := md.Get(ClientTypeMetadataKey)
require.Len(t, clientTypeValues, 1, "Overridden header must appear exactly once, not duplicated")
require.Equal(t, "aggkit-aggsender", clientTypeValues[0])

// The non-overridden default must remain intact.
require.Equal(t, []string{aggkit.Version}, md.Get(ClientVersionMetadataKey))
})

t.Run("AppliesArbitraryConfiguredHeader", func(t *testing.T) {
var capturedCtx context.Context
mockInvoker := func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error {
capturedCtx = ctx
return nil
}

interceptor := HeaderInterceptor(mergeHeaders(map[string]string{"x-trace-source": "obs"}))

var req, reply interface{}
var cc *grpc.ClientConn
err := interceptor(context.Background(), testMethod, req, reply, cc, mockInvoker)
require.NoError(t, err)

md, ok := metadata.FromOutgoingContext(capturedCtx)
require.True(t, ok, "Context should contain outgoing metadata")

traceValues := md.Get("x-trace-source")
require.Len(t, traceValues, 1, "Should have exactly one custom header")
require.Equal(t, "obs", traceValues[0])

// Defaults still present alongside the custom header.
require.Equal(t, []string{aggkit.Version}, md.Get(ClientVersionMetadataKey))
require.Equal(t, []string{ClientTypeMetadataValue}, md.Get(ClientTypeMetadataKey))
})
}

func TestMergeHeaders(t *testing.T) {
t.Run("NilReturnsDefaults", func(t *testing.T) {
got := mergeHeaders(nil)
require.Len(t, got, 2)
require.Equal(t, aggkit.Version, got[ClientVersionMetadataKey])
require.Equal(t, ClientTypeMetadataValue, got[ClientTypeMetadataKey])
})

t.Run("EmptyReturnsDefaults", func(t *testing.T) {
got := mergeHeaders(map[string]string{})
require.Len(t, got, 2)
require.Equal(t, aggkit.Version, got[ClientVersionMetadataKey])
require.Equal(t, ClientTypeMetadataValue, got[ClientTypeMetadataKey])
})

t.Run("OverridesClientTypeOnly", func(t *testing.T) {
got := mergeHeaders(map[string]string{ClientTypeMetadataKey: "aggkit-aggsender"})
require.Len(t, got, 2)
require.Equal(t, "aggkit-aggsender", got[ClientTypeMetadataKey])
require.Equal(t, aggkit.Version, got[ClientVersionMetadataKey], "version should keep its default")
})

t.Run("OverridesBothDefaults", func(t *testing.T) {
got := mergeHeaders(map[string]string{
ClientTypeMetadataKey: "custom-type",
ClientVersionMetadataKey: "v9.9.9",
})
require.Len(t, got, 2)
require.Equal(t, "custom-type", got[ClientTypeMetadataKey])
require.Equal(t, "v9.9.9", got[ClientVersionMetadataKey])
})

t.Run("AddsArbitraryHeaderAlongsideDefaults", func(t *testing.T) {
got := mergeHeaders(map[string]string{"x-trace-source": "obs"})
require.Len(t, got, 3)
require.Equal(t, "obs", got["x-trace-source"])
require.Equal(t, aggkit.Version, got[ClientVersionMetadataKey])
require.Equal(t, ClientTypeMetadataValue, got[ClientTypeMetadataKey])
})

t.Run("NormalizesKeyCaseForOverride", func(t *testing.T) {
got := mergeHeaders(map[string]string{"X-Client-Type": "cased"})
require.Len(t, got, 2, "mixed-case override must not create a duplicate entry")
require.Equal(t, "cased", got[ClientTypeMetadataKey])
})
}
Loading