Skip to content

Commit 652e728

Browse files
feat: First step in-process evaluation (#3413)
* WIP Signed-off-by: Thomas Poignant <[email protected]> * WIP Signed-off-by: Thomas Poignant <[email protected]> * clean test Signed-off-by: Thomas Poignant <[email protected]> * ci: test wasm build Signed-off-by: Thomas Poignant <[email protected]> * test release setup Signed-off-by: Thomas Poignant <[email protected]> * test release setup Signed-off-by: Thomas Poignant <[email protected]> * Enable release of the wasm file Signed-off-by: Thomas Poignant <[email protected]> * Add tests for new api Signed-off-by: Thomas Poignant <[email protected]> * adding test for evaluation Signed-off-by: Thomas Poignant <[email protected]> * adding test for offline Signed-off-by: Thomas Poignant <[email protected]> * adding test for new metric Signed-off-by: Thomas Poignant <[email protected]> * fix lint Signed-off-by: Thomas Poignant <[email protected]> * fix golines Signed-off-by: Thomas Poignant <[email protected]> * adding test Signed-off-by: Thomas Poignant <[email protected]> * remove not valid test Signed-off-by: Thomas Poignant <[email protected]> * Adding test for main Signed-off-by: Thomas Poignant <[email protected]> * adding test for GetEvaluationContextEnrichment Signed-off-by: Thomas Poignant <[email protected]> * fix lint Signed-off-by: Thomas Poignant <[email protected]> --------- Signed-off-by: Thomas Poignant <[email protected]>
1 parent ebcb09e commit 652e728

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1975
-164
lines changed

.github/ci-scripts/release_wasm.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env bash
2+
3+
VERSION=$1
4+
5+
make build-wasm
6+
make build-wasi
7+
8+
mkdir -p "./out/release-wasm/"
9+
mv "./out/bin/gofeatureflag-evaluation.wasi" "./out/release-wasm/gofeatureflag-evaluation_${VERSION}.wasi"
10+
mv "./out/bin/gofeatureflag-evaluation.wasm" "./out/release-wasm/gofeatureflag-evaluation_${VERSION}.wasm"
11+

.github/workflows/release.yml

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
echo "This is a pre-release version, stopping workflow..."
1616
exit 1
1717
fi
18-
18+
1919
integration-tests:
2020
name: Integration Tests
2121
needs:
@@ -45,6 +45,32 @@ jobs:
4545
dotnet-version: '7.0.x'
4646
- run: make vendor
4747
- run: make provider-tests
48+
49+
wasm-release:
50+
needs: integration-tests
51+
runs-on: ubuntu-latest
52+
steps:
53+
- name: Checkout
54+
uses: actions/checkout@v4
55+
with:
56+
fetch-depth: 0
57+
- name: Setup go
58+
uses: actions/setup-go@v5
59+
with:
60+
go-version-file: go.mod
61+
check-latest: true
62+
- uses: acifani/setup-tinygo@v2
63+
with:
64+
tinygo-version: '0.37.0'
65+
- run: ./.github/ci-scripts/release_wasm.sh ${{ github.ref_name }}
66+
- name: Upload assets
67+
uses: softprops/action-gh-release@v2
68+
if: github.ref_type == 'tag'
69+
with:
70+
files: |
71+
./out/release-wasm/gofeatureflag-evaluation_${{ github.ref_name }}.wasi
72+
./out/release-wasm/gofeatureflag-evaluation_${{ github.ref_name }}.wasm
73+
4874
goreleaser:
4975
needs: integration-tests
5076
runs-on: ubuntu-latest

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ formatters:
170170
- gci
171171
- gofmt
172172
- goimports
173-
- golines
173+
# - golines
174174
settings:
175175
gofumpt:
176176
module-path: github.com/thomaspoignant/go-feature-flag

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
GOCMD=go
2+
TINYGOCMD=tinygo
23
GOTEST=$(GOCMD) test
34
GOVET=$(GOCMD) vet
45

@@ -10,8 +11,6 @@ RESET := $(shell tput -Txterm sgr0)
1011

1112
.PHONY: all test build vendor
1213

13-
14-
1514
all: help
1615
## Build:
1716
build: build-relayproxy build-lint build-editor-api build-jsonschema-generator build-cli ## Build all the binaries and put the output in out/bin/
@@ -34,6 +33,12 @@ build-editor-api: create-out-dir ## Build the linter in out/bin/
3433
build-jsonschema-generator: create-out-dir ## Build the jsonschema-generator in out/bin/
3534
CGO_ENABLED=0 GO111MODULE=on $(GOCMD) build -mod vendor -o out/bin/jsonschema-generator ./cmd/jsonschema-generator/
3635

36+
build-wasm: create-out-dir ## Build the wasm evaluation library in out/bin/
37+
cd wasm && $(TINYGOCMD) build -o ../out/bin/gofeatureflag-evaluation.wasm -target wasm -opt=2 -opt=s --no-debug -scheduler=none && cd ..
38+
39+
build-wasi: create-out-dir ## Build the wasi evaluation library in out/bin/
40+
cd wasm && $(TINYGOCMD) build -o ../out/bin/gofeatureflag-evaluation.wasi -target wasi -opt=2 -opt=s --no-debug -scheduler=none && cd ..
41+
3742
build-doc: ## Build the documentation
3843
cd website; \
3944
npm i && npm run build

cmd/relayproxy/api/routes_goff.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ func (s *Server) addGOFFRoutes(
1313
cAllFlags controller.Controller,
1414
cFlagEval controller.Controller,
1515
cEvalDataCollector controller.Controller,
16-
cFlagChange controller.Controller) {
16+
cFlagChange controller.Controller,
17+
cFlagConfiguration controller.Controller) {
1718
// Grouping the routes
1819
v1 := s.apiEcho.Group("/v1")
1920
// nolint: staticcheck
@@ -28,7 +29,9 @@ func (s *Server) addGOFFRoutes(
2829
v1.Use(etag.WithConfig(etag.Config{
2930
Skipper: func(c echo.Context) bool {
3031
switch c.Path() {
31-
case "/v1/flag/change":
32+
case
33+
"/v1/flag/change",
34+
"/v1/flag/configuration":
3235
return false
3336
default:
3437
return true
@@ -41,6 +44,7 @@ func (s *Server) addGOFFRoutes(
4144
v1.POST("/feature/:flagKey/eval", cFlagEval.Handler)
4245
v1.POST("/data/collector", cEvalDataCollector.Handler)
4346
v1.GET("/flag/change", cFlagChange.Handler)
47+
v1.POST("/flag/configuration", cFlagConfiguration.Handler)
4448

4549
// Swagger - only available if option is enabled
4650
if s.config.EnableSwagger {

cmd/relayproxy/api/server.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,13 @@ func (s *Server) initRoutes() {
9898
s.services.GOFeatureFlagService,
9999
s.services.Metrics,
100100
)
101+
cFlagConfiguration := controller.NewAPIFlagConfiguration(
102+
s.services.GOFeatureFlagService,
103+
s.services.Metrics,
104+
)
101105

102106
// Init routes
103-
s.addGOFFRoutes(cAllFlags, cFlagEval, cEvalDataCollector, cFlagChangeAPI)
107+
s.addGOFFRoutes(cAllFlags, cFlagEval, cEvalDataCollector, cFlagChangeAPI, cFlagConfiguration)
104108
s.addOFREPRoutes(cFlagEvalOFREP)
105109
s.addWebsocketRoutes()
106110
s.addMonitoringRoutes()
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package controller
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"time"
7+
8+
"github.com/labstack/echo/v4"
9+
ffclient "github.com/thomaspoignant/go-feature-flag"
10+
"github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config"
11+
"github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/metric"
12+
"github.com/thomaspoignant/go-feature-flag/internal/flag"
13+
"go.opentelemetry.io/otel"
14+
"go.opentelemetry.io/otel/attribute"
15+
)
16+
17+
type FlagConfigurationAPICtrl struct {
18+
goFF *ffclient.GoFeatureFlag
19+
metrics metric.Metrics
20+
}
21+
22+
func NewAPIFlagConfiguration(goFF *ffclient.GoFeatureFlag, metrics metric.Metrics) Controller {
23+
return &FlagConfigurationAPICtrl{
24+
goFF: goFF,
25+
metrics: metrics,
26+
}
27+
}
28+
29+
type FlagConfigurationRequest struct {
30+
Flags []string `json:"flags"`
31+
}
32+
33+
type FlagConfigurationError = string
34+
35+
const (
36+
FlagConfigErrorInvalidRequest FlagConfigurationError = "INVALID_REQUEST"
37+
FlagConfigErrorRetrievingFlags FlagConfigurationError = "RETRIEVING_FLAGS_ERROR"
38+
)
39+
40+
type FlagConfigurationResponse struct {
41+
Flags map[string]flag.Flag `json:"flags,omitempty"`
42+
EvaluationContextEnrichment map[string]interface{} `json:"evaluationContextEnrichment,omitempty"`
43+
ErrorCode string `json:"errorCode,omitempty"`
44+
ErrorDetails string `json:"errorDetails,omitempty"`
45+
}
46+
47+
// Handler is the endpoint to poll if you want to get the configuration of the flags.
48+
// @Summary Endpoint to poll if you want to get the configuration of the flags.
49+
// @Tags GO Feature Flag Evaluation API
50+
// @Description Making a **POST** request to the URL `/v1/flag/configuration` will give you the list of
51+
// @Description the flags to use them for local evaluation in your provider.
52+
// @Security ApiKeyAuth
53+
// @Produce json
54+
// @Accept json
55+
// @Param data body FlagConfigurationRequest false "List of flags to get the configuration from."
56+
// @Param If-None-Match header string false "The request will be processed only if ETag doesn't match."
57+
// @Success 200 {object} FlagConfigurationResponse "Success"
58+
// @Success 304 {string} string "Etag: \"117-0193435c612c50d93b798619d9464856263dbf9f\""
59+
// @Failure 500 {object} modeldocs.HTTPErrorDoc "Internal server error"
60+
// @Router /v1/flag/configuration [post]
61+
func (h *FlagConfigurationAPICtrl) Handler(c echo.Context) error {
62+
tracer := otel.GetTracerProvider().Tracer(config.OtelTracerName)
63+
_, span := tracer.Start(c.Request().Context(), "flagConfiguration")
64+
defer span.End()
65+
66+
reqBody := new(FlagConfigurationRequest)
67+
if err := c.Bind(reqBody); err != nil {
68+
return c.JSON(
69+
http.StatusBadRequest,
70+
FlagConfigurationResponse{
71+
ErrorCode: FlagConfigErrorInvalidRequest,
72+
ErrorDetails: fmt.Sprintf("impossible to read request body: %s", err),
73+
},
74+
)
75+
}
76+
77+
flags, err := h.goFF.GetFlagsFromCache()
78+
if err != nil {
79+
return c.JSON(http.StatusInternalServerError, FlagConfigurationResponse{
80+
ErrorCode: FlagConfigErrorRetrievingFlags,
81+
ErrorDetails: fmt.Sprintf("impossible to retrieve flag configuration: %s", err),
82+
})
83+
}
84+
85+
// filter if we have a list of flags in the request.
86+
if len(reqBody.Flags) > 0 {
87+
tmpFlags := map[string]flag.Flag{}
88+
for _, flagKey := range reqBody.Flags {
89+
if _, ok := flags[flagKey]; ok {
90+
tmpFlags[flagKey] = flags[flagKey]
91+
}
92+
}
93+
flags = tmpFlags
94+
}
95+
96+
span.SetAttributes(attribute.Int("flagConfiguration.configurationSize", len(flags)))
97+
c.Response().Header().
98+
Set(echo.HeaderLastModified, h.goFF.GetCacheRefreshDate().
99+
Format(time.RFC1123))
100+
return c.JSON(
101+
http.StatusOK,
102+
FlagConfigurationResponse{
103+
EvaluationContextEnrichment: h.goFF.GetEvaluationContextEnrichment(),
104+
Flags: flags,
105+
},
106+
)
107+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package controller_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"strings"
8+
"testing"
9+
10+
"github.com/labstack/echo/v4"
11+
"github.com/stretchr/testify/assert"
12+
ffclient "github.com/thomaspoignant/go-feature-flag"
13+
"github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/controller"
14+
"github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/metric"
15+
"github.com/thomaspoignant/go-feature-flag/retriever"
16+
"github.com/thomaspoignant/go-feature-flag/retriever/fileretriever"
17+
)
18+
19+
const mockConfigFlagsLocation = "../testdata/controller/configuration/"
20+
21+
func TestFlagConfigurationAPICtrl_Handler(t *testing.T) {
22+
defaultGoff, err := ffclient.New(ffclient.Config{
23+
Retrievers: []retriever.Retriever{
24+
&fileretriever.Retriever{Path: "../testdata/controller/configuration_flags.yaml"},
25+
},
26+
})
27+
assert.NoError(t, err)
28+
type want struct {
29+
bodyLocation string
30+
statusCode int
31+
}
32+
test := []struct {
33+
name string
34+
goff *ffclient.GoFeatureFlag
35+
requestBody string
36+
want struct {
37+
bodyLocation string
38+
statusCode int
39+
}
40+
}{
41+
{
42+
name: "Test with empty body",
43+
requestBody: mockConfigFlagsLocation + "requests/empty.json",
44+
goff: defaultGoff,
45+
want: want{
46+
statusCode: http.StatusOK,
47+
bodyLocation: mockConfigFlagsLocation + "responses/empty.json",
48+
},
49+
},
50+
{
51+
name: "Test with empty flags ",
52+
requestBody: mockConfigFlagsLocation + "requests/empty-flag-array.json",
53+
goff: defaultGoff,
54+
want: want{
55+
statusCode: http.StatusOK,
56+
bodyLocation: mockConfigFlagsLocation + "responses/empty-flag-array.json",
57+
},
58+
},
59+
{
60+
name: "Filter flags",
61+
requestBody: mockConfigFlagsLocation + "requests/filter-flags.json",
62+
goff: defaultGoff,
63+
want: want{
64+
statusCode: http.StatusOK,
65+
bodyLocation: mockConfigFlagsLocation + "responses/filter-flags.json",
66+
},
67+
},
68+
{
69+
name: "Invalid JSON",
70+
requestBody: mockConfigFlagsLocation + "requests/invalid-json.json",
71+
goff: defaultGoff,
72+
want: want{
73+
statusCode: http.StatusBadRequest,
74+
},
75+
},
76+
{
77+
name: "Offline mode",
78+
requestBody: mockConfigFlagsLocation + "requests/empty.json",
79+
goff: func() *ffclient.GoFeatureFlag {
80+
goff, err := ffclient.New(ffclient.Config{
81+
Retrievers: []retriever.Retriever{
82+
&fileretriever.Retriever{Path: "../testdata/controller/configuration_flags.yaml"},
83+
},
84+
Offline: true,
85+
})
86+
assert.NoError(t, err)
87+
return goff
88+
}(),
89+
want: want{
90+
statusCode: http.StatusInternalServerError,
91+
},
92+
},
93+
}
94+
95+
for _, tt := range test {
96+
t.Run(tt.name, func(t *testing.T) {
97+
ctrl := controller.NewAPIFlagConfiguration(tt.goff, metric.Metrics{})
98+
e := echo.New()
99+
rec := httptest.NewRecorder()
100+
101+
// read the request body from the file
102+
requestBody, err := os.ReadFile(tt.requestBody)
103+
assert.NoError(t, err)
104+
105+
req := httptest.NewRequest(echo.POST, "/v1/flag/configuration", strings.NewReader(string(requestBody)))
106+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
107+
c := e.NewContext(req, rec)
108+
c.SetPath("/v1/flag/configuration")
109+
110+
// Call the handler
111+
assert.NoError(t, ctrl.Handler(c))
112+
113+
assert.Equal(t, tt.want.statusCode, rec.Code)
114+
115+
if tt.want.bodyLocation != "" {
116+
wantBody, err := os.ReadFile(tt.want.bodyLocation)
117+
assert.NoError(t, err)
118+
assert.JSONEq(t, string(wantBody), rec.Body.String())
119+
}
120+
})
121+
}
122+
}

0 commit comments

Comments
 (0)