From 19c7f2392c8e23bd72d75568f819cc67f1e63e53 Mon Sep 17 00:00:00 2001 From: Jon Canning Date: Fri, 21 Nov 2025 17:30:52 +0000 Subject: [PATCH 1/2] feat(optimizely): Add unofficial Optimizely OpenFeature provider for Go Signed-off-by: jon.canning@gmail.com --- providers/optimizely/LICENSE | 201 +++++++++++++ providers/optimizely/README.md | 63 ++++ providers/optimizely/example/example.go | 94 ++++++ providers/optimizely/go.mod | 27 ++ providers/optimizely/go.sum | 69 +++++ providers/optimizely/provider.go | 158 ++++++++++ providers/optimizely/provider_test.go | 364 ++++++++++++++++++++++++ release-please-config.json | 12 +- 8 files changed, 986 insertions(+), 2 deletions(-) create mode 100644 providers/optimizely/LICENSE create mode 100644 providers/optimizely/README.md create mode 100644 providers/optimizely/example/example.go create mode 100644 providers/optimizely/go.mod create mode 100644 providers/optimizely/go.sum create mode 100644 providers/optimizely/provider.go create mode 100644 providers/optimizely/provider_test.go diff --git a/providers/optimizely/LICENSE b/providers/optimizely/LICENSE new file mode 100644 index 000000000..96b3dc8fc --- /dev/null +++ b/providers/optimizely/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/providers/optimizely/README.md b/providers/optimizely/README.md new file mode 100644 index 000000000..bca4976dc --- /dev/null +++ b/providers/optimizely/README.md @@ -0,0 +1,63 @@ +# Unofficial Optimizely OpenFeature Provider for Go + +OpenFeature Go provider implementation for [Optimizely](https://optimizely.com) that uses the official [Optimizely Go SDK](https://github.com/optimizely/go-sdk). + +## Installation + +```shell +# Optimizely SDK +go get github.com/optimizely/go-sdk/v2 + +# OpenFeature SDK +go get github.com/open-feature/go-sdk/openfeature +go get github.com/open-feature/go-sdk-contrib/providers/optimizely +``` + +## Usage + +```go +import ( + "github.com/open-feature/go-sdk/openfeature" + "github.com/optimizely/go-sdk/v2/pkg/client" + optimizely "github.com/open-feature/go-sdk-contrib/providers/optimizely" +) + +func main() { + optimizelyClient, err := (&client.OptimizelyFactory{ + SDKKey: "your-sdk-key", + }).Client() + if err != nil { + panic(err) + } + + provider := optimizely.NewProvider(optimizelyClient) + openfeature.SetProviderAndWait(provider) + defer openfeature.Shutdown() + + ofClient := openfeature.NewClient("my-app") + + evalCtx := openfeature.NewEvaluationContext("user-123", map[string]any{ + "email": "user@example.com", + }) + value, err := ofClient.BooleanValue(ctx, "my_flag", false, evalCtx) +} +``` + +See [example/example.go](./example/example.go) for a complete example. + +## Evaluation Context + +The `targetingKey` is required and maps to the Optimizely user ID. Additional attributes are passed to Optimizely for audience targeting. + +## Variable Key Selection + +Optimizely flags can have multiple variables. By default, the provider looks for a variable named `"value"`. Specify a different variable using the `variableKey` attribute: + +```go +evalCtx := openfeature.NewEvaluationContext("user-123", map[string]any{ + "variableKey": "button_color", +}) +``` + +## References +* [Optimizely Go SDK documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/go-sdk) diff --git a/providers/optimizely/example/example.go b/providers/optimizely/example/example.go new file mode 100644 index 000000000..f6517f79c --- /dev/null +++ b/providers/optimizely/example/example.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/open-feature/go-sdk/openfeature" + "github.com/optimizely/go-sdk/v2/pkg/client" + + optimizely "github.com/open-feature/go-sdk-contrib/providers/optimizely" +) + +func main() { + ctx := context.Background() + sdkKey := os.Getenv("OPTIMIZELY_SDK_KEY") + if sdkKey == "" { + fmt.Println("OPTIMIZELY_SDK_KEY environment variable is required") + os.Exit(1) + } + + optimizelyClient, err := (&client.OptimizelyFactory{ + SDKKey: sdkKey, + }).Client() + if err != nil { + panic(err) + } + + provider := optimizely.NewProvider(optimizelyClient) + err = openfeature.SetProviderAndWait(provider) + if err != nil { + panic(err) + } + defer openfeature.Shutdown() + + ofClient := openfeature.NewClient("my-app") + + const newConst = "user_123" + + // Boolean evaluation + boolCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ + "variableKey": "boolean_variable", + }) + boolValue, err := ofClient.BooleanValue(ctx, "flag", false, boolCtx) + if err != nil { + fmt.Printf("boolean evaluation error: %v\n", err) + } else { + fmt.Printf("boolean value: %v\n", boolValue) + } + + // String evaluation + stringCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ + "variableKey": "string_variable", + }) + stringValue, err := ofClient.StringValue(ctx, "flag", "default", stringCtx) + if err != nil { + fmt.Printf("string evaluation error: %v\n", err) + } else { + fmt.Printf("string value: %s\n", stringValue) + } + + // Int evaluation with custom variableKey + intCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ + "variableKey": "integer_variable", + }) + intValue, err := ofClient.IntValue(ctx, "flag", 10, intCtx) + if err != nil { + fmt.Printf("int evaluation error: %v\n", err) + } else { + fmt.Printf("int value: %d\n", intValue) + } + + // Float evaluation with custom variableKey + floatCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ + "variableKey": "double_variable", + }) + floatValue, err := ofClient.FloatValue(ctx, "flag", 0.0, floatCtx) + if err != nil { + fmt.Printf("float evaluation error: %v\n", err) + } else { + fmt.Printf("float value: %.2f\n", floatValue) + } + + // Object evaluation with custom variableKey + objectCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ + "variableKey": "json_variable", + }) + objectValue, err := ofClient.ObjectValue(ctx, "flag", map[string]any{}, objectCtx) + if err != nil { + fmt.Printf("object evaluation error: %v\n", err) + } else { + fmt.Printf("object value: %v\n", objectValue) + } +} diff --git a/providers/optimizely/go.mod b/providers/optimizely/go.mod new file mode 100644 index 000000000..3faedf07f --- /dev/null +++ b/providers/optimizely/go.mod @@ -0,0 +1,27 @@ +module github.com/open-feature/go-sdk-contrib/providers/optimizely + +go 1.24.0 + +require ( + github.com/open-feature/go-sdk v1.17.0 + github.com/optimizely/go-sdk/v2 v2.2.1 +) + +require ( + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/twmb/murmur3 v1.1.8 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/mock v0.6.0 // indirect + golang.org/x/sync v0.18.0 // indirect +) diff --git a/providers/optimizely/go.sum b/providers/optimizely/go.sum new file mode 100644 index 000000000..7d4c137aa --- /dev/null +++ b/providers/optimizely/go.sum @@ -0,0 +1,69 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/open-feature/go-sdk v1.17.0 h1:/OUBBw5d9D61JaNZZxb2Nnr5/EJrEpjtKCTY3rspJQk= +github.com/open-feature/go-sdk v1.17.0/go.mod h1:lPxPSu1UnZ4E3dCxZi5gV3et2ACi8O8P+zsTGVsDZUw= +github.com/optimizely/go-sdk/v2 v2.2.1 h1:LHcgFL3Imq4xyKHQivnO06Lcacy/H4DrAVEm6T+/MWk= +github.com/optimizely/go-sdk/v2 v2.2.1/go.mod h1:MusRCFsU7+XzJCoCTgheLoENJSf1iiFYm4KbJqz6BYA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= +github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= +github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/providers/optimizely/provider.go b/providers/optimizely/provider.go new file mode 100644 index 000000000..03d206d3c --- /dev/null +++ b/providers/optimizely/provider.go @@ -0,0 +1,158 @@ +package optimizely + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/open-feature/go-sdk/openfeature" + optimizely "github.com/optimizely/go-sdk/v2/pkg/client" + "github.com/optimizely/go-sdk/v2/pkg/decide" +) + +// Compile-time check that Provider implements FeatureProvider +var _ openfeature.FeatureProvider = (*Provider)(nil) + +// Compile-time check that Provider implements StateHandler +var _ openfeature.StateHandler = (*Provider)(nil) + +// ErrTargetingKeyMissing is returned when the targeting key is not provided in the evaluation context. +var ErrTargetingKeyMissing = errors.New("targeting key is required") + +// flagNotFoundReason is the string pattern used by Optimizely to indicate a flag was not found. +const flagNotFoundReason = "No flag was found" + +type Provider struct { + client *optimizely.OptimizelyClient +} + +func NewProvider(client *optimizely.OptimizelyClient) *Provider { + return &Provider{ + client: client, + } +} + +func (p *Provider) Metadata() openfeature.Metadata { + return openfeature.Metadata{ + Name: "Optimizely", + } +} + +func (p *Provider) evaluate(flagKey string, evalCtx openfeature.FlattenedContext) (any, openfeature.ProviderResolutionDetail) { + userID, ok := evalCtx[openfeature.TargetingKey].(string) + if !ok { + return nil, openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTargetingKeyMissingResolutionError(ErrTargetingKeyMissing.Error()), + Reason: openfeature.Reason(openfeature.TargetingKeyMissingCode), + } + } + + attributes := make(map[string]any) + for k, v := range evalCtx { + if k != openfeature.TargetingKey { + attributes[k] = v + } + } + + userCtx := p.client.CreateUserContext(userID, attributes) + decision := userCtx.Decide(flagKey, []decide.OptimizelyDecideOptions{}) + + for _, reason := range decision.Reasons { + if strings.Contains(reason, flagNotFoundReason) { + return nil, openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewFlagNotFoundResolutionError(reason), + Reason: openfeature.ErrorReason, + } + } + } + + variables := decision.Variables.ToMap() + if !decision.Enabled || variables == nil { + return nil, openfeature.ProviderResolutionDetail{ + Reason: openfeature.DisabledReason, + } + } + + variableKey := "value" + if key, ok := evalCtx["variableKey"].(string); ok && key != "" { + variableKey = key + } + + val, exists := variables[variableKey] + if !exists { + return nil, openfeature.ProviderResolutionDetail{ + Reason: openfeature.DefaultReason, + } + } + + return val, openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + Variant: decision.VariationKey, + } +} + +func resolve[T any](p *Provider, flagKey string, defaultValue T, evalCtx openfeature.FlattenedContext) openfeature.GenericResolutionDetail[T] { + val, detail := p.evaluate(flagKey, evalCtx) + if val == nil { + return openfeature.GenericResolutionDetail[T]{ + Value: defaultValue, + ProviderResolutionDetail: detail, + } + } + + if converted, ok := val.(T); ok { + return openfeature.GenericResolutionDetail[T]{ + Value: converted, + ProviderResolutionDetail: detail, + } + } + + return openfeature.GenericResolutionDetail[T]{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError(fmt.Sprintf("variable is not a %T", defaultValue)), + Reason: openfeature.ErrorReason, + }, + } +} + +func (p *Provider) BooleanEvaluation(ctx context.Context, flagKey string, defaultValue bool, evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { + return resolve(p, flagKey, defaultValue, evalCtx) +} + +func (p *Provider) StringEvaluation(ctx context.Context, flagKey string, defaultValue string, evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { + return resolve(p, flagKey, defaultValue, evalCtx) +} + +func (p *Provider) FloatEvaluation(ctx context.Context, flagKey string, defaultValue float64, evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { + return resolve(p, flagKey, defaultValue, evalCtx) +} + +func (p *Provider) IntEvaluation(ctx context.Context, flagKey string, defaultValue int64, evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { + res := resolve(p, flagKey, int(defaultValue), evalCtx) + return openfeature.IntResolutionDetail{ + Value: int64(res.Value), + ProviderResolutionDetail: res.ProviderResolutionDetail, + } +} + +func (p *Provider) ObjectEvaluation(ctx context.Context, flagKey string, defaultValue any, evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { + return resolve(p, flagKey, defaultValue, evalCtx) +} + +func (p *Provider) Hooks() []openfeature.Hook { + return []openfeature.Hook{} +} + +func (p *Provider) Init(evaluationContext openfeature.EvaluationContext) error { + return nil +} + +func (p *Provider) Shutdown() { + p.client.Close() +} + +func (p *Provider) Status() openfeature.State { + return openfeature.ReadyState +} diff --git a/providers/optimizely/provider_test.go b/providers/optimizely/provider_test.go new file mode 100644 index 000000000..2e9201691 --- /dev/null +++ b/providers/optimizely/provider_test.go @@ -0,0 +1,364 @@ +package optimizely + +import ( + "testing" + + "github.com/open-feature/go-sdk/openfeature" + "github.com/optimizely/go-sdk/v2/pkg/client" +) + +const testDatafile = ` +{ + "version": "4", + "rollouts": [ + { + "id": "rollout_1", + "experiments": [ + { + "id": "exp_1", + "key": "exp_1", + "status": "Running", + "layerId": "layer_1", + "audienceIds": [], + "variations": [ + { + "id": "var_1", + "key": "on", + "featureEnabled": true, + "variables": [ + {"id": "var_string", "value": "test_value"}, + {"id": "var_int", "value": "42"}, + {"id": "var_float", "value": "3.14"}, + {"id": "var_json", "value": "{\"nested\": \"object\"}"}, + {"id": "var_bool", "value": "true"} + ] + } + ], + "trafficAllocation": [ + {"entityId": "var_1", "endOfRange": 10000} + ], + "forcedVariations": {} + } + ] + } + ], + "experiments": [], + "featureFlags": [ + { + "id": "flag_1", + "key": "test_flag", + "rolloutId": "rollout_1", + "experimentIds": [], + "variables": [ + {"id": "var_string", "key": "value", "defaultValue": "default", "type": "string"}, + {"id": "var_int", "key": "int_value", "defaultValue": "0", "type": "integer"}, + {"id": "var_float", "key": "float_value", "defaultValue": "0.0", "type": "double"}, + {"id": "var_json", "key": "json_value", "defaultValue": "{}", "type": "json"}, + {"id": "var_bool", "key": "bool_value", "defaultValue": "false", "type": "boolean"} + ] + }, + { + "id": "flag_2", + "key": "disabled_flag", + "rolloutId": "", + "experimentIds": [], + "variables": [] + } + ], + "events": [], + "audiences": [], + "attributes": [], + "groups": [], + "projectId": "12345", + "accountId": "67890", + "anonymizeIP": true, + "botFiltering": false +} +` + +func TestProvider_Metadata(t *testing.T) { + p := NewProvider(nil) + if p.Metadata().Name != "Optimizely" { + t.Errorf("expected metadata name 'Optimizely', got %s", p.Metadata().Name) + } +} + +func TestProvider_BooleanEvaluation(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + flattenedCtx := map[string]any{ + openfeature.TargetingKey: "user-1", + "variableKey": "bool_value", + } + + // Test successful evaluation + res := p.BooleanEvaluation(t.Context(), "test_flag", false, flattenedCtx) + if res.ResolutionError != (openfeature.ResolutionError{}) { + t.Errorf("expected no resolution error, got %v", res.ResolutionError) + } + if res.Value != true { + t.Errorf("expected true, got %v", res.Value) + } + + // Test missing targeting key + resMissingKey := p.BooleanEvaluation(t.Context(), "test_flag", false, map[string]any{ + "variableKey": "bool_value", + }) + if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected targeting key missing error, got nil") + } + + // Test flag not found + resNotFound := p.BooleanEvaluation(t.Context(), "nonexistent_flag", false, flattenedCtx) + if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected flag not found error, got nil") + } + if resNotFound.Value != false { + t.Errorf("expected default value false, got %v", resNotFound.Value) + } +} + +func TestProvider_StringEvaluation(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + flattenedCtx := map[string]any{ + openfeature.TargetingKey: "user-1", + } + + // Test successful evaluation with default variableKey + res := p.StringEvaluation(t.Context(), "test_flag", "default", flattenedCtx) + if res.ResolutionError != (openfeature.ResolutionError{}) { + t.Errorf("expected no resolution error, got %v", res.ResolutionError) + } + if res.Value != "test_value" { + t.Errorf("expected 'test_value', got %s", res.Value) + } + + // Test missing targeting key + resMissingKey := p.StringEvaluation(t.Context(), "test_flag", "", map[string]any{}) + if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected targeting key missing error, got nil") + } + + // Test flag not found + resNotFound := p.StringEvaluation(t.Context(), "nonexistent_flag", "default", flattenedCtx) + if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected flag not found error, got nil") + } +} + +func TestProvider_IntEvaluation(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + + // Test with custom variableKey + flattenedCtx := map[string]any{ + openfeature.TargetingKey: "user-1", + "variableKey": "int_value", + } + + res := p.IntEvaluation(t.Context(), "test_flag", 0, flattenedCtx) + if res.ResolutionError != (openfeature.ResolutionError{}) { + t.Errorf("expected no resolution error, got %v", res.ResolutionError) + } + if res.Value != 42 { + t.Errorf("expected 42, got %d", res.Value) + } + + // Test missing targeting key + resMissingKey := p.IntEvaluation(t.Context(), "test_flag", 0, map[string]any{}) + if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected targeting key missing error, got nil") + } + + // Test flag not found + resNotFound := p.IntEvaluation(t.Context(), "nonexistent_flag", 99, map[string]any{ + openfeature.TargetingKey: "user-1", + }) + if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected flag not found error, got nil") + } + if resNotFound.Value != 99 { + t.Errorf("expected default value 99, got %d", resNotFound.Value) + } +} + +func TestProvider_FloatEvaluation(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + + // Test with custom variableKey + flattenedCtx := map[string]any{ + openfeature.TargetingKey: "user-1", + "variableKey": "float_value", + } + + res := p.FloatEvaluation(t.Context(), "test_flag", 0.0, flattenedCtx) + if res.ResolutionError != (openfeature.ResolutionError{}) { + t.Errorf("expected no resolution error, got %v", res.ResolutionError) + } + if res.Value != 3.14 { + t.Errorf("expected 3.14, got %f", res.Value) + } + + // Test missing targeting key + resMissingKey := p.FloatEvaluation(t.Context(), "test_flag", 0.0, map[string]any{}) + if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected targeting key missing error, got nil") + } + + // Test flag not found + resNotFound := p.FloatEvaluation(t.Context(), "nonexistent_flag", 1.5, map[string]any{ + openfeature.TargetingKey: "user-1", + }) + if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected flag not found error, got nil") + } + if resNotFound.Value != 1.5 { + t.Errorf("expected default value 1.5, got %f", resNotFound.Value) + } +} + +func TestProvider_ObjectEvaluation(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + + // Test with default variableKey + flattenedCtx := map[string]any{ + openfeature.TargetingKey: "user-1", + } + + res := p.ObjectEvaluation(t.Context(), "test_flag", nil, flattenedCtx) + if res.ResolutionError != (openfeature.ResolutionError{}) { + t.Errorf("expected no resolution error, got %v", res.ResolutionError) + } + if res.Value != "test_value" { + t.Errorf("expected 'test_value', got %v", res.Value) + } + + // Test missing targeting key + resMissingKey := p.ObjectEvaluation(t.Context(), "test_flag", nil, map[string]any{}) + if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected targeting key missing error, got nil") + } + + // Test flag not found + defaultVal := map[string]any{"default": true} + resNotFound := p.ObjectEvaluation(t.Context(), "nonexistent_flag", defaultVal, map[string]any{ + openfeature.TargetingKey: "user-1", + }) + if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected flag not found error, got nil") + } +} + +func TestProvider_TypeMismatch(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + flattenedCtx := map[string]any{ + openfeature.TargetingKey: "user-1", + } + + // Try to get string variable as int (should fail with type mismatch) + res := p.IntEvaluation(t.Context(), "test_flag", 0, flattenedCtx) + if res.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected type mismatch error when getting string as int") + } + + // Try to get string variable as float (should fail with type mismatch) + resFloat := p.FloatEvaluation(t.Context(), "test_flag", 0.0, flattenedCtx) + if resFloat.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected type mismatch error when getting string as float") + } +} + +func TestProvider_CustomVariableKey(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + + // Test custom variableKey for string + flattenedCtx := map[string]any{ + openfeature.TargetingKey: "user-1", + "variableKey": "int_value", + } + + // Get int_value as string should fail (type mismatch) + res := p.StringEvaluation(t.Context(), "test_flag", "default", flattenedCtx) + if res.ResolutionError == (openfeature.ResolutionError{}) { + t.Errorf("expected type mismatch error when getting int as string") + } +} + +func TestProvider_Hooks(t *testing.T) { + p := NewProvider(nil) + hooks := p.Hooks() + if len(hooks) != 0 { + t.Errorf("expected empty hooks slice, got %d hooks", len(hooks)) + } +} + +func TestProvider_StateHandler(t *testing.T) { + optimizelyClient, err := (&client.OptimizelyFactory{ + Datafile: []byte(testDatafile), + }).Client() + if err != nil { + t.Fatalf("failed to create optimizely client: %v", err) + } + + p := NewProvider(optimizelyClient) + + // Test Init + if err := p.Init(openfeature.EvaluationContext{}); err != nil { + t.Errorf("expected Init to return nil, got %v", err) + } + + // Test Status + if p.Status() != openfeature.ReadyState { + t.Errorf("expected ReadyState, got %v", p.Status()) + } + + // Test Shutdown + p.Shutdown() +} diff --git a/release-please-config.json b/release-please-config.json index 960d31e31..d80713536 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -159,11 +159,11 @@ "providers/aws-ssm": { "release-type": "go", "package-name": "providers/aws-ssm", - "bump-minor-pre-major": true, + "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", "extra-files": [] - }, + }, "providers/rocketflag": { "release-type": "go", "package-name": "providers/rocketflag", @@ -171,6 +171,14 @@ "bump-patch-for-minor-pre-major": true, "versioning": "default", "extra-files": [] + }, + "providers/optimizely": { + "release-type": "go", + "package-name": "providers/optimizely", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [] } }, "changelog-sections": [ From 81efb7d9740a8d413e4a915f233dccc5b5e1bb22 Mon Sep 17 00:00:00 2001 From: Jon Canning Date: Wed, 3 Dec 2025 21:32:04 +0000 Subject: [PATCH 2/2] refactor: use variable resolution rules suggested by @sahidvelji --- providers/optimizely/README.md | 71 ++- providers/optimizely/example/example.go | 84 ++-- providers/optimizely/go.mod | 4 + providers/optimizely/go.sum | 29 +- providers/optimizely/provider.go | 222 +++++++-- providers/optimizely/provider_test.go | 623 +++++++++++++----------- 6 files changed, 636 insertions(+), 397 deletions(-) diff --git a/providers/optimizely/README.md b/providers/optimizely/README.md index bca4976dc..6afb01785 100644 --- a/providers/optimizely/README.md +++ b/providers/optimizely/README.md @@ -5,10 +5,7 @@ OpenFeature Go provider implementation for [Optimizely](https://optimizely.com) ## Installation ```shell -# Optimizely SDK go get github.com/optimizely/go-sdk/v2 - -# OpenFeature SDK go get github.com/open-feature/go-sdk/openfeature go get github.com/open-feature/go-sdk-contrib/providers/optimizely ``` @@ -35,10 +32,10 @@ func main() { defer openfeature.Shutdown() ofClient := openfeature.NewClient("my-app") - evalCtx := openfeature.NewEvaluationContext("user-123", map[string]any{ "email": "user@example.com", }) + value, err := ofClient.BooleanValue(ctx, "my_flag", false, evalCtx) } ``` @@ -49,15 +46,69 @@ See [example/example.go](./example/example.go) for a complete example. The `targetingKey` is required and maps to the Optimizely user ID. Additional attributes are passed to Optimizely for audience targeting. -## Variable Key Selection +## Flag Variable Mapping + +The evaluation method you use depends on the number of variables configured in your Optimizely flag: -Optimizely flags can have multiple variables. By default, the provider looks for a variable named `"value"`. Specify a different variable using the `variableKey` attribute: +| Variables | Evaluation Method | Returns | +|-----------|-------------------|---------| +| 0 | `BooleanEvaluation` | Flag enabled state (`true`/`false`) | +| 1 | Type-specific method | The single variable's value | +| N (>1) | `ObjectEvaluation` | Map of all variable names to values | + +### Flags with No Variables + +Use `BooleanEvaluation` to get the flag's enabled state: ```go -evalCtx := openfeature.NewEvaluationContext("user-123", map[string]any{ - "variableKey": "button_color", -}) +// Returns true if flag is enabled for user, false otherwise +enabled, err := ofClient.BooleanValue(ctx, "feature_flag", false, evalCtx) ``` +### Flags with One Variable + +Use the evaluation method matching the variable's type: + +```go +// String variable +message, err := ofClient.StringValue(ctx, "welcome_message_flag", "Hello", evalCtx) + +// Integer variable +limit, err := ofClient.IntValue(ctx, "rate_limit_flag", 100, evalCtx) + +// Double variable +price, err := ofClient.FloatValue(ctx, "price_flag", 9.99, evalCtx) + +// Boolean variable +enabled, err := ofClient.BooleanValue(ctx, "dark_mode_flag", false, evalCtx) + +// Any type (returns the value as interface{}) +value, err := ofClient.ObjectValue(ctx, "config_flag", nil, evalCtx) +``` + +### Flags with Multiple Variables + +Use `ObjectEvaluation` to get all variables as a map: + +```go +// Returns map[string]any with all variable values +config, err := ofClient.ObjectValue(ctx, "ui_config_flag", nil, evalCtx) +if err == nil { + configMap := config.(map[string]any) + buttonColor := configMap["button_color"].(string) + fontSize := configMap["font_size"].(int) +} +``` + +### Error Cases + +Using the wrong evaluation method for your flag configuration returns an error: + +- `BooleanEvaluation` on a flag with multiple variables returns an error +- `StringEvaluation`, `IntEvaluation`, `FloatEvaluation` on a flag with 0 or multiple variables returns an error +- Type mismatches (e.g., `IntEvaluation` on a string variable) return an error + ## References -* [Optimizely Go SDK documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/go-sdk) + +- [Optimizely Go SDK documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/go-sdk) +- [OpenFeature Go SDK](https://github.com/open-feature/go-sdk) diff --git a/providers/optimizely/example/example.go b/providers/optimizely/example/example.go index f6517f79c..d599269a2 100644 --- a/providers/optimizely/example/example.go +++ b/providers/optimizely/example/example.go @@ -13,82 +13,94 @@ import ( func main() { ctx := context.Background() + + // Required: Optimizely SDK key sdkKey := os.Getenv("OPTIMIZELY_SDK_KEY") if sdkKey == "" { fmt.Println("OPTIMIZELY_SDK_KEY environment variable is required") os.Exit(1) } + // Optional: customize flag key and user ID + flagKey := os.Getenv("FLAG_KEY") + if flagKey == "" { + flagKey = "my_flag" + } + userID := os.Getenv("USER_ID") + if userID == "" { + userID = "user_123" + } + + // Create Optimizely client optimizelyClient, err := (&client.OptimizelyFactory{ SDKKey: sdkKey, }).Client() if err != nil { - panic(err) + fmt.Printf("failed to create Optimizely client: %v\n", err) + os.Exit(1) } + // Set up OpenFeature with the Optimizely provider provider := optimizely.NewProvider(optimizelyClient) err = openfeature.SetProviderAndWait(provider) if err != nil { - panic(err) + fmt.Printf("failed to set provider: %v\n", err) + os.Exit(1) } defer openfeature.Shutdown() ofClient := openfeature.NewClient("my-app") + evalCtx := openfeature.NewEvaluationContext(userID, nil) - const newConst = "user_123" + fmt.Printf("Testing flag: %s (user: %s)\n\n", flagKey, userID) // Boolean evaluation - boolCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ - "variableKey": "boolean_variable", - }) - boolValue, err := ofClient.BooleanValue(ctx, "flag", false, boolCtx) + // Use for: flags with 0 variables (returns enabled state) OR flags with 1 bool variable + fmt.Println("=== BooleanEvaluation ===") + boolResult, err := ofClient.BooleanValueDetails(ctx, flagKey, false, evalCtx) if err != nil { - fmt.Printf("boolean evaluation error: %v\n", err) + fmt.Printf("Error: %v\n", err) } else { - fmt.Printf("boolean value: %v\n", boolValue) + fmt.Printf("Value: %v, Reason: %s, Variant: %s\n", boolResult.Value, boolResult.Reason, boolResult.Variant) } // String evaluation - stringCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ - "variableKey": "string_variable", - }) - stringValue, err := ofClient.StringValue(ctx, "flag", "default", stringCtx) + // Use for: flags with 1 string variable + fmt.Println("\n=== StringEvaluation ===") + stringResult, err := ofClient.StringValueDetails(ctx, flagKey, "default", evalCtx) if err != nil { - fmt.Printf("string evaluation error: %v\n", err) + fmt.Printf("Error: %v\n", err) } else { - fmt.Printf("string value: %s\n", stringValue) + fmt.Printf("Value: %s, Reason: %s, Variant: %s\n", stringResult.Value, stringResult.Reason, stringResult.Variant) } - // Int evaluation with custom variableKey - intCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ - "variableKey": "integer_variable", - }) - intValue, err := ofClient.IntValue(ctx, "flag", 10, intCtx) + // Int evaluation + // Use for: flags with 1 integer variable + fmt.Println("\n=== IntEvaluation ===") + intResult, err := ofClient.IntValueDetails(ctx, flagKey, 0, evalCtx) if err != nil { - fmt.Printf("int evaluation error: %v\n", err) + fmt.Printf("Error: %v\n", err) } else { - fmt.Printf("int value: %d\n", intValue) + fmt.Printf("Value: %d, Reason: %s, Variant: %s\n", intResult.Value, intResult.Reason, intResult.Variant) } - // Float evaluation with custom variableKey - floatCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ - "variableKey": "double_variable", - }) - floatValue, err := ofClient.FloatValue(ctx, "flag", 0.0, floatCtx) + // Float evaluation + // Use for: flags with 1 double variable + fmt.Println("\n=== FloatEvaluation ===") + floatResult, err := ofClient.FloatValueDetails(ctx, flagKey, 0.0, evalCtx) if err != nil { - fmt.Printf("float evaluation error: %v\n", err) + fmt.Printf("Error: %v\n", err) } else { - fmt.Printf("float value: %.2f\n", floatValue) + fmt.Printf("Value: %.2f, Reason: %s, Variant: %s\n", floatResult.Value, floatResult.Reason, floatResult.Variant) } - // Object evaluation with custom variableKey - objectCtx := openfeature.NewEvaluationContext(newConst, map[string]any{ - "variableKey": "json_variable", - }) - objectValue, err := ofClient.ObjectValue(ctx, "flag", map[string]any{}, objectCtx) + // Object evaluation + // Use for: flags with 1 variable (returns single value) OR flags with multiple variables (returns map) + fmt.Println("\n=== ObjectEvaluation ===") + objectResult, err := ofClient.ObjectValueDetails(ctx, flagKey, nil, evalCtx) if err != nil { - fmt.Printf("object evaluation error: %v\n", err) + fmt.Printf("Error: %v\n", err) } else { - fmt.Printf("object value: %v\n", objectValue) + fmt.Printf("Value: %v, Reason: %s, Variant: %s\n", objectResult.Value, objectResult.Reason, objectResult.Variant) } } diff --git a/providers/optimizely/go.mod b/providers/optimizely/go.mod index 3faedf07f..43234ec54 100644 --- a/providers/optimizely/go.mod +++ b/providers/optimizely/go.mod @@ -5,9 +5,11 @@ go 1.24.0 require ( github.com/open-feature/go-sdk v1.17.0 github.com/optimizely/go-sdk/v2 v2.2.1 + github.com/stretchr/testify v1.11.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect @@ -17,6 +19,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/twmb/murmur3 v1.1.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect @@ -24,4 +27,5 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/sync v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/providers/optimizely/go.sum b/providers/optimizely/go.sum index 7d4c137aa..6444d5fb7 100644 --- a/providers/optimizely/go.sum +++ b/providers/optimizely/go.sum @@ -6,11 +6,9 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -20,6 +18,10 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -33,37 +35,32 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= -github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= -go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= -go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= -go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/providers/optimizely/provider.go b/providers/optimizely/provider.go index 03d206d3c..fafb32410 100644 --- a/providers/optimizely/provider.go +++ b/providers/optimizely/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "github.com/open-feature/go-sdk/openfeature" optimizely "github.com/optimizely/go-sdk/v2/pkg/client" @@ -20,8 +19,19 @@ var _ openfeature.StateHandler = (*Provider)(nil) // ErrTargetingKeyMissing is returned when the targeting key is not provided in the evaluation context. var ErrTargetingKeyMissing = errors.New("targeting key is required") -// flagNotFoundReason is the string pattern used by Optimizely to indicate a flag was not found. -const flagNotFoundReason = "No flag was found" +// Error messages for evaluation method restrictions based on variable count. +const ( + errNoVariables = "flag has no variables; use BooleanEvaluation" + errMultipleVariables = "flag has multiple variables; use ObjectEvaluation" +) + +type evaluationResult struct { + enabled bool + variables map[string]any + variant string + detail openfeature.ProviderResolutionDetail + hasError bool +} type Provider struct { client *optimizely.OptimizelyClient @@ -39,12 +49,15 @@ func (p *Provider) Metadata() openfeature.Metadata { } } -func (p *Provider) evaluate(flagKey string, evalCtx openfeature.FlattenedContext) (any, openfeature.ProviderResolutionDetail) { +func (p *Provider) getDecision(flagKey string, evalCtx openfeature.FlattenedContext) evaluationResult { userID, ok := evalCtx[openfeature.TargetingKey].(string) if !ok { - return nil, openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTargetingKeyMissingResolutionError(ErrTargetingKeyMissing.Error()), - Reason: openfeature.Reason(openfeature.TargetingKeyMissingCode), + return evaluationResult{ + hasError: true, + detail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTargetingKeyMissingResolutionError(ErrTargetingKeyMissing.Error()), + Reason: openfeature.Reason(openfeature.TargetingKeyMissingCode), + }, } } @@ -57,88 +70,199 @@ func (p *Provider) evaluate(flagKey string, evalCtx openfeature.FlattenedContext userCtx := p.client.CreateUserContext(userID, attributes) decision := userCtx.Decide(flagKey, []decide.OptimizelyDecideOptions{}) + sdkNotReadyMsg := decide.GetDecideMessage(decide.SDKNotReady) + flagNotFoundMsg := decide.GetDecideMessage(decide.FlagKeyInvalid, flagKey) for _, reason := range decision.Reasons { - if strings.Contains(reason, flagNotFoundReason) { - return nil, openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewFlagNotFoundResolutionError(reason), - Reason: openfeature.ErrorReason, + if reason == sdkNotReadyMsg { + return evaluationResult{ + hasError: true, + detail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewProviderNotReadyResolutionError(reason), + Reason: openfeature.ErrorReason, + }, + } + } + if reason == flagNotFoundMsg { + return evaluationResult{ + hasError: true, + detail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewFlagNotFoundResolutionError(reason), + Reason: openfeature.ErrorReason, + }, } } } variables := decision.Variables.ToMap() - if !decision.Enabled || variables == nil { - return nil, openfeature.ProviderResolutionDetail{ - Reason: openfeature.DisabledReason, - } - } - variableKey := "value" - if key, ok := evalCtx["variableKey"].(string); ok && key != "" { - variableKey = key + return evaluationResult{ + enabled: decision.Enabled, + variables: variables, + variant: decision.VariationKey, } +} - val, exists := variables[variableKey] - if !exists { - return nil, openfeature.ProviderResolutionDetail{ - Reason: openfeature.DefaultReason, - } +func getSingleVariable(variables map[string]any) any { + for _, v := range variables { + return v } + return nil +} + +// requireSingleVariable checks that the result has exactly one variable, +// then attempts to cast the variable to type T. +// Returns the typed value, variant, and nil detail on success. +// Returns zero value and error detail if validation fails or type doesn't match. +func requireSingleVariable[T any](result evaluationResult) (T, string, *openfeature.ProviderResolutionDetail) { + var zero T + numVars := len(result.variables) - return val, openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, - Variant: decision.VariationKey, + if numVars == 0 { + detail := openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewGeneralResolutionError(errNoVariables), + Reason: openfeature.ErrorReason, + } + return zero, "", &detail } -} -func resolve[T any](p *Provider, flagKey string, defaultValue T, evalCtx openfeature.FlattenedContext) openfeature.GenericResolutionDetail[T] { - val, detail := p.evaluate(flagKey, evalCtx) - if val == nil { - return openfeature.GenericResolutionDetail[T]{ - Value: defaultValue, - ProviderResolutionDetail: detail, + if numVars > 1 { + detail := openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewGeneralResolutionError(errMultipleVariables), + Reason: openfeature.ErrorReason, } + return zero, "", &detail } - if converted, ok := val.(T); ok { - return openfeature.GenericResolutionDetail[T]{ - Value: converted, - ProviderResolutionDetail: detail, + val := getSingleVariable(result.variables) + typedVal, ok := val.(T) + if !ok { + detail := openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError(fmt.Sprintf("variable is not %T, got %T", zero, val)), + Reason: openfeature.ErrorReason, } + return zero, "", &detail } + return typedVal, result.variant, nil +} + +func resolutionSuccess[T any](value T, variant string) openfeature.GenericResolutionDetail[T] { return openfeature.GenericResolutionDetail[T]{ - Value: defaultValue, + Value: value, ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError(fmt.Sprintf("variable is not a %T", defaultValue)), - Reason: openfeature.ErrorReason, + Reason: openfeature.TargetingMatchReason, + Variant: variant, }, } } +func resolutionFromDetail[T any](value T, detail openfeature.ProviderResolutionDetail) openfeature.GenericResolutionDetail[T] { + return openfeature.GenericResolutionDetail[T]{ + Value: value, + ProviderResolutionDetail: detail, + } +} + func (p *Provider) BooleanEvaluation(ctx context.Context, flagKey string, defaultValue bool, evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { - return resolve(p, flagKey, defaultValue, evalCtx) + result := p.getDecision(flagKey, evalCtx) + if result.hasError { + return resolutionFromDetail(defaultValue, result.detail) + } + + if !result.enabled { + return resolutionFromDetail(defaultValue, openfeature.ProviderResolutionDetail{Reason: openfeature.DisabledReason}) + } + + // 0 variables: return decision.Enabled + if len(result.variables) == 0 { + return resolutionSuccess(result.enabled, result.variant) + } + + val, variant, errDetail := requireSingleVariable[bool](result) + if errDetail != nil { + return resolutionFromDetail(defaultValue, *errDetail) + } + + return resolutionSuccess(val, variant) } func (p *Provider) StringEvaluation(ctx context.Context, flagKey string, defaultValue string, evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { - return resolve(p, flagKey, defaultValue, evalCtx) + result := p.getDecision(flagKey, evalCtx) + if result.hasError { + return resolutionFromDetail(defaultValue, result.detail) + } + + if !result.enabled { + return resolutionFromDetail(defaultValue, openfeature.ProviderResolutionDetail{Reason: openfeature.DisabledReason}) + } + + val, variant, errDetail := requireSingleVariable[string](result) + if errDetail != nil { + return resolutionFromDetail(defaultValue, *errDetail) + } + + return resolutionSuccess(val, variant) } func (p *Provider) FloatEvaluation(ctx context.Context, flagKey string, defaultValue float64, evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { - return resolve(p, flagKey, defaultValue, evalCtx) + result := p.getDecision(flagKey, evalCtx) + if result.hasError { + return resolutionFromDetail(defaultValue, result.detail) + } + + if !result.enabled { + return resolutionFromDetail(defaultValue, openfeature.ProviderResolutionDetail{Reason: openfeature.DisabledReason}) + } + + val, variant, errDetail := requireSingleVariable[float64](result) + if errDetail != nil { + return resolutionFromDetail(defaultValue, *errDetail) + } + + return resolutionSuccess(val, variant) } func (p *Provider) IntEvaluation(ctx context.Context, flagKey string, defaultValue int64, evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { - res := resolve(p, flagKey, int(defaultValue), evalCtx) - return openfeature.IntResolutionDetail{ - Value: int64(res.Value), - ProviderResolutionDetail: res.ProviderResolutionDetail, + result := p.getDecision(flagKey, evalCtx) + if result.hasError { + return resolutionFromDetail(defaultValue, result.detail) + } + + if !result.enabled { + return resolutionFromDetail(defaultValue, openfeature.ProviderResolutionDetail{Reason: openfeature.DisabledReason}) } + + val, variant, errDetail := requireSingleVariable[int](result) + if errDetail != nil { + return resolutionFromDetail(defaultValue, *errDetail) + } + + return resolutionSuccess(int64(val), variant) } func (p *Provider) ObjectEvaluation(ctx context.Context, flagKey string, defaultValue any, evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { - return resolve(p, flagKey, defaultValue, evalCtx) + result := p.getDecision(flagKey, evalCtx) + if result.hasError { + return resolutionFromDetail(defaultValue, result.detail) + } + + if !result.enabled { + return resolutionFromDetail(defaultValue, openfeature.ProviderResolutionDetail{Reason: openfeature.DisabledReason}) + } + + // Multiple variables: return the full map + if len(result.variables) > 1 { + return resolutionSuccess[any](result.variables, result.variant) + } + + // 0 or 1 variables + val, variant, errDetail := requireSingleVariable[any](result) + if errDetail != nil { + return resolutionFromDetail(defaultValue, *errDetail) + } + + return resolutionSuccess(val, variant) } func (p *Provider) Hooks() []openfeature.Hook { diff --git a/providers/optimizely/provider_test.go b/providers/optimizely/provider_test.go index 2e9201691..9e146187a 100644 --- a/providers/optimizely/provider_test.go +++ b/providers/optimizely/provider_test.go @@ -1,364 +1,415 @@ package optimizely import ( + "encoding/json" "testing" "github.com/open-feature/go-sdk/openfeature" "github.com/optimizely/go-sdk/v2/pkg/client" + "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities" + coreEntities "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -const testDatafile = ` -{ - "version": "4", - "rollouts": [ - { - "id": "rollout_1", - "experiments": [ - { - "id": "exp_1", - "key": "exp_1", - "status": "Running", - "layerId": "layer_1", - "audienceIds": [], - "variations": [ - { - "id": "var_1", - "key": "on", - "featureEnabled": true, - "variables": [ - {"id": "var_string", "value": "test_value"}, - {"id": "var_int", "value": "42"}, - {"id": "var_float", "value": "3.14"}, - {"id": "var_json", "value": "{\"nested\": \"object\"}"}, - {"id": "var_bool", "value": "true"} - ] - } - ], - "trafficAllocation": [ - {"entityId": "var_1", "endOfRange": 10000} - ], - "forcedVariations": {} - } - ] - } - ], - "experiments": [], - "featureFlags": [ - { - "id": "flag_1", - "key": "test_flag", - "rolloutId": "rollout_1", - "experimentIds": [], - "variables": [ - {"id": "var_string", "key": "value", "defaultValue": "default", "type": "string"}, - {"id": "var_int", "key": "int_value", "defaultValue": "0", "type": "integer"}, - {"id": "var_float", "key": "float_value", "defaultValue": "0.0", "type": "double"}, - {"id": "var_json", "key": "json_value", "defaultValue": "{}", "type": "json"}, - {"id": "var_bool", "key": "bool_value", "defaultValue": "false", "type": "boolean"} - ] - }, - { - "id": "flag_2", - "key": "disabled_flag", - "rolloutId": "", - "experimentIds": [], - "variables": [] - } - ], - "events": [], - "audiences": [], - "attributes": [], - "groups": [], - "projectId": "12345", - "accountId": "67890", - "anonymizeIP": true, - "botFiltering": false +// Helper to build a datafile with common defaults +func buildDatafile(flags []entities.FeatureFlag, rollouts []entities.Rollout) entities.Datafile { + return entities.Datafile{ + Version: "4", + ProjectID: "12345", + AccountID: "67890", + AnonymizeIP: true, + BotFiltering: false, + FeatureFlags: flags, + Rollouts: rollouts, + Experiments: []entities.Experiment{}, + Events: []entities.Event{}, + Audiences: []entities.Audience{}, + Attributes: []entities.Attribute{}, + Groups: []entities.Group{}, + } } -` -func TestProvider_Metadata(t *testing.T) { - p := NewProvider(nil) - if p.Metadata().Name != "Optimizely" { - t.Errorf("expected metadata name 'Optimizely', got %s", p.Metadata().Name) +// Helper to build a simple rollout with one variation +func buildRollout(id string, variables []entities.VariationVariable) entities.Rollout { + return entities.Rollout{ + ID: "rollout_" + id, + Experiments: []entities.Experiment{ + { + ID: "exp_" + id, + Key: "exp_" + id, + Status: "Running", + LayerID: "layer_" + id, + AudienceIds: []string{}, + Variations: []entities.Variation{ + { + ID: "var_1", + Key: "on", + FeatureEnabled: true, + Variables: variables, + }, + }, + TrafficAllocation: []entities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + ForcedVariations: map[string]string{}, + }, + }, } } -func TestProvider_BooleanEvaluation(t *testing.T) { +// Helper to create a client from a datafile struct +func createClient(t *testing.T, datafile entities.Datafile) *client.OptimizelyClient { + jsonData, err := json.Marshal(datafile) + require.NoError(t, err, "failed to marshal datafile") + optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), + Datafile: jsonData, }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } + require.NoError(t, err, "failed to create optimizely client") - p := NewProvider(optimizelyClient) - flattenedCtx := map[string]any{ - openfeature.TargetingKey: "user-1", - "variableKey": "bool_value", - } + return optimizelyClient +} - // Test successful evaluation - res := p.BooleanEvaluation(t.Context(), "test_flag", false, flattenedCtx) - if res.ResolutionError != (openfeature.ResolutionError{}) { - t.Errorf("expected no resolution error, got %v", res.ResolutionError) - } - if res.Value != true { - t.Errorf("expected true, got %v", res.Value) - } +// Test datafiles built from structs +var ( + datafileNoVars = buildDatafile( + []entities.FeatureFlag{ + { + ID: "flag_no_vars", + Key: "flag_no_vars", + RolloutID: "rollout_no_vars", + ExperimentIDs: []string{}, + Variables: []entities.Variable{}, + }, + }, + []entities.Rollout{ + buildRollout("no_vars", []entities.VariationVariable{}), + }, + ) + + datafileSingleString = buildDatafile( + []entities.FeatureFlag{ + { + ID: "flag_single_string", + Key: "flag_single_string", + RolloutID: "rollout_single_string", + ExperimentIDs: []string{}, + Variables: []entities.Variable{ + {ID: "var_string", Key: "my_string", DefaultValue: "default", Type: coreEntities.String}, + }, + }, + }, + []entities.Rollout{ + buildRollout("single_string", []entities.VariationVariable{ + {ID: "var_string", Value: "hello_world"}, + }), + }, + ) + + datafileSingleInt = buildDatafile( + []entities.FeatureFlag{ + { + ID: "flag_single_int", + Key: "flag_single_int", + RolloutID: "rollout_single_int", + ExperimentIDs: []string{}, + Variables: []entities.Variable{ + {ID: "var_int", Key: "my_int", DefaultValue: "0", Type: coreEntities.Integer}, + }, + }, + }, + []entities.Rollout{ + buildRollout("single_int", []entities.VariationVariable{ + {ID: "var_int", Value: "42"}, + }), + }, + ) + + datafileSingleFloat = buildDatafile( + []entities.FeatureFlag{ + { + ID: "flag_single_float", + Key: "flag_single_float", + RolloutID: "rollout_single_float", + ExperimentIDs: []string{}, + Variables: []entities.Variable{ + {ID: "var_float", Key: "my_float", DefaultValue: "0.0", Type: coreEntities.Double}, + }, + }, + }, + []entities.Rollout{ + buildRollout("single_float", []entities.VariationVariable{ + {ID: "var_float", Value: "3.14"}, + }), + }, + ) + + datafileSingleBool = buildDatafile( + []entities.FeatureFlag{ + { + ID: "flag_single_bool", + Key: "flag_single_bool", + RolloutID: "rollout_single_bool", + ExperimentIDs: []string{}, + Variables: []entities.Variable{ + {ID: "var_bool", Key: "my_bool", DefaultValue: "false", Type: coreEntities.Boolean}, + }, + }, + }, + []entities.Rollout{ + buildRollout("single_bool", []entities.VariationVariable{ + {ID: "var_bool", Value: "true"}, + }), + }, + ) + + datafileMultipleVars = buildDatafile( + []entities.FeatureFlag{ + { + ID: "flag_multi", + Key: "flag_multi", + RolloutID: "rollout_multi", + ExperimentIDs: []string{}, + Variables: []entities.Variable{ + {ID: "var_string", Key: "string_val", DefaultValue: "default", Type: coreEntities.String}, + {ID: "var_int", Key: "int_val", DefaultValue: "0", Type: coreEntities.Integer}, + }, + }, + }, + []entities.Rollout{ + buildRollout("multi", []entities.VariationVariable{ + {ID: "var_string", Value: "test_value"}, + {ID: "var_int", Value: "42"}, + }), + }, + ) + + datafileDisabled = buildDatafile( + []entities.FeatureFlag{ + { + ID: "flag_disabled", + Key: "disabled_flag", + RolloutID: "", + ExperimentIDs: []string{}, + Variables: []entities.Variable{}, + }, + }, + []entities.Rollout{}, + ) +) + +func TestProvider_BooleanEvaluation_NoVars(t *testing.T) { + p := NewProvider(createClient(t, datafileNoVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + // Test successful evaluation - returns decision.Enabled (true) + res := p.BooleanEvaluation(t.Context(), "flag_no_vars", false, ctx) + assert.Empty(t, res.ResolutionError) + assert.True(t, res.Value) + assert.Equal(t, openfeature.TargetingMatchReason, res.Reason) // Test missing targeting key - resMissingKey := p.BooleanEvaluation(t.Context(), "test_flag", false, map[string]any{ - "variableKey": "bool_value", - }) - if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected targeting key missing error, got nil") - } + resMissingKey := p.BooleanEvaluation(t.Context(), "flag_no_vars", false, nil) + assert.NotEmpty(t, resMissingKey.ResolutionError) // Test flag not found - resNotFound := p.BooleanEvaluation(t.Context(), "nonexistent_flag", false, flattenedCtx) - if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected flag not found error, got nil") - } - if resNotFound.Value != false { - t.Errorf("expected default value false, got %v", resNotFound.Value) - } + resNotFound := p.BooleanEvaluation(t.Context(), "nonexistent_flag", false, ctx) + assert.NotEmpty(t, resNotFound.ResolutionError) + assert.False(t, resNotFound.Value) } -func TestProvider_StringEvaluation(t *testing.T) { - optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), - }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } +func TestProvider_BooleanEvaluation_SingleBoolVar(t *testing.T) { + p := NewProvider(createClient(t, datafileSingleBool)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - p := NewProvider(optimizelyClient) - flattenedCtx := map[string]any{ - openfeature.TargetingKey: "user-1", - } + res := p.BooleanEvaluation(t.Context(), "flag_single_bool", false, ctx) + assert.Empty(t, res.ResolutionError) + assert.True(t, res.Value) +} - // Test successful evaluation with default variableKey - res := p.StringEvaluation(t.Context(), "test_flag", "default", flattenedCtx) - if res.ResolutionError != (openfeature.ResolutionError{}) { - t.Errorf("expected no resolution error, got %v", res.ResolutionError) - } - if res.Value != "test_value" { - t.Errorf("expected 'test_value', got %s", res.Value) - } +func TestProvider_BooleanEvaluation_MultipleVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileMultipleVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + res := p.BooleanEvaluation(t.Context(), "flag_multi", false, ctx) + assert.NotEmpty(t, res.ResolutionError) + assert.False(t, res.Value) +} + +func TestProvider_BooleanEvaluation_TypeMismatch(t *testing.T) { + p := NewProvider(createClient(t, datafileSingleString)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + res := p.BooleanEvaluation(t.Context(), "flag_single_string", false, ctx) + assert.NotEmpty(t, res.ResolutionError) +} + +func TestProvider_StringEvaluation_SingleStringVar(t *testing.T) { + p := NewProvider(createClient(t, datafileSingleString)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + res := p.StringEvaluation(t.Context(), "flag_single_string", "default", ctx) + assert.Empty(t, res.ResolutionError) + assert.Equal(t, "hello_world", res.Value) // Test missing targeting key - resMissingKey := p.StringEvaluation(t.Context(), "test_flag", "", map[string]any{}) - if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected targeting key missing error, got nil") - } + resMissingKey := p.StringEvaluation(t.Context(), "flag_single_string", "", map[string]any{}) + assert.NotEmpty(t, resMissingKey.ResolutionError) // Test flag not found - resNotFound := p.StringEvaluation(t.Context(), "nonexistent_flag", "default", flattenedCtx) - if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected flag not found error, got nil") - } + resNotFound := p.StringEvaluation(t.Context(), "nonexistent_flag", "default", ctx) + assert.NotEmpty(t, resNotFound.ResolutionError) } -func TestProvider_IntEvaluation(t *testing.T) { - optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), - }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } +func TestProvider_StringEvaluation_NoVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileNoVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - p := NewProvider(optimizelyClient) + res := p.StringEvaluation(t.Context(), "flag_no_vars", "default", ctx) + assert.NotEmpty(t, res.ResolutionError) +} - // Test with custom variableKey - flattenedCtx := map[string]any{ - openfeature.TargetingKey: "user-1", - "variableKey": "int_value", - } +func TestProvider_StringEvaluation_MultipleVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileMultipleVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - res := p.IntEvaluation(t.Context(), "test_flag", 0, flattenedCtx) - if res.ResolutionError != (openfeature.ResolutionError{}) { - t.Errorf("expected no resolution error, got %v", res.ResolutionError) - } - if res.Value != 42 { - t.Errorf("expected 42, got %d", res.Value) - } + res := p.StringEvaluation(t.Context(), "flag_multi", "default", ctx) + assert.NotEmpty(t, res.ResolutionError) +} + +func TestProvider_IntEvaluation_SingleIntVar(t *testing.T) { + p := NewProvider(createClient(t, datafileSingleInt)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + res := p.IntEvaluation(t.Context(), "flag_single_int", 0, ctx) + assert.Empty(t, res.ResolutionError) + assert.Equal(t, int64(42), res.Value) // Test missing targeting key - resMissingKey := p.IntEvaluation(t.Context(), "test_flag", 0, map[string]any{}) - if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected targeting key missing error, got nil") - } + resMissingKey := p.IntEvaluation(t.Context(), "flag_single_int", 0, map[string]any{}) + assert.NotEmpty(t, resMissingKey.ResolutionError) // Test flag not found resNotFound := p.IntEvaluation(t.Context(), "nonexistent_flag", 99, map[string]any{ openfeature.TargetingKey: "user-1", }) - if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected flag not found error, got nil") - } - if resNotFound.Value != 99 { - t.Errorf("expected default value 99, got %d", resNotFound.Value) - } + assert.NotEmpty(t, resNotFound.ResolutionError) + assert.Equal(t, int64(99), resNotFound.Value) } -func TestProvider_FloatEvaluation(t *testing.T) { - optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), - }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } +func TestProvider_IntEvaluation_NoVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileNoVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - p := NewProvider(optimizelyClient) + res := p.IntEvaluation(t.Context(), "flag_no_vars", 0, ctx) + assert.NotEmpty(t, res.ResolutionError) +} - // Test with custom variableKey - flattenedCtx := map[string]any{ - openfeature.TargetingKey: "user-1", - "variableKey": "float_value", - } +func TestProvider_IntEvaluation_MultipleVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileMultipleVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - res := p.FloatEvaluation(t.Context(), "test_flag", 0.0, flattenedCtx) - if res.ResolutionError != (openfeature.ResolutionError{}) { - t.Errorf("expected no resolution error, got %v", res.ResolutionError) - } - if res.Value != 3.14 { - t.Errorf("expected 3.14, got %f", res.Value) - } + res := p.IntEvaluation(t.Context(), "flag_multi", 0, ctx) + assert.NotEmpty(t, res.ResolutionError) +} + +func TestProvider_FloatEvaluation_SingleFloatVar(t *testing.T) { + p := NewProvider(createClient(t, datafileSingleFloat)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + res := p.FloatEvaluation(t.Context(), "flag_single_float", 0.0, ctx) + assert.Empty(t, res.ResolutionError) + assert.Equal(t, 3.14, res.Value) // Test missing targeting key - resMissingKey := p.FloatEvaluation(t.Context(), "test_flag", 0.0, map[string]any{}) - if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected targeting key missing error, got nil") - } + resMissingKey := p.FloatEvaluation(t.Context(), "flag_single_float", 0.0, map[string]any{}) + assert.NotEmpty(t, resMissingKey.ResolutionError) // Test flag not found resNotFound := p.FloatEvaluation(t.Context(), "nonexistent_flag", 1.5, map[string]any{ openfeature.TargetingKey: "user-1", }) - if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected flag not found error, got nil") - } - if resNotFound.Value != 1.5 { - t.Errorf("expected default value 1.5, got %f", resNotFound.Value) - } + assert.NotEmpty(t, resNotFound.ResolutionError) + assert.Equal(t, 1.5, resNotFound.Value) } -func TestProvider_ObjectEvaluation(t *testing.T) { - optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), - }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } +func TestProvider_FloatEvaluation_NoVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileNoVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - p := NewProvider(optimizelyClient) + res := p.FloatEvaluation(t.Context(), "flag_no_vars", 0.0, ctx) + assert.NotEmpty(t, res.ResolutionError) +} - // Test with default variableKey - flattenedCtx := map[string]any{ - openfeature.TargetingKey: "user-1", - } +func TestProvider_FloatEvaluation_MultipleVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileMultipleVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - res := p.ObjectEvaluation(t.Context(), "test_flag", nil, flattenedCtx) - if res.ResolutionError != (openfeature.ResolutionError{}) { - t.Errorf("expected no resolution error, got %v", res.ResolutionError) - } - if res.Value != "test_value" { - t.Errorf("expected 'test_value', got %v", res.Value) - } + res := p.FloatEvaluation(t.Context(), "flag_multi", 0.0, ctx) + assert.NotEmpty(t, res.ResolutionError) +} + +func TestProvider_ObjectEvaluation_SingleVar(t *testing.T) { + p := NewProvider(createClient(t, datafileSingleString)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + res := p.ObjectEvaluation(t.Context(), "flag_single_string", nil, ctx) + assert.Empty(t, res.ResolutionError) + assert.Equal(t, "hello_world", res.Value) +} + +func TestProvider_ObjectEvaluation_MultipleVars(t *testing.T) { + p := NewProvider(createClient(t, datafileMultipleVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} + + res := p.ObjectEvaluation(t.Context(), "flag_multi", nil, ctx) + assert.Empty(t, res.ResolutionError) + + resultMap, ok := res.Value.(map[string]any) + require.True(t, ok, "expected map[string]any, got %T", res.Value) + assert.Equal(t, "test_value", resultMap["string_val"]) + assert.Equal(t, 42, resultMap["int_val"]) // Test missing targeting key - resMissingKey := p.ObjectEvaluation(t.Context(), "test_flag", nil, map[string]any{}) - if resMissingKey.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected targeting key missing error, got nil") - } + resMissingKey := p.ObjectEvaluation(t.Context(), "flag_multi", nil, map[string]any{}) + assert.NotEmpty(t, resMissingKey.ResolutionError) // Test flag not found defaultVal := map[string]any{"default": true} resNotFound := p.ObjectEvaluation(t.Context(), "nonexistent_flag", defaultVal, map[string]any{ openfeature.TargetingKey: "user-1", }) - if resNotFound.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected flag not found error, got nil") - } + assert.NotEmpty(t, resNotFound.ResolutionError) } -func TestProvider_TypeMismatch(t *testing.T) { - optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), - }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } - - p := NewProvider(optimizelyClient) - flattenedCtx := map[string]any{ - openfeature.TargetingKey: "user-1", - } +func TestProvider_ObjectEvaluation_NoVars_Error(t *testing.T) { + p := NewProvider(createClient(t, datafileNoVars)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - // Try to get string variable as int (should fail with type mismatch) - res := p.IntEvaluation(t.Context(), "test_flag", 0, flattenedCtx) - if res.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected type mismatch error when getting string as int") - } - - // Try to get string variable as float (should fail with type mismatch) - resFloat := p.FloatEvaluation(t.Context(), "test_flag", 0.0, flattenedCtx) - if resFloat.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected type mismatch error when getting string as float") - } + res := p.ObjectEvaluation(t.Context(), "flag_no_vars", nil, ctx) + assert.NotEmpty(t, res.ResolutionError) } -func TestProvider_CustomVariableKey(t *testing.T) { - optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), - }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } - - p := NewProvider(optimizelyClient) - - // Test custom variableKey for string - flattenedCtx := map[string]any{ - openfeature.TargetingKey: "user-1", - "variableKey": "int_value", - } +func TestProvider_TypeMismatch(t *testing.T) { + p := NewProvider(createClient(t, datafileSingleString)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - // Get int_value as string should fail (type mismatch) - res := p.StringEvaluation(t.Context(), "test_flag", "default", flattenedCtx) - if res.ResolutionError == (openfeature.ResolutionError{}) { - t.Errorf("expected type mismatch error when getting int as string") - } -} + // Try to get string variable as int + resInt := p.IntEvaluation(t.Context(), "flag_single_string", 0, ctx) + assert.NotEmpty(t, resInt.ResolutionError) -func TestProvider_Hooks(t *testing.T) { - p := NewProvider(nil) - hooks := p.Hooks() - if len(hooks) != 0 { - t.Errorf("expected empty hooks slice, got %d hooks", len(hooks)) - } + // Try to get string variable as float + resFloat := p.FloatEvaluation(t.Context(), "flag_single_string", 0.0, ctx) + assert.NotEmpty(t, resFloat.ResolutionError) } -func TestProvider_StateHandler(t *testing.T) { - optimizelyClient, err := (&client.OptimizelyFactory{ - Datafile: []byte(testDatafile), - }).Client() - if err != nil { - t.Fatalf("failed to create optimizely client: %v", err) - } - - p := NewProvider(optimizelyClient) - - // Test Init - if err := p.Init(openfeature.EvaluationContext{}); err != nil { - t.Errorf("expected Init to return nil, got %v", err) - } - - // Test Status - if p.Status() != openfeature.ReadyState { - t.Errorf("expected ReadyState, got %v", p.Status()) - } +func TestProvider_DisabledFlag(t *testing.T) { + p := NewProvider(createClient(t, datafileDisabled)) + ctx := map[string]any{openfeature.TargetingKey: "user-1"} - // Test Shutdown - p.Shutdown() + res := p.BooleanEvaluation(t.Context(), "disabled_flag", true, ctx) + assert.Empty(t, res.ResolutionError) + assert.True(t, res.Value) + assert.Equal(t, openfeature.DisabledReason, res.Reason) }