diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index fe72baed9..c84af78fb 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -12,7 +12,7 @@ This is a complete feature flagging solution with the possibility to target only ## Install dependencies -The first things we will do is install the **Open Feature SDK** and the **GO Feature Flag provider**. +The first things we will do are to install the **Open Feature SDK** and the **GO Feature Flag provider**. ```shell go get github.com/open-feature/go-sdk-contrib/providers/go-feature-flag @@ -20,12 +20,9 @@ go get github.com/open-feature/go-sdk-contrib/providers/go-feature-flag ## Initialize your Open Feature provider -Despite other providers, this GO provider can be used with the **relay proxy** or used standalone -using the **GO Feature Flag module**. +### Connecting to the relay proxy -### Using the relay proxy - -If you want to use the provider with the **relay proxy** you should set the field `Endpoint` in the options. +This provider has to connect with the **relay proxy**, to do that you should set the field `Endpoint` in the options. By default it will use a default `HTTPClient` with a **timeout** configured at **10000** milliseconds. You can change this configuration by providing your own configuration of the `HTTPClient`. @@ -40,30 +37,9 @@ options := gofeatureflag.ProviderOptions{ provider, _ := gofeatureflag.NewProviderWithContext(ctx, options) ``` -### Using the GO module _(standalone version)_ -If you want to use the provider in standalone mode using the GO module, you should set the field `GOFeatureFlagConfig` -in the options. - -You can check the [GO Feature Flag documentation website](https://docs.gofeatureflag.org) to look how to configure the -GO module. - -#### Example -```go -options := gofeatureflag.ProviderOptions{ - GOFeatureFlagConfig: &ffclient.Config{ - PollingInterval: 10 * time.Second, - Context: context.Background(), - Retriever: &fileretriever.Retriever{ - Path: "../testutils/module/flags.yaml", - }, - }, -} -provider, _ := gofeatureflag.NewProviderWithContext(ctx, options) -``` - ## Initialize your Open Feature client -To evaluate the flags you need to have an Open Feature configured in you app. +To evaluate the flag you need to have an Open Feature configured in you app. This code block shows you how you can create a client that you can use in your application. ```go diff --git a/providers/go-feature-flag/go.mod b/providers/go-feature-flag/go.mod index 873caf340..9433d89ae 100644 --- a/providers/go-feature-flag/go.mod +++ b/providers/go-feature-flag/go.mod @@ -1,29 +1,23 @@ module github.com/open-feature/go-sdk-contrib/providers/go-feature-flag -go 1.21 +go 1.21.0 -toolchain go1.22.3 +toolchain go1.22.5 require ( github.com/bluele/gcache v0.0.2 github.com/open-feature/go-sdk v1.11.0 + github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.4 github.com/stretchr/testify v1.9.0 - github.com/thomaspoignant/go-feature-flag v1.25.0 ) require ( - github.com/BurntSushi/toml v1.3.2 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect - github.com/apache/thrift v0.16.0 // indirect - github.com/blang/semver v3.5.1+incompatible // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-logr/logr v1.4.1 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/nikunjy/rules v1.5.0 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 // indirect + golang.org/x/text v0.16.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/providers/go-feature-flag/go.sum b/providers/go-feature-flag/go.sum index 1296042eb..895d56200 100644 --- a/providers/go-feature-flag/go.sum +++ b/providers/go-feature-flag/go.sum @@ -1,81 +1,12 @@ -cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME= -cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= -cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0= -cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc= -cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE= -cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w= -cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= -github.com/apache/arrow/go/arrow v0.0.0-20200730104253-651201b0f516 h1:byKBBF2CKWBjjA4J1ZL2JXttJULvWSl50LegTyRZ728= -github.com/apache/arrow/go/arrow v0.0.0-20200730104253-651201b0f516/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0= -github.com/apache/thrift v0.16.0 h1:qEy6UW60iVOlUy+b9ZR0d5WzUWYGOo4HfopoyBaNmoY= -github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= -github.com/aws/aws-sdk-go v1.47.9 h1:rarTsos0mA16q+huicGx0e560aYRtOucV5z2Mw23JRY= -github.com/aws/aws-sdk-go v1.47.9/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go-v2 v1.22.2 h1:lV0U8fnhAnPz8YcdmZVV60+tr6CakHzqA6P8T46ExJI= -github.com/aws/aws-sdk-go-v2 v1.22.2/go.mod h1:Kd0OJtkW3Q0M0lUWGszapWjEvrXDzRW+D21JNsroB+c= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.0 h1:hHgLiIrTRtddC0AKcJr5s7i/hLgcpTt+q/FKxf1Zayk= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.0/go.mod h1:w4I/v3NOWgD+qvs1NPEwhd++1h3XPHFaVxasfY6HlYQ= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.13.5 h1:P/xwilRdRLLg1PzfviDq0Zjb74weOoDCrh8J5lRCQAY= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.13.5/go.mod h1:9cLHf2IwX6Jyw0KjLVbXly/g6DmzExgUzB1w/AQPGQE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.2 h1:AaQsr5vvGR7rmeSWBtTCcw16tT9r51mWijuCQhzLnq8= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.2/go.mod h1:o1IiRn7CWocIFTXJjGKJDOwxv1ibL53NpcvcqGWyRBA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.2 h1:UZx8SXZ0YtzRiALzYAWcjb9Y9hZUR7MBKaBQ5ouOjPs= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.2/go.mod h1:ipuRpcSaklmxR6C39G187TpBAO132gUfleTGccUPs8c= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.2 h1:pyVrNAf7Hwz0u39dLKN5t+n0+K/3rMYKuiOoIum3AsU= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.2/go.mod h1:mydrfOb9uiOYCxuCPR8YHQNQyGQwUQ7gPMZGBKbH8NY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.0 h1:CJxo7ZBbaIzmXfV3hjcx36n9V87gJsIUPJflwqEHl3Q= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.0/go.mod h1:yjVfjuY4nD1EW9i387Kau+I6V5cBA5YnC/mWNopjZrI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.2 h1:f2LhPofnjcdOQKRtumKjMvIHkfSQ8aH/rwKUDEQ/SB4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.2/go.mod h1:q+xX0H4OfuWDuBy7y/LDi4v8IBOWuF+vtp8Z6ex+lw4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.2 h1:h7j73yuAVVjic8pqswh+L/7r2IHP43QwRyOu6zcCDDE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.2/go.mod h1:H07AHdK5LSy8F7EJUQhoxyiCNkePoHj2D8P2yGTWafo= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.2 h1:gbIaOzpXixUpoPK+js/bCBK1QBDXM22SigsnzGZio0U= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.2/go.mod h1:p+S7RNbdGN8qgHDSg2SCQJ9FeMAmvcETQiVpeGhYnNM= -github.com/aws/aws-sdk-go-v2/service/s3 v1.42.1 h1:o6MCcX1rJW8Y3g+hvg2xpjF6JR6DftuYhfl3Nc1WV9Q= -github.com/aws/aws-sdk-go-v2/service/s3 v1.42.1/go.mod h1:UDtxEWbREX6y4KREapT+jjtjoH0TiVSS6f5nfaY1UaM= -github.com/aws/smithy-go v1.16.0 h1:gJZEH/Fqh+RsvlJ1Zt4tVAtV6bKkp3cC+R6FCZMNzik= -github.com/aws/smithy-go v1.16.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= -github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= -github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -83,90 +14,21 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/nikunjy/rules v1.5.0 h1:KJDSLOsFhwt7kcXUyZqwkgrQg5YoUwj+TVu6ItCQShw= -github.com/nikunjy/rules v1.5.0/go.mod h1:TlZtZdBChrkqi8Lr2AXocme8Z7EsbxtFdDoKeI6neBQ= -github.com/open-feature/go-sdk v1.9.0 h1:1Nyj+XNHfL0rRGZgGCbZ29CHDD57PQJL7Q/2ZbW/E8c= -github.com/open-feature/go-sdk v1.9.0/go.mod h1:n5BM4DfvIiKaWWquZnL/yVihcGM5aLsz7rNYE3BkXAM= -github.com/open-feature/go-sdk v1.10.0 h1:druQtYOrN+gyz3rMsXp0F2jW1oBXJb0V26PVQnUGLbM= -github.com/open-feature/go-sdk v1.10.0/go.mod h1:+rkJhLBtYsJ5PZNddAgFILhRAAxwrJ32aU7UEUm4zQI= github.com/open-feature/go-sdk v1.11.0 h1:4cp9rXl16ZvlMCef7O+I3vQSXae8DzAF0SfV9mvYInw= github.com/open-feature/go-sdk v1.11.0/go.mod h1:+rkJhLBtYsJ5PZNddAgFILhRAAxwrJ32aU7UEUm4zQI= -github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= -github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.4 h1:BElq1EOES8DfLjW6UIFMNVG7w9MzoeC7JpD/1rXouhk= +github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.4/go.mod h1:jrD4UG3ZCzuwImKHlyuIN2iWeYjlOX5+zJ/sX45efuE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/thomaspoignant/go-feature-flag v1.18.2 h1:EmU4Ja+xfVoHuTBlZmP6YzwTgmiuwHeCS8/GNymFkHI= -github.com/thomaspoignant/go-feature-flag v1.18.2/go.mod h1:ePtHa9auiio7QHlWKUE8ggsgUtF9MvzLDKnIK6009sM= -github.com/thomaspoignant/go-feature-flag v1.21.0 h1:r+D2oiS+nxxCFhaEX+dMK4nNj7e2lpZTT7rvlZ95Kl8= -github.com/thomaspoignant/go-feature-flag v1.21.0/go.mod h1:lG+lAUiWuXsR5Rga6L5Bihlz2qyr/TTEr2Siljjwnec= -github.com/thomaspoignant/go-feature-flag v1.24.0 h1:jB3QDkynJuhvhhU4DEa3tqVzMzRq7w0vOFdjmLhoQg4= -github.com/thomaspoignant/go-feature-flag v1.24.0/go.mod h1:4IyAMQujXyeQi4qP33COnMpQREHLdzuQHCHCov0MxWA= -github.com/thomaspoignant/go-feature-flag v1.24.2 h1:nIapGGFI4nhMCzGcsdLxI+nyL2gnAz8IvySM0IygCRU= -github.com/thomaspoignant/go-feature-flag v1.24.2/go.mod h1:fLM/Ojhj8fhTj/XnNWTEjD70YAwy1FJJjLd4MSRazXk= -github.com/thomaspoignant/go-feature-flag v1.25.0 h1:gqICHeR2YKG+cS83q+nAgaZzCO9cqgNmILm9V0reJv8= -github.com/thomaspoignant/go-feature-flag v1.25.0/go.mod h1:RGlg/ykVr7wxOprGWHTd6V6/0oU2TsT869TErdW4s3U= -github.com/xitongsys/parquet-go v1.6.2 h1:MhCaXii4eqceKPu9BwrjLqyK10oX9WF+xGhwvwbw7xM= -github.com/xitongsys/parquet-go v1.6.2/go.mod h1:IulAQyalCm0rPiZVNnCgm/PCL64X2tdSVGMQ/UeKqWA= -github.com/xitongsys/parquet-go-source v0.0.0-20230830030807-0dd610dbff1d h1:VVWj8KWdzpebBaXpTVpOaQW32y2UCWy3JXJ5lVDa/e8= -github.com/xitongsys/parquet-go-source v0.0.0-20230830030807-0dd610dbff1d/go.mod h1:HaLl1OAA7RAuQURU3Enxn7aRAI9yezsPPaxiGrbzxW4= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -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/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o= -golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo= golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= -golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= -golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= -golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -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.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.150.0 h1:Z9k22qD289SZ8gCJrk4DrWXkNjtfvKAUo/l1ma8eBYE= -google.golang.org/api v0.150.0/go.mod h1:ccy+MJ6nrYFgE3WgRx/AMXOxOmU8Q4hSa+jjibzhxcg= -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-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= -google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k= -google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= -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.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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= diff --git a/providers/go-feature-flag/pkg/controller/cache.go b/providers/go-feature-flag/pkg/controller/cache.go new file mode 100644 index 000000000..6fa833a4f --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/cache.go @@ -0,0 +1,139 @@ +package controller + +import ( + "fmt" + "github.com/bluele/gcache" + of "github.com/open-feature/go-sdk/openfeature" + "hash/fnv" + "time" +) + +const defaultCacheSize = 10000 +const defaultCacheTTL = 1 * time.Minute + +type Cache struct { + internalCache gcache.Cache + maxEventInMemory int64 + ttl time.Duration + disabled bool +} + +// NewCache creates a new cache with the given options. +func NewCache(cacheSize int, ttl time.Duration, disabled bool) *Cache { + if cacheSize == 0 { + cacheSize = defaultCacheSize + } + if ttl == 0 { + ttl = defaultCacheTTL + } + c := &Cache{ + ttl: ttl, + disabled: disabled, + } + if cacheSize > 0 && !disabled { + c.internalCache = gcache.New(cacheSize). + LRU(). + Build() + } + return c +} + +// GetBool returns the boolean value of the flag from the cache. +func (c *Cache) GetBool(flag string, evalCtx of.FlattenedContext) (*of.BoolResolutionDetail, error) { + if c.disabled || c.internalCache == nil { + return nil, nil + } + cacheValue, err := c.internalCache.Get(c.buildCacheKey(flag, evalCtx)) + if err != nil { + return nil, err + } + if value, ok := cacheValue.(of.BoolResolutionDetail); ok { + return &value, nil + } + return nil, fmt.Errorf("unexpected type in cache (expecting bool)") +} + +// GetString returns the string value of the flag from the cache. +func (c *Cache) GetString(flag string, evalCtx of.FlattenedContext) (*of.StringResolutionDetail, error) { + if c.disabled || c.internalCache == nil { + return nil, nil + } + cacheValue, err := c.internalCache.Get(c.buildCacheKey(flag, evalCtx)) + if err != nil { + return nil, err + } + if value, ok := cacheValue.(of.StringResolutionDetail); ok { + return &value, nil + } + return nil, fmt.Errorf("unexpected type in cache (expecting string)") +} + +// GetFloat returns the float value of the flag from the cache. +func (c *Cache) GetFloat(flag string, evalCtx of.FlattenedContext) (*of.FloatResolutionDetail, error) { + if c.disabled || c.internalCache == nil { + return nil, nil + } + cacheValue, err := c.internalCache.Get(c.buildCacheKey(flag, evalCtx)) + if err != nil { + return nil, err + } + if value, ok := cacheValue.(of.FloatResolutionDetail); ok { + return &value, nil + } + return nil, fmt.Errorf("unexpected type in cache (expecting float)") +} + +// GetInt returns the int value of the flag from the cache. +func (c *Cache) GetInt(flag string, evalCtx of.FlattenedContext) (*of.IntResolutionDetail, error) { + if c.disabled || c.internalCache == nil { + return nil, nil + } + cacheValue, err := c.internalCache.Get(c.buildCacheKey(flag, evalCtx)) + if err != nil { + return nil, err + } + if value, ok := cacheValue.(of.IntResolutionDetail); ok { + return &value, nil + } + return nil, fmt.Errorf("unexpected type in cache (expecting int)") +} + +// GetInterface returns the interface value of the flag from the cache. +func (c *Cache) GetInterface(flag string, evalCtx of.FlattenedContext) (*of.InterfaceResolutionDetail, error) { + if c.disabled || c.internalCache == nil { + return nil, nil + } + cacheValue, err := c.internalCache.Get(c.buildCacheKey(flag, evalCtx)) + if err != nil { + return nil, err + } + if value, ok := cacheValue.(of.InterfaceResolutionDetail); ok { + return &value, nil + } + return nil, fmt.Errorf("unexpected type in cache (expecting interface)") +} + +// Set sets the value of the flag in the cache. +func (c *Cache) Set(flag string, evalCtx of.FlattenedContext, value interface{}) error { + if c.disabled || c.internalCache == nil { + return nil + } + if c.ttl >= 0 { + return c.internalCache.SetWithExpire(c.buildCacheKey(flag, evalCtx), value, c.ttl) + } + return c.internalCache.Set(c.buildCacheKey(flag, evalCtx), value) +} + +func (c *Cache) Purge() { + if c.internalCache != nil { + c.internalCache.Purge() + } +} + +// buildCacheKey builds a cache key from the flag and evaluation context. +func (c *Cache) buildCacheKey(flag string, evalCtx of.FlattenedContext) uint32 { + key := fmt.Sprintf("%s-%+v", flag, evalCtx) + h := fnv.New32a() + _, _ = h.Write([]byte(key)) + return h.Sum32() +} diff --git a/providers/go-feature-flag/pkg/controller/cache_test.go b/providers/go-feature-flag/pkg/controller/cache_test.go new file mode 100644 index 000000000..d4d193782 --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/cache_test.go @@ -0,0 +1,225 @@ +package controller_test + +import ( + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/controller" + "github.com/open-feature/go-sdk/openfeature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestCache(t *testing.T) { + evalCtx := openfeature.FlattenedContext{ + "targetingKey": "5e83aec4-0559-415a-82a9-f2d751ba47c0", + } + + t.Run("should return a BoolResolutionDetail", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.BoolResolutionDetail{ + Value: true, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + got, err := c.GetBool("flag", evalCtx) + require.NoError(t, err) + assert.Equal(t, &brd, got) + assert.IsType(t, &openfeature.BoolResolutionDetail{}, got) + }) + + t.Run("should return a StringResolutionDetail", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.StringResolutionDetail{ + Value: "xxx", + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + got, err := c.GetString("flag", evalCtx) + require.NoError(t, err) + assert.Equal(t, &brd, got) + assert.IsType(t, &openfeature.StringResolutionDetail{}, got) + }) + + t.Run("should return a FloatResolutionDetail", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.FloatResolutionDetail{ + Value: 1.1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + got, err := c.GetFloat("flag", evalCtx) + require.NoError(t, err) + assert.Equal(t, &brd, got) + assert.IsType(t, &openfeature.FloatResolutionDetail{}, got) + }) + + t.Run("should return a IntResolutionDetail", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.IntResolutionDetail{ + Value: 1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + got, err := c.GetInt("flag", evalCtx) + require.NoError(t, err) + assert.Equal(t, &brd, got) + assert.IsType(t, &openfeature.IntResolutionDetail{}, got) + }) + + t.Run("should return a InterfaceResolutionDetail", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.InterfaceResolutionDetail{ + Value: 1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + got, err := c.GetInterface("flag", evalCtx) + require.NoError(t, err) + assert.Equal(t, &brd, got) + assert.IsType(t, &openfeature.InterfaceResolutionDetail{}, got) + }) + + t.Run("should have a type error for Bool", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.InterfaceResolutionDetail{ + Value: 1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + _, err = c.GetBool("flag", evalCtx) + require.Error(t, err) + }) + + t.Run("should have a type error for String", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.InterfaceResolutionDetail{ + Value: 1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + _, err = c.GetString("flag", evalCtx) + require.Error(t, err) + }) + + t.Run("should have a type error for Float", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.InterfaceResolutionDetail{ + Value: 1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + _, err = c.GetFloat("flag", evalCtx) + require.Error(t, err) + }) + + t.Run("should have a type error for Int", func(t *testing.T) { + c := controller.NewCache(10, -1, false) + brd := openfeature.InterfaceResolutionDetail{ + Value: 1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + _, err = c.GetInt("flag", evalCtx) + require.Error(t, err) + }) + + t.Run("should have a type error for Interface", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, false) + brd := openfeature.IntResolutionDetail{ + Value: 1, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.ResolutionError{}, + Reason: "TARGETING_MATCH", + Variant: "varA", + FlagMetadata: nil, + }, + } + err := c.Set("flag", evalCtx, brd) + require.NoError(t, err) + _, err = c.GetInterface("flag", evalCtx) + require.Error(t, err) + }) + + t.Run("should return nil if cache disabled", func(t *testing.T) { + c := controller.NewCache(10, 0, true) + val, err := c.GetBool("flag", evalCtx) + require.NoError(t, err) + assert.Nil(t, val) + + val1, err1 := c.GetString("flag", evalCtx) + require.NoError(t, err1) + assert.Nil(t, val1) + + val2, err2 := c.GetFloat("flag", evalCtx) + require.NoError(t, err2) + assert.Nil(t, val2) + + val3, err3 := c.GetInt("flag", evalCtx) + require.NoError(t, err3) + assert.Nil(t, val3) + + val4, err4 := c.GetInterface("flag", evalCtx) + require.NoError(t, err4) + assert.Nil(t, val4) + }) + + t.Run("should return nil if cache disabled", func(t *testing.T) { + c := controller.NewCache(10, 1*time.Minute, true) + err := c.Set("flag", evalCtx, openfeature.BoolResolutionDetail{}) + require.NoError(t, err) + }) +} diff --git a/providers/go-feature-flag/pkg/controller/configuration_change_status.go b/providers/go-feature-flag/pkg/controller/configuration_change_status.go new file mode 100644 index 000000000..2f19107e7 --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/configuration_change_status.go @@ -0,0 +1,10 @@ +package controller + +type ConfigurationChangeStatus = string + +const ( + FlagConfigurationInitialized ConfigurationChangeStatus = "FLAG_CONFIGURATION_INITIALIZED" + FlagConfigurationUpdated ConfigurationChangeStatus = "FLAG_CONFIGURATION_UPDATED" + FlagConfigurationNotChanged ConfigurationChangeStatus = "FLAG_CONFIGURATION_NOT_CHANGED" + ErrorConfigurationChange ConfigurationChangeStatus = "ERROR_CONFIGURATION_CHANGE" +) diff --git a/providers/go-feature-flag/pkg/controller/data_collector_manager.go b/providers/go-feature-flag/pkg/controller/data_collector_manager.go new file mode 100644 index 000000000..3094364f4 --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/data_collector_manager.go @@ -0,0 +1,91 @@ +package controller + +import ( + "fmt" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/model" + "sync" + "time" +) + +// DataCollectorManager is a manager for the GO Feature Flag data collector +type DataCollectorManager struct { + mutex *sync.Mutex + goffAPI GoFeatureFlagAPI + events []model.FeatureEvent + dataCollectorMaxEventStored int64 + + ticker *time.Ticker + collectChannel chan bool +} + +// NewDataCollectorManager creates a new data collector manager +func NewDataCollectorManager( + goffAPI GoFeatureFlagAPI, + dataCollectorMaxEventStored int64, + collectInterval time.Duration) DataCollectorManager { + if dataCollectorMaxEventStored <= 0 { + dataCollectorMaxEventStored = 100000 + } + if collectInterval <= 0 { + collectInterval = 1 * time.Minute + } + return DataCollectorManager{ + mutex: &sync.Mutex{}, + goffAPI: goffAPI, + events: make([]model.FeatureEvent, 0), + dataCollectorMaxEventStored: dataCollectorMaxEventStored, + ticker: time.NewTicker(collectInterval), + collectChannel: make(chan bool), + } +} + +func (d *DataCollectorManager) Start() { + go func() { + for { + select { + case <-d.collectChannel: + return + case <-d.ticker.C: + _ = d.SendData() + } + } + }() +} + +func (d *DataCollectorManager) Stop() { + d.collectChannel <- true + d.ticker.Stop() +} + +// SendData sends the data to the data collector +func (d *DataCollectorManager) SendData() error { + d.mutex.Lock() + defer d.mutex.Unlock() + + if len(d.events) <= 0 { + return nil + } + + copySend := make([]model.FeatureEvent, len(d.events)) + copy(copySend, d.events) + err := d.goffAPI.CollectData(copySend) + if err != nil { + return err + } + d.events = make([]model.FeatureEvent, 0) + return nil +} + +// AddEvent adds an event to the data collector manager +// If the number of events in the queue is greater than the maxItem, the event will be skipped +func (d *DataCollectorManager) AddEvent(event model.FeatureEvent) error { + d.mutex.Lock() + defer d.mutex.Unlock() + + if nbItem := int64(len(d.events)); nbItem >= d.dataCollectorMaxEventStored { + return fmt.Errorf("too many events in the queue, this event will be skipped: %d", nbItem) + } + + d.events = append(d.events, event) + return nil +} diff --git a/providers/go-feature-flag/pkg/controller/data_collector_manager_test.go b/providers/go-feature-flag/pkg/controller/data_collector_manager_test.go new file mode 100644 index 000000000..869a037b8 --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/data_collector_manager_test.go @@ -0,0 +1,138 @@ +package controller_test + +import ( + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/controller" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/model" + "github.com/stretchr/testify/assert" + "net/http" + "testing" + "time" +) + +func Test_DataCollectorManager(t *testing.T) { + eventExample := model.FeatureEvent{ + Kind: "feature", + ContextKind: "user", + UserKey: "EFGH", + CreationDate: 1722266324, + Key: "random-key", + Variation: "variationA", + Value: "YO", + Default: false, + Version: "", + Source: "SERVER", + } + t.Run("Should collect only once if there is no event in queue", func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + } + }, Err: nil} + client := &http.Client{Transport: &mrt} + g := controller.NewGoFeatureFlagAPI(controller.GoFeatureFlagApiOptions{ + HTTPClient: client, + }) + + collector := controller.NewDataCollectorManager(g, 100, 100*time.Millisecond) + collector.Start() + defer collector.Stop() + _ = collector.AddEvent(eventExample) + + time.Sleep(300 * time.Millisecond) + assert.Equal(t, 1, mrt.NumberCall) + }) + + t.Run("Should collect multiple times if we are adding events in between intervals", func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + } + }, Err: nil} + client := &http.Client{Transport: &mrt} + g := controller.NewGoFeatureFlagAPI(controller.GoFeatureFlagApiOptions{ + HTTPClient: client, + }) + + collector := controller.NewDataCollectorManager(g, 100, 100*time.Millisecond) + collector.Start() + defer collector.Stop() + _ = collector.AddEvent(eventExample) + _ = collector.AddEvent(eventExample) + _ = collector.AddEvent(eventExample) + time.Sleep(120 * time.Millisecond) + _ = collector.AddEvent(eventExample) + time.Sleep(120 * time.Millisecond) + _ = collector.AddEvent(eventExample) + time.Sleep(120 * time.Millisecond) + assert.Equal(t, 3, mrt.NumberCall) + }) + + t.Run("Should stop adding events if max items reached", func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + } + }, Err: nil} + client := &http.Client{Transport: &mrt} + g := controller.NewGoFeatureFlagAPI(controller.GoFeatureFlagApiOptions{ + HTTPClient: client, + }) + + collector := controller.NewDataCollectorManager(g, 5, 10*time.Minute) + collector.Start() + defer collector.Stop() + err := collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.Error(t, err) + err = collector.AddEvent(eventExample) + assert.Error(t, err) + + _ = collector.SendData() + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + }) + + t.Run("Should not remove items if saveData failed", func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + } + }, Err: nil} + client := &http.Client{Transport: &mrt} + g := controller.NewGoFeatureFlagAPI(controller.GoFeatureFlagApiOptions{ + HTTPClient: client, + }) + + collector := controller.NewDataCollectorManager(g, 5, 100*time.Millisecond) + collector.Start() + defer collector.Stop() + err := collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + err = collector.AddEvent(eventExample) + assert.NoError(t, err) + // Wait until the data collector sends the data (and failed) + time.Sleep(180 * time.Millisecond) + + // Should error because the data collector is full + err = collector.AddEvent(eventExample) + assert.Error(t, err) + + // Should have tried only once to call the API + assert.Equal(t, 1, mrt.NumberCall) + }) +} diff --git a/providers/go-feature-flag/pkg/controller/goff_api.go b/providers/go-feature-flag/pkg/controller/goff_api.go new file mode 100644 index 000000000..317e46b0b --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/goff_api.go @@ -0,0 +1,118 @@ +package controller + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/model" + "net/http" + "net/url" + "path" +) + +type GoFeatureFlagApiOptions struct { + // Endpoint contains the DNS of your GO Feature Flag relay proxy (ex: http://localhost:1031) + Endpoint string + // HTTPClient (optional) is the HTTP Client we will use to contact GO Feature Flag. + // By default, we are using a custom HTTPClient with a timeout configure to 10000 milliseconds. + HTTPClient *http.Client + // APIKey (optional) If the relay proxy is configured to authenticate the requests, you should provide + // an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. + // (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above) + // Default: null + APIKey string +} + +type GoFeatureFlagAPI struct { + options GoFeatureFlagApiOptions + + // --- internal properties + // configChangeEtag is the etag of the last configuration change + configChangeEtag string +} + +func NewGoFeatureFlagAPI(options GoFeatureFlagApiOptions) GoFeatureFlagAPI { + return GoFeatureFlagAPI{options: options} +} + +func (g *GoFeatureFlagAPI) CollectData(events []model.FeatureEvent) error { + u, _ := url.Parse(g.options.Endpoint) + u.Path = path.Join(u.Path, "v1", "data", "collector") + reqBody := model.DataCollectorRequest{ + Events: events, + Meta: map[string]string{"provider": "go", "openfeature": "true"}, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set(ContentTypeHeader, ApplicationJson) + if g.options.APIKey != "" { + req.Header.Set(AuthorizationHeader, BearerPrefix+g.options.APIKey) + } + + response, err := g.getHttpClient().Do(req) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("request failed with status: %v", response.Status) + } + return nil +} + +// ConfigurationHasChanged checks if the configuration has changed since the last call. +func (g *GoFeatureFlagAPI) ConfigurationHasChanged() (ConfigurationChangeStatus, error) { + u, _ := url.Parse(g.options.Endpoint) + u.Path = path.Join(u.Path, "v1", "flag", "change") + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return ErrorConfigurationChange, err + } + req.Header.Set(ContentTypeHeader, ApplicationJson) + if g.options.APIKey != "" { + req.Header.Set(AuthorizationHeader, BearerPrefix+g.options.APIKey) + } + if g.configChangeEtag != "" { + req.Header.Set(IfNoneMatchHeader, g.configChangeEtag) + } + + response, err := g.getHttpClient().Do(req) + if err != nil { + return ErrorConfigurationChange, err + } + _ = response.Body.Close() + + switch response.StatusCode { + case http.StatusOK: + if g.configChangeEtag == "" { + g.configChangeEtag = response.Header.Get("ETag") + return FlagConfigurationInitialized, nil + } + g.configChangeEtag = response.Header.Get("ETag") + return FlagConfigurationUpdated, nil + case http.StatusNotModified: + return FlagConfigurationNotChanged, nil + default: + return ErrorConfigurationChange, err + } +} + +// getHttpClient returns the HTTP Client to use for the request. +func (g *GoFeatureFlagAPI) getHttpClient() *http.Client { + client := g.options.HTTPClient + if client == nil { + client = DefaultHTTPClient() + } + return client +} diff --git a/providers/go-feature-flag/pkg/controller/goff_api_test.go b/providers/go-feature-flag/pkg/controller/goff_api_test.go new file mode 100644 index 000000000..21f578187 --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/goff_api_test.go @@ -0,0 +1,275 @@ +package controller_test + +import ( + "bytes" + "errors" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/controller" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "io" + "net/http" + "testing" +) + +func Test_CollectDataAPI(t *testing.T) { + type test struct { + name string + wantErr assert.ErrorAssertionFunc + options controller.GoFeatureFlagApiOptions + roundtripFunc func(req *http.Request) *http.Response + roundtripErr error + wantHeaders http.Header + wantReqBody string + events []model.FeatureEvent + } + tests := []test{ + { + name: "Valid api call", + wantErr: assert.NoError, + options: controller.GoFeatureFlagApiOptions{ + Endpoint: "http://localhost:1031", + APIKey: "", + }, + events: []model.FeatureEvent{ + { + Kind: "feature", + ContextKind: "user", + UserKey: "ABCD", + CreationDate: 1722266324, + Key: "random-key", + Variation: "variationA", + Value: "YO", + Default: false, + Version: "", + Source: "SERVER", + }, + { + Kind: "feature", + ContextKind: "user", + UserKey: "EFGH", + CreationDate: 1722266324, + Key: "random-key", + Variation: "variationA", + Value: "YO", + Default: false, + Version: "", + Source: "SERVER", + }, + }, + roundtripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"ingestedContentCount":1}`))), + } + }, + wantHeaders: func() http.Header { + headers := http.Header{} + headers.Set(controller.ContentTypeHeader, controller.ApplicationJson) + return headers + }(), + wantReqBody: "{\"events\":[{\"kind\":\"feature\",\"contextKind\":\"user\",\"userKey\":\"ABCD\",\"creationDate\":1722266324,\"key\":\"random-key\",\"variation\":\"variationA\",\"value\":\"YO\",\"default\":false,\"version\":\"\",\"source\":\"SERVER\"},{\"kind\":\"feature\",\"contextKind\":\"user\",\"userKey\":\"EFGH\",\"creationDate\":1722266324,\"key\":\"random-key\",\"variation\":\"variationA\",\"value\":\"YO\",\"default\":false,\"version\":\"\",\"source\":\"SERVER\"}],\"meta\":{\"openfeature\":\"true\",\"provider\":\"go\"}}", + }, + { + name: "Valid api call with API Key", + wantErr: assert.NoError, + options: controller.GoFeatureFlagApiOptions{ + Endpoint: "http://localhost:1031", + APIKey: "my-key", + }, + events: []model.FeatureEvent{ + { + Kind: "feature", + ContextKind: "user", + UserKey: "ABCD", + CreationDate: 1722266324, + Key: "random-key", + Variation: "variationA", + Value: "YO", + Default: false, + Version: "", + Source: "SERVER", + }, + { + Kind: "feature", + ContextKind: "user", + UserKey: "EFGH", + CreationDate: 1722266324, + Key: "random-key", + Variation: "variationA", + Value: "YO", + Default: false, + Version: "", + Source: "SERVER", + }, + }, + roundtripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"ingestedContentCount":1}`))), + } + }, + wantHeaders: func() http.Header { + headers := http.Header{} + headers.Set(controller.ContentTypeHeader, controller.ApplicationJson) + headers.Set(controller.AuthorizationHeader, controller.BearerPrefix+"my-key") + return headers + }(), + wantReqBody: "{\"events\":[{\"kind\":\"feature\",\"contextKind\":\"user\",\"userKey\":\"ABCD\",\"creationDate\":1722266324,\"key\":\"random-key\",\"variation\":\"variationA\",\"value\":\"YO\",\"default\":false,\"version\":\"\",\"source\":\"SERVER\"},{\"kind\":\"feature\",\"contextKind\":\"user\",\"userKey\":\"EFGH\",\"creationDate\":1722266324,\"key\":\"random-key\",\"variation\":\"variationA\",\"value\":\"YO\",\"default\":false,\"version\":\"\",\"source\":\"SERVER\"}],\"meta\":{\"openfeature\":\"true\",\"provider\":\"go\"}}", + }, + { + name: "Request failed", + wantErr: assert.Error, + options: controller.GoFeatureFlagApiOptions{ + Endpoint: "http://localhost:1031", + }, + events: []model.FeatureEvent{}, + roundtripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"ingestedContentCount":1}`))), + } + }, + roundtripErr: errors.New("request failed"), + }, + { + name: "Request return 400", + wantErr: assert.Error, + options: controller.GoFeatureFlagApiOptions{ + Endpoint: "http://localhost:1031", + }, + events: []model.FeatureEvent{}, + roundtripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusBadRequest, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: tt.roundtripFunc, Err: tt.roundtripErr} + client := &http.Client{Transport: &mrt} + + options := tt.options + options.HTTPClient = client + g := controller.NewGoFeatureFlagAPI(options) + err := g.CollectData(tt.events) + tt.wantErr(t, err) + + if err != nil { + return + } + + assert.Equal(t, tt.wantHeaders, mrt.GetLastRequest().Header) + + bodyBytes, err := io.ReadAll(mrt.GetLastRequest().Body) + require.NoError(t, err) + assert.JSONEq(t, tt.wantReqBody, string(bodyBytes)) + }) + } +} + +func Test_ConfigurationHasChanged(t *testing.T) { + t.Run("Initial configuration call", func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + } + }} + client := &http.Client{Transport: &mrt} + options := controller.GoFeatureFlagApiOptions{ + Endpoint: "http://localhost:1031", + HTTPClient: client, + } + g := controller.NewGoFeatureFlagAPI(options) + status, err := g.ConfigurationHasChanged() + require.NoError(t, err) + assert.Equal(t, controller.FlagConfigurationInitialized, status) + }) + + t.Run("Change in the configuration", func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: func(req *http.Request) *http.Response { + if req.Header.Get("If-None-Match") == "123456" { + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: map[string][]string{}, + } + resp.Header.Set("ETag", "78910") + return resp + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: map[string][]string{}, + } + resp.Header.Set("ETag", "123456") + return resp + }} + client := &http.Client{Transport: &mrt} + options := controller.GoFeatureFlagApiOptions{ + Endpoint: "http://localhost:1031", + HTTPClient: client, + } + g := controller.NewGoFeatureFlagAPI(options) + status, err := g.ConfigurationHasChanged() + require.NoError(t, err) + assert.Equal(t, controller.FlagConfigurationInitialized, status) + status, err = g.ConfigurationHasChanged() + require.NoError(t, err) + assert.Equal(t, controller.FlagConfigurationUpdated, status) + }) + + t.Run("No change in the configuration", func(t *testing.T) { + mrt := MockRoundTripper{RoundTripFunc: func(req *http.Request) *http.Response { + if req.Header.Get("If-None-Match") == "123456" { + resp := &http.Response{ + StatusCode: http.StatusNotModified, + } + return resp + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: map[string][]string{}, + } + resp.Header.Set("ETag", "123456") + return resp + }} + client := &http.Client{Transport: &mrt} + options := controller.GoFeatureFlagApiOptions{ + Endpoint: "http://localhost:1031", + HTTPClient: client, + } + g := controller.NewGoFeatureFlagAPI(options) + status, err := g.ConfigurationHasChanged() + require.NoError(t, err) + assert.Equal(t, controller.FlagConfigurationInitialized, status) + status, err = g.ConfigurationHasChanged() + require.NoError(t, err) + assert.Equal(t, controller.FlagConfigurationNotChanged, status) + }) +} + +type MockRoundTripper struct { + RoundTripFunc func(req *http.Request) *http.Response + Err error + LastRequest *http.Request + NumberCall int +} + +func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + m.LastRequest = req + m.NumberCall++ + return m.RoundTripFunc(req), m.Err +} + +// NewMockClient creates a new http.Client with the mock RoundTripper. +func NewMockClient(roundTripFunc func(req *http.Request) *http.Response, err error) *http.Client { + return &http.Client{ + Transport: &MockRoundTripper{RoundTripFunc: roundTripFunc, Err: err}, + } +} + +// GetLastRequest returns the last request made by the mock client. +func (m *MockRoundTripper) GetLastRequest() *http.Request { + return m.LastRequest +} diff --git a/providers/go-feature-flag/pkg/controller/http_client.go b/providers/go-feature-flag/pkg/controller/http_client.go new file mode 100644 index 000000000..e961463e0 --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/http_client.go @@ -0,0 +1,18 @@ +package controller + +import ( + "net/http" + "time" +) + +func DefaultHTTPClient() *http.Client { + netTransport := &http.Transport{ + TLSHandshakeTimeout: 10000 * time.Millisecond, + IdleConnTimeout: 90 * time.Second, + } + + return &http.Client{ + Timeout: 10000 * time.Millisecond, + Transport: netTransport, + } +} diff --git a/providers/go-feature-flag/pkg/controller/http_constants.go b/providers/go-feature-flag/pkg/controller/http_constants.go new file mode 100644 index 000000000..b021ab3a9 --- /dev/null +++ b/providers/go-feature-flag/pkg/controller/http_constants.go @@ -0,0 +1,8 @@ +package controller + +const ContentTypeHeader = "Content-Type" +const IfNoneMatchHeader = "If-None-Match" +const AuthorizationHeader = "Authorization" + +const ApplicationJson = "application/json" +const BearerPrefix = "Bearer " diff --git a/providers/go-feature-flag/pkg/goff_error/invalid_option.go b/providers/go-feature-flag/pkg/goff_error/invalid_option.go new file mode 100644 index 000000000..ebd53c2c4 --- /dev/null +++ b/providers/go-feature-flag/pkg/goff_error/invalid_option.go @@ -0,0 +1,13 @@ +package goff_error + +type InvalidOption struct { + Message string +} + +func (i InvalidOption) Error() string { + return i.Message +} + +func NewInvalidOption(message string) InvalidOption { + return InvalidOption{Message: message} +} diff --git a/providers/go-feature-flag/pkg/hook/data_collector_hook.go b/providers/go-feature-flag/pkg/hook/data_collector_hook.go new file mode 100644 index 000000000..16e99de95 --- /dev/null +++ b/providers/go-feature-flag/pkg/hook/data_collector_hook.go @@ -0,0 +1,63 @@ +package hook + +import ( + "context" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/controller" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/model" + "github.com/open-feature/go-sdk/openfeature" + "time" +) + +func NewDataCollectorHook(dataCollectorManager *controller.DataCollectorManager) openfeature.Hook { + return &dataCollectorHook{dataCollectorManager: dataCollectorManager} +} + +type dataCollectorHook struct { + dataCollectorManager *controller.DataCollectorManager +} + +func (d *dataCollectorHook) After(ctx context.Context, hookCtx openfeature.HookContext, + evalDetails openfeature.InterfaceEvaluationDetails, hint openfeature.HookHints) error { + if evalDetails.Reason != openfeature.CachedReason { + // we send it only when cached because the evaluation will be collected directly in the relay-proxy + return nil + } + event := model.FeatureEvent{ + Kind: "feature", + ContextKind: "user", + UserKey: hookCtx.EvaluationContext().TargetingKey(), + CreationDate: time.Now().Unix(), + Key: hookCtx.FlagKey(), + Variation: evalDetails.Variant, + Value: evalDetails.Value, + Default: false, + Source: "PROVIDER_CACHE", + } + _ = d.dataCollectorManager.AddEvent(event) + return nil +} + +func (d *dataCollectorHook) Error(ctx context.Context, hookCtx openfeature.HookContext, + err error, hint openfeature.HookHints) { + event := model.FeatureEvent{ + Kind: "feature", + ContextKind: "user", + UserKey: hookCtx.EvaluationContext().TargetingKey(), + CreationDate: time.Now().Unix(), + Key: hookCtx.FlagKey(), + Variation: "SdkDefault", + Value: hookCtx.DefaultValue(), + Default: true, + Source: "PROVIDER_CACHE", + } + _ = d.dataCollectorManager.AddEvent(event) +} + +func (d *dataCollectorHook) Before(context.Context, openfeature.HookContext, openfeature.HookHints) (*openfeature.EvaluationContext, error) { + // Do nothing, needed to satisfy the interface + return nil, nil +} + +func (d *dataCollectorHook) Finally(context.Context, openfeature.HookContext, openfeature.HookHints) { + // Do nothing, needed to satisfy the interface +} diff --git a/providers/go-feature-flag/pkg/model/data_collector_request.go b/providers/go-feature-flag/pkg/model/data_collector_request.go new file mode 100644 index 000000000..d49fef9ea --- /dev/null +++ b/providers/go-feature-flag/pkg/model/data_collector_request.go @@ -0,0 +1,6 @@ +package model + +type DataCollectorRequest struct { + Events []FeatureEvent `json:"events"` + Meta map[string]string `json:"meta"` +} diff --git a/providers/go-feature-flag/pkg/model/eval_request.go b/providers/go-feature-flag/pkg/model/eval_request.go deleted file mode 100644 index 440087f61..000000000 --- a/providers/go-feature-flag/pkg/model/eval_request.go +++ /dev/null @@ -1,69 +0,0 @@ -package model - -import ( - of "github.com/open-feature/go-sdk/openfeature" -) - -const targetingKey = "targetingKey" - -func NewEvalFlagRequest[T JsonType](flatCtx of.FlattenedContext, defaultValue T) (EvalFlagRequest, *of.ResolutionError) { - if _, ok := flatCtx[targetingKey]; !ok { - err := of.NewTargetingKeyMissingResolutionError("no targetingKey provided in the evaluation context") - return EvalFlagRequest{}, &err - } - targetingKey, ok := flatCtx[targetingKey].(string) - if !ok { - err := of.NewTargetingKeyMissingResolutionError("targetingKey field MUST be a string") - return EvalFlagRequest{}, &err - } - - anonymous := true - if val, ok := flatCtx["anonymous"].(bool); ok { - anonymous = val - } - - return EvalFlagRequest{ - // We keep user to be compatible with old version of GO Feature Flag proxy. - User: &UserRequest{ - Key: targetingKey, - Anonymous: anonymous, - Custom: flatCtx, - }, - EvaluationContext: &EvaluationContextRequest{ - Key: targetingKey, - Custom: flatCtx, - }, - DefaultValue: defaultValue, - }, nil -} - -type EvalFlagRequest struct { - // User The representation of a user for your feature flag system. - // Deprecated: User please use EvaluationContext instead - User *UserRequest `json:"user" xml:"user" form:"user" query:"user"` - // EvaluationContext the context to evaluate the flag. - EvaluationContext *EvaluationContextRequest `json:"evaluationContext,omitempty" xml:"evaluationContext,omitempty" form:"evaluationContext,omitempty" query:"evaluationContext,omitempty"` - // The value will we use if we are not able to get the variation of the flag. - DefaultValue interface{} `json:"defaultValue" xml:"defaultValue" form:"defaultValue" query:"defaultValue"` -} - -// UserRequest The representation of a user for your feature flag system. -type UserRequest struct { - // Key is the identifier of the UserRequest. - Key string `json:"key" xml:"key" form:"key" query:"key" example:"08b5ffb7-7109-42f4-a6f2-b85560fbd20f"` - - // Anonymous set if this is a logged-in user or not. - Anonymous bool `json:"anonymous" xml:"anonymous" form:"anonymous" query:"anonymous" example:"false"` - - // Custom is a map containing all extra information for this user. - Custom map[string]interface{} `json:"custom" xml:"custom" form:"custom" query:"custom" swaggertype:"object,string" example:"email:contact@gofeatureflag.org,firstname:John,lastname:Doe,company:GO Feature Flag"` // nolint: lll -} - -// EvaluationContextRequest The representation of the evaluation context. -type EvaluationContextRequest struct { - // Key is the identifier of the UserRequest. - Key string `json:"key" xml:"key" form:"key" query:"key" example:"08b5ffb7-7109-42f4-a6f2-b85560fbd20f"` - - // Custom is a map containing all extra information for this user. - Custom map[string]interface{} `json:"custom" xml:"custom" form:"custom" query:"custom" swaggertype:"object,string" example:"email:contact@gofeatureflag.org,firstname:John,lastname:Doe,company:GO Feature Flag"` // nolint: lll -} diff --git a/providers/go-feature-flag/pkg/model/eval_response.go b/providers/go-feature-flag/pkg/model/eval_response.go deleted file mode 100644 index a82f25322..000000000 --- a/providers/go-feature-flag/pkg/model/eval_response.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -type EvalResponse[T JsonType] struct { - TrackEvents bool `json:"trackEvents"` - VariationType string `json:"variationType"` - Failed bool `json:"failed"` - Version string `json:"version"` - Reason string `json:"reason"` - ErrorCode string `json:"errorCode"` - Value T `json:"value"` - Cacheable bool `json:"cacheable"` -} diff --git a/providers/go-feature-flag/pkg/model/feature_event.go b/providers/go-feature-flag/pkg/model/feature_event.go new file mode 100644 index 000000000..a1033cd73 --- /dev/null +++ b/providers/go-feature-flag/pkg/model/feature_event.go @@ -0,0 +1,90 @@ +package model + +import ( + "encoding/json" + of "github.com/open-feature/go-sdk/openfeature" + "time" +) + +func NewFeatureEvent( + evalCtx of.EvaluationContext, + flagKey string, + value interface{}, + variation string, + failed bool, + version string, + source string, +) FeatureEvent { + contextKind := "user" + if evalCtx.Attribute("anonymous") == true { + contextKind = "anonymousUser" + } + + return FeatureEvent{ + Kind: "feature", + ContextKind: contextKind, + UserKey: evalCtx.TargetingKey(), + CreationDate: time.Now().Unix(), + Key: flagKey, + Variation: variation, + Value: value, + Default: failed, + Version: version, + Source: source, + } +} + +// FeatureEvent represent an event that we store in the data storage +// nolint:lll +type FeatureEvent struct { + // Kind for a feature event is feature. + // A feature event will only be generated if the trackEvents attribute of the flag is set to true. + Kind string `json:"kind" example:"feature" parquet:"name=kind, type=BYTE_ARRAY, convertedtype=UTF8"` + + // ContextKind is the kind of context which generated an event. This will only be "anonymousUser" for events generated + // on behalf of an anonymous user or the reserved word "user" for events generated on behalf of a non-anonymous user + ContextKind string `json:"contextKind,omitempty" example:"user" parquet:"name=contextKind, type=BYTE_ARRAY, convertedtype=UTF8"` + + // UserKey The key of the user object used in a feature flag evaluation. Details for the user object used in a feature + // flag evaluation as reported by the "feature" event are transmitted periodically with a separate index event. + UserKey string `json:"userKey" example:"94a25909-20d8-40cc-8500-fee99b569345" parquet:"name=userKey, type=BYTE_ARRAY, convertedtype=UTF8"` + + // CreationDate When the feature flag was requested at Unix epoch time in milliseconds. + CreationDate int64 `json:"creationDate" example:"1680246000011" parquet:"name=creationDate, type=INT64"` + + // Key of the feature flag requested. + Key string `json:"key" example:"my-feature-flag" parquet:"name=key, type=BYTE_ARRAY, convertedtype=UTF8"` + + // Variation of the flag requested. Flag variation values can be "True", "False", "Default" or "SdkDefault" + // depending on which value was taken during flag evaluation. "SdkDefault" is used when an error is detected and the + // default value passed during the call to your variation is used. + Variation string `json:"variation" example:"admin-variation" parquet:"name=variation, type=BYTE_ARRAY, convertedtype=UTF8"` + + // Value of the feature flag returned by feature flag evaluation. + Value interface{} `json:"value" parquet:"name=value, type=BYTE_ARRAY, convertedtype=UTF8"` + + // Default value is set to true if feature flag evaluation failed, in which case the value returned was the default + // value passed to variation. If the default field is omitted, it is assumed to be false. + Default bool `json:"default" example:"false" parquet:"name=default, type=BOOLEAN"` + + // Version contains the version of the flag. If the field is omitted for the flag in the configuration file + // the default version will be 0. + Version string `json:"version" example:"v1.0.0" parquet:"name=version, type=BYTE_ARRAY, convertedtype=UTF8"` + + // Source indicates where the event was generated. + // This is set to SERVER when the event was evaluated in the relay-proxy and PROVIDER_CACHE when it is evaluated from the cache. + Source string `json:"source" example:"SERVER" parquet:"name=source, type=BYTE_ARRAY, convertedtype=UTF8"` +} + +// MarshalInterface marshals all interface type fields in FeatureEvent into JSON-encoded string. +func (f *FeatureEvent) MarshalInterface() error { + if f == nil { + return nil + } + b, err := json.Marshal(f.Value) + if err != nil { + return err + } + f.Value = string(b) + return nil +} diff --git a/providers/go-feature-flag/pkg/model/feature_event_test.go b/providers/go-feature-flag/pkg/model/feature_event_test.go new file mode 100644 index 000000000..cad05114a --- /dev/null +++ b/providers/go-feature-flag/pkg/model/feature_event_test.go @@ -0,0 +1,116 @@ +package model_test + +import ( + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/model" + of "github.com/open-feature/go-sdk/openfeature" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewFeatureEvent(t *testing.T) { + type args struct { + user of.EvaluationContext + flagKey string + value interface{} + variation string + failed bool + version string + source string + } + tests := []struct { + name string + args args + want model.FeatureEvent + }{ + { + name: "anonymous user", + args: args{ + user: of.NewEvaluationContext("ABCD", map[string]interface{}{"anonymous": true}), + flagKey: "random-key", + value: "YO", + variation: "Default", + failed: false, + version: "", + source: "SERVER", + }, + want: model.FeatureEvent{ + Kind: "feature", ContextKind: "anonymousUser", UserKey: "ABCD", CreationDate: time.Now().Unix(), Key: "random-key", + Variation: "Default", Value: "YO", Default: false, Source: "SERVER", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, model.NewFeatureEvent(tt.args.user, tt.args.flagKey, tt.args.value, tt.args.variation, tt.args.failed, tt.args.version, tt.args.source), "NewFeatureEvent(%v, %v, %v, %v, %v, %v, %V)", tt.args.user, tt.args.flagKey, tt.args.value, tt.args.variation, tt.args.failed, tt.args.version, tt.args.source) + }) + } +} + +func TestFeatureEvent_MarshalInterface(t *testing.T) { + tests := []struct { + name string + featureEvent *model.FeatureEvent + want *model.FeatureEvent + wantErr bool + }{ + { + name: "happy path", + featureEvent: &model.FeatureEvent{ + Kind: "feature", + ContextKind: "anonymousUser", + UserKey: "ABCD", + CreationDate: 1617970547, + Key: "random-key", + Variation: "Default", + Value: map[string]interface{}{ + "string": "string", + "bool": true, + "float": 1.23, + "int": 1, + }, + Default: false, + }, + want: &model.FeatureEvent{ + Kind: "feature", + ContextKind: "anonymousUser", + UserKey: "ABCD", + CreationDate: 1617970547, + Key: "random-key", + Variation: "Default", + Value: `{"bool":true,"float":1.23,"int":1,"string":"string"}`, + Default: false, + }, + }, + { + name: "marshal failed", + featureEvent: &model.FeatureEvent{ + Kind: "feature", + ContextKind: "anonymousUser", + UserKey: "ABCD", + CreationDate: 1617970547, + Key: "random-key", + Variation: "Default", + Value: make(chan int), + Default: false, + }, + wantErr: true, + }, + { + name: "nil featureEvent", + featureEvent: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.featureEvent.MarshalInterface(); (err != nil) != tt.wantErr { + t.Errorf("FeatureEvent.MarshalInterface() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.want != nil { + assert.Equal(t, tt.want, tt.featureEvent) + } + }) + } +} diff --git a/providers/go-feature-flag/pkg/model/generic_evaluation_detail.go b/providers/go-feature-flag/pkg/model/generic_evaluation_detail.go deleted file mode 100644 index 5dbe1df0f..000000000 --- a/providers/go-feature-flag/pkg/model/generic_evaluation_detail.go +++ /dev/null @@ -1,8 +0,0 @@ -package model - -import of "github.com/open-feature/go-sdk/openfeature" - -type GenericResolutionDetail[T JsonType] struct { - Value T - of.ProviderResolutionDetail -} diff --git a/providers/go-feature-flag/pkg/model/json_type.go b/providers/go-feature-flag/pkg/model/json_type.go deleted file mode 100644 index 92e651a3a..000000000 --- a/providers/go-feature-flag/pkg/model/json_type.go +++ /dev/null @@ -1,5 +0,0 @@ -package model - -type JsonType interface { - float64 | int64 | string | bool | interface{} -} diff --git a/providers/go-feature-flag/pkg/provider.go b/providers/go-feature-flag/pkg/provider.go index 67edce961..559bd22e5 100644 --- a/providers/go-feature-flag/pkg/provider.go +++ b/providers/go-feature-flag/pkg/provider.go @@ -1,60 +1,32 @@ package gofeatureflag import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" - "path" - "time" - - "github.com/bluele/gcache" - "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/model" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/controller" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/hook" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/util" + "github.com/open-feature/go-sdk-contrib/providers/ofrep" of "github.com/open-feature/go-sdk/openfeature" - client "github.com/thomaspoignant/go-feature-flag" - "github.com/thomaspoignant/go-feature-flag/exporter" - "github.com/thomaspoignant/go-feature-flag/exporter/webhookexporter" - "github.com/thomaspoignant/go-feature-flag/ffcontext" + "time" ) -const defaultCacheSize = 10000 -const defaultCacheTTL = 1 * time.Minute -const defaultDataCacheMaxEventInMemory = 500 -const defaultDataCacheFlushInterval = 1 * time.Minute +const providerName = "GO Feature Flag" +const cacheableMetadataKey = "gofeatureflag_cacheable" -// Provider is the OpenFeature provider for GO Feature Flag. type Provider struct { - httpClient HTTPClient - endpoint string - goFeatureFlagInstance *client.GoFeatureFlag - apiKey string - cache gcache.Cache - cacheTTL time.Duration - cacheDisable bool - dataCollectorDisable bool - dataCollectorScheduler *exporter.Scheduler -} - -// HTTPClient is a custom interface to be able to override it by any implementation -// of an HTTP client. -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -// defaultHTTPClient is the default HTTP client used to call GO Feature Flag. -// By default, we have a timeout of 10000 milliseconds. -func defaultHTTPClient() HTTPClient { - netTransport := &http.Transport{ - TLSHandshakeTimeout: 10000 * time.Millisecond, - } - - return &http.Client{ - Timeout: 10000 * time.Millisecond, - Transport: netTransport, - } + ofrepProvider *ofrep.Provider + cache *controller.Cache + dataCollectorManager controller.DataCollectorManager + options ProviderOptions + status of.State + hooks []of.Hook + goffAPI controller.GoFeatureFlagAPI + pollingInfo struct { + ticker *time.Ticker + channel chan bool + } + events chan of.Event } // NewProvider allows you to create a GO Feature Flag provider without any context. @@ -65,430 +37,228 @@ func NewProvider(options ProviderOptions) (*Provider, error) { // NewProviderWithContext is the easiest way of creating a new GO Feature Flag provider. func NewProviderWithContext(ctx context.Context, options ProviderOptions) (*Provider, error) { - if options.GOFeatureFlagConfig != nil { - goff, err := client.New(*options.GOFeatureFlagConfig) - if err != nil { - return nil, err - } - return &Provider{ - goFeatureFlagInstance: goff, - }, nil - } - - if options.Endpoint == "" { - return nil, fmt.Errorf("invalid provider options, empty endpoint value") + if err := options.Validation(); err != nil { + return nil, err } - - // Set default values - httpClient := options.HTTPClient - if httpClient == nil { - httpClient = defaultHTTPClient() - } - if options.FlagCacheSize == 0 { - options.FlagCacheSize = defaultCacheSize - } - if options.DataMaxEventInMemory == 0 { - options.DataMaxEventInMemory = defaultDataCacheMaxEventInMemory - } - if options.DataFlushInterval == 0 { - options.DataFlushInterval = defaultDataCacheFlushInterval - } - if options.FlagCacheTTL == 0 { - options.FlagCacheTTL = defaultCacheTTL - } - scheduler := startDataCollector(ctx, options) + ofrepOptions := make([]ofrep.Option, 0) + if options.APIKey != "" { + ofrepOptions = append(ofrepOptions, ofrep.WithBearerToken(options.APIKey)) + } + if options.HTTPClient != nil { + ofrepOptions = append(ofrepOptions, ofrep.WithClient(options.HTTPClient)) + } + ofrepOptions = append(ofrepOptions, ofrep.WithHeaderProvider(func() (key string, value string) { + return controller.ContentTypeHeader, controller.ApplicationJson + })) + ofrepProvider := ofrep.NewProvider(options.Endpoint, ofrepOptions...) + cacheCtrl := controller.NewCache(options.FlagCacheSize, options.FlagCacheTTL, options.DisableCache) + goffAPI := controller.NewGoFeatureFlagAPI(controller.GoFeatureFlagApiOptions{ + Endpoint: options.Endpoint, + HTTPClient: options.HTTPClient, + APIKey: options.APIKey, + }) + dataCollectorManager := controller.NewDataCollectorManager( + goffAPI, + options.DataCollectorMaxEventStored, + options.DataFlushInterval, + ) return &Provider{ - apiKey: options.APIKey, - endpoint: options.Endpoint, - httpClient: httpClient, - cacheTTL: options.FlagCacheTTL, - cacheDisable: options.DisableCache, - dataCollectorDisable: options.DisableDataCollector, - cache: gcache.New(options.FlagCacheSize).LRU().Build(), - dataCollectorScheduler: scheduler, + ofrepProvider: ofrepProvider, + cache: cacheCtrl, + dataCollectorManager: dataCollectorManager, + options: options, + goffAPI: goffAPI, + events: make(chan of.Event, 5), }, nil } -func startDataCollector(ctx context.Context, options ProviderOptions) *exporter.Scheduler { - if options.DisableCache || options.DisableDataCollector { - return nil - } - - u, _ := url.Parse(options.Endpoint) - u.Path = path.Join(u.Path, "v1", "/") - u.Path = path.Join(u.Path, "data", "/") - u.Path = path.Join(u.Path, "collector", "/") - - webhook := webhookexporter.Exporter{ - EndpointURL: u.String(), - Meta: map[string]string{ - "provider": "go", - "openfeature": "true", - }, - } - if options.APIKey != "" { - webhook.Headers = map[string][]string{ - "Authorization": {fmt.Sprintf("Bearer %s", options.APIKey)}, - } - } - if ctx == nil { - ctx = context.Background() - } - scheduler := exporter.NewScheduler(ctx, - options.DataFlushInterval, options.DataMaxEventInMemory, &webhook, nil) - go scheduler.StartDaemon() - return scheduler -} -// Metadata returns the meta of the GO Feature Flag provider. func (p *Provider) Metadata() of.Metadata { return of.Metadata{ - Name: "GO Feature Flag", + Name: fmt.Sprintf("%s Provider", providerName), } } func (p *Provider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { - res := genericEvaluation[bool](p, ctx, flag, defaultValue, evalCtx) - return of.BoolResolutionDetail{ - Value: res.Value, - ProviderResolutionDetail: res.ProviderResolutionDetail, + if err := util.ValidateTargetingKey(evalCtx); err != nil { + return of.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ResolutionError: *err, Reason: of.ErrorReason}, + } + } + if cacheValue, err := p.cache.GetBool(flag, evalCtx); err == nil && cacheValue != nil { + cacheValue.Reason = of.CachedReason + return *cacheValue + } + res := p.ofrepProvider.BooleanEvaluation(ctx, flag, defaultValue, evalCtx) + if cachable, err := res.FlagMetadata.GetBool(cacheableMetadataKey); err == nil && cachable { + _ = p.cache.Set(flag, evalCtx, res) } + return res } + func (p *Provider) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail { - res := genericEvaluation[string](p, ctx, flag, defaultValue, evalCtx) - return of.StringResolutionDetail{ - Value: res.Value, - ProviderResolutionDetail: res.ProviderResolutionDetail, + if err := util.ValidateTargetingKey(evalCtx); err != nil { + return of.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ResolutionError: *err, Reason: of.ErrorReason}, + } + } + if cacheValue, err := p.cache.GetString(flag, evalCtx); err == nil && cacheValue != nil { + cacheValue.Reason = of.CachedReason + return *cacheValue + } + res := p.ofrepProvider.StringEvaluation(ctx, flag, defaultValue, evalCtx) + if cachable, err := res.FlagMetadata.GetBool(cacheableMetadataKey); err == nil && cachable { + _ = p.cache.Set(flag, evalCtx, res) } + return res } + func (p *Provider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { - res := genericEvaluation[float64](p, ctx, flag, defaultValue, evalCtx) - return of.FloatResolutionDetail{ - Value: res.Value, - ProviderResolutionDetail: res.ProviderResolutionDetail, + if err := util.ValidateTargetingKey(evalCtx); err != nil { + return of.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ResolutionError: *err, Reason: of.ErrorReason}, + } } -} -func (p *Provider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { - res := genericEvaluation[int64](p, ctx, flag, defaultValue, evalCtx) - return of.IntResolutionDetail{ - Value: res.Value, - ProviderResolutionDetail: res.ProviderResolutionDetail, + if cacheValue, err := p.cache.GetFloat(flag, evalCtx); err == nil && cacheValue != nil { + cacheValue.Reason = of.CachedReason + return *cacheValue } -} -func (p *Provider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { - res := genericEvaluation[interface{}](p, ctx, flag, defaultValue, evalCtx) - return of.InterfaceResolutionDetail{ - Value: res.Value, - ProviderResolutionDetail: res.ProviderResolutionDetail, + res := p.ofrepProvider.FloatEvaluation(ctx, flag, defaultValue, evalCtx) + if cachable, err := res.FlagMetadata.GetBool(cacheableMetadataKey); err == nil && cachable { + _ = p.cache.Set(flag, evalCtx, res) } + return res } -func (p *Provider) Shutdown() { - p.dataCollectorScheduler.Close() -} - -// Hooks is returning an empty array because GO Feature Flag does not use any hooks. -func (p *Provider) Hooks() []of.Hook { - return []of.Hook{} -} - -// genericEvaluation is doing evaluation for all types using generics. -func genericEvaluation[T model.JsonType](provider *Provider, ctx context.Context, flagName string, defaultValue T, evalCtx of.FlattenedContext) model.GenericResolutionDetail[T] { - goffRequestBody, errConvert := model.NewEvalFlagRequest[T](evalCtx, defaultValue) - if errConvert != nil { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: *errConvert, - Reason: of.ErrorReason, - }, +func (p *Provider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + if err := util.ValidateTargetingKey(evalCtx); err != nil { + return of.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ResolutionError: *err, Reason: of.ErrorReason}, } } - - // if we have a GO Feature Flag instance instantiate we evaluate the flag locally, - // using the GO module directly. We will not send any remote calls to the relay proxy. - if provider.goFeatureFlagInstance != nil { - return evaluateLocally(provider, goffRequestBody, flagName, defaultValue) + if cacheValue, err := p.cache.GetInt(flag, evalCtx); err == nil && cacheValue != nil { + cacheValue.Reason = of.CachedReason + return *cacheValue } - return evaluateWithRelayProxy(provider, ctx, goffRequestBody, flagName, defaultValue) -} - -// evaluateLocally is using the GO Feature Flag module to evaluate your flag. -// it means that you don't need any relay proxy to make it work. -func evaluateLocally[T model.JsonType](provider *Provider, goffRequestBody model.EvalFlagRequest, flagName string, defaultValue T) model.GenericResolutionDetail[T] { - // Construct user - ctxBuilder := ffcontext.NewEvaluationContextBuilder(goffRequestBody.EvaluationContext.Key) - for k, v := range goffRequestBody.EvaluationContext.Custom { - ctxBuilder.AddCustom(k, v) + res := p.ofrepProvider.IntEvaluation(ctx, flag, defaultValue, evalCtx) + if cachable, err := res.FlagMetadata.GetBool(cacheableMetadataKey); err == nil && cachable { + _ = p.cache.Set(flag, evalCtx, res) } + return res +} - // Call GO Module - rawResult, err := provider.goFeatureFlagInstance.RawVariation(flagName, ctxBuilder.Build(), defaultValue) - if err != nil { - switch rawResult.ErrorCode { - case string(of.FlagNotFoundCode): - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewFlagNotFoundResolutionError(fmt.Sprintf("flag %s was not found in GO Feature Flag", flagName)), - Reason: of.ErrorReason, - }, - } - case string(of.ProviderNotReadyCode): - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewProviderNotReadyResolutionError( - fmt.Sprintf("provider not ready for evaluation of flag %s", flagName)), - Reason: of.ErrorReason, - }, - } - case string(of.ParseErrorCode): - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewParseErrorResolutionError( - fmt.Sprintf("parse error during evaluation of flag %s", flagName)), - Reason: of.ErrorReason, - }, - } - case string(of.TypeMismatchCode): - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewTypeMismatchResolutionError( - fmt.Sprintf("unexpected type for flag %s", flagName)), - Reason: of.ErrorReason, - }, - } - case string(of.GeneralCode): - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError( - fmt.Sprintf("unexpected error during evaluation of the flag %s", flagName)), - Reason: of.ErrorReason, - }, - } +func (p *Provider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + if err := util.ValidateTargetingKey(evalCtx); err != nil { + return of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ResolutionError: *err, Reason: of.ErrorReason}, } } - - // This part convert the int received by the module to int64 to be compatible with - // the types expect by Open-feature. - var v model.JsonType - switch value := rawResult.Value.(type) { - case int: - v = int64(value) - default: - v = value + if cacheValue, err := p.cache.GetInterface(flag, evalCtx); err == nil && cacheValue != nil { + cacheValue.Reason = of.CachedReason + return *cacheValue } - - switch value := v.(type) { - case nil: - return model.GenericResolutionDetail[T]{ - ProviderResolutionDetail: of.ProviderResolutionDetail{ - Reason: of.Reason(rawResult.Reason), - Variant: rawResult.VariationType, - }, - } - case T: - return model.GenericResolutionDetail[T]{ - Value: value, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - Reason: of.Reason(rawResult.Reason), - Variant: rawResult.VariationType, - }, - } - default: - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewTypeMismatchResolutionError(fmt.Sprintf("unexpected type for flag %s", flagName)), - Reason: of.ErrorReason, - }, - } + res := p.ofrepProvider.ObjectEvaluation(ctx, flag, defaultValue, evalCtx) + if cachable, err := res.FlagMetadata.GetBool(cacheableMetadataKey); err == nil && cachable { + _ = p.cache.Set(flag, evalCtx, res) } + return res } -func convertCache[T model.JsonType](value interface{}) (model.GenericResolutionDetail[T], error) { - switch v := value.(type) { - case model.GenericResolutionDetail[T]: - return v, nil - default: - return model.GenericResolutionDetail[T]{}, fmt.Errorf("impossible to convert into the cache") - } +func (p *Provider) Hooks() []of.Hook { + return p.hooks } -// evaluateWithRelayProxy is calling GO Feature Flag relay proxy to evaluate the file. -func evaluateWithRelayProxy[T model.JsonType](provider *Provider, ctx context.Context, goffRequestBody model.EvalFlagRequest, flagName string, defaultValue T) model.GenericResolutionDetail[T] { - cacheKey := fmt.Sprintf("%s-%+v", flagName, goffRequestBody.EvaluationContext) - // check if flag is available in the cache - cacheResInterface, err := provider.cache.Get(cacheKey) - if err == nil { - // we have retrieve something from the cache. - cacheValue, err := convertCache[T](cacheResInterface) - cacheValue.Reason = of.CachedReason - if err != nil { - // impossible to convert the cache, we remove the entry from the cache assuming the next - // call to convertCache wouldn't result in the same error on the next call. - provider.cache.Remove(cacheKey) - } else { - if provider.dataCollectorDisable { // if we don't collect data we return the cache value early. - return cacheValue - } - event := exporter.NewFeatureEvent( - ffcontext.NewEvaluationContext(goffRequestBody.EvaluationContext.Key), - flagName, - cacheValue.Value, - cacheValue.Variant, - cacheValue.Reason == of.ErrorReason, - "", - "PROVIDER_CACHE", - ) - provider.dataCollectorScheduler.AddEvent(event) - return cacheValue - } +// Init holds initialization logic of the provider +func (p *Provider) Init(_ of.EvaluationContext) error { + if !p.options.DisableDataCollector { + dataCollectorHook := hook.NewDataCollectorHook(&p.dataCollectorManager) + p.hooks = []of.Hook{dataCollectorHook} + p.dataCollectorManager.Start() } - goffRequestBodyStr, err := json.Marshal(goffRequestBody) - if err != nil { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError("impossible to marshal GO Feature Flag request"), - Reason: of.ErrorReason, - }, - } + // Start polling to check if there is any flag change in order to invalidate the cache. + if p.options.FlagChangePollingInterval >= 0 && !p.options.DisableCache { + p.startPolling(p.options.FlagChangePollingInterval) } - evalURL, err := url.Parse(provider.endpoint) - if err != nil { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError("impossible to parse GO Feature Flag endpoint option"), - Reason: of.ErrorReason, - }, - } - } - evalURL.Path = path.Join(evalURL.Path, "v1", "/") - evalURL.Path = path.Join(evalURL.Path, "feature", "/") - evalURL.Path = path.Join(evalURL.Path, flagName, "/") - evalURL.Path = path.Join(evalURL.Path, "eval", "/") + p.status = of.ReadyState + p.events <- of.Event{ + ProviderName: providerName, EventType: of.ProviderReady, + ProviderEventDetails: of.ProviderEventDetails{Message: "Provider is ready"}} + return nil +} - goffRequest, err := - http.NewRequestWithContext(ctx, http.MethodPost, evalURL.String(), bytes.NewBuffer(goffRequestBodyStr)) - if err != nil { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError("error while building GO Feature Flag relay proxy request"), - Reason: of.ErrorReason, - }, - } - } - goffRequest.Header.Set("Content-Type", "application/json") - if provider.apiKey != "" { - goffRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.apiKey)) - } +// Status exposes the status of the provider +func (p *Provider) Status() of.State { + return p.status +} - response, err := provider.httpClient.Do(goffRequest) - if err != nil { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError("impossible to contact GO Feature Flag relay proxy instance"), - Reason: of.ErrorReason, - }, - } - } - responseStr, err := io.ReadAll(response.Body) - if err != nil { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError("impossible to read API response from GO Feature Flag"), - Reason: of.ErrorReason, - }, - } +// Shutdown defines the shutdown operation of the provider +func (p *Provider) Shutdown() { + if !p.options.DisableDataCollector { + p.hooks = []of.Hook{} + p.dataCollectorManager.Stop() } + p.stopPolling() +} - if response.StatusCode == http.StatusUnauthorized { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError( - "invalid token used to contact GO Feature Flag relay proxy instance"), - Reason: of.ErrorReason, - }, - } - } - if response.StatusCode >= http.StatusBadRequest { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewGeneralResolutionError( - "unexpected answer from the relay proxy"), - Reason: of.ErrorReason, - }, - } - } +// EventChannel returns the event channel of this provider +func (p *Provider) EventChannel() <-chan of.Event { + return p.events +} - var evalResponse model.EvalResponse[T] - err = json.Unmarshal(responseStr, &evalResponse) - if err != nil { - if err.Error() == "unexpected end of JSON input" { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewParseErrorResolutionError( - fmt.Sprintf("impossible to parse response for flag %s: %s", flagName, responseStr)), - Reason: of.ErrorReason, - }, +// startPolling starts the polling mechanism that checks if the configuration has changed. +func (p *Provider) startPolling(pollingInterval time.Duration) { + if pollingInterval == 0 { + pollingInterval = 120000 * time.Millisecond + } + p.pollingInfo = struct { + ticker *time.Ticker + channel chan bool + }{ + ticker: time.NewTicker(pollingInterval), + channel: make(chan bool), + } + go func() { + for { + select { + case <-p.pollingInfo.channel: + return + case <-p.pollingInfo.ticker.C: + changeStatus, err := p.goffAPI.ConfigurationHasChanged() + switch changeStatus { + case controller.FlagConfigurationInitialized, + controller.FlagConfigurationNotChanged: + // do nothing + + case controller.FlagConfigurationUpdated: + // Clearing the cache when the configuration is updated + p.cache.Purge() + p.events <- of.Event{ + ProviderName: providerName, EventType: of.ProviderConfigChange, + ProviderEventDetails: of.ProviderEventDetails{Message: "Configuration has changed"}} + case controller.ErrorConfigurationChange: + p.events <- of.Event{ + ProviderName: providerName, EventType: of.ProviderStale, + ProviderEventDetails: of.ProviderEventDetails{ + Message: "Impossible to check configuration change " + err.Error()}, + } + } } } - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewTypeMismatchResolutionError(fmt.Sprintf("unexpected type for flag %s", flagName)), - Reason: of.ErrorReason, - }, - } - } - - if evalResponse.ErrorCode == string(of.FlagNotFoundCode) { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - ResolutionError: of.NewFlagNotFoundResolutionError(fmt.Sprintf("flag %s was not found in GO Feature Flag", flagName)), - Reason: of.ErrorReason, - }, - } - } - - if evalResponse.Reason == string(of.DisabledReason) { - return model.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - Reason: of.DisabledReason, - Variant: "SdkDefault", - }, - } - } + }() +} - resDetail := model.GenericResolutionDetail[T]{ - Value: evalResponse.Value, - ProviderResolutionDetail: of.ProviderResolutionDetail{ - Reason: of.Reason(evalResponse.Reason), - Variant: evalResponse.VariationType, - }, +// stopPolling stops the polling mechanism that check if the configuration has changed. +func (p *Provider) stopPolling() { + if p.pollingInfo.channel != nil { + p.pollingInfo.channel <- true } - - if !provider.cacheDisable && evalResponse.Cacheable { - if provider.cacheTTL == -1 { - _ = provider.cache.Set(cacheKey, resDetail) - } else { - _ = provider.cache.SetWithExpire(cacheKey, resDetail, provider.cacheTTL) - } + if p.pollingInfo.ticker != nil { + p.pollingInfo.ticker.Stop() } - return resDetail } diff --git a/providers/go-feature-flag/pkg/provider_module_test.go b/providers/go-feature-flag/pkg/provider_module_test.go deleted file mode 100644 index 593d28fc7..000000000 --- a/providers/go-feature-flag/pkg/provider_module_test.go +++ /dev/null @@ -1,658 +0,0 @@ -package gofeatureflag_test - -import ( - "context" - "fmt" - gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" - of "github.com/open-feature/go-sdk/openfeature" - "github.com/stretchr/testify/assert" - ffclient "github.com/thomaspoignant/go-feature-flag" - "github.com/thomaspoignant/go-feature-flag/retriever/fileretriever" - "log" - "os" - "testing" - "time" -) - -func TestProvider_module_BooleanEvaluation(t *testing.T) { - type args struct { - flag string - defaultValue bool - evalCtx of.EvaluationContext - } - tests := []struct { - name string - args args - want of.BooleanEvaluationDetails - }{ - { - name: "should resolve a valid boolean flag with TARGETING_MATCH reason", - args: args{ - flag: "bool_targeting_match", - defaultValue: false, - evalCtx: defaultEvaluationCtx(), - }, - want: of.BooleanEvaluationDetails{ - Value: true, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "bool_targeting_match", - FlagType: of.Boolean, - ResolutionDetail: of.ResolutionDetail{ - Variant: "True", - Reason: of.TargetingMatchReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should use boolean default value if the flag is disabled", - args: args{ - flag: "disabled_bool", - defaultValue: false, - evalCtx: defaultEvaluationCtx(), - }, - want: of.BooleanEvaluationDetails{ - Value: false, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "disabled_bool", - FlagType: of.Boolean, - ResolutionDetail: of.ResolutionDetail{ - Variant: "SdkDefault", - Reason: of.DisabledReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if we expect a boolean and got another type", - args: args{ - flag: "string_key", - defaultValue: false, - evalCtx: defaultEvaluationCtx(), - }, - want: of.BooleanEvaluationDetails{ - Value: false, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "string_key", - FlagType: of.Boolean, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag string_key", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if flag does not exists", - args: args{ - flag: "does_not_exists", - defaultValue: false, - evalCtx: defaultEvaluationCtx(), - }, - want: of.BooleanEvaluationDetails{ - Value: false, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "does_not_exists", - FlagType: of.Boolean, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, err := gofeatureflag.NewProvider(gofeatureflag.ProviderOptions{ - GOFeatureFlagConfig: &ffclient.Config{ - PollingInterval: 10 * time.Second, - Logger: log.New(os.Stdout, "", 0), - Context: context.Background(), - Retriever: &fileretriever.Retriever{ - Path: "../testutils/module/flags.yaml", - }, - }, - }) - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - value, err := client.BooleanValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) - - if tt.want.ErrorCode != "" { - assert.Error(t, err) - want := fmt.Sprintf("error code: %s: %s", tt.want.ErrorCode, tt.want.ErrorMessage) - assert.Equal(t, want, err.Error()) - } else { - assert.NoError(t, err) - } - - assert.Equal(t, tt.want, value) - }) - } -} - -func TestProvider_module_StringEvaluation(t *testing.T) { - type args struct { - flag string - defaultValue string - evalCtx of.EvaluationContext - } - tests := []struct { - name string - args args - want of.StringEvaluationDetails - }{ - { - name: "should resolve a valid string flag with TARGETING_MATCH reason", - args: args{ - flag: "string_key", - defaultValue: "default", - evalCtx: defaultEvaluationCtx(), - }, - want: of.StringEvaluationDetails{ - Value: "CC0000", - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "string_key", - FlagType: of.String, - ResolutionDetail: of.ResolutionDetail{ - Variant: "True", - Reason: of.TargetingMatchReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should use string default value if the flag is disabled", - args: args{ - flag: "disabled_string", - defaultValue: "default", - evalCtx: defaultEvaluationCtx(), - }, - want: of.StringEvaluationDetails{ - Value: "default", - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "disabled_string", - FlagType: of.String, - ResolutionDetail: of.ResolutionDetail{ - Variant: "SdkDefault", - Reason: of.DisabledReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if we expect a string and got another type", - args: args{ - flag: "bool_targeting_match", - defaultValue: "default", - evalCtx: defaultEvaluationCtx(), - }, - want: of.StringEvaluationDetails{ - Value: "default", - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "bool_targeting_match", - FlagType: of.String, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag bool_targeting_match", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if flag does not exists", - args: args{ - flag: "does_not_exists", - defaultValue: "default", - evalCtx: defaultEvaluationCtx(), - }, - want: of.StringEvaluationDetails{ - Value: "default", - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "does_not_exists", - FlagType: of.String, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, err := gofeatureflag.NewProvider(gofeatureflag.ProviderOptions{ - GOFeatureFlagConfig: &ffclient.Config{ - PollingInterval: 10 * time.Second, - Logger: log.New(os.Stdout, "", 0), - Context: context.Background(), - Retriever: &fileretriever.Retriever{ - Path: "../testutils/module/flags.yaml", - }, - }, - }) - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - value, err := client.StringValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) - - if tt.want.ErrorCode != "" { - assert.Error(t, err) - want := fmt.Sprintf("error code: %s: %s", tt.want.ErrorCode, tt.want.ErrorMessage) - assert.Equal(t, want, err.Error()) - } else { - assert.NoError(t, err) - } - - assert.Equal(t, tt.want, value) - }) - } -} - -func TestProvider_module_FloatEvaluation(t *testing.T) { - type args struct { - flag string - defaultValue float64 - evalCtx of.EvaluationContext - } - tests := []struct { - name string - args args - want of.FloatEvaluationDetails - }{ - { - name: "should resolve a valid float flag with TARGETING_MATCH reason", - args: args{ - flag: "double_key", - defaultValue: 123.45, - evalCtx: defaultEvaluationCtx(), - }, - want: of.FloatEvaluationDetails{ - Value: 100.25, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "double_key", - FlagType: of.Float, - ResolutionDetail: of.ResolutionDetail{ - Variant: "True", - Reason: of.TargetingMatchReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should use float default value if the flag is disabled", - args: args{ - flag: "disabled_float", - defaultValue: 123.45, - evalCtx: defaultEvaluationCtx(), - }, - want: of.FloatEvaluationDetails{ - Value: 123.45, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "disabled_float", - FlagType: of.Float, - ResolutionDetail: of.ResolutionDetail{ - Variant: "SdkDefault", - Reason: of.DisabledReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if we expect a string and got another type", - args: args{ - flag: "bool_targeting_match", - defaultValue: 123.45, - evalCtx: defaultEvaluationCtx(), - }, - want: of.FloatEvaluationDetails{ - Value: 123.45, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "bool_targeting_match", - FlagType: of.Float, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag bool_targeting_match", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if flag does not exists", - args: args{ - flag: "does_not_exists", - defaultValue: 123.45, - evalCtx: defaultEvaluationCtx(), - }, - want: of.FloatEvaluationDetails{ - Value: 123.45, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "does_not_exists", - FlagType: of.Float, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, err := gofeatureflag.NewProvider(gofeatureflag.ProviderOptions{ - GOFeatureFlagConfig: &ffclient.Config{ - PollingInterval: 10 * time.Second, - Logger: log.New(os.Stdout, "", 0), - Context: context.Background(), - Retriever: &fileretriever.Retriever{ - Path: "../testutils/module/flags.yaml", - }, - }, - }) - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - value, err := client.FloatValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) - - if tt.want.ErrorCode != "" { - assert.Error(t, err) - want := fmt.Sprintf("error code: %s: %s", tt.want.ErrorCode, tt.want.ErrorMessage) - assert.Equal(t, want, err.Error()) - } else { - assert.NoError(t, err) - } - - assert.Equal(t, tt.want, value) - }) - } -} - -func TestProvider_module_IntEvaluation(t *testing.T) { - type args struct { - flag string - defaultValue int64 - evalCtx of.EvaluationContext - } - tests := []struct { - name string - args args - want of.IntEvaluationDetails - }{ - { - name: "should resolve a valid float flag with TARGETING_MATCH reason", - args: args{ - flag: "integer_key", - defaultValue: 123, - evalCtx: defaultEvaluationCtx(), - }, - want: of.IntEvaluationDetails{ - Value: 100, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "integer_key", - FlagType: of.Int, - ResolutionDetail: of.ResolutionDetail{ - Variant: "True", - Reason: of.TargetingMatchReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should use float default value if the flag is disabled", - args: args{ - flag: "disabled_int", - defaultValue: 123, - evalCtx: defaultEvaluationCtx(), - }, - want: of.IntEvaluationDetails{ - Value: 123, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "disabled_int", - FlagType: of.Int, - ResolutionDetail: of.ResolutionDetail{ - Variant: "SdkDefault", - Reason: of.DisabledReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if we expect a string and got another type", - args: args{ - flag: "bool_targeting_match", - defaultValue: 123, - evalCtx: defaultEvaluationCtx(), - }, - want: of.IntEvaluationDetails{ - Value: 123, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "bool_targeting_match", - FlagType: of.Int, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag bool_targeting_match", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if flag does not exists", - args: args{ - flag: "does_not_exists", - defaultValue: 123, - evalCtx: defaultEvaluationCtx(), - }, - want: of.IntEvaluationDetails{ - Value: 123, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "does_not_exists", - FlagType: of.Int, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, err := gofeatureflag.NewProvider(gofeatureflag.ProviderOptions{ - GOFeatureFlagConfig: &ffclient.Config{ - PollingInterval: 10 * time.Second, - Logger: log.New(os.Stdout, "", 0), - Context: context.Background(), - Retriever: &fileretriever.Retriever{ - Path: "../testutils/module/flags.yaml", - }, - }, - }) - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - value, err := client.IntValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) - - if tt.want.ErrorCode != "" { - assert.Error(t, err) - want := fmt.Sprintf("error code: %s: %s", tt.want.ErrorCode, tt.want.ErrorMessage) - assert.Equal(t, want, err.Error()) - } else { - assert.NoError(t, err) - } - - assert.Equal(t, tt.want, value) - }) - } -} - -func TestProvider_module_ObjectEvaluation(t *testing.T) { - type args struct { - flag string - defaultValue interface{} - evalCtx of.EvaluationContext - } - tests := []struct { - name string - args args - want of.InterfaceEvaluationDetails - }{ - { - name: "should resolve a valid interface flag with TARGETING_MATCH reason", - args: args{ - flag: "object_key", - defaultValue: nil, - evalCtx: defaultEvaluationCtx(), - }, - want: of.InterfaceEvaluationDetails{ - Value: map[string]interface{}{ - "test": "test1", - "test2": false, - "test3": 123.3, - "test4": 1, - }, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "object_key", - FlagType: of.Object, - ResolutionDetail: of.ResolutionDetail{ - Variant: "True", - Reason: of.TargetingMatchReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should use interface default value if the flag is disabled", - args: args{ - flag: "disabled_interface", - defaultValue: nil, - evalCtx: defaultEvaluationCtx(), - }, - want: of.InterfaceEvaluationDetails{ - Value: nil, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "disabled_interface", - FlagType: of.Object, - ResolutionDetail: of.ResolutionDetail{ - Variant: "SdkDefault", - Reason: of.DisabledReason, - ErrorCode: "", - ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - { - name: "should error if flag does not exists", - args: args{ - flag: "does_not_exists", - defaultValue: nil, - evalCtx: defaultEvaluationCtx(), - }, - want: of.InterfaceEvaluationDetails{ - Value: nil, - EvaluationDetails: of.EvaluationDetails{ - FlagKey: "does_not_exists", - FlagType: of.Object, - ResolutionDetail: of.ResolutionDetail{ - Variant: "", - Reason: of.ErrorReason, - ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", - FlagMetadata: map[string]interface{}{}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, err := gofeatureflag.NewProvider(gofeatureflag.ProviderOptions{ - GOFeatureFlagConfig: &ffclient.Config{ - PollingInterval: 10 * time.Second, - Logger: log.New(os.Stdout, "", 0), - Context: context.Background(), - Retriever: &fileretriever.Retriever{ - Path: "../testutils/module/flags.yaml", - }, - }, - }) - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - value, err := client.ObjectValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) - - if tt.want.ErrorCode != "" { - assert.Error(t, err) - want := fmt.Sprintf("error code: %s: %s", tt.want.ErrorCode, tt.want.ErrorMessage) - assert.Equal(t, want, err.Error()) - } else { - assert.NoError(t, err) - } - - assert.Equal(t, tt.want, value) - }) - } -} diff --git a/providers/go-feature-flag/pkg/provider_options.go b/providers/go-feature-flag/pkg/provider_options.go index a8c012ad8..871568819 100644 --- a/providers/go-feature-flag/pkg/provider_options.go +++ b/providers/go-feature-flag/pkg/provider_options.go @@ -1,9 +1,10 @@ package gofeatureflag import ( + "fmt" + "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg/goff_error" + "net/http" "time" - - ffclient "github.com/thomaspoignant/go-feature-flag" ) // ProviderOptions is the struct containing the provider options you can @@ -15,11 +16,7 @@ type ProviderOptions struct { // HTTPClient (optional) is the HTTP Client we will use to contact GO Feature Flag. // By default, we are using a custom HTTPClient with a timeout configure to 10000 milliseconds. - HTTPClient HTTPClient - - // GOFeatureFlagConfig is the configuration struct for the GO Feature Flag module. - // If not nil we will launch the provider using the GO Feature Flag module. - GOFeatureFlagConfig *ffclient.Config + HTTPClient *http.Client // APIKey (optional) If the relay proxy is configured to authenticate the requests, you should provide // an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. @@ -30,9 +27,6 @@ type ProviderOptions struct { // DisableCache (optional) set to true if you would like that every flag evaluation goes to the GO Feature Flag directly. DisableCache bool - // DisableDataCollector (optional) set to true if you would like to disable the cache metrics collection. - DisableDataCollector bool - // FlagCacheSize (optional) is the maximum number of flag events we keep in memory to cache your flags. // default: 10000 FlagCacheSize int @@ -54,4 +48,25 @@ type ProviderOptions struct { // when calling the evaluation API. // default: 500 DataMaxEventInMemory int64 + + // DataCollectorMaxEventStored (optional) maximum number of event we keep in memory, if we reach this number it means + // that we will start to drop the new events. This is a security to avoid a memory leak. + // default: 100000 + DataCollectorMaxEventStored int64 + + // DisableDataCollector (optional) set to true if you would like to disable the data collector. + DisableDataCollector bool + + // FlagChangePollingInterval (optional) interval time we poll the proxy to check if the configuration has changed. + // If the cache is enabled, we will poll the relay-proxy every X milliseconds to check if the configuration has changed. + // Use -1 if you want to deactivate polling. + // default: 120000ms + FlagChangePollingInterval time.Duration +} + +func (o *ProviderOptions) Validation() error { + if o.Endpoint == "" { + return goff_error.NewInvalidOption(fmt.Sprintf("invalid option: %s", o.Endpoint)) + } + return nil } diff --git a/providers/go-feature-flag/pkg/provider_test.go b/providers/go-feature-flag/pkg/provider_test.go index 060fe8c4c..aa832241a 100644 --- a/providers/go-feature-flag/pkg/provider_test.go +++ b/providers/go-feature-flag/pkg/provider_test.go @@ -16,31 +16,83 @@ import ( "time" ) +// MockRoundTripper is a mock implementation of http.RoundTripper. +type MockRoundTripper struct { + RoundTripFunc func(req *http.Request) *http.Response +} + +func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return m.RoundTripFunc(req), nil +} + +// NewMockClient creates a new http.Client with the mock RoundTripper. +func NewMockClient(roundTripFunc func(req *http.Request) *http.Response) *http.Client { + return &http.Client{ + Transport: &MockRoundTripper{RoundTripFunc: roundTripFunc}, + } +} + type mockClient struct { - callCount int + callCount int + collectorCallCount int + flagChangeCallCount int } -func (m *mockClient) Do(req *http.Request) (*http.Response, error) { +func (m *mockClient) roundTripFunc(req *http.Request) *http.Response { + if req.URL.Path == "/v1/data/collector" { + m.collectorCallCount++ + return &http.Response{ + StatusCode: http.StatusOK, + } + } + + if req.URL.Path == "/v1/flag/change" { + m.flagChangeCallCount++ + if req.Header.Get("If-None-Match") == "123456" { + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: map[string][]string{}, + } + resp.Header.Set("ETag", "78910") + return resp + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: map[string][]string{}, + } + resp.Header.Set("ETag", "123456") + return resp + } + m.callCount++ mockPath := "../testutils/mock_responses/%s.json" - flagName := strings.Replace(strings.Replace(req.URL.Path, "/v1/feature/", "", -1), "/eval", "", -1) + flagName := strings.Replace(req.URL.Path, "/ofrep/v1/evaluate/flags/", "", -1) if flagName == "unauthorized" { return &http.Response{ StatusCode: http.StatusUnauthorized, Body: io.NopCloser(bytes.NewReader([]byte(""))), - }, nil + } } content, err := os.ReadFile(fmt.Sprintf(mockPath, flagName)) if err != nil { content, _ = os.ReadFile(fmt.Sprintf(mockPath, "flag_not_found")) } + statusCode := http.StatusOK + + if strings.Contains(string(content), "errorCode") { + statusCode = http.StatusBadRequest + } + if strings.Contains(string(content), "FLAG_NOT_FOUND") { + statusCode = http.StatusNotFound + } + body := io.NopCloser(bytes.NewReader(content)) return &http.Response{ - StatusCode: http.StatusOK, + StatusCode: statusCode, Body: body, - }, nil + } } func defaultEvaluationCtx() of.EvaluationContext { @@ -93,7 +145,7 @@ func TestProvider_BooleanEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.GeneralCode, - ErrorMessage: "invalid token used to contact GO Feature Flag relay proxy instance", + ErrorMessage: "authentication/authorization error", FlagMetadata: map[string]interface{}{}, }, }, @@ -116,7 +168,9 @@ func TestProvider_BooleanEvaluation(t *testing.T) { Reason: of.TargetingMatchReason, ErrorCode: "", ErrorMessage: "", - FlagMetadata: map[string]interface{}{}, + FlagMetadata: map[string]interface{}{ + "gofeatureflag_cacheable": true, + }, }, }, }, @@ -159,7 +213,7 @@ func TestProvider_BooleanEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag string_key", + ErrorMessage: "resolved value CC0000 is not of boolean type", FlagMetadata: map[string]interface{}{}, }, }, @@ -181,7 +235,7 @@ func TestProvider_BooleanEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", + ErrorMessage: "flag for key 'does_not_exists' does not exist", FlagMetadata: map[string]interface{}{}, }, }, @@ -245,7 +299,7 @@ func TestProvider_BooleanEvaluation(t *testing.T) { ResolutionDetail: of.ResolutionDetail{ Reason: of.ErrorReason, ErrorCode: of.ParseErrorCode, - ErrorMessage: "impossible to parse response for flag invalid_json_body: {\n \"trackEvents\": true,\n \"variationType\": \"True\",\n \"failed\": false,\n \"version\": \"\",\n \"reason\": \"TARGETING_MATCH\",\n \"errorCode\": \"\",\n \"value\": true\n", + ErrorMessage: "error parsing the response: unexpected end of JSON input", FlagMetadata: map[string]interface{}{}, }, }, @@ -253,29 +307,28 @@ func TestProvider_BooleanEvaluation(t *testing.T) { }, } for _, tt := range tests { + cli := mockClient{} t.Run(tt.name, func(t *testing.T) { options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockClient{}, - GOFeatureFlagConfig: nil, - DisableCache: true, + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: true, } provider, err := gofeatureflag.NewProvider(options) assert.NoError(t, err) - err = of.SetProvider(provider) - assert.NoError(t, err) + err = of.SetProviderAndWait(provider) + require.NoError(t, err) client := of.NewClient("test-app") value, err := client.BooleanValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) if tt.want.ErrorCode != "" { - assert.Error(t, err) + require.Error(t, err) want := fmt.Sprintf("error code: %s: %s", tt.want.ErrorCode, tt.want.ErrorMessage) assert.Equal(t, want, err.Error()) } else { assert.NoError(t, err) } - assert.Equal(t, tt.want, value) }) } @@ -352,7 +405,7 @@ func TestProvider_StringEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag bool_targeting_match", + ErrorMessage: "resolved value true is not of string type", FlagMetadata: map[string]interface{}{}, }, }, @@ -374,7 +427,7 @@ func TestProvider_StringEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", + ErrorMessage: "flag for key 'does_not_exists' does not exist", FlagMetadata: map[string]interface{}{}, }, }, @@ -383,16 +436,16 @@ func TestProvider_StringEvaluation(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + cli := mockClient{} options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockClient{}, - GOFeatureFlagConfig: nil, - DisableCache: true, + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: true, } provider, err := gofeatureflag.NewProvider(options) assert.NoError(t, err) - err = of.SetProvider(provider) + err = of.SetProviderAndWait(provider) assert.NoError(t, err) client := of.NewClient("test-app") value, err := client.StringValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) @@ -481,7 +534,7 @@ func TestProvider_FloatEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag bool_targeting_match", + ErrorMessage: "resolved value true is not of float type", FlagMetadata: map[string]interface{}{}, }, }, @@ -503,7 +556,7 @@ func TestProvider_FloatEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", + ErrorMessage: "flag for key 'does_not_exists' does not exist", FlagMetadata: map[string]interface{}{}, }, }, @@ -512,16 +565,16 @@ func TestProvider_FloatEvaluation(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + cli := mockClient{} options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockClient{}, - GOFeatureFlagConfig: nil, - DisableCache: true, + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: true, } provider, err := gofeatureflag.NewProvider(options) assert.NoError(t, err) - err = of.SetProvider(provider) + err = of.SetProviderAndWait(provider) assert.NoError(t, err) client := of.NewClient("test-app") value, err := client.FloatValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) @@ -610,7 +663,7 @@ func TestProvider_IntEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.TypeMismatchCode, - ErrorMessage: "unexpected type for flag bool_targeting_match", + ErrorMessage: "resolved value true is not of integer type", FlagMetadata: map[string]interface{}{}, }, }, @@ -632,7 +685,7 @@ func TestProvider_IntEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", + ErrorMessage: "flag for key 'does_not_exists' does not exist", FlagMetadata: map[string]interface{}{}, }, }, @@ -641,16 +694,16 @@ func TestProvider_IntEvaluation(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + cli := mockClient{} options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockClient{}, - GOFeatureFlagConfig: nil, - DisableCache: true, + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: true, } provider, err := gofeatureflag.NewProvider(options) assert.NoError(t, err) - err = of.SetProvider(provider) + err = of.SetProviderAndWait(provider) assert.NoError(t, err) client := of.NewClient("test-app") value, err := client.IntValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) @@ -745,7 +798,7 @@ func TestProvider_ObjectEvaluation(t *testing.T) { Variant: "", Reason: of.ErrorReason, ErrorCode: of.FlagNotFoundCode, - ErrorMessage: "flag does_not_exists was not found in GO Feature Flag", + ErrorMessage: "flag for key 'does_not_exists' does not exist", FlagMetadata: map[string]interface{}{}, }, }, @@ -754,16 +807,16 @@ func TestProvider_ObjectEvaluation(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + cli := mockClient{} options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockClient{}, - GOFeatureFlagConfig: nil, - DisableCache: true, + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: true, } provider, err := gofeatureflag.NewProvider(options) assert.NoError(t, err) - err = of.SetProvider(provider) + err = of.SetProviderAndWait(provider) assert.NoError(t, err) client := of.NewClient("test-app") value, err := client.ObjectValueDetails(context.TODO(), tt.args.flag, tt.args.defaultValue, tt.args.evalCtx) @@ -781,132 +834,236 @@ func TestProvider_ObjectEvaluation(t *testing.T) { } } -func TestProvider_Cache_Calling_Flag_Multiple_Time_Same_User(t *testing.T) { - mockedHttpClient := mockClient{} - options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockedHttpClient, - GOFeatureFlagConfig: nil, - DisableCache: false, - FlagCacheTTL: 5 * time.Minute, - FlagCacheSize: 5, - } +func TestProvider_Cache(t *testing.T) { + t.Run("Call flag multiple times with the same user", func(t *testing.T) { + cli := mockClient{} + options := gofeatureflag.ProviderOptions{ + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: false, + FlagCacheTTL: 5 * time.Minute, + FlagCacheSize: 5, + } - provider, err := gofeatureflag.NewProvider(options) - defer provider.Shutdown() - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - got1, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) - assert.NoError(t, err) - assert.Equal(t, got1.Reason, of.TargetingMatchReason) - got2, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) - assert.NoError(t, err) - assert.Equal(t, got2.Reason, of.CachedReason) - got3, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) - assert.NoError(t, err) - assert.Equal(t, got3.Reason, of.CachedReason) - got4, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) - assert.NoError(t, err) - assert.Equal(t, got4.Reason, of.CachedReason) - assert.Equal(t, 1, mockedHttpClient.callCount) -} + provider, err := gofeatureflag.NewProvider(options) + defer provider.Shutdown() + assert.NoError(t, err) -func TestProvider_Cache_Calling_Flag_Multiple_Time_Different_EvalutationCtx(t *testing.T) { - mockedHttpClient := mockClient{} - options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockedHttpClient, - GOFeatureFlagConfig: nil, - DisableCache: false, - FlagCacheTTL: 5 * time.Minute, - FlagCacheSize: 5, - } + err = of.SetProviderAndWait(provider) + assert.NoError(t, err) + client := of.NewClient("test-app") + got1, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + assert.NoError(t, err) + assert.Equal(t, got1.Reason, of.TargetingMatchReason) + got2, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + assert.NoError(t, err) + assert.Equal(t, got2.Reason, of.CachedReason) + got3, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + assert.NoError(t, err) + assert.Equal(t, got3.Reason, of.CachedReason) + got4, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + assert.NoError(t, err) + assert.Equal(t, got4.Reason, of.CachedReason) + assert.Equal(t, 1, cli.callCount) + }) + + t.Run("Call flag multiple times with different evaluation context", func(t *testing.T) { + cli := mockClient{} + options := gofeatureflag.ProviderOptions{ + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: false, + FlagCacheTTL: 5 * time.Minute, + FlagCacheSize: 5, + } + + provider, err := gofeatureflag.NewProvider(options) + defer provider.Shutdown() + assert.NoError(t, err) + + err = of.SetProviderAndWait(provider) + assert.NoError(t, err) + client := of.NewClient("test-app") + ctx1 := of.NewEvaluationContext("ffbe55ca-2150-4f15-a842-af6efb3a1391", map[string]interface{}{}) + ctx2 := of.NewEvaluationContext("316d4ac7-6072-472d-8a33-e35ed1702337", map[string]interface{}{}) + ctx3 := of.NewEvaluationContext("2b31904a-bfb0-46b8-8923-6bf32925de05", map[string]interface{}{}) + ctx4 := of.NewEvaluationContext("5d1d5245-23fd-466e-96a1-101e5088396e", map[string]interface{}{}) + got1, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) + assert.NoError(t, err) + assert.NotEqual(t, got1.Reason, of.CachedReason) + got2, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx2) + assert.NoError(t, err) + assert.NotEqual(t, got2.Reason, of.CachedReason) + got3, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx3) + assert.NoError(t, err) + assert.NotEqual(t, got3.Reason, of.CachedReason) + got4, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx4) + assert.NotEqual(t, got4.Reason, of.CachedReason) + assert.NoError(t, err) + assert.Equal(t, 4, cli.callCount) + }) + + t.Run("Cache fill all cache", func(t *testing.T) { + mockedHttpClient := mockClient{} + options := gofeatureflag.ProviderOptions{ + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(mockedHttpClient.roundTripFunc), + DisableCache: false, + FlagCacheTTL: 5 * time.Minute, + FlagCacheSize: 2, + } + + provider, err := gofeatureflag.NewProvider(options) + defer provider.Shutdown() + assert.NoError(t, err) + + err = of.SetProviderAndWait(provider) + assert.NoError(t, err) + client := of.NewClient("test-app") + ctx1 := of.NewEvaluationContext("ffbe55ca-2150-4f15-a842-af6efb3a1391", map[string]interface{}{}) + ctx2 := of.NewEvaluationContext("316d4ac7-6072-472d-8a33-e35ed1702337", map[string]interface{}{}) + ctx3 := of.NewEvaluationContext("2b31904a-bfb0-46b8-8923-6bf32925de05", map[string]interface{}{}) + r, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) + assert.NoError(t, err) + assert.Equal(t, of.TargetingMatchReason, r.Reason) + r, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) + assert.NoError(t, err) + assert.Equal(t, of.CachedReason, r.Reason) + r, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx2) + assert.NoError(t, err) + assert.Equal(t, of.TargetingMatchReason, r.Reason) + r, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx2) + assert.NoError(t, err) + assert.Equal(t, of.CachedReason, r.Reason) + r, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx3) + assert.NoError(t, err) + assert.Equal(t, of.TargetingMatchReason, r.Reason) + r, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx3) + assert.NoError(t, err) + assert.Equal(t, of.CachedReason, r.Reason) + r, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) + assert.NoError(t, err) + assert.Equal(t, of.TargetingMatchReason, r.Reason) + assert.Equal(t, 4, mockedHttpClient.callCount) + }) + + t.Run("Cache TTL reached", func(t *testing.T) { + mockedHttpClient := mockClient{} + options := gofeatureflag.ProviderOptions{ + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(mockedHttpClient.roundTripFunc), + DisableCache: false, + FlagCacheTTL: 500 * time.Millisecond, + FlagCacheSize: 200, + } + + provider, err := gofeatureflag.NewProvider(options) + defer provider.Shutdown() + assert.NoError(t, err) - provider, err := gofeatureflag.NewProvider(options) - defer provider.Shutdown() - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - ctx1 := of.NewEvaluationContext("ffbe55ca-2150-4f15-a842-af6efb3a1391", map[string]interface{}{}) - ctx2 := of.NewEvaluationContext("316d4ac7-6072-472d-8a33-e35ed1702337", map[string]interface{}{}) - ctx3 := of.NewEvaluationContext("2b31904a-bfb0-46b8-8923-6bf32925de05", map[string]interface{}{}) - ctx4 := of.NewEvaluationContext("5d1d5245-23fd-466e-96a1-101e5088396e", map[string]interface{}{}) - got1, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) - assert.NoError(t, err) - assert.NotEqual(t, got1.Reason, of.CachedReason) - got2, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx2) - assert.NoError(t, err) - assert.NotEqual(t, got2.Reason, of.CachedReason) - got3, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx3) - assert.NoError(t, err) - assert.NotEqual(t, got3.Reason, of.CachedReason) - got4, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx4) - assert.NotEqual(t, got4.Reason, of.CachedReason) - assert.NoError(t, err) - assert.Equal(t, 4, mockedHttpClient.callCount) + err = of.SetProviderAndWait(provider) + assert.NoError(t, err) + client := of.NewClient("test-app") + _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + assert.NoError(t, err) + time.Sleep(700 * time.Millisecond) + _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + assert.NoError(t, err) + assert.Equal(t, 2, mockedHttpClient.callCount) + }) } -func TestProvider_Cache_Fill_All_Cache(t *testing.T) { - mockedHttpClient := mockClient{} - options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockedHttpClient, - GOFeatureFlagConfig: nil, - DisableCache: false, - FlagCacheTTL: 5 * time.Minute, - FlagCacheSize: 2, - } +func TestProvider_DataCollectorHook(t *testing.T) { + t.Run("DataCollectorHook is called for success and call API", func(t *testing.T) { + cli := mockClient{} + options := gofeatureflag.ProviderOptions{ + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: false, + DataFlushInterval: 100 * time.Millisecond, + DisableDataCollector: false, + } + provider, err := gofeatureflag.NewProvider(options) + defer provider.Shutdown() + assert.NoError(t, err) + err = of.SetProviderAndWait(provider) + assert.NoError(t, err) + client := of.NewClient("test-app") + + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + _ = client.Boolean(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + + time.Sleep(500 * time.Millisecond) + assert.Equal(t, 1, cli.callCount) + assert.Equal(t, 1, cli.collectorCallCount) + }) - provider, err := gofeatureflag.NewProvider(options) - defer provider.Shutdown() - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - ctx1 := of.NewEvaluationContext("ffbe55ca-2150-4f15-a842-af6efb3a1391", map[string]interface{}{}) - ctx2 := of.NewEvaluationContext("316d4ac7-6072-472d-8a33-e35ed1702337", map[string]interface{}{}) - ctx3 := of.NewEvaluationContext("2b31904a-bfb0-46b8-8923-6bf32925de05", map[string]interface{}{}) - _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) - assert.NoError(t, err) - _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) - assert.NoError(t, err) - _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx2) - assert.NoError(t, err) - _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx3) - assert.NoError(t, err) - _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, ctx1) - assert.NoError(t, err) - assert.Equal(t, 4, mockedHttpClient.callCount) + t.Run("DataCollectorHook is called for errors and call API", func(t *testing.T) { + cli := mockClient{} + options := gofeatureflag.ProviderOptions{ + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: false, + DataFlushInterval: 100 * time.Millisecond, + DisableDataCollector: false, + } + provider, err := gofeatureflag.NewProvider(options) + defer provider.Shutdown() + assert.NoError(t, err) + err = of.SetProviderAndWait(provider) + assert.NoError(t, err) + client := of.NewClient("test-app") + + _ = client.String(context.TODO(), "bool_targeting_match", "false", defaultEvaluationCtx()) + + time.Sleep(1000 * time.Millisecond) + assert.Equal(t, 1, cli.callCount) + assert.Equal(t, 1, cli.collectorCallCount) + }) } -func TestProvider_Cache_TTL_Reached(t *testing.T) { - mockedHttpClient := mockClient{} - options := gofeatureflag.ProviderOptions{ - Endpoint: "https://gofeatureflag.org/", - HTTPClient: &mockedHttpClient, - GOFeatureFlagConfig: nil, - DisableCache: false, - FlagCacheTTL: 500 * time.Millisecond, - FlagCacheSize: 200, - } +func TestProvider_FlagChangePolling(t *testing.T) { + t.Run("Should purge the cache if configuration has changed", func(t *testing.T) { + cli := mockClient{} + options := gofeatureflag.ProviderOptions{ + Endpoint: "https://gofeatureflag.org/", + HTTPClient: NewMockClient(cli.roundTripFunc), + DisableCache: false, + FlagCacheTTL: 10 * time.Minute, + DisableDataCollector: true, + FlagChangePollingInterval: 100 * time.Millisecond, + } + provider, err := gofeatureflag.NewProvider(options) + defer provider.Shutdown() + assert.NoError(t, err) + err = of.SetProviderAndWait(provider) + assert.NoError(t, err) + client := of.NewClient("test-app") + + details, err := client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + require.NoError(t, err) + assert.Equal(t, of.TargetingMatchReason, details.Reason) + + details, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + require.NoError(t, err) + assert.Equal(t, of.CachedReason, details.Reason) + + details, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + require.NoError(t, err) + assert.Equal(t, of.CachedReason, details.Reason) + + time.Sleep(220 * time.Millisecond) // Waiting > 200ms to trigger the polling in the mock + + details, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) + require.NoError(t, err) + assert.Equal(t, of.TargetingMatchReason, details.Reason) + }) - provider, err := gofeatureflag.NewProvider(options) - defer provider.Shutdown() - assert.NoError(t, err) - - err = of.SetProvider(provider) - assert.NoError(t, err) - client := of.NewClient("test-app") - _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) - assert.NoError(t, err) - time.Sleep(700 * time.Millisecond) - _, err = client.BooleanValueDetails(context.TODO(), "bool_targeting_match", false, defaultEvaluationCtx()) - assert.NoError(t, err) - assert.Equal(t, 2, mockedHttpClient.callCount) } diff --git a/providers/go-feature-flag/pkg/util/targeting_key_validator.go b/providers/go-feature-flag/pkg/util/targeting_key_validator.go new file mode 100644 index 000000000..463d921ba --- /dev/null +++ b/providers/go-feature-flag/pkg/util/targeting_key_validator.go @@ -0,0 +1,19 @@ +package util + +import "github.com/open-feature/go-sdk/openfeature" + +const targetingKey = "targetingKey" + +func ValidateTargetingKey(evalCtx openfeature.FlattenedContext) *openfeature.ResolutionError { + if _, ok := evalCtx[targetingKey]; !ok { + err := openfeature.NewTargetingKeyMissingResolutionError("no targetingKey provided in the evaluation context") + return &err + } + + if _, ok := evalCtx[targetingKey].(string); !ok { + err := openfeature.NewTargetingKeyMissingResolutionError("targetingKey field MUST be a string") + return &err + } + + return nil +} diff --git a/providers/go-feature-flag/testutils/mock_responses/bool_targeting_match.json b/providers/go-feature-flag/testutils/mock_responses/bool_targeting_match.json index 55905b1c3..702b5f7e9 100644 --- a/providers/go-feature-flag/testutils/mock_responses/bool_targeting_match.json +++ b/providers/go-feature-flag/testutils/mock_responses/bool_targeting_match.json @@ -1,10 +1,9 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", - "reason": "TARGETING_MATCH", - "errorCode": "", "value": true, - "cacheable": true -} + "key": "bool_targeting_match", + "reason": "TARGETING_MATCH", + "variant": "True", + "metadata": { + "gofeatureflag_cacheable": true + } +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/disabled_bool.json b/providers/go-feature-flag/testutils/mock_responses/disabled_bool.json index 2431e5be5..a28f77a31 100644 --- a/providers/go-feature-flag/testutils/mock_responses/disabled_bool.json +++ b/providers/go-feature-flag/testutils/mock_responses/disabled_bool.json @@ -1,9 +1,7 @@ { - "trackEvents": true, - "variationType": "SdkDefault", - "failed": false, - "version": "", + "value": false, + "key": "disabled_bool", "reason": "DISABLED", - "errorCode": "", - "value": false -} + "variant": "SdkDefault", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/disabled_float.json b/providers/go-feature-flag/testutils/mock_responses/disabled_float.json index 8f38f2b82..1113a1ff9 100644 --- a/providers/go-feature-flag/testutils/mock_responses/disabled_float.json +++ b/providers/go-feature-flag/testutils/mock_responses/disabled_float.json @@ -1,9 +1,7 @@ { - "trackEvents": true, - "variationType": "SdkDefault", - "failed": false, - "version": "", + "value": 123.45, + "key": "disabled_float", "reason": "DISABLED", - "errorCode": "", - "value": 123.45 -} + "variant": "SdkDefault", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/disabled_int.json b/providers/go-feature-flag/testutils/mock_responses/disabled_int.json index ed2843e69..2899a0a7c 100644 --- a/providers/go-feature-flag/testutils/mock_responses/disabled_int.json +++ b/providers/go-feature-flag/testutils/mock_responses/disabled_int.json @@ -1,9 +1,6 @@ { - "trackEvents": true, - "variationType": "SdkDefault", - "failed": false, - "version": "", + "key": "disabled_int", "reason": "DISABLED", - "errorCode": "", - "value": 123 -} + "variant": "SdkDefault", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/disabled_string.json b/providers/go-feature-flag/testutils/mock_responses/disabled_string.json index e0bd91837..5e12d2c6d 100644 --- a/providers/go-feature-flag/testutils/mock_responses/disabled_string.json +++ b/providers/go-feature-flag/testutils/mock_responses/disabled_string.json @@ -1,9 +1,7 @@ { - "trackEvents": true, - "variationType": "SdkDefault", - "failed": false, - "version": "", + "value": "default", + "key": "disabled_string", "reason": "DISABLED", - "errorCode": "", - "value": "default" -} + "variant": "SdkDefault", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/double_key.json b/providers/go-feature-flag/testutils/mock_responses/double_key.json index 180b826af..94765ba62 100644 --- a/providers/go-feature-flag/testutils/mock_responses/double_key.json +++ b/providers/go-feature-flag/testutils/mock_responses/double_key.json @@ -1,9 +1,7 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", + "value": 100.25, + "key": "double_key", "reason": "TARGETING_MATCH", - "errorCode": "", - "value": 100.25 + "variant": "True", + "metadata": {} } diff --git a/providers/go-feature-flag/testutils/mock_responses/flag_not_found.json b/providers/go-feature-flag/testutils/mock_responses/flag_not_found.json index 2e2ec17d3..5a3bc9752 100644 --- a/providers/go-feature-flag/testutils/mock_responses/flag_not_found.json +++ b/providers/go-feature-flag/testutils/mock_responses/flag_not_found.json @@ -1,8 +1,6 @@ { - "trackEvents": true, - "variationType": "SdkDefault", - "failed": true, - "version": "", + "key": "flag_not_found", "reason": "ERROR", - "errorCode": "FLAG_NOT_FOUND" -} + "errorCode": "FLAG_NOT_FOUND", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/integer_key.json b/providers/go-feature-flag/testutils/mock_responses/integer_key.json index b768cab1b..b2ab5d903 100644 --- a/providers/go-feature-flag/testutils/mock_responses/integer_key.json +++ b/providers/go-feature-flag/testutils/mock_responses/integer_key.json @@ -1,9 +1,7 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", + "value": 100, + "key": "integer_key", "reason": "TARGETING_MATCH", - "errorCode": "", - "value": 100 -} + "variant": "True", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/invalid_json_body.json b/providers/go-feature-flag/testutils/mock_responses/invalid_json_body.json index 3a7574fcf..a6dc4d180 100644 --- a/providers/go-feature-flag/testutils/mock_responses/invalid_json_body.json +++ b/providers/go-feature-flag/testutils/mock_responses/invalid_json_body.json @@ -1,8 +1,5 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", + "value": 100, + "key": "integer_key", "reason": "TARGETING_MATCH", - "errorCode": "", - "value": true + "variant": "True" diff --git a/providers/go-feature-flag/testutils/mock_responses/list_key.json b/providers/go-feature-flag/testutils/mock_responses/list_key.json index 27672ed40..f9eed0802 100644 --- a/providers/go-feature-flag/testutils/mock_responses/list_key.json +++ b/providers/go-feature-flag/testutils/mock_responses/list_key.json @@ -1,15 +1,13 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", - "reason": "TARGETING_MATCH", - "errorCode": "", "value": [ "test", "test1", "test2", "false", "test3" - ] -} + ], + "key": "list_key", + "reason": "TARGETING_MATCH", + "variant": "True", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/object_key.json b/providers/go-feature-flag/testutils/mock_responses/object_key.json index d792554d3..29c04fb89 100644 --- a/providers/go-feature-flag/testutils/mock_responses/object_key.json +++ b/providers/go-feature-flag/testutils/mock_responses/object_key.json @@ -1,15 +1,13 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", - "reason": "TARGETING_MATCH", - "errorCode": "", "value": { "test": "test1", "test2": false, "test3": 123.3, "test4": 1, "test5": null - } -} + }, + "key": "object_key", + "reason": "TARGETING_MATCH", + "variant": "True", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/string_key.json b/providers/go-feature-flag/testutils/mock_responses/string_key.json index 630160dcc..5443fb8cf 100644 --- a/providers/go-feature-flag/testutils/mock_responses/string_key.json +++ b/providers/go-feature-flag/testutils/mock_responses/string_key.json @@ -1,9 +1,7 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", + "value": "CC0000", + "key": "string_key", "reason": "TARGETING_MATCH", - "errorCode": "", - "value": "CC0000" -} + "variant": "True", + "metadata": {} +} \ No newline at end of file diff --git a/providers/go-feature-flag/testutils/mock_responses/unknown_reason.json b/providers/go-feature-flag/testutils/mock_responses/unknown_reason.json index f9d10df1a..cd702d09a 100644 --- a/providers/go-feature-flag/testutils/mock_responses/unknown_reason.json +++ b/providers/go-feature-flag/testutils/mock_responses/unknown_reason.json @@ -1,9 +1,7 @@ { - "trackEvents": true, - "variationType": "True", - "failed": false, - "version": "", + "value": true, + "key": "unknown_reason", "reason": "CUSTOM_REASON", - "errorCode": "", - "value": true -} + "variant": "True", + "metadata": {} +} \ No newline at end of file