Skip to content

Commit

Permalink
feat(relay-proxy): Add the possibility to specify in the evaluation c…
Browse files Browse the repository at this point in the history
…ontext which flag to evaluate in bulk evaluation flags (#2171)
  • Loading branch information
thomaspoignant committed Aug 16, 2024
1 parent 74dbfe5 commit ea9cbe2
Show file tree
Hide file tree
Showing 24 changed files with 839 additions and 280 deletions.
11 changes: 10 additions & 1 deletion cmd/relayproxy/controller/all_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package controller

import (
"github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config"
"github.com/thomaspoignant/go-feature-flag/internal/flagstate"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"net/http"
Expand Down Expand Up @@ -59,7 +60,15 @@ func (h *allFlags) Handler(c echo.Context) error {
tracer := otel.GetTracerProvider().Tracer(config.OtelTracerName)
_, span := tracer.Start(c.Request().Context(), "AllFlagsState")
defer span.End()
allFlags := h.goFF.AllFlagsState(evaluationCtx)

var allFlags flagstate.AllFlags
if len(evaluationCtx.ExtractGOFFProtectedFields().FlagList) > 0 {
// if we have a list of flags to evaluate in the evaluation context, we evaluate only those flags.
allFlags = h.goFF.GetFlagStates(evaluationCtx, evaluationCtx.ExtractGOFFProtectedFields().FlagList)
} else {
allFlags = h.goFF.AllFlagsState(evaluationCtx)
}

span.SetAttributes(
attribute.Bool("AllFlagsState.valid", allFlags.IsValid()),
attribute.Int("AllFlagsState.numberEvaluation", len(allFlags.GetFlags())),
Expand Down
11 changes: 11 additions & 0 deletions cmd/relayproxy/controller/all_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ func Test_all_flag_Handler(t *testing.T) {
errorCode: http.StatusBadRequest,
},
},
{
name: "specify flags in evaluation context",
args: args{
bodyFile: "../testdata/controller/all_flags/valid_request_specify_flags.json",
configFlagsLocation: configFlagsLocation,
},
want: want{
httpCode: http.StatusOK,
bodyFile: "../testdata/controller/all_flags/valid_response_specify_flags.json",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
9 changes: 8 additions & 1 deletion cmd/relayproxy/ofrep/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/model"
"github.com/thomaspoignant/go-feature-flag/ffcontext"
"github.com/thomaspoignant/go-feature-flag/internal/flag"
"github.com/thomaspoignant/go-feature-flag/internal/flagstate"
"github.com/thomaspoignant/go-feature-flag/internal/utils"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
Expand Down Expand Up @@ -173,7 +174,13 @@ func (h *EvaluateCtrl) BulkEvaluate(c echo.Context) error {
_, span := tracer.Start(c.Request().Context(), "AllFlagsState")
defer span.End()

allFlagsResp := h.goFF.AllFlagsState(evalCtx)
var allFlagsResp flagstate.AllFlags
if len(evalCtx.ExtractGOFFProtectedFields().FlagList) > 0 {
// if we have a list of flags to evaluate in the evaluation context, we evaluate only those flags.
allFlagsResp = h.goFF.GetFlagStates(evalCtx, evalCtx.ExtractGOFFProtectedFields().FlagList)
} else {
allFlagsResp = h.goFF.AllFlagsState(evalCtx)
}
for key, val := range allFlagsResp.GetFlags() {
value := val.Value
if val.Reason == flag.ReasonError {
Expand Down
11 changes: 11 additions & 0 deletions cmd/relayproxy/ofrep/evaluate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ func Test_Bulk_Evaluation(t *testing.T) {
bodyFile: "../testdata/ofrep/responses/valid_response.json",
},
},
{
name: "specify flag list in context",
args: args{
bodyFile: "../testdata/ofrep/valid_request_specify_flags.json",
configFlagsLocation: configFlagsLocation,
},
want: want{
httpCode: http.StatusOK,
bodyFile: "../testdata/ofrep/responses/valid_response_specify_flags.json",
},
},
{
name: "Invalid context",
args: args{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"user": {
"key": "a20b1cd5-7165-4e02-a279-c0c8b90a8912",
"anonymous": false,
"custom": {
"custom1": "value1",
"custom2": "value2",
"gofeatureflag":{
"flagList":["array-flag", "flag-only-for-admin"]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"flags": {
"array-flag": {
"value": [
"batmanDefault",
"supermanDefault",
"superherosDefault"
],
"timestamp": 1652273630,
"variationType": "Default",
"trackEvents": true,
"errorCode": "",
"reason": "DEFAULT"
},
"flag-only-for-admin": {
"value": false,
"timestamp": 1652273630,
"variationType": "Default",
"trackEvents": true,
"errorCode": "",
"reason": "DEFAULT"
}
},
"valid": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"flags": [
{
"key": "array-flag",
"value": [
"batmanDefault",
"supermanDefault",
"superherosDefault"
],
"reason": "DEFAULT",
"variant": "Default"
},
{
"key": "flag-only-for-admin",
"value": false,
"reason": "DEFAULT",
"variant": "Default"
}
]
}
11 changes: 11 additions & 0 deletions cmd/relayproxy/testdata/ofrep/valid_request_specify_flags.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"context": {
"company": "GO Feature Flag",
"firstname": "John",
"lastname": "Doe",
"targetingKey": "4f433951-4c8c-42b3-9f18-8c9a5ed8e9eb",
"gofeatureflag": {
"flagList": ["array-flag", "flag-only-for-admin"]
}
}
}
23 changes: 4 additions & 19 deletions ffcontext/context.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package ffcontext

import (
"time"
)

type Context interface {
// GetKey return the unique key for the context.
GetKey() string
Expand All @@ -17,10 +13,6 @@ type Context interface {
ExtractGOFFProtectedFields() GoffContextSpecifics
}

type GoffContextSpecifics struct {
CurrentDateTime *time.Time `json:"currentDateTime"`
}

// value is a type to define custom attribute.
type value map[string]interface{}

Expand Down Expand Up @@ -85,20 +77,13 @@ func (u EvaluationContext) AddCustomAttribute(name string, value interface{}) {
// ExtractGOFFProtectedFields extract the goff specific attributes from the evaluation context.
func (u EvaluationContext) ExtractGOFFProtectedFields() GoffContextSpecifics {
goff := GoffContextSpecifics{}

switch v := u.custom["gofeatureflag"].(type) {
case map[string]string:
if currentDateTimeStr, ok := v["currentDateTime"]; ok {
if currentDateTime, err := time.ParseInLocation(time.RFC3339, currentDateTimeStr, time.Local); err == nil {
goff.CurrentDateTime = &currentDateTime
}
}
goff.addCurrentDateTime(v["currentDateTime"])
goff.addListFlags(v["flagList"])
case map[string]interface{}:
if currentDateTimeStr, ok := v["currentDateTime"].(string); ok {
if currentDateTime, err := time.ParseInLocation(time.RFC3339, currentDateTimeStr, time.Local); err == nil {
goff.CurrentDateTime = &currentDateTime
}
}
goff.addCurrentDateTime(v["currentDateTime"])
goff.addListFlags(v["flagList"])
case GoffContextSpecifics:
return v
}
Expand Down
49 changes: 49 additions & 0 deletions ffcontext/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ func Test_ExtractGOFFProtectedFields(t *testing.T) {
CurrentDateTime: testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
},
},
{
name: "context goff specifics as map[string]interface and date as time.Time",
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", map[string]interface{}{
"currentDateTime": time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC),
}).Build(),
want: ffcontext.GoffContextSpecifics{
CurrentDateTime: testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
},
},
{
name: "context goff specifics as map[string]interface and date as *time.Time",
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", map[string]interface{}{
"currentDateTime": testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
}).Build(),
want: ffcontext.GoffContextSpecifics{
CurrentDateTime: testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
},
},
{
name: "context goff specifics as map[string]interface",
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", map[string]interface{}{
Expand Down Expand Up @@ -92,6 +110,37 @@ func Test_ExtractGOFFProtectedFields(t *testing.T) {
CurrentDateTime: testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
},
},
{
name: "context goff specifics as GoffContextSpecifics type contains flagList",
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", ffcontext.GoffContextSpecifics{
CurrentDateTime: testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
FlagList: []string{"flag1", "flag2"},
}).Build(),
want: ffcontext.GoffContextSpecifics{
CurrentDateTime: testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
FlagList: []string{"flag1", "flag2"},
},
},
{
name: "context goff specifics as map[string]interface type contains flagList",
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", map[string]interface{}{
"currentDateTime": testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)).Format(time.RFC3339),
"flagList": []string{"flag1", "flag2"},
}).Build(),
want: ffcontext.GoffContextSpecifics{
CurrentDateTime: testconvert.Time(time.Date(2022, 8, 1, 0, 0, 10, 0, time.UTC)),
FlagList: []string{"flag1", "flag2"},
},
},
{
name: "context goff specifics only flagList",
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", map[string]interface{}{
"flagList": []string{"flag1", "flag2"},
}).Build(),
want: ffcontext.GoffContextSpecifics{
FlagList: []string{"flag1", "flag2"},
},
},
}

for _, tt := range tests {
Expand Down
42 changes: 42 additions & 0 deletions ffcontext/goff_context_specifics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package ffcontext

import "time"

type GoffContextSpecifics struct {
// CurrentDateTime is the current date time to use for the evaluation.
CurrentDateTime *time.Time `json:"currentDateTime"`
// FlagList is the list of flags to evaluate in a bulk evaluation.
FlagList []string `json:"flagList"`
}

// addCurrentDateTime adds the current date time to the context.
// This function formats the current date time to RFC3339 format.
func (g *GoffContextSpecifics) addCurrentDateTime(currentDateTime any) {
switch value := currentDateTime.(type) {
case *time.Time:
g.CurrentDateTime = value
case time.Time:
g.CurrentDateTime = &value
case string:
if currentDateTime, err := time.ParseInLocation(time.RFC3339, value, time.Local); err == nil {
g.CurrentDateTime = &currentDateTime
}
return
default:
return
}
}

// addListFlags adds the list of flags to evaluate in a bulk evaluation.
func (g *GoffContextSpecifics) addListFlags(flagList any) {
if value, ok := flagList.([]string); ok {
g.FlagList = value
}
if value, ok := flagList.([]interface{}); ok {
for _, val := range value {
if valAsString, ok := val.(string); ok {
g.FlagList = append(g.FlagList, valAsString)
}
}
}
}
47 changes: 47 additions & 0 deletions internal/flagstate/flag_state.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package flagstate

import (
"github.com/thomaspoignant/go-feature-flag/ffcontext"
"github.com/thomaspoignant/go-feature-flag/internal/flag"
"time"
)

// FlagState represents the state of an individual feature flag, with regard to a specific user, when it was called.
Expand All @@ -15,3 +17,48 @@ type FlagState struct {
Reason flag.ResolutionReason `json:"reason"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}

func FromFlagEvaluation(key string, evaluationCtx ffcontext.Context,
flagCtx flag.Context, currentFlag flag.Flag) FlagState {
flagValue, resolutionDetails := currentFlag.Value(key, evaluationCtx, flagCtx)

// if the flag is disabled, we are ignoring it.
if resolutionDetails.Reason == flag.ReasonDisabled {
return FlagState{
Timestamp: time.Now().Unix(),
TrackEvents: currentFlag.IsTrackEvents(),
Failed: resolutionDetails.ErrorCode != "",
ErrorCode: resolutionDetails.ErrorCode,
Reason: resolutionDetails.Reason,
Metadata: resolutionDetails.Metadata,
}
}

switch v := flagValue; v.(type) {
case int, float64, bool, string, []interface{}, map[string]interface{}:
return FlagState{
Value: v,
Timestamp: time.Now().Unix(),
VariationType: resolutionDetails.Variant,
TrackEvents: currentFlag.IsTrackEvents(),
Failed: resolutionDetails.ErrorCode != "",
ErrorCode: resolutionDetails.ErrorCode,
Reason: resolutionDetails.Reason,
Metadata: resolutionDetails.Metadata,
}

default:
defaultVariationName := flag.VariationSDKDefault
defaultVariationValue := currentFlag.GetVariationValue(defaultVariationName)
return FlagState{
Value: defaultVariationValue,
Timestamp: time.Now().Unix(),
VariationType: defaultVariationName,
TrackEvents: currentFlag.IsTrackEvents(),
Failed: true,
ErrorCode: flag.ErrorCodeTypeMismatch,
Reason: flag.ReasonError,
Metadata: resolutionDetails.Metadata,
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ describe('Provider tests', () => {
});
goffClient = OpenFeature.getClient('my-app')
await OpenFeature.setProviderAndWait('my-app', goFeatureFlagProvider);
OpenFeature.setContext({
gofeatureflag:{
flagList: ["flag1", "flag2"]
}
})

userCtx = {
targetingKey: 'd45e303a-38c2-11ed-a261-0242ac120002', // user unique identifier (mandatory)
Expand Down
Loading

0 comments on commit ea9cbe2

Please sign in to comment.