From 496a561812829934acea6d5e2fec5c65beef8596 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Apr 2025 11:29:10 +0200 Subject: [PATCH 1/2] WIP Signed-off-by: Thomas Poignant --- Makefile | 3 + exporter/fileexporter/exporter.go | 2 +- internal/evaluation/evaluation.go | 82 ++++++++++++++++++++++++++ internal/evaluation/evaluation_test.go | 56 ++++++++++++++++++ variation.go | 65 +------------------- variation_test.go | 49 --------------- wasm/helpers/wasm.go | 23 ++++++++ wasm/main.go | 30 ++++++++++ wasm/model/evaluate_input.go | 14 +++++ 9 files changed, 211 insertions(+), 113 deletions(-) create mode 100644 internal/evaluation/evaluation.go create mode 100644 internal/evaluation/evaluation_test.go create mode 100644 wasm/helpers/wasm.go create mode 100644 wasm/main.go create mode 100644 wasm/model/evaluate_input.go diff --git a/Makefile b/Makefile index 2d6eadf412a..ab5ff950957 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,9 @@ build-editor-api: create-out-dir ## Build the linter in out/bin/ build-jsonschema-generator: create-out-dir ## Build the jsonschema-generator in out/bin/ CGO_ENABLED=0 GO111MODULE=on $(GOCMD) build -mod vendor -o out/bin/jsonschema-generator ./cmd/jsonschema-generator/ +build-wasm: + CGO_ENABLED=0 GO111MODULE=on $(GOCMD) build -mod vendor -o out/bin/evaluation ./lib/evaluation + build-doc: ## Build the documentation cd website; \ npm i && npm run build diff --git a/exporter/fileexporter/exporter.go b/exporter/fileexporter/exporter.go index cf7f59e98f9..e54d9dd7818 100644 --- a/exporter/fileexporter/exporter.go +++ b/exporter/fileexporter/exporter.go @@ -137,7 +137,7 @@ func (f *Exporter) writeFile(filePath string, events []exporter.ExportableEvent) } _, errWrite := file.Write(line) if errWrite != nil { - return fmt.Errorf("error while writing the export file: %v", err) + return fmt.Errorf("error while writing the export file: %v", errWrite) } } return nil diff --git a/internal/evaluation/evaluation.go b/internal/evaluation/evaluation.go new file mode 100644 index 00000000000..9f447633005 --- /dev/null +++ b/internal/evaluation/evaluation.go @@ -0,0 +1,82 @@ +package evaluation + +import ( + "fmt" + "github.com/thomaspoignant/go-feature-flag/ffcontext" + "github.com/thomaspoignant/go-feature-flag/internal/flag" + "github.com/thomaspoignant/go-feature-flag/model" + "maps" +) + +const errorWrongVariation = "wrong variation used for flag %v" + +func Evaluate[T model.JSONType]( + f flag.Flag, + flagKey string, + evaluationCtx ffcontext.Context, + flagCtx flag.Context, + expectedType string, + sdkDefaultValue T) (model.VariationResult[T], error) { + flagValue, resolutionDetails := f.Value(flagKey, evaluationCtx, flagCtx) + var convertedValue interface{} + switch value := flagValue.(type) { + case float64: + // this part ensures that we convert float64 value into int if we call IntVariation on a float64 value. + if expectedType == "int" { + convertedValue = int(value) + } else { + convertedValue = value + } + default: + convertedValue = value + } + + var v T + switch val := convertedValue.(type) { + case T: + v = val + default: + if val != nil { + return model.VariationResult[T]{ + Value: sdkDefaultValue, + VariationType: flag.VariationSDKDefault, + Reason: flag.ReasonError, + ErrorCode: flag.ErrorCodeTypeMismatch, + Failed: true, + TrackEvents: f.IsTrackEvents(), + Version: f.GetVersion(), + Metadata: f.GetMetadata(), + }, fmt.Errorf(errorWrongVariation, flagKey) + } + } + return model.VariationResult[T]{ + Value: v, + VariationType: resolutionDetails.Variant, + Reason: resolutionDetails.Reason, + ErrorCode: resolutionDetails.ErrorCode, + ErrorDetails: resolutionDetails.ErrorMessage, + Failed: resolutionDetails.ErrorCode != "", + TrackEvents: f.IsTrackEvents(), + Version: f.GetVersion(), + Cacheable: resolutionDetails.Cacheable, + Metadata: constructMetadata(f, resolutionDetails), + }, nil +} + +// constructMetadata is the internal generic func used to enhance model.VariationResult adding +// the targeting.rule's name (from configuration) to the Metadata. +// That way, it is possible to see when a targeting rule is match during the evaluation process. +func constructMetadata( + f flag.Flag, + resolutionDetails flag.ResolutionDetails, +) map[string]interface{} { + metadata := maps.Clone(f.GetMetadata()) + if resolutionDetails.RuleName == nil || *resolutionDetails.RuleName == "" { + return metadata + } + if metadata == nil { + metadata = make(map[string]interface{}) + } + metadata["evaluatedRuleName"] = *resolutionDetails.RuleName + return metadata +} diff --git a/internal/evaluation/evaluation_test.go b/internal/evaluation/evaluation_test.go new file mode 100644 index 00000000000..9f0b920b343 --- /dev/null +++ b/internal/evaluation/evaluation_test.go @@ -0,0 +1,56 @@ +package evaluation + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "github.com/thomaspoignant/go-feature-flag/internal/flag" + "runtime" + "testing" +) + +func Test_constructMetadataParallel(t *testing.T) { + sharedFlag := flag.InternalFlag{ + Metadata: &map[string]interface{}{ + "key1": "value1", + }, + } + + type args struct { + resolutionDetails flag.ResolutionDetails + } + var tests []struct { + name string + args args + wantEvaluatedRuleName string + } + + runtime.GOMAXPROCS(runtime.NumCPU()) + + // generate test cases + for i := 0; i < 10_000; i++ { + ruleName := fmt.Sprintf("rule-%d", i) + tests = append(tests, struct { + name string + args args + wantEvaluatedRuleName string + }{ + name: fmt.Sprintf("Rule %d", i), + args: args{ + resolutionDetails: flag.ResolutionDetails{ + RuleName: &ruleName, + }, + }, + wantEvaluatedRuleName: ruleName, + }) + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := constructMetadata(&sharedFlag, tt.args.resolutionDetails) + assert.Equal(t, tt.wantEvaluatedRuleName, got["evaluatedRuleName"]) + }) + } +} diff --git a/variation.go b/variation.go index c0d480523b8..dcbee8a7dc3 100644 --- a/variation.go +++ b/variation.go @@ -2,6 +2,7 @@ package ffclient import ( "fmt" + "github.com/thomaspoignant/go-feature-flag/internal/evaluation" "maps" "github.com/thomaspoignant/go-feature-flag/exporter" @@ -399,67 +400,5 @@ func getVariation[T model.JSONType]( if g.config.Environment != "" { flagCtx.AddIntoEvaluationContextEnrichment("env", g.config.Environment) } - flagValue, resolutionDetails := f.Value(flagKey, evaluationCtx, flagCtx) - - var convertedValue interface{} - switch value := flagValue.(type) { - case float64: - // this part ensures that we convert float64 value into int if we call IntVariation on a float64 value. - if expectedType == "int" { - convertedValue = int(value) - } else { - convertedValue = value - } - default: - convertedValue = value - } - - var v T - switch val := convertedValue.(type) { - case T: - v = val - default: - if val != nil { - return model.VariationResult[T]{ - Value: sdkDefaultValue, - VariationType: flag.VariationSDKDefault, - Reason: flag.ReasonError, - ErrorCode: flag.ErrorCodeTypeMismatch, - Failed: true, - TrackEvents: f.IsTrackEvents(), - Version: f.GetVersion(), - Metadata: f.GetMetadata(), - }, fmt.Errorf(errorWrongVariation, flagKey) - } - } - return model.VariationResult[T]{ - Value: v, - VariationType: resolutionDetails.Variant, - Reason: resolutionDetails.Reason, - ErrorCode: resolutionDetails.ErrorCode, - ErrorDetails: resolutionDetails.ErrorMessage, - Failed: resolutionDetails.ErrorCode != "", - TrackEvents: f.IsTrackEvents(), - Version: f.GetVersion(), - Cacheable: resolutionDetails.Cacheable, - Metadata: constructMetadata(f, resolutionDetails), - }, nil -} - -// constructMetadata is the internal generic func used to enhance model.VariationResult adding -// the targeting.rule's name (from configuration) to the Metadata. -// That way, it is possible to see when a targeting rule is match during the evaluation process. -func constructMetadata( - f flag.Flag, - resolutionDetails flag.ResolutionDetails, -) map[string]interface{} { - metadata := maps.Clone(f.GetMetadata()) - if resolutionDetails.RuleName == nil || *resolutionDetails.RuleName == "" { - return metadata - } - if metadata == nil { - metadata = make(map[string]interface{}) - } - metadata["evaluatedRuleName"] = *resolutionDetails.RuleName - return metadata + return evaluation.Evaluate[T](f, flagKey, evaluationCtx, flagCtx, expectedType, sdkDefaultValue) } diff --git a/variation_test.go b/variation_test.go index 1de83867528..772add2d884 100644 --- a/variation_test.go +++ b/variation_test.go @@ -3,10 +3,8 @@ package ffclient import ( "context" "errors" - "fmt" "log/slog" "os" - "runtime" "strings" "testing" "time" @@ -4153,53 +4151,6 @@ func TestRawVariation(t *testing.T) { } } -func Test_constructMetadataParallel(t *testing.T) { - sharedFlag := flag.InternalFlag{ - Metadata: &map[string]interface{}{ - "key1": "value1", - }, - } - - type args struct { - resolutionDetails flag.ResolutionDetails - } - var tests []struct { - name string - args args - wantEvaluatedRuleName string - } - - runtime.GOMAXPROCS(runtime.NumCPU()) - - // generate test cases - for i := 0; i < 10_000; i++ { - ruleName := fmt.Sprintf("rule-%d", i) - tests = append(tests, struct { - name string - args args - wantEvaluatedRuleName string - }{ - name: fmt.Sprintf("Rule %d", i), - args: args{ - resolutionDetails: flag.ResolutionDetails{ - RuleName: &ruleName, - }, - }, - wantEvaluatedRuleName: ruleName, - }) - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := constructMetadata(&sharedFlag, tt.args.resolutionDetails) - assert.Equal(t, tt.wantEvaluatedRuleName, got["evaluatedRuleName"]) - }) - } -} - func Test_OverrideContextEnrichmentWithEnvironment(t *testing.T) { tempFile, err := os.CreateTemp("", "") require.NoError(t, err) diff --git a/wasm/helpers/wasm.go b/wasm/helpers/wasm.go new file mode 100644 index 00000000000..82a3f55f888 --- /dev/null +++ b/wasm/helpers/wasm.go @@ -0,0 +1,23 @@ +package helpers + +import "unsafe" + +// WasmReadBufferFromMemory reads a buffer from memory and returns it as a byte slice. +func WasmReadBufferFromMemory(bufferPosition *uint32, length uint32) []byte { + subjectBuffer := make([]byte, length) + pointer := uintptr(unsafe.Pointer(bufferPosition)) + for i := 0; i < int(length); i++ { + s := *(*int32)(unsafe.Pointer(pointer + uintptr(i))) + subjectBuffer[i] = byte(s) + } + return subjectBuffer +} + +// WasmCopyBufferToMemory copies a buffer to memory and returns a pointer to the memory location. +func WasmCopyBufferToMemory(buffer []byte) uint64 { + bufferPtr := &buffer[0] + unsafePtr := uintptr(unsafe.Pointer(bufferPtr)) + pos := uint32(unsafePtr) + size := uint32(len(buffer)) + return (uint64(pos) << uint64(32)) | uint64(size) +} diff --git a/wasm/main.go b/wasm/main.go new file mode 100644 index 00000000000..7e6cf1ff093 --- /dev/null +++ b/wasm/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "github.com/thomaspoignant/go-feature-flag/ffcontext" + "github.com/thomaspoignant/go-feature-flag/internal/evaluation" + "github.com/thomaspoignant/go-feature-flag/internal/flag" + "github.com/thomaspoignant/go-feature-flag/wasm/helpers" + "strings" +) + +func main() { + // We keep this main empty because it is required by the tinygo when building wasm. +} + +//export evaluate +func evaluate(valuePosition *uint32, length uint32) uint64 { + input := helpers.WasmReadBufferFromMemory(valuePosition, length) + inputAsString := strings.SplitAfter(string(input), "\n") + + var f flag.InternalFlag + var flagkey string + var evaluationCtx ffcontext.EvaluationContext + var flagCtx flag.Context + var sdkDefaultValue interface{} + + c, err := evaluation.Evaluate[interface{}]( + &f, flagkey, evaluationCtx, flagCtx, "interface{}", sdkDefaultValue) + fmt.Println(c, err) +} diff --git a/wasm/model/evaluate_input.go b/wasm/model/evaluate_input.go new file mode 100644 index 00000000000..fab68bfd6d5 --- /dev/null +++ b/wasm/model/evaluate_input.go @@ -0,0 +1,14 @@ +package model + +import ( + "github.com/thomaspoignant/go-feature-flag/ffcontext" + "github.com/thomaspoignant/go-feature-flag/internal/flag" +) + +type EvaluateInput struct { + FlagKey string `json:"flagKey"` + Flag flag.InternalFlag `json:"flag"` + EvaluationCtx ffcontext.EvaluationContext `json:"evaluationContext"` + FlagContext flag.Context `json:"flagContext"` + SdkDefaultValue interface{} `json:"sdkDefaultValue"` +} From e53474135f6d1790961ff775ed96341efe9739f7 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 15 Apr 2025 11:29:29 +0200 Subject: [PATCH 2/2] WIP Signed-off-by: Thomas Poignant --- Makefile | 8 +- cmd/relayproxy/api/routes_goff.go | 8 +- cmd/relayproxy/api/server.go | 6 +- .../controller/flag_configuration.go | 105 ++++++++++++++++++ cmd/relayproxy/docs/docs.go | 88 +++++++++++++++ cmd/relayproxy/docs/swagger.json | 88 +++++++++++++++ cmd/relayproxy/docs/swagger.yaml | 58 ++++++++++ cmd/relayproxy/metric/metrics.go | 65 +++++++---- .../evaluation => evaluation}/evaluation.go | 3 +- .../evaluation_test.go | 5 +- feature_flag.go | 5 + go.mod | 5 +- go.sum | 10 +- internal/flag/context.go | 4 +- internal/flag/rule.go | 25 +++-- model/variation_result.go | 13 ++- variation.go | 2 +- wasm/evaluate_input.go | 12 ++ wasm/main.go | 62 +++++++++-- wasm/main_test.go | 33 ++++++ wasm/model/evaluate_input.go | 14 --- .../local_evaluation_inputs/valid.json | 39 +++++++ .../local_evaluation_outputs/valid.json | 15 +++ 23 files changed, 596 insertions(+), 77 deletions(-) create mode 100644 cmd/relayproxy/controller/flag_configuration.go rename {internal/evaluation => evaluation}/evaluation.go (99%) rename {internal/evaluation => evaluation}/evaluation_test.go (99%) create mode 100644 wasm/evaluate_input.go create mode 100644 wasm/main_test.go delete mode 100644 wasm/model/evaluate_input.go create mode 100644 wasm/testdata/local_evaluation_inputs/valid.json create mode 100644 wasm/testdata/local_evaluation_outputs/valid.json diff --git a/Makefile b/Makefile index ab5ff950957..4753d4fc76b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ GOCMD=go +TINYGOCMD=tinygo GOTEST=$(GOCMD) test GOVET=$(GOCMD) vet @@ -34,8 +35,11 @@ build-editor-api: create-out-dir ## Build the linter in out/bin/ build-jsonschema-generator: create-out-dir ## Build the jsonschema-generator in out/bin/ CGO_ENABLED=0 GO111MODULE=on $(GOCMD) build -mod vendor -o out/bin/jsonschema-generator ./cmd/jsonschema-generator/ -build-wasm: - CGO_ENABLED=0 GO111MODULE=on $(GOCMD) build -mod vendor -o out/bin/evaluation ./lib/evaluation +build-wasm: create-out-dir ## Build the wasm evaluation library in out/bin/ + cd wasm && $(TINYGOCMD) build -o ../out/bin/gofeatureflag-evaluation.wasm -target wasm --no-debug -scheduler=none && cd .. + +build-wasi: create-out-dir ## Build the wasi evaluation library in out/bin/ + cd wasm && $(TINYGOCMD) build -o ../out/bin/gofeatureflag-evaluation.wasi -target wasi --no-debug -scheduler=none && cd .. build-doc: ## Build the documentation cd website; \ diff --git a/cmd/relayproxy/api/routes_goff.go b/cmd/relayproxy/api/routes_goff.go index e35fc01d15c..69bb9aabe8f 100644 --- a/cmd/relayproxy/api/routes_goff.go +++ b/cmd/relayproxy/api/routes_goff.go @@ -13,7 +13,8 @@ func (s *Server) addGOFFRoutes( cAllFlags controller.Controller, cFlagEval controller.Controller, cEvalDataCollector controller.Controller, - cFlagChange controller.Controller) { + cFlagChange controller.Controller, + cFlagConfiguration controller.Controller) { // Grouping the routes v1 := s.apiEcho.Group("/v1") // nolint: staticcheck @@ -28,7 +29,9 @@ func (s *Server) addGOFFRoutes( v1.Use(etag.WithConfig(etag.Config{ Skipper: func(c echo.Context) bool { switch c.Path() { - case "/v1/flag/change": + case + "/v1/flag/change", + "/v1/flag/configuration": return false default: return true @@ -41,6 +44,7 @@ func (s *Server) addGOFFRoutes( v1.POST("/feature/:flagKey/eval", cFlagEval.Handler) v1.POST("/data/collector", cEvalDataCollector.Handler) v1.GET("/flag/change", cFlagChange.Handler) + v1.POST("/flag/configuration", cFlagConfiguration.Handler) // Swagger - only available if option is enabled if s.config.EnableSwagger { diff --git a/cmd/relayproxy/api/server.go b/cmd/relayproxy/api/server.go index 46f961b475f..dcebfb5e359 100644 --- a/cmd/relayproxy/api/server.go +++ b/cmd/relayproxy/api/server.go @@ -98,9 +98,13 @@ func (s *Server) initRoutes() { s.services.GOFeatureFlagService, s.services.Metrics, ) + cFlagConfiguration := controller.NewAPIFlagConfiguration( + s.services.GOFeatureFlagService, + s.services.Metrics, + ) // Init routes - s.addGOFFRoutes(cAllFlags, cFlagEval, cEvalDataCollector, cFlagChangeAPI) + s.addGOFFRoutes(cAllFlags, cFlagEval, cEvalDataCollector, cFlagChangeAPI, cFlagConfiguration) s.addOFREPRoutes(cFlagEvalOFREP) s.addWebsocketRoutes() s.addMonitoringRoutes() diff --git a/cmd/relayproxy/controller/flag_configuration.go b/cmd/relayproxy/controller/flag_configuration.go new file mode 100644 index 00000000000..cf149f1c93f --- /dev/null +++ b/cmd/relayproxy/controller/flag_configuration.go @@ -0,0 +1,105 @@ +package controller + +import ( + "fmt" + "net/http" + "time" + + "github.com/labstack/echo/v4" + ffclient "github.com/thomaspoignant/go-feature-flag" + "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config" + "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/metric" + "github.com/thomaspoignant/go-feature-flag/internal/flag" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" +) + +type FlagConfigurationAPICtrl struct { + goFF *ffclient.GoFeatureFlag + metrics metric.Metrics +} + +func NewAPIFlagConfiguration(goFF *ffclient.GoFeatureFlag, metrics metric.Metrics) Controller { + return &FlagConfigurationAPICtrl{ + goFF: goFF, + metrics: metrics, + } +} + +type FlagConfigurationRequest struct { + Flags []string `json:"flags"` +} + +type FlagConfigurationError = string + +const ( + FlagConfigErrorInvalidRequest FlagConfigurationError = "INVALID_REQUEST" + FlagConfigErrorRetrievingFlags FlagConfigurationError = "RETRIEVING_FLAGS_ERROR" +) + +type FlagConfigurationResponse struct { + Flags map[string]flag.Flag `json:"flags,omitempty"` + EvaluationContextEnrichment map[string]interface{} `json:"evaluationContextEnrichment,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorDetails string `json:"errorDetails,omitempty"` +} + +// Handler is the endpoint to poll if you want to get the configuration of the flags. +// @Summary Endpoint to poll if you want to get the configuration of the flags. +// @Tags GO Feature Flag Evaluation API +// @Description Making a **POST** request to the URL `/v1/flag/configuration` will give you the list of +// @Description the flags to use them for local evaluation in your provider. +// @Security ApiKeyAuth +// @Produce json +// @Accept json +// @Param data body FlagConfigurationRequest false "List of flags to get the configuration from." +// @Param If-None-Match header string false "The request will be processed only if ETag doesn't match." +// @Success 200 {object} FlagConfigurationResponse "Success" +// @Success 304 {string} string "Etag: \"117-0193435c612c50d93b798619d9464856263dbf9f\"" +// @Failure 500 {object} modeldocs.HTTPErrorDoc "Internal server error" +// @Router /v1/flag/configuration [post] +func (h *FlagConfigurationAPICtrl) Handler(c echo.Context) error { + tracer := otel.GetTracerProvider().Tracer(config.OtelTracerName) + _, span := tracer.Start(c.Request().Context(), "flagConfiguration") + defer span.End() + + reqBody := new(FlagConfigurationRequest) + if err := c.Bind(reqBody); err != nil { + return c.JSON( + http.StatusBadRequest, + FlagConfigurationResponse{ + ErrorCode: FlagConfigErrorInvalidRequest, + ErrorDetails: fmt.Sprintf("impossible to read request body: %s", err), + }, + ) + } + if reqBody == nil { + return c.JSON( + http.StatusBadRequest, + FlagConfigurationResponse{ + ErrorCode: FlagConfigErrorInvalidRequest, + ErrorDetails: fmt.Sprintf("empty request body"), + }, + ) + } + + flags, err := h.goFF.GetFlagsFromCache() + if err != nil { + return c.JSON(http.StatusInternalServerError, FlagConfigurationResponse{ + ErrorCode: FlagConfigErrorRetrievingFlags, + ErrorDetails: fmt.Sprintf("impossible to retrieve flag configuration: %s", err), + }) + } + // TODO: add a filter to only return the flags that are in the request body + + span.SetAttributes(attribute.Int("flagConfiguration.configurationSize", len(flags))) + + c.Response().Header().Set(echo.HeaderLastModified, h.goFF.GetCacheRefreshDate().Format(time.RFC1123)) + return c.JSON( + http.StatusOK, + FlagConfigurationResponse{ + EvaluationContextEnrichment: h.goFF.GetEvaluationContextEnrichment(), + Flags: flags, + }, + ) +} diff --git a/cmd/relayproxy/docs/docs.go b/cmd/relayproxy/docs/docs.go index e418fddfa2e..f17d14e1ba8 100644 --- a/cmd/relayproxy/docs/docs.go +++ b/cmd/relayproxy/docs/docs.go @@ -556,6 +556,62 @@ const docTemplate = `{ } } }, + "/v1/flag/configuration": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Making a **POST** request to the URL ` + "`" + `/v1/flag/configuration` + "`" + ` will give you the list of\nthe flags to use them for local evaluation in your provider.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "GO Feature Flag Evaluation API" + ], + "summary": "Endpoint to poll if you want to get the configuration of the flags.", + "parameters": [ + { + "description": "List of flags to get the configuration from.", + "name": "data", + "in": "body", + "schema": { + "$ref": "#/definitions/controller.FlagConfigurationRequest" + } + }, + { + "type": "string", + "description": "The request will be processed only if ETag doesn't match.", + "name": "If-None-Match", + "in": "header" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/controller.FlagConfigurationResponse" + } + }, + "304": { + "description": "Etag: \\\"117-0193435c612c50d93b798619d9464856263dbf9f\\", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/modeldocs.HTTPErrorDoc" + } + } + } + } + }, "/ws/v1/flag/change": { "post": { "description": "This endpoint is a websocket endpoint to be notified about flag changes, every change\nwill send a request to the client with a model.DiffCache format.\n", @@ -615,6 +671,38 @@ const docTemplate = `{ } } }, + "controller.FlagConfigurationRequest": { + "type": "object", + "properties": { + "flags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "controller.FlagConfigurationResponse": { + "type": "object", + "properties": { + "errorCode": { + "type": "string" + }, + "errorDetails": { + "type": "string" + }, + "evaluationContextEnrichment": { + "type": "object", + "additionalProperties": true + }, + "flags": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/flag.Flag" + } + } + } + }, "controller.retrieverRefreshResponse": { "type": "object", "properties": { diff --git a/cmd/relayproxy/docs/swagger.json b/cmd/relayproxy/docs/swagger.json index 80c7f4310c2..f186e83608d 100644 --- a/cmd/relayproxy/docs/swagger.json +++ b/cmd/relayproxy/docs/swagger.json @@ -548,6 +548,62 @@ } } }, + "/v1/flag/configuration": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Making a **POST** request to the URL `/v1/flag/configuration` will give you the list of\nthe flags to use them for local evaluation in your provider.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "GO Feature Flag Evaluation API" + ], + "summary": "Endpoint to poll if you want to get the configuration of the flags.", + "parameters": [ + { + "description": "List of flags to get the configuration from.", + "name": "data", + "in": "body", + "schema": { + "$ref": "#/definitions/controller.FlagConfigurationRequest" + } + }, + { + "type": "string", + "description": "The request will be processed only if ETag doesn't match.", + "name": "If-None-Match", + "in": "header" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/controller.FlagConfigurationResponse" + } + }, + "304": { + "description": "Etag: \\\"117-0193435c612c50d93b798619d9464856263dbf9f\\", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/modeldocs.HTTPErrorDoc" + } + } + } + } + }, "/ws/v1/flag/change": { "post": { "description": "This endpoint is a websocket endpoint to be notified about flag changes, every change\nwill send a request to the client with a model.DiffCache format.\n", @@ -607,6 +663,38 @@ } } }, + "controller.FlagConfigurationRequest": { + "type": "object", + "properties": { + "flags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "controller.FlagConfigurationResponse": { + "type": "object", + "properties": { + "errorCode": { + "type": "string" + }, + "errorDetails": { + "type": "string" + }, + "evaluationContextEnrichment": { + "type": "object", + "additionalProperties": true + }, + "flags": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/flag.Flag" + } + } + } + }, "controller.retrieverRefreshResponse": { "type": "object", "properties": { diff --git a/cmd/relayproxy/docs/swagger.yaml b/cmd/relayproxy/docs/swagger.yaml index aec9c758747..36638858501 100644 --- a/cmd/relayproxy/docs/swagger.yaml +++ b/cmd/relayproxy/docs/swagger.yaml @@ -9,6 +9,27 @@ definitions: hash: type: integer type: object + controller.FlagConfigurationRequest: + properties: + flags: + items: + type: string + type: array + type: object + controller.FlagConfigurationResponse: + properties: + errorCode: + type: string + errorDetails: + type: string + evaluationContextEnrichment: + additionalProperties: true + type: object + flags: + additionalProperties: + $ref: '#/definitions/flag.Flag' + type: object + type: object controller.retrieverRefreshResponse: properties: refreshed: @@ -721,6 +742,43 @@ paths: in the flags tags: - GO Feature Flag Evaluation API + /v1/flag/configuration: + post: + consumes: + - application/json + description: |- + Making a **POST** request to the URL `/v1/flag/configuration` will give you the list of + the flags to use them for local evaluation in your provider. + parameters: + - description: List of flags to get the configuration from. + in: body + name: data + schema: + $ref: '#/definitions/controller.FlagConfigurationRequest' + - description: The request will be processed only if ETag doesn't match. + in: header + name: If-None-Match + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/controller.FlagConfigurationResponse' + "304": + description: 'Etag: \"117-0193435c612c50d93b798619d9464856263dbf9f\' + schema: + type: string + "500": + description: Internal server error + schema: + $ref: '#/definitions/modeldocs.HTTPErrorDoc' + security: + - ApiKeyAuth: [] + summary: Endpoint to poll if you want to get the configuration of the flags. + tags: + - GO Feature Flag Evaluation API /ws/v1/flag/change: post: consumes: diff --git a/cmd/relayproxy/metric/metrics.go b/cmd/relayproxy/metric/metrics.go index 05823718369..65e10940454 100644 --- a/cmd/relayproxy/metric/metrics.go +++ b/cmd/relayproxy/metric/metrics.go @@ -90,6 +90,13 @@ func NewMetrics() (Metrics, error) { Subsystem: GOFFSubSystem, }) + // counts the number of call to the flag configuration endpoint + flagConfigurationCounter := prom.NewCounter(prom.CounterOpts{ + Name: "flag_configuration_total", + Help: "Counter events for number of configuration api requests.", + Subsystem: GOFFSubSystem, + }) + metricToRegister := []prom.Collector{ flagEvaluationCounter, allFlagCounter, @@ -102,6 +109,7 @@ func NewMetrics() (Metrics, error) { flagDeleteCounterVec, flagCreateCounterVec, forceRefreshCounter, + flagConfigurationCounter, collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), collectors.NewGoCollector(), } @@ -114,35 +122,37 @@ func NewMetrics() (Metrics, error) { } return Metrics{ - flagEvaluationCounter: *flagEvaluationCounter, - allFlagCounter: allFlagCounter, - collectEvalDataCounter: collectEvalDataCounter, - flagChange: flagChange, - flagCreateCounter: flagCreateCounter, - flagDeleteCounter: flagDeleteCounter, - flagUpdateCounter: flagUpdateCounter, - flagUpdateCounterVec: *flagUpdateCounterVec, - flagDeleteCounterVec: *flagDeleteCounterVec, - flagCreateCounterVec: *flagCreateCounterVec, - forceRefreshCounter: forceRefreshCounter, - Registry: customRegistry, + flagEvaluationCounter: *flagEvaluationCounter, + allFlagCounter: allFlagCounter, + collectEvalDataCounter: collectEvalDataCounter, + flagChange: flagChange, + flagCreateCounter: flagCreateCounter, + flagDeleteCounter: flagDeleteCounter, + flagUpdateCounter: flagUpdateCounter, + flagUpdateCounterVec: *flagUpdateCounterVec, + flagDeleteCounterVec: *flagDeleteCounterVec, + flagCreateCounterVec: *flagCreateCounterVec, + forceRefreshCounter: forceRefreshCounter, + flagConfigurationCounter: flagConfigurationCounter, + Registry: customRegistry, }, nil } // Metrics is a struct containing all custom prometheus metrics type Metrics struct { - Registry *prom.Registry - flagEvaluationCounter prom.CounterVec - allFlagCounter prom.Counter - collectEvalDataCounter prom.Counter - flagChange prom.Counter - flagCreateCounter prom.Counter - flagDeleteCounter prom.Counter - flagUpdateCounter prom.Counter - flagUpdateCounterVec prom.CounterVec - flagDeleteCounterVec prom.CounterVec - flagCreateCounterVec prom.CounterVec - forceRefreshCounter prom.Counter + Registry *prom.Registry + flagEvaluationCounter prom.CounterVec + allFlagCounter prom.Counter + collectEvalDataCounter prom.Counter + flagChange prom.Counter + flagCreateCounter prom.Counter + flagDeleteCounter prom.Counter + flagUpdateCounter prom.Counter + forceRefreshCounter prom.Counter + flagConfigurationCounter prom.Counter + flagUpdateCounterVec prom.CounterVec + flagDeleteCounterVec prom.CounterVec + flagCreateCounterVec prom.CounterVec } func (m *Metrics) IncFlagEvaluation(flagName string) { @@ -203,3 +213,10 @@ func (m *Metrics) IncFlagChange() { m.flagChange.Inc() } } + +// IncFlagConfigurationCall is incrementing the counters when the flag configuration endpoint is called. +func (m *Metrics) IncFlagConfigurationCall() { + if m.flagConfigurationCounter != nil { + m.flagConfigurationCounter.Inc() + } +} diff --git a/internal/evaluation/evaluation.go b/evaluation/evaluation.go similarity index 99% rename from internal/evaluation/evaluation.go rename to evaluation/evaluation.go index 9f447633005..270546d30eb 100644 --- a/internal/evaluation/evaluation.go +++ b/evaluation/evaluation.go @@ -2,10 +2,11 @@ package evaluation import ( "fmt" + "maps" + "github.com/thomaspoignant/go-feature-flag/ffcontext" "github.com/thomaspoignant/go-feature-flag/internal/flag" "github.com/thomaspoignant/go-feature-flag/model" - "maps" ) const errorWrongVariation = "wrong variation used for flag %v" diff --git a/internal/evaluation/evaluation_test.go b/evaluation/evaluation_test.go similarity index 99% rename from internal/evaluation/evaluation_test.go rename to evaluation/evaluation_test.go index 9f0b920b343..140a0bb3d94 100644 --- a/internal/evaluation/evaluation_test.go +++ b/evaluation/evaluation_test.go @@ -2,10 +2,11 @@ package evaluation import ( "fmt" - "github.com/stretchr/testify/assert" - "github.com/thomaspoignant/go-feature-flag/internal/flag" "runtime" "testing" + + "github.com/stretchr/testify/assert" + "github.com/thomaspoignant/go-feature-flag/internal/flag" ) func Test_constructMetadataParallel(t *testing.T) { diff --git a/feature_flag.go b/feature_flag.go index c35747741e7..63b8aef5c5a 100644 --- a/feature_flag.go +++ b/feature_flag.go @@ -394,6 +394,11 @@ func (g *GoFeatureFlag) GetCacheRefreshDate() time.Time { return g.cache.GetLatestUpdateDate() } +// GetEvaluationContextEnrichment returns the evaluation context enrichment +func (g *GoFeatureFlag) GetEvaluationContextEnrichment() map[string]any { + return g.config.EvaluationContextEnrichment +} + // ForceRefresh is a function that forces to call the retrievers and refresh the configuration of flags. // This function can be called explicitly to refresh the flags if you know that a change has been made in // the configuration. diff --git a/go.mod b/go.mod index 695a65cb581..3214e5a4d7e 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 github.com/BurntSushi/toml v1.5.0 + github.com/GeorgeD19/json-logic-go v0.0.0-20220225111652-48cc2d2c387e github.com/IBM/sarama v1.45.1 github.com/atc0005/go-teams-notify/v2 v2.13.0 github.com/aws/aws-lambda-go v1.47.0 @@ -24,7 +25,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sqs v1.38.4 github.com/aws/smithy-go v1.22.3 github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 - github.com/diegoholiveira/jsonlogic/v3 v3.8.1 github.com/fsouza/fake-gcs-server v1.52.2 github.com/go-viper/mapstructure/v2 v2.2.1 github.com/golang/mock v1.6.0 @@ -116,7 +116,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.18 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/buger/jsonparser v1.1.1 // indirect @@ -126,6 +125,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/dariubs/percent v0.0.0-20190521174708-8153fcbd48ae // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect @@ -215,6 +215,7 @@ require ( github.com/samber/slog-common v0.18.1 // indirect github.com/shirou/gopsutil/v4 v4.25.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cast v1.3.0 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect diff --git a/go.sum b/go.sum index ff6e4dc0143..dbc15654f6f 100644 --- a/go.sum +++ b/go.sum @@ -164,6 +164,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GeorgeD19/json-logic-go v0.0.0-20220225111652-48cc2d2c387e h1:pGKbZyClLVd95fyMC8yib8STgy76ShCwIaPOSZPhDMM= +github.com/GeorgeD19/json-logic-go v0.0.0-20220225111652-48cc2d2c387e/go.mod h1:vIXtt8GZPXz4N4IZmJHYp8W8QWCi2IfNhOKWeqYc6RY= github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0/go.mod h1:spvB9eLJH9dutlbPSRmHvSXXHOwGRyeXh1jVdquA2G8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= @@ -267,8 +269,6 @@ github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -317,6 +317,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/dariubs/percent v0.0.0-20190521174708-8153fcbd48ae h1:0SUXUFz3+ksMulwvkS6XZnxCqw5ygjYJPKjpEBWNCJU= +github.com/dariubs/percent v0.0.0-20190521174708-8153fcbd48ae/go.mod h1:NqjuQSHe8CjRVziJtxGCQDmOwoj68QdlKRkbddHfRtY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -325,8 +327,6 @@ github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfz github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/diegoholiveira/jsonlogic/v3 v3.8.1 h1:SNoduSf0CYywaSPEKwRa1Eg87RfhT9kSnkMoDJRXJpI= -github.com/diegoholiveira/jsonlogic/v3 v3.8.1/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -861,6 +861,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= diff --git a/internal/flag/context.go b/internal/flag/context.go index a0c8da72af2..88abebcac22 100644 --- a/internal/flag/context.go +++ b/internal/flag/context.go @@ -7,10 +7,10 @@ type Context struct { // All those fields will be included in the custom attributes of the evaluation context, // if in the evaluation context you have a field with the same name, it will override the common one. // Default: nil - EvaluationContextEnrichment map[string]interface{} + EvaluationContextEnrichment map[string]interface{} `json:"evaluationContextEnrichment,omitempty"` // DefaultSdkValue is the default value of the SDK when calling the variation. - DefaultSdkValue interface{} + DefaultSdkValue interface{} `json:"defaultSdkValue,omitempty"` } func (s *Context) AddIntoEvaluationContextEnrichment(key string, value interface{}) { diff --git a/internal/flag/rule.go b/internal/flag/rule.go index 1bbafc1f513..45776e5426d 100644 --- a/internal/flag/rule.go +++ b/internal/flag/rule.go @@ -1,15 +1,13 @@ package flag import ( - "bytes" "encoding/json" "fmt" "log/slog" "sort" - "strings" "time" - jsonlogic "github.com/diegoholiveira/jsonlogic/v3" + jsonlogic "github.com/GeorgeD19/json-logic-go" "github.com/nikunjy/rules/parser" "github.com/thomaspoignant/go-feature-flag/ffcontext" "github.com/thomaspoignant/go-feature-flag/internal/internalerror" @@ -94,14 +92,20 @@ func evaluateRule(query string, queryFormat QueryFormat, ctx ffcontext.Context) slog.Any("mapCtx", mapCtx), slog.Any("error", err)) return false } - var result bytes.Buffer - err = jsonlogic.Apply(strings.NewReader(query), strings.NewReader(string(strCtx)), &result) + result, err := jsonlogic.Apply(query, string(strCtx)) if err != nil { slog.Error("error while evaluating the jsonlogic query", slog.String("query", query), slog.Any("error", err)) return false } - return utils.StrTrim(result.String()) == "true" + switch v := result.(type) { + case bool: + return v + case string: + return utils.StrTrim(v) == "true" + default: + return false + } default: return parser.Evaluate(query, mapCtx) } @@ -351,9 +355,12 @@ func (r *Rule) isQueryValid(defaultRule bool) error { // Validate the query with the parser switch r.GetQueryFormat() { case JSONLogicQueryFormat: - if !jsonlogic.IsValid(strings.NewReader(r.GetTrimmedQuery())) { - return fmt.Errorf("invalid jsonlogic query: %s", r.GetTrimmedQuery()) - } + //var rule interface{} + //_ = json.Unmarshal([]byte(r.GetTrimmedQuery()), &rule) + //err := jsonlogic.Validate(rule) + //if err != nil { + // return fmt.Errorf("invalid jsonlogic query: %s", r.GetTrimmedQuery()) + //} return nil default: return validateNikunjyQuery(r.GetTrimmedQuery()) diff --git a/model/variation_result.go b/model/variation_result.go index 572f973d872..a1fe33af224 100644 --- a/model/variation_result.go +++ b/model/variation_result.go @@ -1,6 +1,10 @@ package model -import "github.com/thomaspoignant/go-feature-flag/internal/flag" +import ( + "encoding/json" + + "github.com/thomaspoignant/go-feature-flag/internal/flag" +) // JSONType contains all acceptable flag value types type JSONType interface { @@ -15,12 +19,17 @@ type VariationResult[T JSONType] struct { Version string `json:"version"` Reason flag.ResolutionReason `json:"reason"` ErrorCode flag.ErrorCode `json:"errorCode"` - ErrorDetails string `json:"errrorDetails,omitempty"` + ErrorDetails string `json:"errorDetails,omitempty"` Value T `json:"value"` Cacheable bool `json:"cacheable"` Metadata map[string]interface{} `json:"metadata,omitempty"` } +func (v VariationResult[T]) ToJsonStr() string { + content, _ := json.Marshal(v) + return string(content) +} + // RawVarResult is the result of the raw variation call. // This is used by ffclient.RawVariation functions, this should be used only by internal calls. type RawVarResult struct { diff --git a/variation.go b/variation.go index dcbee8a7dc3..226ebd32d80 100644 --- a/variation.go +++ b/variation.go @@ -2,9 +2,9 @@ package ffclient import ( "fmt" - "github.com/thomaspoignant/go-feature-flag/internal/evaluation" "maps" + "github.com/thomaspoignant/go-feature-flag/evaluation" "github.com/thomaspoignant/go-feature-flag/exporter" "github.com/thomaspoignant/go-feature-flag/ffcontext" "github.com/thomaspoignant/go-feature-flag/internal/flag" diff --git a/wasm/evaluate_input.go b/wasm/evaluate_input.go new file mode 100644 index 00000000000..035dddb44f0 --- /dev/null +++ b/wasm/evaluate_input.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/thomaspoignant/go-feature-flag/internal/flag" +) + +type EvaluateInput struct { + FlagKey string `json:"flagKey"` + Flag flag.InternalFlag `json:"flag"` + EvaluationCtx map[string]any `json:"evalContext"` + FlagContext flag.Context `json:"flagContext"` +} diff --git a/wasm/main.go b/wasm/main.go index 7e6cf1ff093..824f614327f 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -1,30 +1,70 @@ package main import ( + "encoding/json" "fmt" + + "github.com/thomaspoignant/go-feature-flag/evaluation" "github.com/thomaspoignant/go-feature-flag/ffcontext" - "github.com/thomaspoignant/go-feature-flag/internal/evaluation" "github.com/thomaspoignant/go-feature-flag/internal/flag" + "github.com/thomaspoignant/go-feature-flag/internal/utils" + "github.com/thomaspoignant/go-feature-flag/model" "github.com/thomaspoignant/go-feature-flag/wasm/helpers" - "strings" ) func main() { // We keep this main empty because it is required by the tinygo when building wasm. } +// evaluate is the entry point for the wasm module. +// what it does is: +// 1. read the input from the memory +// 2. call the localEvaluation function +// 3. write the result to the memory +// //export evaluate func evaluate(valuePosition *uint32, length uint32) uint64 { input := helpers.WasmReadBufferFromMemory(valuePosition, length) - inputAsString := strings.SplitAfter(string(input), "\n") + c := localEvaluation(string(input)) + return helpers.WasmCopyBufferToMemory([]byte(c)) +} + +// localEvaluation is the function that will be called from the evaluate function. +// It will unmarshal the input, call the evaluation function and return the result. +func localEvaluation(input string) string { + var evaluateInput EvaluateInput + err := json.Unmarshal([]byte(input), &evaluateInput) + if err != nil { + return model.VariationResult[interface{}]{ + ErrorCode: flag.ErrorCodeParseError, + ErrorDetails: err.Error(), + }.ToJsonStr() + } - var f flag.InternalFlag - var flagkey string - var evaluationCtx ffcontext.EvaluationContext - var flagCtx flag.Context - var sdkDefaultValue interface{} + evalCtx, err := convertEvaluationCtx(evaluateInput.EvaluationCtx) + if err != nil { + return model.VariationResult[interface{}]{ + ErrorCode: flag.ErrorCodeTargetingKeyMissing, + ErrorDetails: err.Error(), + }.ToJsonStr() + } + + c, _ := evaluation.Evaluate[interface{}]( + &evaluateInput.Flag, + evaluateInput.FlagKey, + evalCtx, + evaluateInput.FlagContext, + "interface{}", + evaluateInput.FlagContext.DefaultSdkValue, + ) + return c.ToJsonStr() +} - c, err := evaluation.Evaluate[interface{}]( - &f, flagkey, evaluationCtx, flagCtx, "interface{}", sdkDefaultValue) - fmt.Println(c, err) +// convertEvaluationCtx converts the evaluation context from the input to a ffcontext.Context. +func convertEvaluationCtx(ctx map[string]any) (ffcontext.Context, error) { + if targetingKey, ok := ctx["targetingKey"].(string); ok { + evalCtx := utils.ConvertEvaluationCtxFromRequest(targetingKey, ctx) + return evalCtx, nil + } + return ffcontext.NewEvaluationContextBuilder("").Build(), fmt.Errorf("targetingKey not found in context") } diff --git a/wasm/main_test.go b/wasm/main_test.go new file mode 100644 index 00000000000..e70ab82a6d8 --- /dev/null +++ b/wasm/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_localEvaluation(t *testing.T) { + tests := []struct { + name string + inputLocation string + wantLocation string + }{ + { + name: "Test with a valid input", + inputLocation: "testdata/local_evaluation_inputs/valid.json", + wantLocation: "testdata/local_evaluation_outputs/valid.json", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := os.ReadFile(tt.inputLocation) + assert.NoError(t, err) + want, err := os.ReadFile(tt.wantLocation) + assert.NoError(t, err) + got := localEvaluation(string(content)) + assert.JSONEq(t, string(want), got) + }) + + } +} diff --git a/wasm/model/evaluate_input.go b/wasm/model/evaluate_input.go deleted file mode 100644 index fab68bfd6d5..00000000000 --- a/wasm/model/evaluate_input.go +++ /dev/null @@ -1,14 +0,0 @@ -package model - -import ( - "github.com/thomaspoignant/go-feature-flag/ffcontext" - "github.com/thomaspoignant/go-feature-flag/internal/flag" -) - -type EvaluateInput struct { - FlagKey string `json:"flagKey"` - Flag flag.InternalFlag `json:"flag"` - EvaluationCtx ffcontext.EvaluationContext `json:"evaluationContext"` - FlagContext flag.Context `json:"flagContext"` - SdkDefaultValue interface{} `json:"sdkDefaultValue"` -} diff --git a/wasm/testdata/local_evaluation_inputs/valid.json b/wasm/testdata/local_evaluation_inputs/valid.json new file mode 100644 index 00000000000..1caf3dfa38d --- /dev/null +++ b/wasm/testdata/local_evaluation_inputs/valid.json @@ -0,0 +1,39 @@ +{ + "flagKey": "TEST", + "flag": { + "variations": { + "enable": true, + "disable": false + }, + "targeting": [ + { + "name": "targetingID rule", + "query": "targetingKey eq \"random-key\"", + "percentage": { + "enable": 90, + "disable": 10 + } + } + ], + "defaultRule": { + "variation": "disable" + }, + "metadata": { + "description": "test flag", + "type": "boolean" + } + }, + "evalContext": { + "targetingKey": "random-key", + "name": "foo", + "age": 42, + "fullname": "foo bar", + "email": "foo.bar@gofeatureflag.org" + }, + "flagContext": { + "evaluationContextEnrichment": { + "env": "production" + }, + "defaultSdkValue": false + } +} \ No newline at end of file diff --git a/wasm/testdata/local_evaluation_outputs/valid.json b/wasm/testdata/local_evaluation_outputs/valid.json new file mode 100644 index 00000000000..a2a3d136385 --- /dev/null +++ b/wasm/testdata/local_evaluation_outputs/valid.json @@ -0,0 +1,15 @@ +{ + "trackEvents": true, + "variationType": "enable", + "failed": false, + "version": "", + "reason": "TARGETING_MATCH_SPLIT", + "errorCode": "", + "value": true, + "cacheable": true, + "metadata": { + "description": "test flag", + "evaluatedRuleName": "targetingID rule", + "type": "boolean" + } +}