diff --git a/go.mod b/go.mod index 1cae8a28b..2c9cf881e 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20250812085011-4cf7e8485428 github.com/go-kit/log v0.2.1 github.com/golang/mock v1.6.0 + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index 88a039ef9..6df7fc4c0 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -10,6 +10,7 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/structpb" "github.com/envoyproxy/ratelimit/src/settings" "github.com/envoyproxy/ratelimit/src/stats" @@ -17,6 +18,7 @@ import ( "github.com/envoyproxy/ratelimit/src/utils" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + ratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" logger "github.com/sirupsen/logrus" "golang.org/x/net/context" @@ -38,18 +40,19 @@ type RateLimitServiceServer interface { } type service struct { - configLock sync.RWMutex - configUpdateEvent <-chan provider.ConfigUpdateEvent - config config.RateLimitConfig - cache limiter.RateLimitCache - stats stats.ServiceStats - health *server.HealthChecker - customHeadersEnabled bool - customHeaderLimitHeader string - customHeaderRemainingHeader string - customHeaderResetHeader string - customHeaderClock utils.TimeSource - globalShadowMode bool + configLock sync.RWMutex + configUpdateEvent <-chan provider.ConfigUpdateEvent + config config.RateLimitConfig + cache limiter.RateLimitCache + stats stats.ServiceStats + health *server.HealthChecker + customHeadersEnabled bool + customHeaderLimitHeader string + customHeaderRemainingHeader string + customHeaderResetHeader string + customHeaderClock utils.TimeSource + globalShadowMode bool + responseDynamicMetadataEnabled bool } func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWithAtLeastOneConfigLoad bool) { @@ -84,6 +87,7 @@ func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWi rlSettings := settings.NewSettings() this.globalShadowMode = rlSettings.GlobalShadowMode + this.responseDynamicMetadataEnabled = rlSettings.ResponseDynamicMetadata if rlSettings.RateLimitResponseHeadersEnabled { this.customHeadersEnabled = true @@ -239,10 +243,72 @@ func (this *service) shouldRateLimitWorker( this.stats.GlobalShadowMode.Inc() } + // If response dynamic data enabled, set dynamic data on response. + if this.responseDynamicMetadataEnabled { + response.DynamicMetadata = ratelimitToMetadata(request) + } + response.OverallCode = finalCode return response } +func ratelimitToMetadata(req *pb.RateLimitRequest) *structpb.Struct { + fields := make(map[string]*structpb.Value) + + // Domain + fields["domain"] = structpb.NewStringValue(req.Domain) + + // Descriptors + descriptorsValues := make([]*structpb.Value, 0, len(req.Descriptors)) + for _, descriptor := range req.Descriptors { + s := descriptorToStruct(descriptor) + if s == nil { + continue + } + descriptorsValues = append(descriptorsValues, structpb.NewStructValue(s)) + } + fields["descriptors"] = structpb.NewListValue(&structpb.ListValue{ + Values: descriptorsValues, + }) + + // HitsAddend + if hitsAddend := req.GetHitsAddend(); hitsAddend != 0 { + fields["hitsAddend"] = structpb.NewNumberValue(float64(hitsAddend)) + } + + return &structpb.Struct{Fields: fields} +} + +func descriptorToStruct(descriptor *ratelimitv3.RateLimitDescriptor) *structpb.Struct { + if descriptor == nil { + return nil + } + + fields := make(map[string]*structpb.Value) + + // Entries + entriesValues := make([]*structpb.Value, 0, len(descriptor.Entries)) + for _, entry := range descriptor.Entries { + val := fmt.Sprintf("%s=%s", entry.GetKey(), entry.GetValue()) + entriesValues = append(entriesValues, structpb.NewStringValue(val)) + } + fields["entries"] = structpb.NewListValue(&structpb.ListValue{ + Values: entriesValues, + }) + + // Limit + if descriptor.GetLimit() != nil { + fields["limit"] = structpb.NewStringValue(descriptor.Limit.String()) + } + + // HitsAddend + if hitsAddend := descriptor.GetHitsAddend(); hitsAddend != nil { + fields["hitsAddend"] = structpb.NewNumberValue(float64(hitsAddend.GetValue())) + } + + return &structpb.Struct{Fields: fields} +} + func (this *service) rateLimitLimitHeader(descriptor *pb.RateLimitResponse_DescriptorStatus) *core.HeaderValue { // Limit header only provides the mandatory part from the spec, the actual limit // the optional quota policy is currently not provided diff --git a/src/service/ratelimit_test.go b/src/service/ratelimit_test.go new file mode 100644 index 000000000..8cd4f2ee3 --- /dev/null +++ b/src/service/ratelimit_test.go @@ -0,0 +1,61 @@ +package ratelimit + +import ( + "testing" + + ratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" + pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestRatelimitToMetadata(t *testing.T) { + cases := []struct { + name string + req *pb.RateLimitRequest + expected string + }{ + { + name: "Single descriptor with single entry", + req: &pb.RateLimitRequest{ + Domain: "fake-domain", + Descriptors: []*ratelimitv3.RateLimitDescriptor{ + { + Entries: []*ratelimitv3.RateLimitDescriptor_Entry{ + { + Key: "key1", + Value: "val1", + }, + }, + }, + }, + }, + expected: `{ + "descriptors": [ + { + "entries": [ + "key1=val1" + ] + } + ], + "domain": "fake-domain" +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ratelimitToMetadata(tc.req) + expected := &structpb.Struct{} + err := protojson.Unmarshal([]byte(tc.expected), expected) + require.NoError(t, err) + + if diff := cmp.Diff(got, expected, protocmp.Transform()); diff != "" { + t.Errorf("diff: %s", diff) + } + }) + } +} diff --git a/src/settings/settings.go b/src/settings/settings.go index 2c14a478e..1d48d8715 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -202,6 +202,8 @@ type Settings struct { // Should the ratelimiting be running in Global shadow-mode, ie. never report a ratelimit status, unless a rate was provided from envoy as an override GlobalShadowMode bool `envconfig:"SHADOW_MODE" default:"false"` + ResponseDynamicMetadata bool `envconfig:"RESPONSE_DYNAMIC_METADATA" default:"false"` + // Allow merging of multiple yaml files referencing the same domain MergeDomainConfigurations bool `envconfig:"MERGE_DOMAIN_CONFIG" default:"false"`