diff --git a/README.md b/README.md index a709a8760..819276b90 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Experiment with flagd in your browser using [the Killercoda tutorial](https://ki Retrieve a `String` value: ```sh - curl -X POST "http://localhost:8013/schema.v1.Service/ResolveString" \ + curl -X POST "http://localhost:8013/flagd.evaluation.v1.Service/ResolveString" \ -d '{"flagKey":"myStringFlag","context":{}}' -H "Content-Type: application/json" ``` @@ -105,7 +105,7 @@ Experiment with flagd in your browser using [the Killercoda tutorial](https://ki ```sh set json={"flagKey":"myStringFlag","context":{}} - curl -i -X POST -H "Content-Type: application/json" -d %json:"=\"% "localhost:8013/schema.v1.Service/ResolveString" + curl -i -X POST -H "Content-Type: application/json" -d %json:"=\"% "localhost:8013/flagd.evaluation.v1.Service/ResolveString" ``` Result: diff --git a/core/go.mod b/core/go.mod index e4225b420..cf1ff0fa6 100644 --- a/core/go.mod +++ b/core/go.mod @@ -4,7 +4,7 @@ go 1.20 require ( buf.build/gen/go/open-feature/flagd/connectrpc/go v1.12.0-20231031123731-ac2ec0f39838.1 - buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20230710190440-2333a9579c1a.1 + buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20231031123731-ac2ec0f39838.2 buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.31.0-20231031123731-ac2ec0f39838.2 connectrpc.com/connect v1.13.0 connectrpc.com/otelconnect v0.6.0 diff --git a/core/go.sum b/core/go.sum index ef04183b5..dcaec164b 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,11 +1,11 @@ -buf.build/gen/go/grpc-ecosystem/grpc-gateway/grpc/go v1.3.0-20220906183531-bc28b723cd77.1/go.mod h1:9Ec7rvBnjfZvU/TnWjtcSGgiLQ4B+U3B+6SnZgVTA7A= +buf.build/gen/go/grpc-ecosystem/grpc-gateway/grpc/go v1.3.0-20220906183531-bc28b723cd77.2/go.mod h1:9Ec7rvBnjfZvU/TnWjtcSGgiLQ4B+U3B+6SnZgVTA7A= buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.28.1-20220906183531-bc28b723cd77.4/go.mod h1:92ejKVTiuvnKoAtRlpJpIxKfloI935DDqhs0NCRx+KM= buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.31.0-20220906183531-bc28b723cd77.2/go.mod h1:/j/LOrpev/FdyGhdj/sOc0peUf2KR0y4nMmLp4t1g14= buf.build/gen/go/open-feature/flagd/connectrpc/go v1.12.0-20231031123731-ac2ec0f39838.1 h1:wgTgPwRPfD+xXJW6bD+Hcn9KhyPTewy3uOOnpYbeA0c= buf.build/gen/go/open-feature/flagd/connectrpc/go v1.12.0-20231031123731-ac2ec0f39838.1/go.mod h1:l+36EM5Mg5mkmpPNCaIdAt4hvbwYRJKcOe/8ZP/383M= -buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20230710190440-2333a9579c1a.1 h1:P20N6hN+bx4U9Iccb0dkmvHO+H2lUwdm6QDI57o5U8s= -buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20230710190440-2333a9579c1a.1/go.mod h1:+lhRQ8QpGLbYqHVf4S9cNpKwytWTyXmcmOoeBPqXm94= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.28.1-20230710190440-2333a9579c1a.4/go.mod h1:+Bnrjo56uVn/aBcLWchTveR8UeCj+KSJN4fE0xSmBNc= +buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20231031123731-ac2ec0f39838.2 h1:DCww6WQNaepShZVh/jDVpIfCHQy5QwrpKl8iYAZeaV8= +buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20231031123731-ac2ec0f39838.2/go.mod h1:NmrKm2OIzFV3sUPs9cWMCmbYeCM3xVEzt4YzFgY5HO4= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.28.1-20231031123731-ac2ec0f39838.4/go.mod h1:+Bnrjo56uVn/aBcLWchTveR8UeCj+KSJN4fE0xSmBNc= buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.31.0-20231031123731-ac2ec0f39838.2 h1:oYhz5yXOku2FUOFil3hlKp3phfLBinKyUMHkml267kI= buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.31.0-20231031123731-ac2ec0f39838.2/go.mod h1:QXsT/9pJTFDRE9VnNkVgkfJFAAEVwkTp7/f5JBjyw2Y= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -438,8 +438,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/diegoholiveira/jsonlogic/v3 v3.3.2 h1:srg/h16pzyuS0/+P2HOt2zdDPDnzaFZtsHtfTugRPVc= -github.com/diegoholiveira/jsonlogic/v3 v3.3.2/go.mod h1:9oE8z9G+0OMxOoLHF3fhek3KuqD5CBqM0B6XFL08MSg= github.com/diegoholiveira/jsonlogic/v3 v3.4.0 h1:TN++nRmEMA5UHzKl8MJ1kbF5SSzWtKHE0PZ6ITbJeH4= github.com/diegoholiveira/jsonlogic/v3 v3.4.0/go.mod h1:9oE8z9G+0OMxOoLHF3fhek3KuqD5CBqM0B6XFL08MSg= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= @@ -868,8 +866,6 @@ golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri golang.org/x/oauth2 v0.0.0-20221006150949-b44042a4b9c1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= -golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= -golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1108,7 +1104,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= @@ -1224,13 +1219,9 @@ google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZV google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd/go.mod h1:cTsE614GARnxrLsqKREzmNYJACSWWpAWdNMwnD7c2BE= google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= +google.golang.org/genproto v0.0.0-20231002182017-d307bd883b97 h1:SeZZZx0cP0fqUyA+oRzP9k7cSwJlvDFiROO72uwD6i0= google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97 h1:W18sezcAYs+3tDZX4F80yctqa12jcP1PUS2gQu1zTPU= google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97/go.mod h1:iargEX0SFPm3xcfMI0d1domjg0ZF4Aa0p2awqyxhvF0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 h1:6GQBEOdGkX6MMTLT9V+TjtIRZCw9VPD5Z+yHY9wMgS0= google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -1271,8 +1262,6 @@ google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCD google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= google.golang.org/grpc v1.60.0 h1:6FQAR0kM31P6MRdeluor2w2gPaS4SVNrD/DNTxrQ15k= google.golang.org/grpc v1.60.0/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU= diff --git a/core/pkg/service/flag-evaluation/connect_service.go b/core/pkg/service/flag-evaluation/connect_service.go index 9bcefbf2b..d961f8043 100644 --- a/core/pkg/service/flag-evaluation/connect_service.go +++ b/core/pkg/service/flag-evaluation/connect_service.go @@ -11,6 +11,7 @@ import ( "sync" "time" + evaluationV1 "buf.build/gen/go/open-feature/flagd/connectrpc/go/flagd/evaluation/v1/evaluationv1connect" schemaConnectV1 "buf.build/gen/go/open-feature/flagd/connectrpc/go/schema/v1/schemav1connect" "github.com/open-feature/flagd/core/pkg/evaluator" "github.com/open-feature/flagd/core/pkg/logger" @@ -31,7 +32,27 @@ import ( "google.golang.org/protobuf/encoding/protojson" ) -const ErrorPrefix = "FlagdError:" +const ( + ErrorPrefix = "FlagdError:" + + flagdSchemaPrefix = "/flagd" +) + +// bufSwitchHandler combines the handlers of the old and new evaluation schema and combines them into one +// this way we support both the new and the (deprecated) old schemas until only the new schema is supported +// NOTE: this will not be required anymore when it is time to work on https://github.com/open-feature/flagd/issues/1088 +type bufSwitchHandler struct { + old http.Handler + new http.Handler +} + +func (b bufSwitchHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if strings.HasPrefix(request.URL.Path, flagdSchemaPrefix) { + b.new.ServeHTTP(writer, request) + } else { + b.old.ServeHTTP(writer, request) + } +} type ConnectService struct { logger *logger.Logger @@ -107,10 +128,11 @@ func (s *ConnectService) Notify(n service.Notification) { s.eventingConfiguration.emitToAll(n) } +// nolint: funlen func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listener, error) { var lis net.Listener var err error - mux := http.NewServeMux() + if svcConf.SocketPath != "" { lis, err = net.Listen("unix", svcConf.SocketPath) } else { @@ -120,7 +142,10 @@ func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listene if err != nil { return nil, fmt.Errorf("error creating listener for flag evaluation service: %w", err) } - fes := NewFlagEvaluationService( + + // register handler for old flag evaluation schema + // can be removed as a part of https://github.com/open-feature/flagd/issues/1088 + fes := NewOldFlagEvaluationService( s.logger.WithFields(zap.String("component", "flagservice")), s.eval, s.eventingConfiguration, @@ -133,12 +158,27 @@ func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listene protojson.UnmarshalOptions{DiscardUnknown: true}, ) - mux.Handle(schemaConnectV1.NewServiceHandler(fes, append(svcConf.Options, marshalOpts)...)) + _, oldHandler := schemaConnectV1.NewServiceHandler(fes, append(svcConf.Options, marshalOpts)...) + + // register handler for new flag evaluation schema + + newFes := NewFlagEvaluationService(s.logger.WithFields(zap.String("component", "flagd.evaluation.v1")), + s.eval, + s.eventingConfiguration, + s.metrics, + ) + + _, newHandler := evaluationV1.NewServiceHandler(newFes, svcConf.Options...) + + bs := bufSwitchHandler{ + old: oldHandler, + new: newHandler, + } s.serverMtx.Lock() s.server = &http.Server{ ReadHeaderTimeout: time.Second, - Handler: mux, + Handler: bs, } s.serverMtx.Unlock() diff --git a/core/pkg/service/flag-evaluation/connect_service_test.go b/core/pkg/service/flag-evaluation/connect_service_test.go index 90892cf16..dae550c39 100644 --- a/core/pkg/service/flag-evaluation/connect_service_test.go +++ b/core/pkg/service/flag-evaluation/connect_service_test.go @@ -154,7 +154,7 @@ func TestAddMiddleware(t *testing.T) { }() require.Eventually(t, func() bool { - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/schema.v1.Service/ResolveAll", port)) + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/flagd.evaluation.v1.Service/ResolveAll", port)) // with the default http handler we should get a method not allowed (405) when attempting a GET request return err == nil && resp.StatusCode == http.StatusMethodNotAllowed }, 3*time.Second, 100*time.Millisecond) @@ -162,7 +162,7 @@ func TestAddMiddleware(t *testing.T) { svc.AddMiddleware(mwMock) // with the injected middleware, the GET method should work - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/schema.v1.Service/ResolveAll", port)) + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/flagd.evaluation.v1.Service/ResolveAll", port)) require.Nil(t, err) // verify that the status we return in the mocked middleware diff --git a/core/pkg/service/flag-evaluation/flag_evaluator.go b/core/pkg/service/flag-evaluation/flag_evaluator.go index 70527f73a..a01f0c0ba 100644 --- a/core/pkg/service/flag-evaluation/flag_evaluator.go +++ b/core/pkg/service/flag-evaluation/flag_evaluator.go @@ -24,7 +24,9 @@ import ( type resolverSignature[T constraints] func(context context.Context, reqID, flagKey string, ctx map[string]any) ( T, string, string, map[string]interface{}, error) -type FlagEvaluationService struct { +// OldFlagEvaluationService implements the methods required for the soon-to-be deprecated flag evaluation schema +// this can be removed as a part of https://github.com/open-feature/flagd/issues/1088 +type OldFlagEvaluationService struct { logger *logger.Logger eval evaluator.IEvaluator metrics *telemetry.MetricsRecorder @@ -32,11 +34,11 @@ type FlagEvaluationService struct { flagEvalTracer trace.Tracer } -// NewFlagEvaluationService creates a FlagEvaluationService with provided parameters -func NewFlagEvaluationService(log *logger.Logger, +// NewOldFlagEvaluationService creates a OldFlagEvaluationService with provided parameters +func NewOldFlagEvaluationService(log *logger.Logger, eval evaluator.IEvaluator, eventingCfg *eventingConfiguration, metricsRecorder *telemetry.MetricsRecorder, -) *FlagEvaluationService { - return &FlagEvaluationService{ +) *OldFlagEvaluationService { + return &OldFlagEvaluationService{ logger: log, eval: eval, metrics: metricsRecorder, @@ -45,7 +47,8 @@ func NewFlagEvaluationService(log *logger.Logger, } } -func (s *FlagEvaluationService) ResolveAll( +// nolint:dupl +func (s *OldFlagEvaluationService) ResolveAll( ctx context.Context, req *connect.Request[schemaV1.ResolveAllRequest], ) (*connect.Response[schemaV1.ResolveAllResponse], error) { @@ -108,7 +111,7 @@ func (s *FlagEvaluationService) ResolveAll( return connect.NewResponse(res), nil } -func (s *FlagEvaluationService) EventStream( +func (s *OldFlagEvaluationService) EventStream( ctx context.Context, req *connect.Request[schemaV1.EventStreamRequest], stream *connect.ServerStream[schemaV1.EventStreamResponse], @@ -147,7 +150,7 @@ func (s *FlagEvaluationService) EventStream( } } -func (s *FlagEvaluationService) ResolveBoolean( +func (s *OldFlagEvaluationService) ResolveBoolean( ctx context.Context, req *connect.Request[schemaV1.ResolveBooleanRequest], ) (*connect.Response[schemaV1.ResolveBooleanResponse], error) { @@ -160,7 +163,7 @@ func (s *FlagEvaluationService) ResolveBoolean( s.eval.ResolveBooleanValue, req.Msg.GetFlagKey(), req.Msg.GetContext(), - &booleanResponse{res}, + &booleanResponse{schemaV1Resp: res}, s.metrics, ) if err != nil { @@ -171,7 +174,7 @@ func (s *FlagEvaluationService) ResolveBoolean( return res, err } -func (s *FlagEvaluationService) ResolveString( +func (s *OldFlagEvaluationService) ResolveString( ctx context.Context, req *connect.Request[schemaV1.ResolveStringRequest], ) (*connect.Response[schemaV1.ResolveStringResponse], error) { @@ -185,7 +188,7 @@ func (s *FlagEvaluationService) ResolveString( s.eval.ResolveStringValue, req.Msg.GetFlagKey(), req.Msg.GetContext(), - &stringResponse{res}, + &stringResponse{schemaV1Resp: res}, s.metrics, ) if err != nil { @@ -196,7 +199,7 @@ func (s *FlagEvaluationService) ResolveString( return res, err } -func (s *FlagEvaluationService) ResolveInt( +func (s *OldFlagEvaluationService) ResolveInt( ctx context.Context, req *connect.Request[schemaV1.ResolveIntRequest], ) (*connect.Response[schemaV1.ResolveIntResponse], error) { @@ -210,7 +213,7 @@ func (s *FlagEvaluationService) ResolveInt( s.eval.ResolveIntValue, req.Msg.GetFlagKey(), req.Msg.GetContext(), - &intResponse{res}, + &intResponse{schemaV1Resp: res}, s.metrics, ) if err != nil { @@ -221,7 +224,7 @@ func (s *FlagEvaluationService) ResolveInt( return res, err } -func (s *FlagEvaluationService) ResolveFloat( +func (s *OldFlagEvaluationService) ResolveFloat( ctx context.Context, req *connect.Request[schemaV1.ResolveFloatRequest], ) (*connect.Response[schemaV1.ResolveFloatResponse], error) { @@ -235,7 +238,7 @@ func (s *FlagEvaluationService) ResolveFloat( s.eval.ResolveFloatValue, req.Msg.GetFlagKey(), req.Msg.GetContext(), - &floatResponse{res}, + &floatResponse{schemaV1Resp: res}, s.metrics, ) if err != nil { @@ -246,7 +249,7 @@ func (s *FlagEvaluationService) ResolveFloat( return res, err } -func (s *FlagEvaluationService) ResolveObject( +func (s *OldFlagEvaluationService) ResolveObject( ctx context.Context, req *connect.Request[schemaV1.ResolveObjectRequest], ) (*connect.Response[schemaV1.ResolveObjectResponse], error) { @@ -260,7 +263,7 @@ func (s *FlagEvaluationService) ResolveObject( s.eval.ResolveObjectValue, req.Msg.GetFlagKey(), req.Msg.GetContext(), - &objectResponse{res}, + &objectResponse{schemaV1Resp: res}, s.metrics, ) if err != nil { diff --git a/core/pkg/service/flag-evaluation/flag_evaluator_test.go b/core/pkg/service/flag-evaluation/flag_evaluator_test.go index 2f57803ac..c6fc3c55d 100644 --- a/core/pkg/service/flag-evaluation/flag_evaluator_test.go +++ b/core/pkg/service/flag-evaluation/flag_evaluator_test.go @@ -121,7 +121,7 @@ func TestConnectService_ResolveAll(t *testing.T) { tt.evalRes, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -228,7 +228,7 @@ func TestFlag_Evaluation_ResolveBoolean(t *testing.T) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -283,7 +283,7 @@ func BenchmarkFlag_Evaluation_ResolveBoolean(b *testing.B) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -381,7 +381,7 @@ func TestFlag_Evaluation_ResolveString(t *testing.T) { tt.wantErr, ) metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -436,7 +436,7 @@ func BenchmarkFlag_Evaluation_ResolveString(b *testing.B) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -533,7 +533,7 @@ func TestFlag_Evaluation_ResolveFloat(t *testing.T) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -588,7 +588,7 @@ func BenchmarkFlag_Evaluation_ResolveFloat(b *testing.B) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -685,7 +685,7 @@ func TestFlag_Evaluation_ResolveInt(t *testing.T) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -740,7 +740,7 @@ func BenchmarkFlag_Evaluation_ResolveInt(b *testing.B) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -840,7 +840,7 @@ func TestFlag_Evaluation_ResolveObject(t *testing.T) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, @@ -903,7 +903,7 @@ func BenchmarkFlag_Evaluation_ResolveObject(b *testing.B) { tt.wantErr, ).AnyTimes() metrics, exp := getMetricReader() - s := NewFlagEvaluationService( + s := NewOldFlagEvaluationService( logger.NewLogger(nil, false), eval, &eventingConfiguration{}, diff --git a/core/pkg/service/flag-evaluation/flag_evaluator_types.go b/core/pkg/service/flag-evaluation/flag_evaluator_types.go index 1668ec7f0..f14964843 100644 --- a/core/pkg/service/flag-evaluation/flag_evaluator_types.go +++ b/core/pkg/service/flag-evaluation/flag_evaluator_types.go @@ -3,6 +3,7 @@ package service import ( "fmt" + evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" schemaV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/schema/v1" "connectrpc.com/connect" "google.golang.org/protobuf/types/known/structpb" @@ -17,98 +18,145 @@ type constraints interface { } type booleanResponse struct { - *connect.Response[schemaV1.ResolveBooleanResponse] + schemaV1Resp *connect.Response[schemaV1.ResolveBooleanResponse] + evalV1Resp *connect.Response[evalV1.ResolveBooleanResponse] } func (r *booleanResponse) SetResult(value bool, variant, reason string, metadata map[string]interface{}) error { - r.Msg.Value = value - r.Msg.Variant = variant - r.Msg.Reason = reason - newStruct, err := structpb.NewStruct(metadata) if err != nil { return fmt.Errorf("failure to wrap metadata %w", err) } - r.Msg.Metadata = newStruct + if r.schemaV1Resp != nil { + r.schemaV1Resp.Msg.Value = value + r.schemaV1Resp.Msg.Variant = variant + r.schemaV1Resp.Msg.Reason = reason + r.schemaV1Resp.Msg.Metadata = newStruct + } + if r.evalV1Resp != nil { + r.evalV1Resp.Msg.Value = value + r.evalV1Resp.Msg.Variant = variant + r.evalV1Resp.Msg.Reason = reason + r.evalV1Resp.Msg.Metadata = newStruct + } + return nil } type stringResponse struct { - *connect.Response[schemaV1.ResolveStringResponse] + schemaV1Resp *connect.Response[schemaV1.ResolveStringResponse] + evalV1Resp *connect.Response[evalV1.ResolveStringResponse] } func (r *stringResponse) SetResult(value string, variant, reason string, metadata map[string]interface{}) error { - r.Msg.Value = value - r.Msg.Variant = variant - r.Msg.Reason = reason - newStruct, err := structpb.NewStruct(metadata) if err != nil { return fmt.Errorf("failure to wrap metadata %w", err) } - r.Msg.Metadata = newStruct + if r.schemaV1Resp != nil { + r.schemaV1Resp.Msg.Value = value + r.schemaV1Resp.Msg.Variant = variant + r.schemaV1Resp.Msg.Reason = reason + r.schemaV1Resp.Msg.Metadata = newStruct + } + if r.evalV1Resp != nil { + r.evalV1Resp.Msg.Value = value + r.evalV1Resp.Msg.Variant = variant + r.evalV1Resp.Msg.Reason = reason + r.evalV1Resp.Msg.Metadata = newStruct + } + return nil } type floatResponse struct { - *connect.Response[schemaV1.ResolveFloatResponse] + schemaV1Resp *connect.Response[schemaV1.ResolveFloatResponse] + evalV1Resp *connect.Response[evalV1.ResolveFloatResponse] } func (r *floatResponse) SetResult(value float64, variant, reason string, metadata map[string]interface{}) error { - r.Msg.Value = value - r.Msg.Variant = variant - r.Msg.Reason = reason - newStruct, err := structpb.NewStruct(metadata) if err != nil { return fmt.Errorf("failure to wrap metadata %w", err) } - r.Msg.Metadata = newStruct + if r.schemaV1Resp != nil { + r.schemaV1Resp.Msg.Value = value + r.schemaV1Resp.Msg.Variant = variant + r.schemaV1Resp.Msg.Reason = reason + r.schemaV1Resp.Msg.Metadata = newStruct + } + if r.evalV1Resp != nil { + r.evalV1Resp.Msg.Value = value + r.evalV1Resp.Msg.Variant = variant + r.evalV1Resp.Msg.Reason = reason + r.evalV1Resp.Msg.Metadata = newStruct + } + return nil } type intResponse struct { - *connect.Response[schemaV1.ResolveIntResponse] + schemaV1Resp *connect.Response[schemaV1.ResolveIntResponse] + evalV1Resp *connect.Response[evalV1.ResolveIntResponse] } func (r *intResponse) SetResult(value int64, variant, reason string, metadata map[string]interface{}) error { - r.Msg.Value = value - r.Msg.Variant = variant - r.Msg.Reason = reason - newStruct, err := structpb.NewStruct(metadata) if err != nil { return fmt.Errorf("failure to wrap metadata %w", err) } - r.Msg.Metadata = newStruct + if r.schemaV1Resp != nil { + r.schemaV1Resp.Msg.Value = value + r.schemaV1Resp.Msg.Variant = variant + r.schemaV1Resp.Msg.Reason = reason + r.schemaV1Resp.Msg.Metadata = newStruct + } + if r.evalV1Resp != nil { + r.evalV1Resp.Msg.Value = value + r.evalV1Resp.Msg.Variant = variant + r.evalV1Resp.Msg.Reason = reason + r.evalV1Resp.Msg.Metadata = newStruct + } return nil } type objectResponse struct { - *connect.Response[schemaV1.ResolveObjectResponse] + schemaV1Resp *connect.Response[schemaV1.ResolveObjectResponse] + evalV1Resp *connect.Response[evalV1.ResolveObjectResponse] } func (r *objectResponse) SetResult(value map[string]any, variant, reason string, metadata map[string]interface{}, ) error { - r.Msg.Reason = reason - val, err := structpb.NewStruct(value) - if err != nil { - return fmt.Errorf("struct response construction: %w", err) - } - - r.Msg.Value = val - r.Msg.Variant = variant - newStruct, err := structpb.NewStruct(metadata) if err != nil { return fmt.Errorf("failure to wrap metadata %w", err) } - - r.Msg.Metadata = newStruct + if r.schemaV1Resp != nil { + r.schemaV1Resp.Msg.Reason = reason + val, err := structpb.NewStruct(value) + if err != nil { + return fmt.Errorf("struct response construction: %w", err) + } + + r.schemaV1Resp.Msg.Value = val + r.schemaV1Resp.Msg.Variant = variant + r.schemaV1Resp.Msg.Metadata = newStruct + } + if r.evalV1Resp != nil { + r.evalV1Resp.Msg.Reason = reason + val, err := structpb.NewStruct(value) + if err != nil { + return fmt.Errorf("struct response construction: %w", err) + } + + r.evalV1Resp.Msg.Value = val + r.evalV1Resp.Msg.Variant = variant + r.evalV1Resp.Msg.Metadata = newStruct + } return nil } diff --git a/core/pkg/service/flag-evaluation/flag_evaluator_v2.go b/core/pkg/service/flag-evaluation/flag_evaluator_v2.go new file mode 100644 index 000000000..32f9cf1c6 --- /dev/null +++ b/core/pkg/service/flag-evaluation/flag_evaluator_v2.go @@ -0,0 +1,274 @@ +package service + +import ( + "context" + "fmt" + "time" + + evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" + "connectrpc.com/connect" + "github.com/open-feature/flagd/core/pkg/evaluator" + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/service" + "github.com/open-feature/flagd/core/pkg/telemetry" + "github.com/rs/xid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/structpb" +) + +type FlagEvaluationService struct { + logger *logger.Logger + eval evaluator.IEvaluator + metrics *telemetry.MetricsRecorder + eventingConfiguration *eventingConfiguration + flagEvalTracer trace.Tracer +} + +// NewFlagEvaluationService creates a FlagEvaluationService with provided parameters +func NewFlagEvaluationService(log *logger.Logger, + eval evaluator.IEvaluator, + eventingCfg *eventingConfiguration, + metricsRecorder *telemetry.MetricsRecorder, +) *FlagEvaluationService { + return &FlagEvaluationService{ + logger: log, + eval: eval, + metrics: metricsRecorder, + eventingConfiguration: eventingCfg, + flagEvalTracer: otel.Tracer("flagd.evaluation.v1"), + } +} + +// nolint:dupl,funlen +func (s *FlagEvaluationService) ResolveAll( + ctx context.Context, + req *connect.Request[evalV1.ResolveAllRequest], +) (*connect.Response[evalV1.ResolveAllResponse], error) { + reqID := xid.New().String() + defer s.logger.ClearFields(reqID) + + sCtx, span := s.flagEvalTracer.Start(ctx, "resolveAll", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + res := &evalV1.ResolveAllResponse{ + Flags: make(map[string]*evalV1.AnyFlag), + } + + evalCtx := map[string]any{} + if e := req.Msg.GetContext(); e != nil { + evalCtx = e.AsMap() + } + + values := s.eval.ResolveAllValues(sCtx, reqID, evalCtx) + span.SetAttributes(attribute.Int("feature_flag.count", len(values))) + for _, value := range values { + // register the impression and reason for each flag evaluated + s.metrics.RecordEvaluation(sCtx, value.Error, value.Reason, value.Variant, value.FlagKey) + switch v := value.Value.(type) { + case bool: + res.Flags[value.FlagKey] = &evalV1.AnyFlag{ + Reason: value.Reason, + Variant: value.Variant, + Value: &evalV1.AnyFlag_BoolValue{ + BoolValue: v, + }, + } + case string: + res.Flags[value.FlagKey] = &evalV1.AnyFlag{ + Reason: value.Reason, + Variant: value.Variant, + Value: &evalV1.AnyFlag_StringValue{ + StringValue: v, + }, + } + case float64: + res.Flags[value.FlagKey] = &evalV1.AnyFlag{ + Reason: value.Reason, + Variant: value.Variant, + Value: &evalV1.AnyFlag_DoubleValue{ + DoubleValue: v, + }, + } + case map[string]any: + val, err := structpb.NewStruct(v) + if err != nil { + s.logger.ErrorWithID(reqID, fmt.Sprintf("struct response construction: %v", err)) + continue + } + res.Flags[value.FlagKey] = &evalV1.AnyFlag{ + Reason: value.Reason, + Variant: value.Variant, + Value: &evalV1.AnyFlag_ObjectValue{ + ObjectValue: val, + }, + } + } + } + return connect.NewResponse(res), nil +} + +func (s *FlagEvaluationService) EventStream( + ctx context.Context, + req *connect.Request[evalV1.EventStreamRequest], + stream *connect.ServerStream[evalV1.EventStreamResponse], +) error { + requestNotificationChan := make(chan service.Notification, 1) + s.eventingConfiguration.subscribe(req, requestNotificationChan) + defer s.eventingConfiguration.unSubscribe(req) + + requestNotificationChan <- service.Notification{ + Type: service.ProviderReady, + } + for { + select { + case <-time.After(20 * time.Second): + err := stream.Send(&evalV1.EventStreamResponse{ + Type: string(service.KeepAlive), + }) + if err != nil { + s.logger.Error(err.Error()) + } + case notification := <-requestNotificationChan: + d, err := structpb.NewStruct(notification.Data) + if err != nil { + s.logger.Error(err.Error()) + } + err = stream.Send(&evalV1.EventStreamResponse{ + Type: string(notification.Type), + Data: d, + }) + if err != nil { + s.logger.Error(err.Error()) + } + case <-ctx.Done(): + return nil + } + } +} + +func (s *FlagEvaluationService) ResolveBoolean( + ctx context.Context, + req *connect.Request[evalV1.ResolveBooleanRequest], +) (*connect.Response[evalV1.ResolveBooleanResponse], error) { + sCtx, span := s.flagEvalTracer.Start(ctx, "resolveBoolean", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + res := connect.NewResponse(&evalV1.ResolveBooleanResponse{}) + err := resolve[bool]( + sCtx, + s.logger, + s.eval.ResolveBooleanValue, + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &booleanResponse{evalV1Resp: res}, + s.metrics, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveString( + ctx context.Context, + req *connect.Request[evalV1.ResolveStringRequest], +) (*connect.Response[evalV1.ResolveStringResponse], error) { + sCtx, span := s.flagEvalTracer.Start(ctx, "resolveString", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + res := connect.NewResponse(&evalV1.ResolveStringResponse{}) + err := resolve[string]( + sCtx, + s.logger, + s.eval.ResolveStringValue, + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &stringResponse{evalV1Resp: res}, + s.metrics, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveInt( + ctx context.Context, + req *connect.Request[evalV1.ResolveIntRequest], +) (*connect.Response[evalV1.ResolveIntResponse], error) { + sCtx, span := s.flagEvalTracer.Start(ctx, "resolveInt", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + res := connect.NewResponse(&evalV1.ResolveIntResponse{}) + err := resolve[int64]( + sCtx, + s.logger, + s.eval.ResolveIntValue, + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &intResponse{evalV1Resp: res}, + s.metrics, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveFloat( + ctx context.Context, + req *connect.Request[evalV1.ResolveFloatRequest], +) (*connect.Response[evalV1.ResolveFloatResponse], error) { + sCtx, span := s.flagEvalTracer.Start(ctx, "resolveFloat", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + res := connect.NewResponse(&evalV1.ResolveFloatResponse{}) + err := resolve[float64]( + sCtx, + s.logger, + s.eval.ResolveFloatValue, + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &floatResponse{evalV1Resp: res}, + s.metrics, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveObject( + ctx context.Context, + req *connect.Request[evalV1.ResolveObjectRequest], +) (*connect.Response[evalV1.ResolveObjectResponse], error) { + sCtx, span := s.flagEvalTracer.Start(ctx, "resolveObject", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + res := connect.NewResponse(&evalV1.ResolveObjectResponse{}) + err := resolve[map[string]any]( + sCtx, + s.logger, + s.eval.ResolveObjectValue, + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &objectResponse{evalV1Resp: res}, + s.metrics, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} diff --git a/core/pkg/service/flag-evaluation/flag_evaluator_v2_test.go b/core/pkg/service/flag-evaluation/flag_evaluator_v2_test.go new file mode 100644 index 000000000..7b4fb1385 --- /dev/null +++ b/core/pkg/service/flag-evaluation/flag_evaluator_v2_test.go @@ -0,0 +1,946 @@ +package service + +import ( + "context" + "errors" + "testing" + + evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" + "connectrpc.com/connect" + "github.com/golang/mock/gomock" + "github.com/open-feature/flagd/core/pkg/evaluator" + mock "github.com/open-feature/flagd/core/pkg/evaluator/mock" + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestConnectServiceV2_ResolveAll(t *testing.T) { + tests := map[string]struct { + req *evalV1.ResolveAllRequest + evalRes []evaluator.AnyValue + wantErr error + wantRes *evalV1.ResolveAllResponse + }{ + "happy-path": { + req: &evalV1.ResolveAllRequest{}, + evalRes: []evaluator.AnyValue{ + { + Value: true, + Variant: "bool-true", + Reason: "true", + FlagKey: "bool", + }, + { + Value: float64(12.12), + Variant: "float", + Reason: "float", + FlagKey: "float", + }, + { + Value: "hello", + Variant: "string", + Reason: "string", + FlagKey: "string", + }, + { + Value: "hello", + Variant: "object", + Reason: "string", + FlagKey: "object", + }, + }, + wantErr: nil, + wantRes: &evalV1.ResolveAllResponse{ + Flags: map[string]*evalV1.AnyFlag{ + "bool": { + Value: &evalV1.AnyFlag_BoolValue{ + BoolValue: true, + }, + Reason: "STATIC", + }, + "float": { + Value: &evalV1.AnyFlag_DoubleValue{ + DoubleValue: float64(12.12), + }, + Reason: "STATIC", + }, + "string": { + Value: &evalV1.AnyFlag_StringValue{ + StringValue: "hello", + }, + Reason: "STATIC", + }, + }, + }, + }, + } + ctrl := gomock.NewController(t) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveAllValues(gomock.Any(), gomock.Any(), gomock.Any()).Return( + tt.evalRes, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + got, err := s.ResolveAll(context.Background(), connect.NewRequest(tt.req)) + if err != nil && !errors.Is(err, tt.wantErr) { + t.Errorf("ConnectService.ResolveAll() error = %v, wantErr %v", err.Error(), tt.wantErr.Error()) + return + } + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), 1) + for _, flag := range tt.evalRes { + switch v := flag.Value.(type) { + case bool: + val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_BoolValue) + require.Equal(t, v, val.BoolValue) + case string: + val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_StringValue) + require.Equal(t, v, val.StringValue) + case float64: + val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_DoubleValue) + require.Equal(t, v, val.DoubleValue) + } + } + }) + } +} + +type resolveBooleanArgsV2 struct { + evalFields resolveBooleanEvalFieldsV2 + functionArgs resolveBooleanFunctionArgsV2 + want *evalV1.ResolveBooleanResponse + wantErr error + mCount int +} +type resolveBooleanFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveBooleanRequest +} +type resolveBooleanEvalFieldsV2 struct { + result bool + evalCommons +} + +func TestFlag_EvaluationV2_ResolveBoolean(t *testing.T) { + ctrl := gomock.NewController(t) + + tests := map[string]resolveBooleanArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveBooleanEvalFieldsV2{ + result: true, + evalCommons: happyCommon, + }, + functionArgs: resolveBooleanFunctionArgsV2{ + context.Background(), + &evalV1.ResolveBooleanRequest{ + FlagKey: "bool", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveBooleanResponse{ + Value: true, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveBooleanEvalFieldsV2{ + result: true, + evalCommons: sadCommon, + }, + functionArgs: resolveBooleanFunctionArgsV2{ + context.Background(), + &evalV1.ResolveBooleanRequest{ + FlagKey: "bool", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveBooleanResponse{ + Value: true, + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + got, err := s.ResolveBoolean(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveBoolean() error = %v, wantErr %v", err.Error(), tt.wantErr.Error()) + return + } + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + require.Equal(t, tt.want, got.Msg) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveBoolean(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveBooleanArgsV2{ + "happy path": { + evalFields: resolveBooleanEvalFieldsV2{ + result: true, + evalCommons: happyCommon, + }, + functionArgs: resolveBooleanFunctionArgsV2{ + context.Background(), + &evalV1.ResolveBooleanRequest{ + FlagKey: "bool", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveBooleanResponse{ + Value: true, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveBoolean(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveBoolean() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveStringArgsV2 struct { + evalFields resolveStringEvalFieldsV2 + functionArgs resolveStringFunctionArgsV2 + want *evalV1.ResolveStringResponse + wantErr error + mCount int +} +type resolveStringFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveStringRequest +} +type resolveStringEvalFieldsV2 struct { + result string + evalCommons +} + +func TestFlag_EvaluationV2_ResolveString(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveStringArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveStringEvalFieldsV2{ + result: "true", + evalCommons: happyCommon, + }, + functionArgs: resolveStringFunctionArgsV2{ + context.Background(), + &evalV1.ResolveStringRequest{ + FlagKey: "string", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveStringResponse{ + Value: "true", + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveStringEvalFieldsV2{ + result: "true", + evalCommons: sadCommon, + }, + functionArgs: resolveStringFunctionArgsV2{ + context.Background(), + &evalV1.ResolveStringRequest{ + FlagKey: "string", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveStringResponse{ + Value: "true", + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveStringValue( + gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ) + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + got, err := s.ResolveString(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveString() error = %v, wantErr %v", err, tt.wantErr) + return + } + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + require.Equal(t, tt.want, got.Msg) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveString(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveStringArgsV2{ + "happy path": { + evalFields: resolveStringEvalFieldsV2{ + result: "true", + evalCommons: happyCommon, + }, + functionArgs: resolveStringFunctionArgsV2{ + context.Background(), + &evalV1.ResolveStringRequest{ + FlagKey: "string", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveStringResponse{ + Value: "true", + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveStringValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveString(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveString() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveFloatArgsV2 struct { + evalFields resolveFloatEvalFieldsV2 + functionArgs resolveFloatFunctionArgsV2 + want *evalV1.ResolveFloatResponse + wantErr error + mCount int +} +type resolveFloatFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveFloatRequest +} +type resolveFloatEvalFieldsV2 struct { + result float64 + evalCommons +} + +func TestFlag_EvaluationV2_ResolveFloat(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveFloatArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveFloatEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveFloatFunctionArgsV2{ + context.Background(), + &evalV1.ResolveFloatRequest{ + FlagKey: "float", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveFloatResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveFloatEvalFieldsV2{ + result: 12, + evalCommons: sadCommon, + }, + functionArgs: resolveFloatFunctionArgsV2{ + context.Background(), + &evalV1.ResolveFloatRequest{ + FlagKey: "float", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveFloatResponse{ + Value: 12, + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveFloatValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + got, err := s.ResolveFloat(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(t, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveFloat(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveFloatArgsV2{ + "happy path": { + evalFields: resolveFloatEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveFloatFunctionArgsV2{ + context.Background(), + &evalV1.ResolveFloatRequest{ + FlagKey: "float", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveFloatResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveFloatValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveFloat(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveIntArgsV2 struct { + evalFields resolveIntEvalFieldsV2 + functionArgs resolveIntFunctionArgsV2 + want *evalV1.ResolveIntResponse + wantErr error + mCount int +} +type resolveIntFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveIntRequest +} +type resolveIntEvalFieldsV2 struct { + result int64 + evalCommons +} + +func TestFlag_EvaluationV2_ResolveInt(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveIntArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveIntEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveIntFunctionArgsV2{ + context.Background(), + &evalV1.ResolveIntRequest{ + FlagKey: "int", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveIntResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveIntEvalFieldsV2{ + result: 12, + evalCommons: sadCommon, + }, + functionArgs: resolveIntFunctionArgsV2{ + context.Background(), + &evalV1.ResolveIntRequest{ + FlagKey: "int", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveIntResponse{ + Value: 12, + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveIntValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + got, err := s.ResolveInt(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(t, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveInt(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveIntArgsV2{ + "happy path": { + evalFields: resolveIntEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveIntFunctionArgsV2{ + context.Background(), + &evalV1.ResolveIntRequest{ + FlagKey: "int", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveIntResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveIntValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveInt(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveObjectArgsV2 struct { + evalFields resolveObjectEvalFieldsV2 + functionArgs resolveObjectFunctionArgsV2 + want *evalV1.ResolveObjectResponse + wantErr error + mCount int +} +type resolveObjectFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveObjectRequest +} +type resolveObjectEvalFieldsV2 struct { + result map[string]interface{} + evalCommons +} + +func TestFlag_EvaluationV2_ResolveObject(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveObjectArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveObjectEvalFieldsV2{ + result: map[string]interface{}{ + "food": "bars", + }, + evalCommons: happyCommon, + }, + functionArgs: resolveObjectFunctionArgsV2{ + context.Background(), + &evalV1.ResolveObjectRequest{ + FlagKey: "object", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveObjectResponse{ + Value: nil, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveObjectEvalFieldsV2{ + result: map[string]interface{}{ + "food": "bars", + }, + evalCommons: sadCommon, + }, + functionArgs: resolveObjectFunctionArgsV2{ + context.Background(), + &evalV1.ResolveObjectRequest{ + FlagKey: "object", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveObjectResponse{ + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveObjectValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + + outParsed, err := structpb.NewStruct(tt.evalFields.result) + if err != nil { + t.Error(err) + } + tt.want.Value = outParsed + got, err := s.ResolveObject(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveObject() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(t, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveObject(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveObjectArgsV2{ + "happy path": { + evalFields: resolveObjectEvalFieldsV2{ + result: map[string]interface{}{ + "food": "bars", + }, + evalCommons: happyCommon, + }, + functionArgs: resolveObjectFunctionArgsV2{ + context.Background(), + &evalV1.ResolveObjectRequest{ + FlagKey: "object", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveObjectResponse{ + Value: nil, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveObjectValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + ) + if name != "eval returns error" { + outParsed, err := structpb.NewStruct(tt.evalFields.result) + if err != nil { + b.Error(err) + } + tt.want.Value = outParsed + } + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveObject(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveObject() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +// TestFlag_EvaluationV2_ErrorCodes test validate error mapping from known errors to connect.Code and avoid accidental +// changes. This is essential as SDK implementations rely on connect. Code to differentiate GRPC errors vs Flag errors. +// For any change in error codes, we must change respective SDK. +func TestFlag_EvaluationV2_ErrorCodes(t *testing.T) { + tests := []struct { + err error + code connect.Code + }{ + { + err: errors.New(model.FlagNotFoundErrorCode), + code: connect.CodeNotFound, + }, + { + err: errors.New(model.TypeMismatchErrorCode), + code: connect.CodeInvalidArgument, + }, + { + err: errors.New(model.ParseErrorCode), + code: connect.CodeDataLoss, + }, + { + err: errors.New(model.FlagDisabledErrorCode), + code: connect.CodeNotFound, + }, + { + err: errors.New(model.GeneralErrorCode), + code: connect.CodeUnknown, + }, + } + + for _, test := range tests { + err := errFormat(test.err) + + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + + if !ok { + t.Error("formatted error is not of type connect.Error") + } + + if connectErr.Code() != test.code { + t.Errorf("expected code %s, but got code %s for model error %s", test.code, connectErr.Code(), + test.err.Error()) + } + } +} diff --git a/core/pkg/service/sync/handler.go b/core/pkg/service/sync/handler.go index 4dee36909..96defcb4c 100644 --- a/core/pkg/service/sync/handler.go +++ b/core/pkg/service/sync/handler.go @@ -4,7 +4,9 @@ import ( "context" "fmt" + "buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc" rpc "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc" + syncv12 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1" syncv1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/sync/v1" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/subscriptions" @@ -12,12 +14,59 @@ import ( ) type handler struct { + syncv1grpc.UnimplementedFlagSyncServiceServer + syncStore subscriptions.Manager + logger *logger.Logger +} + +func (nh *handler) SyncFlags( + request *syncv12.SyncFlagsRequest, + server syncv1grpc.FlagSyncService_SyncFlagsServer, +) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errChan := make(chan error) + dataSync := make(chan sync.DataSync) + nh.syncStore.RegisterSubscription(ctx, request.GetSelector(), request, dataSync, errChan) + for { + select { + case e := <-errChan: + return e + case d := <-dataSync: + if err := server.Send(&syncv12.SyncFlagsResponse{ + FlagConfiguration: d.FlagData, + }); err != nil { + return fmt.Errorf("error sending configuration change event: %w", err) + } + case <-server.Context().Done(): + return nil + } + } +} + +func (nh *handler) FetchAllFlags( + ctx context.Context, + request *syncv12.FetchAllFlagsRequest, +) (*syncv12.FetchAllFlagsResponse, error) { + data, err := nh.syncStore.FetchAllFlags(ctx, request, request.GetSelector()) + if err != nil { + return &syncv12.FetchAllFlagsResponse{}, fmt.Errorf("error fetching all flags from sync store: %w", err) + } + + return &syncv12.FetchAllFlagsResponse{ + FlagConfiguration: data.FlagData, + }, nil +} + +// oldHandler is the implementation of the old sync schema. +// this will not be required anymore when it is time to work on https://github.com/open-feature/flagd/issues/1088 +type oldHandler struct { rpc.UnimplementedFlagSyncServiceServer syncStore subscriptions.Manager logger *logger.Logger } -func (l *handler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlagsRequest) ( +func (l *oldHandler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlagsRequest) ( *syncv1.FetchAllFlagsResponse, error, ) { @@ -31,7 +80,7 @@ func (l *handler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlagsRe }, nil } -func (l *handler) SyncFlags( +func (l *oldHandler) SyncFlags( req *syncv1.SyncFlagsRequest, stream rpc.FlagSyncService_SyncFlagsServer, ) error { diff --git a/core/pkg/service/sync/server.go b/core/pkg/service/sync/server.go index 5642dd281..068eead23 100644 --- a/core/pkg/service/sync/server.go +++ b/core/pkg/service/sync/server.go @@ -9,6 +9,7 @@ import ( "strings" "time" + syncv1 "buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc" rpc "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc" "github.com/open-feature/flagd/core/pkg/logger" iservice "github.com/open-feature/flagd/core/pkg/service" @@ -23,9 +24,11 @@ import ( ) type Server struct { - server *http.Server - metricsServer *http.Server - Logger *logger.Logger + server *http.Server + metricsServer *http.Server + Logger *logger.Logger + // oldHandler will not be required anymore when https://github.com/open-feature/flagd/issues/1088 is being worked on + oldHandler *oldHandler handler *handler config iservice.Configuration grpcServer *grpc.Server @@ -33,12 +36,18 @@ type Server struct { } func NewServer(logger *logger.Logger, store subscriptions.Manager) *Server { + theOldHandler := &oldHandler{ + logger: logger, + syncStore: store, + } + theNewHandler := &handler{ + logger: logger, + syncStore: store, + } return &Server{ - handler: &handler{ - logger: logger, - syncStore: store, - }, - Logger: logger, + oldHandler: theOldHandler, + handler: theNewHandler, + Logger: logger, } } @@ -92,8 +101,10 @@ func (s *Server) startServer() error { if err != nil { return fmt.Errorf("error setting up listener for address %s: %w", address, err) } + s.grpcServer = grpc.NewServer() - rpc.RegisterFlagSyncServiceServer(s.grpcServer, s.handler) + rpc.RegisterFlagSyncServiceServer(s.grpcServer, s.oldHandler) + syncv1.RegisterFlagSyncServiceServer(s.grpcServer, s.handler) if err := s.grpcServer.Serve( lis, @@ -107,8 +118,8 @@ func (s *Server) startServer() error { func (s *Server) startMetricsServer() error { s.Logger.Info(fmt.Sprintf("binding metrics to %d", s.config.ManagementPort)) - grpc := grpc.NewServer() - grpc_health_v1.RegisterHealthServer(grpc, health.NewServer()) + grpcServer := grpc.NewServer() + grpc_health_v1.RegisterHealthServer(grpcServer, health.NewServer()) mux := http.NewServeMux() mux.Handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -124,9 +135,9 @@ func (s *Server) startMetricsServer() error { mux.Handle("/metrics", promhttp.Handler()) handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - // if this is 'application/grpc' and HTTP2, handle with gRPC, otherwise HTTP. - if request.ProtoMajor == 2 && strings.HasPrefix(request.Header.Get("Content-Type"), "application/grpc") { - grpc.ServeHTTP(writer, request) + // if this is 'application/grpcServer' and HTTP2, handle with gRPC, otherwise HTTP. + if request.ProtoMajor == 2 && strings.HasPrefix(request.Header.Get("Content-Type"), "application/grpcServer") { + grpcServer.ServeHTTP(writer, request) } else { mux.ServeHTTP(writer, request) return diff --git a/core/pkg/service/sync/sync_metrics.go b/core/pkg/service/sync/sync_metrics.go index f646313c2..f4dbbb5fc 100644 --- a/core/pkg/service/sync/sync_metrics.go +++ b/core/pkg/service/sync/sync_metrics.go @@ -21,7 +21,7 @@ func (s *Server) captureMetrics() error { provider := metric.NewMeterProvider(metric.WithReader(exporter)) meter := provider.Meter(serviceName) - syncGuage, err := meter.Int64ObservableGauge( + syncGauge, err := meter.Int64ObservableGauge( "sync_active_streams", api.WithDescription("number of open sync subscriptions"), ) @@ -30,9 +30,9 @@ func (s *Server) captureMetrics() error { } _, err = meter.RegisterCallback(func(_ context.Context, o api.Observer) error { - o.ObserveInt64(syncGuage, s.handler.syncStore.GetActiveSubscriptionsInt64()) + o.ObserveInt64(syncGauge, s.handler.syncStore.GetActiveSubscriptionsInt64()) return nil - }, syncGuage) + }, syncGauge) if err != nil { return fmt.Errorf("unable to register active subscription metric callback: %w", err) } diff --git a/docs/quick-start.md b/docs/quick-start.md index 25605eb23..2024c4576 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -48,7 +48,7 @@ docker run \ Test it out by running the following cURL command in a separate terminal: ```shell -curl -X POST "http://localhost:8013/schema.v1.Service/ResolveBoolean" \ +curl -X POST "http://localhost:8013/flagd.evaluation.v1.Service/ResolveBoolean" \ -d '{"flagKey":"show-welcome-banner","context":{}}' -H "Content-Type: application/json" ``` @@ -70,7 +70,7 @@ Open the `demo.flagd.json` file in a text editor and change the `defaultVariant` Save and rerun the following cURL command: ```shell -curl -X POST "http://localhost:8013/schema.v1.Service/ResolveBoolean" \ +curl -X POST "http://localhost:8013/flagd.evaluation.v1.Service/ResolveBoolean" \ -d '{"flagKey":"show-welcome-banner","context":{}}' -H "Content-Type: application/json" ``` @@ -98,7 +98,7 @@ In this section, we'll talk about a multi-variant feature flag can be used to co Save and rerun the following cURL command: ```shell -curl -X POST "http://localhost:8013/schema.v1.Service/ResolveString" \ +curl -X POST "http://localhost:8013/flagd.evaluation.v1.Service/ResolveString" \ -d '{"flagKey":"background-color","context":{}}' -H "Content-Type: application/json" ``` @@ -168,7 +168,7 @@ If there isn't a match, the `defaultVariant` is returned. Let's confirm that customers are still seeing the `red` variant by running the following command: ```shell -curl -X POST "http://localhost:8013/schema.v1.Service/ResolveString" \ +curl -X POST "http://localhost:8013/flagd.evaluation.v1.Service/ResolveString" \ -d '{"flagKey":"background-color","context":{"company": "stark industries"}}' -H "Content-Type: application/json" ``` @@ -190,7 +190,7 @@ Let's confirm that employees of Initech are seeing the updated variant. Run the following cURL command in the terminal: ```shell -curl -X POST "http://localhost:8013/schema.v1.Service/ResolveString" \ +curl -X POST "http://localhost:8013/flagd.evaluation.v1.Service/ResolveString" \ -d '{"flagKey":"background-color","context":{"company": "initech"}}' -H "Content-Type: application/json" ``` diff --git a/docs/reference/custom-operations/fractional-operation.md b/docs/reference/custom-operations/fractional-operation.md index de348647a..a268988a3 100644 --- a/docs/reference/custom-operations/fractional-operation.md +++ b/docs/reference/custom-operations/fractional-operation.md @@ -93,7 +93,7 @@ will return variant `red` 50% of the time, `blue` 20% of the time & `green` 30% Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@bar.com"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@bar.com"}}' -H "Content-Type: application/json" ``` Result: @@ -105,7 +105,7 @@ Result: Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@test.com"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@test.com"}}' -H "Content-Type: application/json" ``` Result: diff --git a/docs/reference/custom-operations/semver-operation.md b/docs/reference/custom-operations/semver-operation.md index 43793ee22..326f5ce72 100644 --- a/docs/reference/custom-operations/semver-operation.md +++ b/docs/reference/custom-operations/semver-operation.md @@ -62,7 +62,7 @@ will return variant `red`, if the value of the `version` is a semantic version t Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "1.0.1"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "1.0.1"}}' -H "Content-Type: application/json" ``` Result: @@ -74,7 +74,7 @@ Result: Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "0.1.0"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "0.1.0"}}' -H "Content-Type: application/json" ``` Result: diff --git a/docs/reference/custom-operations/string-comparison-operation.md b/docs/reference/custom-operations/string-comparison-operation.md index c4f44aa09..71d18b076 100644 --- a/docs/reference/custom-operations/string-comparison-operation.md +++ b/docs/reference/custom-operations/string-comparison-operation.md @@ -59,7 +59,7 @@ will return variant `red`, if the value of the `email` property starts with `use Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "user@faas.com"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "user@faas.com"}}' -H "Content-Type: application/json" ``` Result: @@ -71,7 +71,7 @@ Result: Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@bar.com"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@bar.com"}}' -H "Content-Type: application/json" ``` Result: @@ -132,7 +132,7 @@ will return variant `red`, if the value of the `email` property ends with `faas. Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "user@faas.com"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "user@faas.com"}}' -H "Content-Type: application/json" ``` Result: @@ -144,7 +144,7 @@ Result: Command: ```shell -curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@bar.com"}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"email": "foo@bar.com"}}' -H "Content-Type: application/json" ``` Result: diff --git a/docs/reference/flag-definitions.md b/docs/reference/flag-definitions.md index 089dc91d8..9a7c2bb6f 100644 --- a/docs/reference/flag-definitions.md +++ b/docs/reference/flag-definitions.md @@ -79,8 +79,8 @@ Example: It is an object containing the possible variations supported by the flag. All the values of the object **must** be the same type (e.g. boolean, numbers, string, JSON). The type used as the variant value will correspond directly affects how the flag is accessed. -For example, to use a flag configured with boolean values the `/schema.v1.Service/ResolveBoolean` path should be used. -If another path, such as `/schema.v1.Service/ResolveString` is called, a type mismatch occurs and an error is returned. +For example, to use a flag configured with boolean values the `/flagd.evaluation.v1.Service/ResolveBoolean` path should be used. +If another path, such as `/flagd.evaluation.v1.Service/ResolveString` is called, a type mismatch occurs and an error is returned. Example: diff --git a/docs/reference/openfeature-operator/installation.md b/docs/reference/openfeature-operator/installation.md index 0ed553185..31acb0cd0 100644 --- a/docs/reference/openfeature-operator/installation.md +++ b/docs/reference/openfeature-operator/installation.md @@ -118,7 +118,7 @@ spec: ```bash // From within the pod -curl --location 'http://localhost:8080/schema.v1.Service/ResolveString' --header 'Content-Type: application/json' --data '{ "flagKey":"foo"}' +curl --location 'http://localhost:8080/flagd.evaluation.v1.Service/ResolveString' --header 'Content-Type: application/json' --data '{ "flagKey":"foo"}' ``` In a real application, rather than `curl`, you would probably use the OpenFeature SDK with the `flagd` provider. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c5a22a084..f3908cf27 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -24,7 +24,7 @@ Why is my `int` response a `string`? Command: ```sh -curl -X POST "localhost:8013/schema.v1.Service/ResolveInt" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveInt" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" ``` Result: @@ -40,7 +40,7 @@ If a number value is required, and none of the provided SDK's can be used, then Command: ```sh -curl -X POST "localhost:8013/schema.v1.Service/ResolveFloat" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveFloat" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" ``` Result: diff --git a/test/loadtest/sample_k6.js b/test/loadtest/sample_k6.js index 1027dab1f..0e2112e5f 100644 --- a/test/loadtest/sample_k6.js +++ b/test/loadtest/sample_k6.js @@ -35,8 +35,8 @@ export default function () { export function genUrl(type) { switch (type) { case "boolean": - return "http://localhost:8013/schema.v1.Service/ResolveBoolean" + return "http://localhost:8013/flagd.evaluation.v1.Service/ResolveBoolean" case "string": - return "http://localhost:8013/schema.v1.Service/ResolveString" + return "http://localhost:8013/flagd.evaluation.v1.Service/ResolveString" } } \ No newline at end of file diff --git a/test/zero-downtime/test-pod.yaml b/test/zero-downtime/test-pod.yaml index 0696b9094..ba03cb35c 100644 --- a/test/zero-downtime/test-pod.yaml +++ b/test/zero-downtime/test-pod.yaml @@ -12,7 +12,7 @@ spec: - '-c' - | for i in $(seq 1 3000); do - curl -H 'Cache-Control: no-cache, no-store' -X POST flagd-svc.$FLAGD_DEV_NAMESPACE.svc.cluster.local:8013/schema.v1.Service/ResolveString?$RANDOM -d '{"flagKey":"myStringFlag","context":{}}' -H "Content-Type: application/json" > ~/out.txt + curl -H 'Cache-Control: no-cache, no-store' -X POST flagd-svc.$FLAGD_DEV_NAMESPACE.svc.cluster.local:8013/flagd.evaluation.v1.Service/ResolveString?$RANDOM -d '{"flagKey":"myStringFlag","context":{}}' -H "Content-Type: application/json" > ~/out.txt if ! grep -q "val1" ~/out.txt then cat ~/out.txt