diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 1ab659f76..1c46abde1 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -17,6 +17,8 @@ components: providers/flagsmith: - gagantrivedi - matthewelwell + providers/flipt: + - markphelps providers/from-env: - Kavindu-Dodan - toddbaert @@ -33,4 +35,4 @@ components: - davejohnston ignored-authors: - - renovate-bot \ No newline at end of file + - renovate-bot diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c0dbb9abc..b4c08d36e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,6 +4,7 @@ "providers/configcat": "0.2.0", "providers/flagd": "0.1.18", "providers/flagd-in-process": "0.1.1", + "providers/flipt": "0.1.0", "providers/from-env": "0.1.3", "providers/go-feature-flag": "0.1.30", "providers/flagsmith": "0.1.4", diff --git a/providers/flipt/README.md b/providers/flipt/README.md new file mode 100644 index 000000000..8df19049d --- /dev/null +++ b/providers/flipt/README.md @@ -0,0 +1,99 @@ +# Flipt OpenFeature Provider (Go) + +This repository and package provides a [Flipt](https://github.com/flipt-io/flipt) [OpenFeature Provider](https://docs.openfeature.dev/docs/specification/sections/providers) for interacting with the Flipt service backend using the [OpenFeature Go SDK](https://github.com/open-feature/go-sdk). + +From the [OpenFeature Specification](https://docs.openfeature.dev/docs/specification/sections/providers): + +> Providers are the "translator" between the flag evaluation calls made in application code, and the flag management system that stores flags and in some cases evaluates flags. + +## Requirements + +- Go 1.20+ +- A running instance of [Flipt](https://www.flipt.io/docs/installation) + +## Usage + +### Installation + +```bash +go get github.com/open-feature/go-sdk-contrib/providers/flipt +``` + +### Example + +```go +package main + +import ( + "context" + + "github.com/open-feature/go-sdk-contrib/providers/flipt" + "github.com/open-feature/go-sdk/pkg/openfeature" +) + + +func main() { + // http://localhost:8080 is the default Flipt address + openfeature.SetProvider(flipt.NewProvider()) + + client := openfeature.NewClient("my-app") + value, err := client.BooleanValue(context.Background(), "v2_enabled", false, openfeature.EvaluationContext{ + TargetingKey: "tim@apple.com", + Attributes: map[string]interface{}{ + "favorite_color": "blue", + }, + }) + + if err != nil { + panic(err) + } + + if value { + // do something + } else { + // do something else + } +} +``` + +## Configuration + +The Flipt provider allows you to communicate with Flipt over either HTTP(S) or GRPC, depending on the address provided. + +### HTTP(S) + +```go +provider := flipt.NewProvider(flipt.WithAddress("https://localhost:443")) +``` + +#### Unix Socket + +```go +provider := flipt.NewProvider(flipt.WithAddress("unix:///path/to/socket")) +``` + +### GRPC + +#### HTTP/2 + +```go +type Token string + +func (t Token) ClientToken() (string, error) { + return t, nil +} + +provider := flipt.NewProvider( + flipt.WithAddress("grpc://localhost:9000"), + flipt.WithCertificatePath("/path/to/cert.pem"), // optional + flipt.WithClientProvider(Token("a-client-token")), // optional +) +``` + +#### Unix Socket + +```go +provider := flipt.NewProvider( + flipt.WithAddress("unix:///path/to/socket"), +) +``` diff --git a/providers/flipt/go.mod b/providers/flipt/go.mod new file mode 100644 index 000000000..960d497a4 --- /dev/null +++ b/providers/flipt/go.mod @@ -0,0 +1,38 @@ +module github.com/open-feature/go-sdk-contrib/providers/flipt + +go 1.21 + +require ( + github.com/open-feature/go-sdk v1.8.0 + github.com/stretchr/testify v1.8.4 + go.flipt.io/flipt/rpc/flipt v1.30.0 + go.flipt.io/flipt/sdk/go v0.7.0 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 + google.golang.org/grpc v1.59.0 + google.golang.org/protobuf v1.31.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + go.flipt.io/flipt/errors v1.19.3 // indirect + go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.19.0 // indirect + go.opentelemetry.io/otel/trace v1.19.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/providers/flipt/go.sum b/providers/flipt/go.sum new file mode 100644 index 000000000..6863279a4 --- /dev/null +++ b/providers/flipt/go.sum @@ -0,0 +1,164 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.110.9 h1:e7ITSqGFFk4rbz/JFIqZh3G4VEHguhAL4BQcFlWtU68= +cloud.google.com/go/compute v1.23.2 h1:nWEMDhgbBkBJjfpVySqU4jgWdc22PLR0o4vEexZHers= +cloud.google.com/go/compute v1.23.2/go.mod h1:JJ0atRC0J/oWYiiVBmsSsrRnh92DhZPG4hFDcR04Rns= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+gqO04wryn5h75LSazbRlnya1k= +github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= +github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= +github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 h1:RtRsiaGvWxcwd8y3BiRZxsylPT8hLWZ5SPcfI+3IDNk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0/go.mod h1:TzP6duP4Py2pHLVPPQp42aoYI92+PCrVotyR5e8Vqlk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/open-feature/go-sdk v1.8.0 h1:jRkP7zeSGC3pSYn/s3EzJSpO9Q6CVP8BOnmvBZYQEa0= +github.com/open-feature/go-sdk v1.8.0/go.mod h1:hpKxVZIJ0b+GpnI8imSJf9nFTcmTb0wWJZTgAS/3giw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.flipt.io/flipt/errors v1.19.3 h1:mgQrT3XdambAdu4UykYZ3gm1NG7Ilri5Gt+nLafbJHY= +go.flipt.io/flipt/errors v1.19.3/go.mod h1:I2loVwHUoXy+yT7suRx7+pDiSyO1G7CHu6bby9DywyA= +go.flipt.io/flipt/rpc/flipt v1.30.0 h1:UWEM/mlN0rfpZymYYe6zeI0w7MTG9LQdXWKNIeJtgYU= +go.flipt.io/flipt/rpc/flipt v1.30.0/go.mod h1:QhlPBygpAfbTiP4ObarhJj+xgwWi+MVzoSkINy/8o9I= +go.flipt.io/flipt/sdk/go v0.7.0 h1:eIj92tr7es/zlI5qByApvetiPEdzGx/lc71IA69wUM8= +go.flipt.io/flipt/sdk/go v0.7.0/go.mod h1:fNdGLgm9IFSRY/og+S/9V08Wnhq//6E0AKZ2Upj1JSQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 h1:RsQi0qJ2imFfCvZabqzM9cNXBG8k6gXMv1A0cXRmH6A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0/go.mod h1:vsh3ySueQCiKPxFLvjWC4Z135gIa34TQ/NSqkDTZYUM= +go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= +go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= +go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= +go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= +go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb h1:mIKbk8weKhSeLH2GmUTrvx8CjkyJmnU1wFmg59CUjFA= +golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +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/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405 h1:I6WNifs6pF9tNdSob2W24JtyxIYjzFB9qDlpUC76q+U= +google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405/go.mod h1:3WDQMjmJk36UQhjQ89emUzb1mdaHcPeeAh4SCBKznB4= +google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405 h1:HJMDndgxest5n2y77fnErkM62iUsptE/H8p0dC2Huo4= +google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405/go.mod h1:oT32Z4o8Zv2xPQTg0pbVaPr0MPOH6f14RgXt7zfIpwg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +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/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/providers/flipt/pkg/provider/doc.go b/providers/flipt/pkg/provider/doc.go new file mode 100644 index 000000000..197121a2a --- /dev/null +++ b/providers/flipt/pkg/provider/doc.go @@ -0,0 +1,13 @@ +// This package provides a [Flipt] [OpenFeature Provider] for interacting with the Flipt service backend using the [OpenFeature Go SDK]. +// +// From the [OpenFeature Specification]: +// Providers are the "translator" between the flag evaluation calls made in application code, and the flag management system that stores flags and in some cases evaluates flags. +// +// You can configure the provider to connect to Flipt using any of the provided "[Option]"s. +// This configuration allows you to specify the "[ServiceType]" (protocol), and to configure the host, port and other properties to connect to the Flipt service. +// +// [Flipt]: https://github.com/flipt-io/flipt +// [OpenFeature Provider]: https://docs.openfeature.dev/docs/specification/sections/providers +// [OpenFeature Go SDK]: https://github.com/open-feature/go-sdk +// [OpenFeature Specification]: https://docs.openfeature.dev/docs/specification/sections/providers +package flipt diff --git a/providers/flipt/pkg/provider/example_test.go b/providers/flipt/pkg/provider/example_test.go new file mode 100644 index 000000000..bd6d79ea3 --- /dev/null +++ b/providers/flipt/pkg/provider/example_test.go @@ -0,0 +1,31 @@ +package flipt_test + +import ( + "context" + + flipt "github.com/open-feature/go-sdk-contrib/providers/flipt/pkg/provider" + "github.com/open-feature/go-sdk/pkg/openfeature" +) + +func Example() { + openfeature.SetProvider(flipt.NewProvider( + flipt.WithAddress("localhost:9000"), + )) + + client := openfeature.NewClient("my-app") + value, err := client.BooleanValue( + context.Background(), "v2_enabled", false, openfeature.NewEvaluationContext("tim@apple.com", map[string]interface{}{ + "favorite_color": "blue", + }), + ) + + if err != nil { + panic(err) + } + + if value { + // do something + } else { + // do something else + } +} diff --git a/providers/flipt/pkg/provider/provider.go b/providers/flipt/pkg/provider/provider.go new file mode 100644 index 000000000..982fdbf72 --- /dev/null +++ b/providers/flipt/pkg/provider/provider.go @@ -0,0 +1,400 @@ +package flipt + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/open-feature/go-sdk-contrib/providers/flipt/pkg/service/transport" + of "github.com/open-feature/go-sdk/pkg/openfeature" + flipt "go.flipt.io/flipt/rpc/flipt" + "go.flipt.io/flipt/rpc/flipt/evaluation" + sdk "go.flipt.io/flipt/sdk/go" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" +) + +var _ of.FeatureProvider = (*Provider)(nil) + +// Config is a configuration for the FliptProvider. +type Config struct { + Address string + CertificatePath string + TokenProvider sdk.ClientTokenProvider + Namespace string +} + +// Option is a configuration option for the provider. +type Option func(*Provider) + +// WithAddress sets the address for the remote Flipt gRPC or HTTP API. +func WithAddress(address string) Option { + return func(p *Provider) { + p.config.Address = address + } +} + +// WithCertificatePath is an Option to set the certificate path (grpc only). +func WithCertificatePath(certificatePath string) Option { + return func(p *Provider) { + p.config.CertificatePath = certificatePath + } +} + +// WithConfig is an Option to set the entire configuration. +func WithConfig(config Config) Option { + return func(p *Provider) { + p.config = config + } +} + +// WithService is an Option to set the service for the Provider. +func WithService(svc Service) Option { + return func(p *Provider) { + p.svc = svc + } +} + +// WithClientTokenProvider sets the token provider for auth to support client +// auth needs. +func WithClientTokenProvider(tokenProvider sdk.ClientTokenProvider) Option { + return func(p *Provider) { + p.config.TokenProvider = tokenProvider + } +} + +// ForNamespace sets the namespace for flag lookup and evaluation in Flipt. +func ForNamespace(namespace string) Option { + return func(p *Provider) { + p.config.Namespace = namespace + } +} + +// NewProvider returns a new Flipt provider. +func NewProvider(opts ...Option) *Provider { + p := &Provider{config: Config{ + Address: "http://localhost:8080", + Namespace: "default", + }} + + for _, opt := range opts { + opt(p) + } + + if p.svc == nil { + topts := []transport.Option{transport.WithAddress(p.config.Address), transport.WithCertificatePath(p.config.CertificatePath)} + if p.config.TokenProvider != nil { + topts = append(topts, transport.WithClientTokenProvider(p.config.TokenProvider)) + } + + p.svc = transport.New(topts...) + } + + return p +} + +//go:generate mockery --name=Service --structname=mockService --case=underscore --output=. --outpkg=flipt --filename=provider_support.go --testonly --with-expecter --disable-version-string +type Service interface { + GetFlag(ctx context.Context, namespaceKey, flagKey string) (*flipt.Flag, error) + Evaluate(ctx context.Context, namespaceKey, flagKey string, evalCtx map[string]interface{}) (*evaluation.VariantEvaluationResponse, error) + Boolean(ctx context.Context, namespaceKey, flagKey string, evalCtx map[string]interface{}) (*evaluation.BooleanEvaluationResponse, error) +} + +// Provider implements the FeatureProvider interface and provides functions for evaluating flags with Flipt. +type Provider struct { + svc Service + config Config +} + +// Metadata returns the metadata of the provider. +func (p Provider) Metadata() of.Metadata { + return of.Metadata{Name: "flipt-provider"} +} + +// BooleanEvaluation returns a boolean flag. +func (p Provider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + resp, err := p.svc.Boolean(ctx, p.config.Namespace, flag, evalCtx) + if err != nil { + var ( + rerr of.ResolutionError + detail = of.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + ) + + if errors.As(err, &rerr) { + detail.ProviderResolutionDetail.ResolutionError = rerr + + return detail + } + + detail.ProviderResolutionDetail.ResolutionError = of.NewGeneralResolutionError(err.Error()) + + return detail + } + + return of.BoolResolutionDetail{ + Value: resp.Enabled, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + } +} + +// StringEvaluation returns a string flag. +func (p Provider) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + resp, err := p.svc.Evaluate(ctx, p.config.Namespace, flag, evalCtx) + if err != nil { + var ( + rerr of.ResolutionError + detail = of.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + ) + + if errors.As(err, &rerr) { + detail.ProviderResolutionDetail.ResolutionError = rerr + + return detail + } + + detail.ProviderResolutionDetail.ResolutionError = of.NewGeneralResolutionError(err.Error()) + + return detail + } + + if resp.Reason == evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON { + return of.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DisabledReason, + }, + } + } + + if !resp.Match { + return of.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + } + + return of.StringResolutionDetail{ + Value: resp.VariantKey, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + } +} + +// FloatEvaluation returns a float flag. +func (p Provider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + resp, err := p.svc.Evaluate(ctx, p.config.Namespace, flag, evalCtx) + if err != nil { + var ( + rerr of.ResolutionError + detail = of.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + ) + + if errors.As(err, &rerr) { + detail.ProviderResolutionDetail.ResolutionError = rerr + + return detail + } + + detail.ProviderResolutionDetail.ResolutionError = of.NewGeneralResolutionError(err.Error()) + + return detail + } + + if resp.Reason == evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON { + return of.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DisabledReason, + }, + } + } + + if !resp.Match { + return of.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + } + + fv, err := strconv.ParseFloat(resp.VariantKey, 64) + if err != nil { + return of.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewTypeMismatchResolutionError("value is not a float"), + Reason: of.ErrorReason, + }, + } + } + + return of.FloatResolutionDetail{ + Value: fv, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + } +} + +// IntEvaluation returns an int flag. +func (p Provider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + resp, err := p.svc.Evaluate(ctx, p.config.Namespace, flag, evalCtx) + if err != nil { + var ( + rerr of.ResolutionError + detail = of.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + ) + + if errors.As(err, &rerr) { + detail.ProviderResolutionDetail.ResolutionError = rerr + + return detail + } + + detail.ProviderResolutionDetail.ResolutionError = of.NewGeneralResolutionError(err.Error()) + + return detail + } + + if resp.Reason == evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON { + return of.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DisabledReason, + }, + } + } + + if !resp.Match { + return of.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + } + + iv, err := strconv.ParseInt(resp.VariantKey, 10, 64) + if err != nil { + return of.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewTypeMismatchResolutionError("value is not an integer"), + Reason: of.ErrorReason, + }, + } + } + + return of.IntResolutionDetail{ + Value: iv, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + } +} + +// ObjectEvaluation returns an object flag with attachment if any. Value is a map of key/value pairs ([string]interface{}). +func (p Provider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + resp, err := p.svc.Evaluate(ctx, p.config.Namespace, flag, evalCtx) + if err != nil { + var ( + rerr of.ResolutionError + detail = of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + ) + + if errors.As(err, &rerr) { + detail.ProviderResolutionDetail.ResolutionError = rerr + + return detail + } + + detail.ProviderResolutionDetail.ResolutionError = of.NewGeneralResolutionError(err.Error()) + + return detail + } + + if resp.Reason == evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON { + return of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DisabledReason, + }, + } + } + + if !resp.Match { + return of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + } + } + + if resp.VariantAttachment == "" { + return of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + Variant: resp.VariantKey, + }, + } + } + + out := new(structpb.Struct) + if err := protojson.Unmarshal([]byte(resp.VariantAttachment), out); err != nil { + return of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewTypeMismatchResolutionError(fmt.Sprintf("value is not an object: %q", resp.VariantAttachment)), + Reason: of.ErrorReason, + }, + } + } + + return of.InterfaceResolutionDetail{ + Value: out.AsMap(), + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + Variant: resp.VariantKey, + }, + } +} + +// Hooks returns hooks. +func (p Provider) Hooks() []of.Hook { + // code to retrieve hooks + return []of.Hook{} +} diff --git a/providers/flipt/pkg/provider/provider_support.go b/providers/flipt/pkg/provider/provider_support.go new file mode 100644 index 000000000..e835448ee --- /dev/null +++ b/providers/flipt/pkg/provider/provider_support.go @@ -0,0 +1,211 @@ +// Code generated by mockery. DO NOT EDIT. + +package flipt + +import ( + context "context" + + evaluation "go.flipt.io/flipt/rpc/flipt/evaluation" + + mock "github.com/stretchr/testify/mock" + + rpcflipt "go.flipt.io/flipt/rpc/flipt" +) + +// mockService is an autogenerated mock type for the Service type +type mockService struct { + mock.Mock +} + +type mockService_Expecter struct { + mock *mock.Mock +} + +func (_m *mockService) EXPECT() *mockService_Expecter { + return &mockService_Expecter{mock: &_m.Mock} +} + +// Boolean provides a mock function with given fields: ctx, namespaceKey, flagKey, evalCtx +func (_m *mockService) Boolean(ctx context.Context, namespaceKey string, flagKey string, evalCtx map[string]interface{}) (*evaluation.BooleanEvaluationResponse, error) { + ret := _m.Called(ctx, namespaceKey, flagKey, evalCtx) + + var r0 *evaluation.BooleanEvaluationResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, map[string]interface{}) (*evaluation.BooleanEvaluationResponse, error)); ok { + return rf(ctx, namespaceKey, flagKey, evalCtx) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, map[string]interface{}) *evaluation.BooleanEvaluationResponse); ok { + r0 = rf(ctx, namespaceKey, flagKey, evalCtx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*evaluation.BooleanEvaluationResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, map[string]interface{}) error); ok { + r1 = rf(ctx, namespaceKey, flagKey, evalCtx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockService_Boolean_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Boolean' +type mockService_Boolean_Call struct { + *mock.Call +} + +// Boolean is a helper method to define mock.On call +// - ctx context.Context +// - namespaceKey string +// - flagKey string +// - evalCtx map[string]interface{} +func (_e *mockService_Expecter) Boolean(ctx interface{}, namespaceKey interface{}, flagKey interface{}, evalCtx interface{}) *mockService_Boolean_Call { + return &mockService_Boolean_Call{Call: _e.mock.On("Boolean", ctx, namespaceKey, flagKey, evalCtx)} +} + +func (_c *mockService_Boolean_Call) Run(run func(ctx context.Context, namespaceKey string, flagKey string, evalCtx map[string]interface{})) *mockService_Boolean_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(map[string]interface{})) + }) + return _c +} + +func (_c *mockService_Boolean_Call) Return(_a0 *evaluation.BooleanEvaluationResponse, _a1 error) *mockService_Boolean_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockService_Boolean_Call) RunAndReturn(run func(context.Context, string, string, map[string]interface{}) (*evaluation.BooleanEvaluationResponse, error)) *mockService_Boolean_Call { + _c.Call.Return(run) + return _c +} + +// Evaluate provides a mock function with given fields: ctx, namespaceKey, flagKey, evalCtx +func (_m *mockService) Evaluate(ctx context.Context, namespaceKey string, flagKey string, evalCtx map[string]interface{}) (*evaluation.VariantEvaluationResponse, error) { + ret := _m.Called(ctx, namespaceKey, flagKey, evalCtx) + + var r0 *evaluation.VariantEvaluationResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, map[string]interface{}) (*evaluation.VariantEvaluationResponse, error)); ok { + return rf(ctx, namespaceKey, flagKey, evalCtx) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, map[string]interface{}) *evaluation.VariantEvaluationResponse); ok { + r0 = rf(ctx, namespaceKey, flagKey, evalCtx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*evaluation.VariantEvaluationResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, map[string]interface{}) error); ok { + r1 = rf(ctx, namespaceKey, flagKey, evalCtx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockService_Evaluate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Evaluate' +type mockService_Evaluate_Call struct { + *mock.Call +} + +// Evaluate is a helper method to define mock.On call +// - ctx context.Context +// - namespaceKey string +// - flagKey string +// - evalCtx map[string]interface{} +func (_e *mockService_Expecter) Evaluate(ctx interface{}, namespaceKey interface{}, flagKey interface{}, evalCtx interface{}) *mockService_Evaluate_Call { + return &mockService_Evaluate_Call{Call: _e.mock.On("Evaluate", ctx, namespaceKey, flagKey, evalCtx)} +} + +func (_c *mockService_Evaluate_Call) Run(run func(ctx context.Context, namespaceKey string, flagKey string, evalCtx map[string]interface{})) *mockService_Evaluate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(map[string]interface{})) + }) + return _c +} + +func (_c *mockService_Evaluate_Call) Return(_a0 *evaluation.VariantEvaluationResponse, _a1 error) *mockService_Evaluate_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockService_Evaluate_Call) RunAndReturn(run func(context.Context, string, string, map[string]interface{}) (*evaluation.VariantEvaluationResponse, error)) *mockService_Evaluate_Call { + _c.Call.Return(run) + return _c +} + +// GetFlag provides a mock function with given fields: ctx, namespaceKey, flagKey +func (_m *mockService) GetFlag(ctx context.Context, namespaceKey string, flagKey string) (*rpcflipt.Flag, error) { + ret := _m.Called(ctx, namespaceKey, flagKey) + + var r0 *rpcflipt.Flag + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*rpcflipt.Flag, error)); ok { + return rf(ctx, namespaceKey, flagKey) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *rpcflipt.Flag); ok { + r0 = rf(ctx, namespaceKey, flagKey) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpcflipt.Flag) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, namespaceKey, flagKey) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockService_GetFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFlag' +type mockService_GetFlag_Call struct { + *mock.Call +} + +// GetFlag is a helper method to define mock.On call +// - ctx context.Context +// - namespaceKey string +// - flagKey string +func (_e *mockService_Expecter) GetFlag(ctx interface{}, namespaceKey interface{}, flagKey interface{}) *mockService_GetFlag_Call { + return &mockService_GetFlag_Call{Call: _e.mock.On("GetFlag", ctx, namespaceKey, flagKey)} +} + +func (_c *mockService_GetFlag_Call) Run(run func(ctx context.Context, namespaceKey string, flagKey string)) *mockService_GetFlag_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *mockService_GetFlag_Call) Return(_a0 *rpcflipt.Flag, _a1 error) *mockService_GetFlag_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockService_GetFlag_Call) RunAndReturn(run func(context.Context, string, string) (*rpcflipt.Flag, error)) *mockService_GetFlag_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTnewMockService interface { + mock.TestingT + Cleanup(func()) +} + +// newMockService creates a new instance of mockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func newMockService(t mockConstructorTestingTnewMockService) *mockService { + mock := &mockService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/providers/flipt/pkg/provider/provider_test.go b/providers/flipt/pkg/provider/provider_test.go new file mode 100644 index 000000000..94cd371fd --- /dev/null +++ b/providers/flipt/pkg/provider/provider_test.go @@ -0,0 +1,605 @@ +package flipt + +import ( + "context" + "encoding/json" + "errors" + "testing" + + of "github.com/open-feature/go-sdk/pkg/openfeature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.flipt.io/flipt/rpc/flipt/evaluation" +) + +func TestMetadata(t *testing.T) { + p := NewProvider() + assert.Equal(t, "flipt-provider", p.Metadata().Name) +} + +func TestBooleanEvaluation(t *testing.T) { + tests := []struct { + name string + flagKey string + defaultValue bool + mockRespEvaluation *evaluation.BooleanEvaluationResponse + mockRespEvaluationErr error + expected of.BoolResolutionDetail + }{ + { + name: "false", + flagKey: "boolean-false", + defaultValue: true, + mockRespEvaluation: &evaluation.BooleanEvaluationResponse{ + Enabled: false, + Reason: evaluation.EvaluationReason_MATCH_EVALUATION_REASON, + }, + expected: of.BoolResolutionDetail{Value: false, ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.TargetingMatchReason}}, + }, + { + name: "resolution error", + flagKey: "boolean-res-error", + defaultValue: false, + mockRespEvaluationErr: of.NewInvalidContextResolutionError("boom"), + expected: of.BoolResolutionDetail{ + Value: false, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewInvalidContextResolutionError("boom"), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSvc := newMockService(t) + mockSvc.On("Boolean", mock.Anything, "flipt", tt.flagKey, mock.Anything).Return(tt.mockRespEvaluation, tt.mockRespEvaluationErr).Maybe() + + p := NewProvider(WithService(mockSvc), ForNamespace("flipt")) + + actual := p.BooleanEvaluation(context.Background(), tt.flagKey, tt.defaultValue, map[string]interface{}{}) + + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestStringEvaluation(t *testing.T) { + tests := []struct { + name string + flagKey string + defaultValue string + mockRespEvaluation *evaluation.VariantEvaluationResponse + mockRespEvaluationErr error + expected of.StringResolutionDetail + }{ + { + name: "flag enabled", + flagKey: "string-true", + defaultValue: "false", + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "true", + }, + expected: of.StringResolutionDetail{Value: "true", ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.TargetingMatchReason}}, + }, + { + name: "flag disabled", + flagKey: "string-true", + defaultValue: "false", + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + Reason: evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON, + }, + expected: of.StringResolutionDetail{Value: "false", ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DisabledReason}}, + }, + { + name: "resolution error", + flagKey: "string-res-error", + defaultValue: "true", + mockRespEvaluationErr: of.NewInvalidContextResolutionError("boom"), + expected: of.StringResolutionDetail{ + Value: "true", + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewInvalidContextResolutionError("boom"), + }, + }, + }, + { + name: "error", + flagKey: "string-error", + defaultValue: "true", + mockRespEvaluationErr: errors.New("boom"), + expected: of.StringResolutionDetail{ + Value: "true", + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewGeneralResolutionError("boom"), + }, + }, + }, + { + name: "no match", + flagKey: "string-no-match", + + defaultValue: "default", + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + }, + expected: of.StringResolutionDetail{Value: "default", ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DefaultReason}}, + }, + { + name: "match", + flagKey: "string-match", + + defaultValue: "default", + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "abc", + }, + expected: of.StringResolutionDetail{ + Value: "abc", + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + }, + }, + { + name: "match", + flagKey: "string-match", + defaultValue: "default", + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "abc", + }, + expected: of.StringResolutionDetail{ + Value: "abc", + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSvc := newMockService(t) + mockSvc.On("Evaluate", mock.Anything, "default", tt.flagKey, mock.Anything).Return(tt.mockRespEvaluation, tt.mockRespEvaluationErr).Maybe() + + p := NewProvider(WithService(mockSvc)) + + actual := p.StringEvaluation(context.Background(), tt.flagKey, tt.defaultValue, map[string]interface{}{}) + + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestFloatEvaluation(t *testing.T) { + tests := []struct { + name string + flagKey string + defaultValue float64 + mockRespEvaluation *evaluation.VariantEvaluationResponse + mockRespEvaluationErr error + expected of.FloatResolutionDetail + }{ + { + name: "flag enabled", + flagKey: "float-one", + + defaultValue: 1.0, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "1.0", + }, + expected: of.FloatResolutionDetail{Value: 1.0, ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.TargetingMatchReason}}, + }, + { + name: "flag disabled", + flagKey: "float-zero", + + defaultValue: 0.0, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + Reason: evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON, + }, + expected: of.FloatResolutionDetail{Value: 0.0, ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DisabledReason}}, + }, + { + name: "resolution error", + flagKey: "float-res-error", + defaultValue: 0.0, + mockRespEvaluationErr: of.NewInvalidContextResolutionError("boom"), + expected: of.FloatResolutionDetail{ + Value: 0.0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewInvalidContextResolutionError("boom"), + }, + }, + }, + { + name: "parse error", + flagKey: "float-parse-error", + + defaultValue: 1.0, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "not-a-float", + }, + expected: of.FloatResolutionDetail{ + Value: 1.0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.ErrorReason, + ResolutionError: of.NewTypeMismatchResolutionError("value is not a float"), + }, + }, + }, + { + name: "error", + flagKey: "float-error", + defaultValue: 1.0, + mockRespEvaluationErr: errors.New("boom"), + expected: of.FloatResolutionDetail{ + Value: 1.0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewGeneralResolutionError("boom"), + }, + }, + }, + { + name: "no match", + flagKey: "float-no-match", + + defaultValue: 1.0, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + }, + expected: of.FloatResolutionDetail{Value: 1.0, ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DefaultReason}}, + }, + { + name: "match", + flagKey: "float-match", + + defaultValue: 1.0, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "2.0", + }, + expected: of.FloatResolutionDetail{ + Value: 2.0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSvc := newMockService(t) + mockSvc.On("Evaluate", mock.Anything, "flipt", tt.flagKey, mock.Anything).Return(tt.mockRespEvaluation, tt.mockRespEvaluationErr).Maybe() + + p := NewProvider(WithService(mockSvc), ForNamespace("flipt")) + + actual := p.FloatEvaluation(context.Background(), tt.flagKey, tt.defaultValue, map[string]interface{}{}) + + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestIntEvaluation(t *testing.T) { + tests := []struct { + name string + flagKey string + defaultValue int64 + mockRespEvaluation *evaluation.VariantEvaluationResponse + mockRespEvaluationErr error + expected of.IntResolutionDetail + }{ + { + name: "flag enabled", + flagKey: "int-one", + + defaultValue: 1, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "1", + }, + expected: of.IntResolutionDetail{Value: 1, ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.TargetingMatchReason}}, + }, + { + name: "flag disabled", + flagKey: "int-zero", + + defaultValue: 0, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + Reason: evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON, + }, + expected: of.IntResolutionDetail{Value: 0, ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DisabledReason}}, + }, + { + name: "resolution error", + flagKey: "int-res-error", + defaultValue: 0, + mockRespEvaluationErr: of.NewInvalidContextResolutionError("boom"), + expected: of.IntResolutionDetail{ + Value: 0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewInvalidContextResolutionError("boom"), + }, + }, + }, + { + name: "parse error", + flagKey: "int-parse-error", + + defaultValue: 1, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "not-an-int", + }, + expected: of.IntResolutionDetail{ + Value: 1, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.ErrorReason, + ResolutionError: of.NewTypeMismatchResolutionError("value is not an integer"), + }, + }, + }, + { + name: "error", + flagKey: "int-error", + defaultValue: 1, + mockRespEvaluationErr: errors.New("boom"), + expected: of.IntResolutionDetail{ + Value: 1, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewGeneralResolutionError("boom"), + }, + }, + }, + { + name: "no match", + flagKey: "int-no-match", + + defaultValue: 1, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + }, + expected: of.IntResolutionDetail{Value: 1, ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DefaultReason}}, + }, + { + name: "match", + flagKey: "int-match", + + defaultValue: 1, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "2", + }, + expected: of.IntResolutionDetail{ + Value: 2, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + }, + }, + { + name: "match", + flagKey: "int-match", + defaultValue: 1, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "2", + }, + expected: of.IntResolutionDetail{ + Value: 2, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSvc := newMockService(t) + mockSvc.On("Evaluate", mock.Anything, "default", tt.flagKey, mock.Anything).Return(tt.mockRespEvaluation, tt.mockRespEvaluationErr).Maybe() + + p := NewProvider(WithService(mockSvc)) + + actual := p.IntEvaluation(context.Background(), tt.flagKey, tt.defaultValue, map[string]interface{}{}) + + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestObjectEvaluation(t *testing.T) { + attachment := map[string]interface{}{ + "foo": "bar", + } + + b, _ := json.Marshal(attachment) + attachmentJSON := string(b) + + tests := []struct { + name string + flagKey string + defaultValue map[string]interface{} + mockRespEvaluation *evaluation.VariantEvaluationResponse + mockRespEvaluationErr error + expected of.InterfaceResolutionDetail + }{ + { + name: "flag enabled", + flagKey: "obj-enabled", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantAttachment: attachmentJSON, + }, + expected: of.InterfaceResolutionDetail{ + Value: attachment, + ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.TargetingMatchReason}, + }, + }, + { + name: "flag disabled", + flagKey: "obj-disabled", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + Reason: evaluation.EvaluationReason_FLAG_DISABLED_EVALUATION_REASON, + }, + expected: of.InterfaceResolutionDetail{ + Value: map[string]interface{}{ + "baz": "qux", + }, + ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DisabledReason}, + }, + }, + { + name: "resolution error", + flagKey: "obj-res-error", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluationErr: of.NewInvalidContextResolutionError("boom"), + expected: of.InterfaceResolutionDetail{ + Value: map[string]interface{}{ + "baz": "qux", + }, ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewInvalidContextResolutionError("boom"), + }, + }, + }, + { + name: "unmarshal error", + flagKey: "obj-unmarshal-error", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantAttachment: "x", + }, + expected: of.InterfaceResolutionDetail{ + Value: map[string]interface{}{ + "baz": "qux", + }, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.ErrorReason, + ResolutionError: of.NewTypeMismatchResolutionError("value is not an object: \"x\""), + }, + }, + }, + { + name: "error", + flagKey: "obj-error", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluationErr: errors.New("boom"), + expected: of.InterfaceResolutionDetail{ + Value: map[string]interface{}{ + "baz": "qux", + }, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + ResolutionError: of.NewGeneralResolutionError("boom"), + }, + }, + }, + { + name: "no match", + flagKey: "obj-no-match", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: false, + }, + expected: of.InterfaceResolutionDetail{ + Value: map[string]interface{}{ + "baz": "qux", + }, + ProviderResolutionDetail: of.ProviderResolutionDetail{Reason: of.DefaultReason}, + }, + }, + { + name: "match", + flagKey: "obj-match", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "2", + VariantAttachment: "{\"foo\": \"bar\"}", + }, + expected: of.InterfaceResolutionDetail{ + Value: map[string]interface{}{ + "foo": "bar", + }, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.TargetingMatchReason, + }, + }, + }, + { + name: "match no attachment", + flagKey: "obj-match-no-attach", + + defaultValue: map[string]interface{}{ + "baz": "qux", + }, + mockRespEvaluation: &evaluation.VariantEvaluationResponse{ + Match: true, + VariantKey: "2", + }, + expected: of.InterfaceResolutionDetail{ + Value: map[string]interface{}{ + "baz": "qux", + }, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.DefaultReason, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSvc := newMockService(t) + mockSvc.On("Evaluate", mock.Anything, "flipt", tt.flagKey, mock.Anything).Return(tt.mockRespEvaluation, tt.mockRespEvaluationErr).Maybe() + + p := NewProvider(WithService(mockSvc), ForNamespace("flipt")) + + actual := p.ObjectEvaluation(context.Background(), tt.flagKey, tt.defaultValue, map[string]interface{}{}) + + assert.Equal(t, tt.expected.Value, actual.Value) + }) + } +} diff --git a/providers/flipt/pkg/service/client.go b/providers/flipt/pkg/service/client.go new file mode 100644 index 000000000..0bee68ac8 --- /dev/null +++ b/providers/flipt/pkg/service/client.go @@ -0,0 +1,15 @@ +package flipt + +import ( + "context" + + flipt "go.flipt.io/flipt/rpc/flipt" + "go.flipt.io/flipt/rpc/flipt/evaluation" +) + +//go:generate mockery --name=Client --case=underscore --inpackage --filename=service_support.go --testonly --with-expecter --disable-version-string +type Client interface { + GetFlag(ctx context.Context, c *flipt.GetFlagRequest) (*flipt.Flag, error) + Variant(ctx context.Context, v *evaluation.EvaluationRequest) (*evaluation.VariantEvaluationResponse, error) + Boolean(ctx context.Context, v *evaluation.EvaluationRequest) (*evaluation.BooleanEvaluationResponse, error) +} diff --git a/providers/flipt/pkg/service/doc.go b/providers/flipt/pkg/service/doc.go new file mode 100644 index 000000000..10361bd1e --- /dev/null +++ b/providers/flipt/pkg/service/doc.go @@ -0,0 +1,3 @@ +// This package contains the lower level Flipt service client implementation. +// Under normal circumstances, you should not need to use this package directly. Instead, you should use the `Provider` to configure and interact with the Flipt service. +package flipt diff --git a/providers/flipt/pkg/service/service_support.go b/providers/flipt/pkg/service/service_support.go new file mode 100644 index 000000000..9ed31a7d1 --- /dev/null +++ b/providers/flipt/pkg/service/service_support.go @@ -0,0 +1,205 @@ +// Code generated by mockery. DO NOT EDIT. + +package flipt + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + evaluation "go.flipt.io/flipt/rpc/flipt/evaluation" + + rpcflipt "go.flipt.io/flipt/rpc/flipt" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// Boolean provides a mock function with given fields: ctx, v +func (_m *MockClient) Boolean(ctx context.Context, v *evaluation.EvaluationRequest) (*evaluation.BooleanEvaluationResponse, error) { + ret := _m.Called(ctx, v) + + var r0 *evaluation.BooleanEvaluationResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *evaluation.EvaluationRequest) (*evaluation.BooleanEvaluationResponse, error)); ok { + return rf(ctx, v) + } + if rf, ok := ret.Get(0).(func(context.Context, *evaluation.EvaluationRequest) *evaluation.BooleanEvaluationResponse); ok { + r0 = rf(ctx, v) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*evaluation.BooleanEvaluationResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *evaluation.EvaluationRequest) error); ok { + r1 = rf(ctx, v) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_Boolean_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Boolean' +type MockClient_Boolean_Call struct { + *mock.Call +} + +// Boolean is a helper method to define mock.On call +// - ctx context.Context +// - v *evaluation.EvaluationRequest +func (_e *MockClient_Expecter) Boolean(ctx interface{}, v interface{}) *MockClient_Boolean_Call { + return &MockClient_Boolean_Call{Call: _e.mock.On("Boolean", ctx, v)} +} + +func (_c *MockClient_Boolean_Call) Run(run func(ctx context.Context, v *evaluation.EvaluationRequest)) *MockClient_Boolean_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*evaluation.EvaluationRequest)) + }) + return _c +} + +func (_c *MockClient_Boolean_Call) Return(_a0 *evaluation.BooleanEvaluationResponse, _a1 error) *MockClient_Boolean_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_Boolean_Call) RunAndReturn(run func(context.Context, *evaluation.EvaluationRequest) (*evaluation.BooleanEvaluationResponse, error)) *MockClient_Boolean_Call { + _c.Call.Return(run) + return _c +} + +// GetFlag provides a mock function with given fields: ctx, c +func (_m *MockClient) GetFlag(ctx context.Context, c *rpcflipt.GetFlagRequest) (*rpcflipt.Flag, error) { + ret := _m.Called(ctx, c) + + var r0 *rpcflipt.Flag + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *rpcflipt.GetFlagRequest) (*rpcflipt.Flag, error)); ok { + return rf(ctx, c) + } + if rf, ok := ret.Get(0).(func(context.Context, *rpcflipt.GetFlagRequest) *rpcflipt.Flag); ok { + r0 = rf(ctx, c) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpcflipt.Flag) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *rpcflipt.GetFlagRequest) error); ok { + r1 = rf(ctx, c) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFlag' +type MockClient_GetFlag_Call struct { + *mock.Call +} + +// GetFlag is a helper method to define mock.On call +// - ctx context.Context +// - c *rpcflipt.GetFlagRequest +func (_e *MockClient_Expecter) GetFlag(ctx interface{}, c interface{}) *MockClient_GetFlag_Call { + return &MockClient_GetFlag_Call{Call: _e.mock.On("GetFlag", ctx, c)} +} + +func (_c *MockClient_GetFlag_Call) Run(run func(ctx context.Context, c *rpcflipt.GetFlagRequest)) *MockClient_GetFlag_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*rpcflipt.GetFlagRequest)) + }) + return _c +} + +func (_c *MockClient_GetFlag_Call) Return(_a0 *rpcflipt.Flag, _a1 error) *MockClient_GetFlag_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetFlag_Call) RunAndReturn(run func(context.Context, *rpcflipt.GetFlagRequest) (*rpcflipt.Flag, error)) *MockClient_GetFlag_Call { + _c.Call.Return(run) + return _c +} + +// Variant provides a mock function with given fields: ctx, v +func (_m *MockClient) Variant(ctx context.Context, v *evaluation.EvaluationRequest) (*evaluation.VariantEvaluationResponse, error) { + ret := _m.Called(ctx, v) + + var r0 *evaluation.VariantEvaluationResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *evaluation.EvaluationRequest) (*evaluation.VariantEvaluationResponse, error)); ok { + return rf(ctx, v) + } + if rf, ok := ret.Get(0).(func(context.Context, *evaluation.EvaluationRequest) *evaluation.VariantEvaluationResponse); ok { + r0 = rf(ctx, v) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*evaluation.VariantEvaluationResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *evaluation.EvaluationRequest) error); ok { + r1 = rf(ctx, v) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_Variant_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Variant' +type MockClient_Variant_Call struct { + *mock.Call +} + +// Variant is a helper method to define mock.On call +// - ctx context.Context +// - v *evaluation.EvaluationRequest +func (_e *MockClient_Expecter) Variant(ctx interface{}, v interface{}) *MockClient_Variant_Call { + return &MockClient_Variant_Call{Call: _e.mock.On("Variant", ctx, v)} +} + +func (_c *MockClient_Variant_Call) Run(run func(ctx context.Context, v *evaluation.EvaluationRequest)) *MockClient_Variant_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*evaluation.EvaluationRequest)) + }) + return _c +} + +func (_c *MockClient_Variant_Call) Return(_a0 *evaluation.VariantEvaluationResponse, _a1 error) *MockClient_Variant_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_Variant_Call) RunAndReturn(run func(context.Context, *evaluation.EvaluationRequest) (*evaluation.VariantEvaluationResponse, error)) *MockClient_Variant_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTNewMockClient interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockClient(t mockConstructorTestingTNewMockClient) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/providers/flipt/pkg/service/transport/service.go b/providers/flipt/pkg/service/transport/service.go new file mode 100644 index 000000000..b075ee1f4 --- /dev/null +++ b/providers/flipt/pkg/service/transport/service.go @@ -0,0 +1,290 @@ +package transport + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net/url" + "os" + "strings" + "sync" + + offlipt "github.com/open-feature/go-sdk-contrib/providers/flipt/pkg/service" + of "github.com/open-feature/go-sdk/pkg/openfeature" + flipt "go.flipt.io/flipt/rpc/flipt" + "go.flipt.io/flipt/rpc/flipt/evaluation" + sdk "go.flipt.io/flipt/sdk/go" + sdkgrpc "go.flipt.io/flipt/sdk/go/grpc" + sdkhttp "go.flipt.io/flipt/sdk/go/http" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +const ( + requestID = "requestID" + defaultAddr = "http://localhost:8080" +) + +// Service is a Transport service. +type Service struct { + client offlipt.Client + address string + certificatePath string + unaryInterceptors []grpc.UnaryClientInterceptor + once sync.Once + tokenProvider sdk.ClientTokenProvider +} + +// Option is a service option. +type Option func(*Service) + +// WithAddress sets the address for the remote Flipt gRPC API. +func WithAddress(address string) Option { + return func(s *Service) { + s.address = address + } +} + +// WithCertificatePath sets the certificate path for the service. +func WithCertificatePath(certificatePath string) Option { + return func(s *Service) { + s.certificatePath = certificatePath + } +} + +// WithUnaryClientInterceptor sets the provided unary client interceptors +// to be applied to the established gRPC client connection. +func WithUnaryClientInterceptor(unaryInterceptors ...grpc.UnaryClientInterceptor) Option { + return func(s *Service) { + s.unaryInterceptors = unaryInterceptors + } +} + +// WithClientTokenProvider sets the token provider for auth to support client +// auth needs. +func WithClientTokenProvider(tokenProvider sdk.ClientTokenProvider) Option { + return func(s *Service) { + s.tokenProvider = tokenProvider + } +} + +// New creates a new Transport service. +func New(opts ...Option) *Service { + s := &Service{ + address: defaultAddr, + unaryInterceptors: []grpc.UnaryClientInterceptor{ + // by default this establishes the otel.TextMapPropagator + // registers to the otel package. + otelgrpc.UnaryClientInterceptor(), + }, + } + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (s *Service) connect() (*grpc.ClientConn, error) { + var ( + err error + credentials = insecure.NewCredentials() + ) + + if s.certificatePath != "" { + credentials, err = loadTLSCredentials(s.certificatePath) + if err != nil { + // TODO: log error? + credentials = insecure.NewCredentials() + } + } + + var address = s.address + + if strings.HasPrefix(s.address, "unix://") { + address = "passthrough:///" + s.address + } + + conn, err := grpc.Dial( + address, + grpc.WithTransportCredentials(credentials), + grpc.WithBlock(), + grpc.WithChainUnaryInterceptor(s.unaryInterceptors...), + ) + if err != nil { + return nil, fmt.Errorf("dialing %w", err) + } + + return conn, nil +} + +func (s *Service) instance() (offlipt.Client, error) { + type fclient struct { + *sdk.Flipt + *sdk.Evaluation + } + + if s.client != nil { + return s.client, nil + } + + var err error + + s.once.Do(func() { + u, uerr := url.Parse(s.address) + if uerr != nil { + err = fmt.Errorf("connecting %w", uerr) + } + + opts := []sdk.Option{} + + if s.tokenProvider != nil { + opts = append(opts, sdk.WithClientTokenProvider(s.tokenProvider)) + } + + hclient := sdk.New(sdkhttp.NewTransport(s.address), opts...) + if u.Scheme == "https" || u.Scheme == "http" { + s.client = &fclient{ + hclient.Flipt(), + hclient.Evaluation(), + } + + return + } + + conn, cerr := s.connect() + if cerr != nil { + err = fmt.Errorf("connecting %w", cerr) + } + + gclient := sdk.New(sdkgrpc.NewTransport(conn), opts...) + s.client = &fclient{ + gclient.Flipt(), + gclient.Evaluation(), + } + }) + + return s.client, err +} + +// GetFlag returns a flag if it exists for the given namespace/flag key pair. +func (s *Service) GetFlag(ctx context.Context, namespaceKey, flagKey string) (*flipt.Flag, error) { + conn, err := s.instance() + if err != nil { + return nil, err + } + + flag, err := conn.GetFlag(ctx, &flipt.GetFlagRequest{ + Key: flagKey, + NamespaceKey: namespaceKey, + }) + if err != nil { + return nil, gRPCToOpenFeatureError(err) + } + + return flag, nil +} + +// Boolean evaluates a boolean type flag with the given context and namespace/flag key pair. +func (s *Service) Boolean(ctx context.Context, namespaceKey, flagKey string, evalCtx map[string]interface{}) (*evaluation.BooleanEvaluationResponse, error) { + if evalCtx == nil { + return nil, of.NewInvalidContextResolutionError("evalCtx is nil") + } + + ec := convertMapInterface(evalCtx) + + targetingKey := ec[of.TargetingKey] + if targetingKey == "" { + return nil, of.NewTargetingKeyMissingResolutionError("targetingKey is missing") + } + + conn, err := s.instance() + if err != nil { + return nil, err + } + + ber, err := conn.Boolean(ctx, &evaluation.EvaluationRequest{FlagKey: flagKey, NamespaceKey: namespaceKey, EntityId: targetingKey, RequestId: ec[requestID], Context: ec}) + if err != nil { + return nil, gRPCToOpenFeatureError(err) + } + + return ber, nil +} + +// Evaluate evaluates a variant type flag with the given context and namespace/flag key pair. +func (s *Service) Evaluate(ctx context.Context, namespaceKey, flagKey string, evalCtx map[string]interface{}) (*evaluation.VariantEvaluationResponse, error) { + if evalCtx == nil { + return nil, of.NewInvalidContextResolutionError("evalCtx is nil") + } + + ec := convertMapInterface(evalCtx) + + targetingKey := ec[of.TargetingKey] + if targetingKey == "" { + return nil, of.NewTargetingKeyMissingResolutionError("targetingKey is missing") + } + + conn, err := s.instance() + if err != nil { + return nil, err + } + + resp, err := conn.Variant(ctx, &evaluation.EvaluationRequest{FlagKey: flagKey, NamespaceKey: namespaceKey, EntityId: targetingKey, RequestId: ec[requestID], Context: ec}) + if err != nil { + return nil, gRPCToOpenFeatureError(err) + } + + return resp, nil +} + +func convertMapInterface(m map[string]interface{}) map[string]string { + ee := make(map[string]string) + for k, v := range m { + ee[k] = fmt.Sprintf("%v", v) + } + + return ee +} + +func loadTLSCredentials(serverCertPath string) (credentials.TransportCredentials, error) { + pemServerCA, err := os.ReadFile(serverCertPath) + if err != nil { + return nil, fmt.Errorf("failed to load certificate: %w", err) + } + + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(pemServerCA) { + return nil, fmt.Errorf("failed to add server CA's certificate") + } + + config := &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS12, + } + + return credentials.NewTLS(config), nil +} + +func gRPCToOpenFeatureError(err error) of.ResolutionError { + s, ok := status.FromError(err) + if !ok { + return of.NewGeneralResolutionError("internal error") + } + + switch s.Code() { + case codes.NotFound: + return of.NewFlagNotFoundResolutionError(s.Message()) + case codes.InvalidArgument: + return of.NewInvalidContextResolutionError(s.Message()) + case codes.Unavailable: + return of.NewProviderNotReadyResolutionError(s.Message()) + } + + return of.NewGeneralResolutionError(s.Message()) +} diff --git a/providers/flipt/pkg/service/transport/service_test.go b/providers/flipt/pkg/service/transport/service_test.go new file mode 100644 index 000000000..2ff29510f --- /dev/null +++ b/providers/flipt/pkg/service/transport/service_test.go @@ -0,0 +1,278 @@ +package transport + +import ( + "context" + "testing" + + of "github.com/open-feature/go-sdk/pkg/openfeature" + "github.com/stretchr/testify/assert" + mock "github.com/stretchr/testify/mock" + + offlipt "github.com/open-feature/go-sdk-contrib/providers/flipt/pkg/service" + flipt "go.flipt.io/flipt/rpc/flipt" + "go.flipt.io/flipt/rpc/flipt/evaluation" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + reqID = "987654321" + entityID = "123456789" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + opts []Option + expected Service + }{ + { + name: "default", + expected: Service{ + address: "http://localhost:8080", + }, + }, + { + name: "with host", + opts: []Option{WithAddress("foo:9000")}, + expected: Service{ + address: "foo:9000", + }, + }, + { + name: "with certificate path", + opts: []Option{WithCertificatePath("foo")}, + expected: Service{ + address: "http://localhost:8080", + certificatePath: "foo", + }, + }, + } + + //nolint (copylocks) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := New(tt.opts...) + + assert.NotNil(t, s) + assert.Equal(t, tt.expected.address, s.address) + }) + } +} + +func TestGetFlag(t *testing.T) { + tests := []struct { + name string + err error + expectedErr error + expected *flipt.Flag + }{ + { + name: "success", + expected: &flipt.Flag{ + Key: "foo", + NamespaceKey: "foo-namespace", + }, + }, + { + name: "flag not found", + err: status.Error(codes.NotFound, `flag "foo" not found`), + expectedErr: of.NewFlagNotFoundResolutionError(`flag "foo" not found`), + }, + { + name: "other error", + err: status.Error(codes.Internal, "internal error"), + expectedErr: of.NewGeneralResolutionError("internal error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := offlipt.NewMockClient(t) + + mockClient.On("GetFlag", mock.Anything, &flipt.GetFlagRequest{ + Key: "foo", + NamespaceKey: "foo-namespace", + }).Return(tt.expected, tt.err) + + s := &Service{ + client: mockClient, + } + + actual, err := s.GetFlag(context.Background(), "foo-namespace", "foo") + if tt.expectedErr != nil { + assert.EqualError(t, err, tt.expectedErr.Error()) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, actual) + } + }) + } +} + +func TestEvaluate_NonBoolean(t *testing.T) { + tests := []struct { + name string + err error + expectedErr error + expected *evaluation.VariantEvaluationResponse + }{ + { + name: "success", + expected: &evaluation.VariantEvaluationResponse{ + Match: true, + SegmentKeys: []string{"foo-segment"}, + }, + }, + { + name: "flag not found", + err: status.Error(codes.NotFound, `flag "foo" not found`), + expectedErr: of.NewFlagNotFoundResolutionError(`flag "foo" not found`), + }, + { + name: "other error", + err: status.Error(codes.Internal, "internal error"), + expectedErr: of.NewGeneralResolutionError("internal error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := offlipt.NewMockClient(t) + + mockClient.EXPECT().Variant(mock.Anything, &evaluation.EvaluationRequest{ + FlagKey: "foo", + NamespaceKey: "foo-namespace", + RequestId: reqID, + EntityId: entityID, + Context: map[string]string{ + "requestID": reqID, + "targetingKey": entityID, + }, + }).Return(tt.expected, tt.err) + + s := &Service{ + client: mockClient, + } + + evalCtx := map[string]interface{}{ + "requestID": reqID, + of.TargetingKey: entityID, + } + + actual, err := s.Evaluate(context.Background(), "foo-namespace", "foo", evalCtx) + if tt.expectedErr != nil { + assert.ErrorContains(t, err, tt.expectedErr.Error()) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected.Match, actual.Match) + assert.Equal(t, tt.expected.SegmentKeys, actual.SegmentKeys) + } + }) + } +} + +func TestEvaluate_Boolean(t *testing.T) { + ber := &evaluation.BooleanEvaluationResponse{ + Enabled: false, + } + + mockClient := offlipt.NewMockClient(t) + + mockClient.EXPECT().Boolean(mock.Anything, &evaluation.EvaluationRequest{ + FlagKey: "foo", + NamespaceKey: "foo-namespace", + RequestId: reqID, + EntityId: entityID, + Context: map[string]string{ + "requestID": reqID, + "targetingKey": entityID, + }, + }).Return(ber, nil) + + s := &Service{ + client: mockClient, + } + + evalCtx := map[string]interface{}{ + "requestID": reqID, + of.TargetingKey: entityID, + } + + actual, err := s.Boolean(context.Background(), "foo-namespace", "foo", evalCtx) + assert.NoError(t, err) + assert.False(t, actual.Enabled, "match value should be false") +} + +func TestEvaluateInvalidContext(t *testing.T) { + s := &Service{} + + _, err := s.Evaluate(context.Background(), "foo-namespace", "foo", nil) + assert.EqualError(t, err, of.NewInvalidContextResolutionError("evalCtx is nil").Error()) + + _, err = s.Evaluate(context.Background(), "foo-namespace", "foo", map[string]interface{}{}) + assert.EqualError(t, err, of.NewTargetingKeyMissingResolutionError("targetingKey is missing").Error()) +} + +func TestLoadTLSCredentials(t *testing.T) { + tests := []struct { + name string + certificate string + expectedErrMsg string + }{ + { + name: "no certificate", + certificate: "foo", + expectedErrMsg: "failed to load certificate: open foo: no such file or directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := loadTLSCredentials(tt.certificate) + + if tt.expectedErrMsg != "" { + assert.EqualError(t, err, tt.expectedErrMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGRPCToOpenFeatureError(t *testing.T) { + tests := []struct { + name string + grpcStatus *status.Status + expectedErr of.ResolutionError + }{ + { + name: "invalid argument", + grpcStatus: status.New(codes.InvalidArgument, "invalid argument"), + expectedErr: of.NewInvalidContextResolutionError("invalid argument"), + }, + { + name: "not found", + grpcStatus: status.New(codes.NotFound, "not found"), + expectedErr: of.NewFlagNotFoundResolutionError("not found"), + }, + { + name: "unavailable", + grpcStatus: status.New(codes.Unavailable, "unavailable"), + expectedErr: of.NewProviderNotReadyResolutionError("unavailable"), + }, + { + name: "unknown", + grpcStatus: status.New(codes.Unknown, "unknown"), + expectedErr: of.NewGeneralResolutionError("unknown"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := gRPCToOpenFeatureError(tt.grpcStatus.Err()) + + assert.EqualError(t, err, tt.expectedErr.Error()) + }) + } +} diff --git a/release-please-config.json b/release-please-config.json index 7309bd4e4..66a6d1490 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -99,6 +99,14 @@ "bump-patch-for-minor-pre-major": true, "versioning": "default", "extra-files": [] + }, + "providers/flipt": { + "release-type": "go", + "package-name": "providers/flipt", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [] } }, "changelog-sections": [