From 433708a8d12dbc5db1b633ee14bad6e3e2b143e5 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Mon, 31 Aug 2026 22:08:23 +0200 Subject: [PATCH] feat: OpenFeature provider for Rust --- .github/workflows/checks.yml | 31 +- .github/workflows/publish.yml | 60 ++- Cargo.lock | 211 +++++++++- Cargo.toml | 8 +- Makefile | 37 +- README.md | 111 +++++- openfeature/Cargo.toml | 23 ++ openfeature/LICENSE | 21 + openfeature/README.md | 73 ++++ openfeature/src/lib.rs | 702 ++++++++++++++++++++++++++++++++++ openfeature/tests/provider.rs | 461 ++++++++++++++++++++++ 11 files changed, 1716 insertions(+), 22 deletions(-) create mode 100644 openfeature/Cargo.toml create mode 100644 openfeature/LICENSE create mode 100644 openfeature/README.md create mode 100644 openfeature/src/lib.rs create mode 100644 openfeature/tests/provider.rs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4792816..d0f93e5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -28,14 +28,39 @@ jobs: with: toolchain: ${{ matrix.rust }} - name: Build - run: cargo build --all-features + run: cargo build -p featurevisor --all-features - name: Test - run: cargo test --all-features + run: cargo test -p featurevisor --all-features - name: Format and lint if: matrix.rust == 'stable' run: | cargo fmt --all -- --check - cargo clippy --all-features --all-targets -- -D warnings + cargo clippy -p featurevisor --all-features --all-targets -- -D warnings + + openfeature: + name: OpenFeature (Rust ${{ matrix.rust }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + rust: ["1.80.1", stable] + + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - name: Build and test provider + run: | + cargo build -p featurevisor-openfeature + cargo test -p featurevisor-openfeature + - name: Format, lint, and document + if: matrix.rust == 'stable' + run: | + cargo fmt --all -- --check + cargo clippy -p featurevisor-openfeature --all-targets -- -D warnings + cargo doc -p featurevisor-openfeature --no-deps example: name: Featurevisor example 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5425e2e..d4cc9df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,20 +31,66 @@ jobs: - name: Verify tag matches crate version shell: bash run: | - crate_version=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])') + crate_version=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "featurevisor"))') + provider_version=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "featurevisor-openfeature"))') test "${GITHUB_REF_NAME#v}" = "$crate_version" + test "$crate_version" = "$provider_version" - name: Check run: | cargo fmt --all -- --check - cargo clippy --all-features --all-targets -- -D warnings - cargo test --all-features + cargo clippy --workspace --all-features --all-targets -- -D warnings + cargo test -p featurevisor --all-features + cargo test -p featurevisor-openfeature + cargo doc -p featurevisor-openfeature --no-deps - name: Run example project through Rust SDK run: cargo run --features cli --bin featurevisor -- test --projectDirectoryPath=./example-1 --onlyFailures - name: Verify package contents run: | - cargo package --all-features - cargo package --list --all-features - - name: Publish - run: cargo publish --all-features + cargo package -p featurevisor --all-features + cargo package -p featurevisor --list --all-features + - name: Publish base crate + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + if curl --fail --silent --show-error --user-agent featurevisor-release-workflow "https://crates.io/api/v1/crates/featurevisor/$version" >/dev/null; then + echo "featurevisor $version is already published" + else + cargo publish -p featurevisor --all-features + fi + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + - name: Wait for base crate + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + for attempt in {1..18}; do + if curl --fail --silent --show-error --user-agent featurevisor-release-workflow "https://crates.io/api/v1/crates/featurevisor/$version" >/dev/null; then + exit 0 + fi + sleep 10 + done + echo "featurevisor $version did not become available on crates.io" >&2 + exit 1 + - name: Package and publish OpenFeature provider crate + shell: bash + run: | + # Cargo can only package a crate after exact registry dependencies + # exist. The base crate is therefore published and observed first. + packaged=false + for attempt in {1..18}; do + if cargo package -p featurevisor-openfeature; then + packaged=true + break + fi + sleep 10 + done + test "$packaged" = true + cargo package -p featurevisor-openfeature --list + version="${GITHUB_REF_NAME#v}" + if curl --fail --silent --show-error --user-agent featurevisor-release-workflow "https://crates.io/api/v1/crates/featurevisor-openfeature/$version" >/dev/null; then + echo "featurevisor-openfeature $version is already published" + else + cargo publish -p featurevisor-openfeature + fi env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index c610c0c..c355358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -134,9 +145,24 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "featurevisor" -version = "1.1.0" +version = "1.2.0" dependencies = [ "chrono", "clap", @@ -146,6 +172,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "featurevisor-openfeature" +version = "1.2.0" +dependencies = [ + "featurevisor", + "open-feature", + "serde_json", + "time", + "tokio", +] + +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -217,12 +263,50 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -244,12 +328,59 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open-feature" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc898b62e6a4d3fef7b4ed80d862a80ed3113fb0fa756b43b67a879c93181434" +dependencies = [ + "async-trait", + "log", + "mockall", + "serde_json", + "time", + "tokio", + "typed-builder", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -386,6 +517,84 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "typed-builder" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index d7522b0..a73900b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "featurevisor" -version = "1.1.0" +version = "1.2.0" edition = "2021" rust-version = "1.74.0" description = "Featurevisor SDK for Rust: feature flags, experiments, and remote configuration" @@ -12,7 +12,11 @@ repository = "https://github.com/featurevisor/featurevisor-rust" authors = ["Fahad Heylaal"] keywords = ["feature-flags", "featurevisor", "experimentation", "ab-testing", "remote-config"] categories = ["config", "development-tools"] -exclude = ["/.github", "/Makefile", "/monorepo", "/example-1", "/featurevisor"] +exclude = ["/.github", "/Makefile", "/monorepo", "/example-1", "/featurevisor", "/openfeature"] + +[workspace] +members = ["openfeature"] +resolver = "2" [features] default = [] diff --git a/Makefile b/Makefile index e3af6c2..39a41a8 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,49 @@ FEATUREVISOR_PROJECT ?= ../featurevisor/examples/example-1 FEATUREVISOR_REPO ?= https://github.com/featurevisor/featurevisor.git -.PHONY: build test test-cli fmt lint check test-example-1 setup-monorepo update-monorepo +.PHONY: build test test-cli test-openfeature fmt lint check check-base check-openfeature package package-openfeature test-example-1 setup-monorepo update-monorepo build: - cargo build --all-features + cargo build -p featurevisor --all-features + cargo build -p featurevisor-openfeature test: - cargo test --all-features + cargo test -p featurevisor --all-features + cargo test -p featurevisor-openfeature test-cli: - cargo test --all-features --test cli + cargo test -p featurevisor --all-features --test cli + +test-openfeature: + cargo test -p featurevisor-openfeature fmt: cargo fmt --all -- --check lint: - cargo clippy --all-features --all-targets -- -D warnings + cargo clippy --workspace --all-features --all-targets -- -D warnings + +check-base: + cargo build -p featurevisor --all-features + cargo test -p featurevisor --all-features + cargo clippy -p featurevisor --all-features --all-targets -- -D warnings + +check-openfeature: + cargo build -p featurevisor-openfeature + cargo test -p featurevisor-openfeature + cargo clippy -p featurevisor-openfeature --all-targets -- -D warnings + cargo doc -p featurevisor-openfeature --no-deps + +check: fmt check-base check-openfeature + +package: + cargo package -p featurevisor + cargo package -p featurevisor --list -check: fmt lint test +package-openfeature: + # Requires the matching featurevisor version to exist on crates.io. + cargo package -p featurevisor-openfeature --no-verify + cargo package -p featurevisor-openfeature --list --no-verify test-example-1: $(MAKE) test diff --git a/README.md b/README.md index 9369568..44a3bf1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The SDK supports Featurevisor v3 projects and schema version 2 datafiles. The li - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -423,6 +424,98 @@ f.close(); Close is idempotent. It closes modules, clears diagnostic subscriptions, clears event listeners, and makes later state changes no ops. +## OpenFeature + +The OpenFeature provider is published as a separate crate. Applications that +only use the Featurevisor SDK do not compile or link OpenFeature, Tokio, or the +provider code. + +The official OpenFeature Rust SDK currently requires Rust 1.80.1 or newer. The +base Featurevisor crate continues to support Rust 1.74 or newer. + +```toml +[dependencies] +featurevisor = "1.2" +featurevisor-openfeature = "1.2" +open-feature = "0.3" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +Create a provider that owns its Featurevisor instance: + +```rust +use featurevisor::{DatafileInput, FeaturevisorOptions}; +use featurevisor_openfeature::{FeaturevisorProvider, FeaturevisorProviderOptions}; +use open_feature::{EvaluationContext, OpenFeature}; + +async fn configure(datafile: String) -> Result<(), Box> { + let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile)), + ..Default::default() + }, + ..Default::default() + })?; + + let mut api = OpenFeature::singleton_mut().await; + api.set_provider(provider).await; + let client = api.create_client(); + drop(api); + + let enabled = client + .get_bool_value( + "checkout", + Some(&EvaluationContext::default().with_targeting_key("user-123")), + None, + ) + .await?; + + println!("Checkout enabled: {enabled}"); + Ok(()) +} +``` + +You can also pass an existing Featurevisor instance. The provider borrows it +and does not close it: + +```rust +use featurevisor_openfeature::FeaturevisorProvider; + +let provider = FeaturevisorProvider::from_featurevisor(f.clone())?; +``` + +OpenFeature uses one flag key while Featurevisor supports flags, variations, +feature variables, and global variables: + +| OpenFeature key | Featurevisor evaluation | +| --- | --- | +| `checkout` | Flag for feature `checkout` | +| `checkout:variation` | Variation for feature `checkout` | +| `checkout:title` | Variable `title` inside feature `checkout` | +| `variable:supportEmail` | Global variable `supportEmail` | + +`targeting_key_field`, `key_separator`, `variation_key`, and +`global_variable_prefix` customize this mapping. The targeting key maps to +`userId` by default. The global variable prefix defaults to `variable` and +cannot contain the separator. + +The provider implements boolean, integer, float, string, and structure +resolution. The OpenFeature Rust SDK represents top level object values with +`StructValue`. Arrays can be nested in those objects, but its provider contract +does not expose a separate top level array resolver. + +Featurevisor evaluation reasons, variation values, revision, schema version, +rule keys, bucket information, and override information are mapped to +OpenFeature resolution details and flag metadata. Missing definitions, type +mismatches, invalid contexts, and invalid datafiles use standard OpenFeature +errors. Replacing an invalid datafile with a valid one recovers the provider. + +The OpenFeature Rust SDK does not currently expose provider tracking or +provider event callbacks. Featurevisor modules and diagnostics continue to +work inside the Featurevisor instance. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for the shared key convention and providers for other languages. + ## CLI usage The optional CLI delegates project discovery and datafile generation to the Node.js Featurevisor CLI, then evaluates through this Rust SDK. Install Rust and use the `cli` feature to build it: @@ -468,16 +561,28 @@ The legacy `--with-scopes`, `--with-tags`, `--schemaVersion`, and `--schema-vers ## Development of this package -Install Rust 1.74 or newer, then run: +The base SDK supports Rust 1.74 or newer. The complete workspace requires Rust +1.80.1 or newer because it includes the OpenFeature provider. With a current +stable toolchain, run: ```bash make check make test-example-1 ``` -The package uses `cargo fmt`, `cargo clippy`, and `cargo test`. `Cargo.lock` is committed so library and CLI dependency resolution stays reproducible. +The package uses `cargo fmt`, `cargo clippy`, and `cargo test`. `Cargo.lock` is committed so library, CLI, and provider dependency resolution stays reproducible. + +The repository publishes two crates with the same version: + +- `featurevisor` +- `featurevisor-openfeature` + +To release, update both versions, run the checks, merge the change, and tag the matching version. The release workflow publishes the base crate first and the provider second so the provider's exact Featurevisor dependency is available on crates.io. -To release, update the version in `Cargo.toml`, run the checks, merge the change, and tag the matching version. Publishing to crates.io is performed by `cargo publish` or the release workflow. +Cargo can only package the provider after that exact base crate version is +visible on crates.io. Pull request checks therefore build, test, lint, and +document the provider. The tagged release performs the final provider package +inspection after publishing the base crate. ## License diff --git a/openfeature/Cargo.toml b/openfeature/Cargo.toml new file mode 100644 index 0000000..995fcb3 --- /dev/null +++ b/openfeature/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "featurevisor-openfeature" +version = "1.2.0" +edition = "2021" +rust-version = "1.80.1" +description = "OpenFeature provider for the Featurevisor Rust SDK" +license = "MIT" +readme = "README.md" +homepage = "https://featurevisor.com" +documentation = "https://featurevisor.com/docs/sdks/rust/#openfeature" +repository = "https://github.com/featurevisor/featurevisor-rust" +authors = ["Fahad Heylaal"] +keywords = ["feature-flags", "featurevisor", "openfeature", "experimentation"] +categories = ["config", "development-tools"] + +[dependencies] +featurevisor = { path = "..", version = "=1.2.0" } +open-feature = { version = "0.3.0", features = ["serde_json"] } +serde_json = "1" +time = { version = "=0.3.36", features = ["formatting"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/openfeature/LICENSE b/openfeature/LICENSE new file mode 100644 index 0000000..601d00f --- /dev/null +++ b/openfeature/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fahad Heylaal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openfeature/README.md b/openfeature/README.md new file mode 100644 index 0000000..b80222c --- /dev/null +++ b/openfeature/README.md @@ -0,0 +1,73 @@ +# Featurevisor OpenFeature provider for Rust + +This crate adapts the Featurevisor Rust SDK to the official OpenFeature Rust SDK. + +## Installation + +```toml +[dependencies] +featurevisor = "1.2" +featurevisor-openfeature = "1.2" +open-feature = "0.3" +``` + +The provider is a separate crate, so applications that only use `featurevisor` +do not compile or link OpenFeature. + +## Usage + +Create a provider that owns its Featurevisor instance: + +```rust +use featurevisor::{DatafileInput, FeaturevisorOptions}; +use featurevisor_openfeature::{FeaturevisorProvider, FeaturevisorProviderOptions}; + +let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile)), + ..Default::default() + }, + ..Default::default() +})?; +``` + +You can also pass an existing Featurevisor instance. The provider borrows it +and does not close it: + +```rust +let provider = FeaturevisorProvider::from_featurevisor(f)?; +``` + +Featurevisor supports several evaluation types through one OpenFeature key: + +| OpenFeature key | Featurevisor evaluation | +| --- | --- | +| `checkout` | Flag for feature `checkout` | +| `checkout:variation` | Variation for feature `checkout` | +| `checkout:title` | Variable `title` inside feature `checkout` | +| `variable:supportEmail` | Global variable `supportEmail` | + +`targeting_key_field`, `key_separator`, `variation_key`, and +`global_variable_prefix` customize this mapping. The targeting key maps to +`userId` by default. + +The provider implements boolean, integer, float, string, and structure +resolution. OpenFeature represents top level object values with `StructValue`. +Arrays can be nested in an object, but the provider contract does not expose a +separate top level array resolver. + +Featurevisor reasons and evaluation metadata are mapped to OpenFeature +resolution details. Missing definitions, type mismatches, invalid contexts, +and invalid datafiles use standard OpenFeature errors. Replacing an invalid +datafile with a valid one recovers the provider. + +Calling `close` releases provider subscriptions. It also closes a Featurevisor +instance created by the provider, but never closes a borrowed instance. + +The current OpenFeature Rust SDK does not expose provider tracking or provider +event callbacks. Featurevisor modules and diagnostics continue to run inside +the Featurevisor instance. + +See the [Featurevisor Rust SDK documentation](https://featurevisor.com/docs/sdks/rust/#openfeature) +and the [shared OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) +for more details. diff --git a/openfeature/src/lib.rs b/openfeature/src/lib.rs new file mode 100644 index 0000000..84e9e7f --- /dev/null +++ b/openfeature/src/lib.rs @@ -0,0 +1,702 @@ +#![forbid(unsafe_code)] +#![warn(missing_docs)] +//! OpenFeature provider for the Featurevisor Rust SDK. +//! +//! The provider is intentionally published separately from the base +//! `featurevisor` crate. Applications that do not use OpenFeature therefore do +//! not compile or link the OpenFeature SDK and its asynchronous runtime. +//! +//! ``` +//! use featurevisor_openfeature::{FeaturevisorProvider, FeaturevisorProviderOptions}; +//! use open_feature::{provider::FeatureProvider, EvaluationContext}; +//! +//! # async fn example() -> Result<(), Box> { +//! let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions::default())?; +//! let result = provider +//! .resolve_bool_value("checkout", &EvaluationContext::default()) +//! .await; +//! assert!(result.is_err()); +//! # Ok(()) +//! # } +//! ``` + +use featurevisor::{ + create_featurevisor, AttributeValue, Context, DatafileContent, DatafileInput, Evaluation, + EvaluationReason as FeaturevisorReason, EventDetails, EventName, Featurevisor, + FeaturevisorOptions, Unsubscribe, VariableValue, +}; +use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; +use open_feature::{ + async_trait, EvaluationContext, EvaluationContextFieldValue, EvaluationError, + EvaluationErrorCode, EvaluationReason, EvaluationResult, FlagMetadata, StructValue, Value, +}; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::sync::{Arc, Mutex}; +use time::format_description::well_known::Rfc3339; + +const PROVIDER_NAME: &str = "Featurevisor"; + +/// Configuration for [`FeaturevisorProvider`]. +pub struct FeaturevisorProviderOptions { + /// An existing Featurevisor instance. The provider borrows this instance + /// and does not close it. + pub featurevisor: Option, + /// Options used when the provider creates and owns a Featurevisor instance. + pub featurevisor_options: FeaturevisorOptions, + /// Featurevisor context field that receives the OpenFeature targeting key. + pub targeting_key_field: String, + /// Separator between a feature key and a variation or variable selector. + pub key_separator: String, + /// Selector reserved for feature variation evaluation. + pub variation_key: String, + /// Prefix reserved for global variable evaluation. + pub global_variable_prefix: String, +} + +impl Default for FeaturevisorProviderOptions { + fn default() -> Self { + Self { + featurevisor: None, + featurevisor_options: FeaturevisorOptions::default(), + targeting_key_field: "userId".to_string(), + key_separator: ":".to_string(), + variation_key: "variation".to_string(), + global_variable_prefix: "variable".to_string(), + } + } +} + +/// An invalid provider configuration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProviderConfigurationError { + message: String, +} + +impl ProviderConfigurationError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for ProviderConfigurationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for ProviderConfigurationError {} + +/// OpenFeature provider backed by a Featurevisor SDK instance. +pub struct FeaturevisorProvider { + featurevisor: Featurevisor, + metadata: ProviderMetadata, + targeting_key_field: String, + key_separator: String, + variation_key: String, + global_variable_prefix: String, + datafile_error: Arc>>, + subscriptions: Mutex>, + owns_featurevisor: bool, +} + +impl FeaturevisorProvider { + /// Creates a provider from provider and Featurevisor options. + pub fn new(options: FeaturevisorProviderOptions) -> Result { + if options.key_separator.is_empty() { + return Err(ProviderConfigurationError::new( + "keySeparator cannot be empty", + )); + } + if options.global_variable_prefix.is_empty() { + return Err(ProviderConfigurationError::new( + "globalVariablePrefix cannot be empty", + )); + } + if options + .global_variable_prefix + .contains(&options.key_separator) + { + return Err(ProviderConfigurationError::new( + "globalVariablePrefix cannot contain keySeparator", + )); + } + + let owns_featurevisor = options.featurevisor.is_none(); + let initial_error = if owns_featurevisor { + initial_datafile_error(&options.featurevisor_options) + } else { + None + }; + let featurevisor = options + .featurevisor + .unwrap_or_else(|| create_featurevisor(options.featurevisor_options)); + let datafile_error = Arc::new(Mutex::new(initial_error)); + let subscriptions = subscribe_to_datafile_state(&featurevisor, &datafile_error); + + Ok(Self { + featurevisor, + metadata: ProviderMetadata::new(PROVIDER_NAME), + targeting_key_field: options.targeting_key_field, + key_separator: options.key_separator, + variation_key: options.variation_key, + global_variable_prefix: options.global_variable_prefix, + datafile_error, + subscriptions: Mutex::new(subscriptions), + owns_featurevisor, + }) + } + + /// Creates a provider that borrows an existing Featurevisor instance. + pub fn from_featurevisor( + featurevisor: Featurevisor, + ) -> Result { + Self::new(FeaturevisorProviderOptions { + featurevisor: Some(featurevisor), + ..Default::default() + }) + } + + /// Returns the Featurevisor instance used by the provider. + pub fn featurevisor(&self) -> &Featurevisor { + &self.featurevisor + } + + /// Releases provider subscriptions and closes an owned Featurevisor instance. + /// + /// The operation is idempotent. A borrowed Featurevisor instance is never + /// closed by the provider. + pub fn close(&self) { + if let Ok(mut subscriptions) = self.subscriptions.lock() { + for unsubscribe in subscriptions.drain(..) { + unsubscribe(); + } + } + if self.owns_featurevisor { + self.featurevisor.close(); + } + } + + fn resolve(&self, flag_key: &str, context: &EvaluationContext) -> EvaluationResult { + if let Some(message) = self + .datafile_error + .lock() + .ok() + .and_then(|error| error.clone()) + { + return Err(evaluation_error(EvaluationErrorCode::ParseError, message)); + } + + let context = featurevisor_context(context, &self.targeting_key_field)?; + let (feature_key, selector) = split_key(flag_key, &self.key_separator); + let evaluation = if feature_key == self.global_variable_prefix && selector.is_some() { + self.featurevisor.evaluate_global_variable( + selector.unwrap_or_default(), + Some(&context), + None, + ) + } else if selector.is_none() { + self.featurevisor.evaluate_flag(feature_key, Some(&context)) + } else if selector == Some(self.variation_key.as_str()) { + self.featurevisor + .evaluate_variation(feature_key, Some(&context), None) + } else { + self.featurevisor.evaluate_variable( + feature_key, + selector.unwrap_or_default(), + Some(&context), + None, + ) + }; + + if let Some(error) = error_for_evaluation(&evaluation) { + return Err(error); + } + + let value = if selector.is_none() { + evaluation.enabled.map(ResolvedValue::Bool) + } else if selector == Some(self.variation_key.as_str()) { + evaluation + .variation_value + .clone() + .or_else(|| { + evaluation + .variation + .as_ref() + .map(|value| value.value.clone()) + }) + .map(ResolvedValue::String) + } else { + evaluation + .variable_value + .clone() + .map(|value| normalize_variable(value, variable_type(&evaluation))) + .map(ResolvedValue::Variable) + }; + + Ok(Resolved { evaluation, value }) + } + + fn details(&self, evaluation: &Evaluation, value: T) -> ResolutionDetails { + let mut details = ResolutionDetails::new(value); + details.reason = Some(reason_for(evaluation.reason.clone())); + details.flag_metadata = Some(metadata_for(evaluation, &self.featurevisor)); + details.variant = evaluation.variation_value.clone().or_else(|| { + evaluation + .variation + .as_ref() + .map(|value| value.value.clone()) + }); + details + } + + fn type_mismatch(&self, flag_key: &str, expected: &str) -> EvaluationError { + evaluation_error( + EvaluationErrorCode::TypeMismatch, + format!("Flag \"{flag_key}\" did not resolve to a {expected} value"), + ) + } +} + +impl Drop for FeaturevisorProvider { + fn drop(&mut self) { + self.close(); + } +} + +#[async_trait] +impl FeatureProvider for FeaturevisorProvider { + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn resolve_bool_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + let resolved = self.resolve(flag_key, evaluation_context)?; + match resolved.value { + Some(ResolvedValue::Bool(value)) => Ok(self.details(&resolved.evaluation, value)), + Some(ResolvedValue::Variable(VariableValue::Boolean(value))) => { + Ok(self.details(&resolved.evaluation, value)) + } + _ => Err(self.type_mismatch(flag_key, "boolean")), + } + } + + async fn resolve_int_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + let resolved = self.resolve(flag_key, evaluation_context)?; + match resolved.value { + Some(ResolvedValue::Variable(VariableValue::Integer(value))) => { + Ok(self.details(&resolved.evaluation, value)) + } + _ => Err(self.type_mismatch(flag_key, "integer")), + } + } + + async fn resolve_float_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + let resolved = self.resolve(flag_key, evaluation_context)?; + let value = match resolved.value { + Some(ResolvedValue::Variable(VariableValue::Integer(value))) => Some(value as f64), + Some(ResolvedValue::Variable(VariableValue::Double(value))) if value.is_finite() => { + Some(value) + } + _ => None, + }; + match value { + Some(value) => Ok(self.details(&resolved.evaluation, value)), + None => Err(self.type_mismatch(flag_key, "number")), + } + } + + async fn resolve_string_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + let resolved = self.resolve(flag_key, evaluation_context)?; + let value = match resolved.value { + Some(ResolvedValue::String(value)) => Some(value), + Some(ResolvedValue::Variable(VariableValue::String(value))) => Some(value), + _ => None, + }; + match value { + Some(value) => Ok(self.details(&resolved.evaluation, value)), + None => Err(self.type_mismatch(flag_key, "string")), + } + } + + async fn resolve_struct_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + let resolved = self.resolve(flag_key, evaluation_context)?; + let value = match resolved.value { + Some(ResolvedValue::Variable(VariableValue::Object(value))) => { + Some(variable_object_to_struct(value)) + } + _ => None, + }; + match value { + Some(value) => Ok(self.details(&resolved.evaluation, value)), + None => Err(self.type_mismatch(flag_key, "structure")), + } + } +} + +struct Resolved { + evaluation: Evaluation, + value: Option, +} + +enum ResolvedValue { + Bool(bool), + String(String), + Variable(VariableValue), +} + +fn initial_datafile_error(options: &FeaturevisorOptions) -> Option { + match options.datafile.as_ref() { + Some(DatafileInput::Json(json)) => serde_json::from_str::(json) + .ok() + .filter(|datafile| !datafile.revision.is_empty()) + .map(|_| None) + .unwrap_or_else(|| Some("Could not parse datafile".to_string())), + Some(DatafileInput::Content(datafile)) if datafile.revision.is_empty() => { + Some("Could not parse datafile".to_string()) + } + _ => None, + } +} + +fn subscribe_to_datafile_state( + featurevisor: &Featurevisor, + error: &Arc>>, +) -> Vec { + let error_for_diagnostics = Arc::clone(error); + let diagnostic_subscription = featurevisor.on( + EventName::Error, + Arc::new(move |details| { + if let EventDetails::Error { diagnostic } = details { + if diagnostic.code == "invalid_datafile" { + if let Ok(mut current) = error_for_diagnostics.lock() { + *current = Some(diagnostic.message.clone()); + } + } + } + }), + ); + let error_for_datafile = Arc::clone(error); + let datafile_subscription = featurevisor.on( + EventName::DatafileSet, + Arc::new(move |_| { + if let Ok(mut current) = error_for_datafile.lock() { + *current = None; + } + }), + ); + vec![diagnostic_subscription, datafile_subscription] +} + +fn split_key<'a>(key: &'a str, separator: &str) -> (&'a str, Option<&'a str>) { + match key.find(separator) { + Some(index) => (&key[..index], Some(&key[index + separator.len()..])), + None => (key, None), + } +} + +fn variable_type(evaluation: &Evaluation) -> Option<&str> { + evaluation + .variable_schema + .as_ref() + .map(|schema| schema.variable_type.as_str()) + .or_else(|| { + evaluation + .variable + .as_ref() + .map(|variable| variable.variable_type.as_str()) + }) +} + +fn normalize_variable(value: VariableValue, variable_type: Option<&str>) -> VariableValue { + if variable_type == Some("json") { + if let VariableValue::String(raw) = &value { + if let Ok(parsed) = serde_json::from_str(raw) { + return VariableValue::from_json(parsed); + } + } + } + value +} + +fn error_for_evaluation(evaluation: &Evaluation) -> Option { + match evaluation.reason { + FeaturevisorReason::FeatureNotFound => Some(evaluation_error( + EvaluationErrorCode::FlagNotFound, + format!("Feature \"{}\" was not found", evaluation.feature_key), + )), + FeaturevisorReason::VariableNotFound => Some(evaluation_error( + EvaluationErrorCode::FlagNotFound, + match evaluation.variable_key.as_deref() { + Some(variable_key) if evaluation.feature_key.is_empty() => { + format!("Global variable \"{variable_key}\" was not found") + } + Some(variable_key) => format!( + "Variable \"{variable_key}\" was not found for feature \"{}\"", + evaluation.feature_key + ), + None => "Variable was not found".to_string(), + }, + )), + FeaturevisorReason::NoVariations => Some(evaluation_error( + EvaluationErrorCode::FlagNotFound, + format!("Feature \"{}\" has no variations", evaluation.feature_key), + )), + FeaturevisorReason::Error => Some(evaluation_error( + EvaluationErrorCode::General("GENERAL".to_string()), + evaluation + .error + .clone() + .unwrap_or_else(|| "Featurevisor evaluation failed".to_string()), + )), + _ => None, + } +} + +fn evaluation_error(code: EvaluationErrorCode, message: impl Into) -> EvaluationError { + EvaluationError { + code, + message: Some(message.into()), + } +} + +fn reason_for(reason: FeaturevisorReason) -> EvaluationReason { + match reason { + FeaturevisorReason::FeatureNotFound + | FeaturevisorReason::VariableNotFound + | FeaturevisorReason::NoVariations + | FeaturevisorReason::Error => EvaluationReason::Error, + FeaturevisorReason::Required + | FeaturevisorReason::Forced + | FeaturevisorReason::Sticky + | FeaturevisorReason::Rule + | FeaturevisorReason::VariableOverrideVariation + | FeaturevisorReason::VariableOverrideRule => EvaluationReason::TargetingMatch, + FeaturevisorReason::Allocated => EvaluationReason::Split, + FeaturevisorReason::Disabled + | FeaturevisorReason::VariationDisabled + | FeaturevisorReason::VariableDisabled + | FeaturevisorReason::RequiredFeaturesUnmet => EvaluationReason::Disabled, + _ => EvaluationReason::Default, + } +} + +fn metadata_for(evaluation: &Evaluation, featurevisor: &Featurevisor) -> FlagMetadata { + let mut metadata = FlagMetadata::default(); + metadata.add_value( + "featurevisorReason", + serde_json::to_value(&evaluation.reason) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_else(|| "error".to_string()), + ); + metadata.add_value("schemaVersion", featurevisor.get_schema_version()); + let revision = featurevisor.get_revision(); + if !revision.is_empty() { + metadata.add_value("revision", revision); + } + if !evaluation.feature_key.is_empty() { + metadata.add_value("featureKey", evaluation.feature_key.clone()); + } + if let Some(value) = &evaluation.variable_key { + metadata.add_value("variableKey", value.clone()); + } + if let Some(value) = &evaluation.rule_key { + metadata.add_value("ruleKey", value.clone()); + } + if let Some(value) = &evaluation.bucket_key { + metadata.add_value("bucketKey", value.clone()); + } + if let Some(value) = evaluation.bucket_value { + metadata.add_value("bucketValue", i64::from(value)); + } + if let Some(value) = evaluation + .force_index + .and_then(|value| i64::try_from(value).ok()) + { + metadata.add_value("forceIndex", value); + } + if let Some(value) = evaluation + .variable_override_index + .and_then(|value| i64::try_from(value).ok()) + { + metadata.add_value("variableOverrideIndex", value); + } + if let Some(value) = &evaluation.variable_override_key { + metadata.add_value("variableOverrideKey", value.clone()); + } + metadata +} + +fn featurevisor_context( + context: &EvaluationContext, + targeting_key_field: &str, +) -> EvaluationResult { + let mut result = Context::new(); + for (key, value) in &context.custom_fields { + result.insert(key.clone(), context_value(value)?); + } + if let Some(targeting_key) = &context.targeting_key { + result.insert( + "targetingKey".to_string(), + AttributeValue::String(targeting_key.clone()), + ); + result.insert( + targeting_key_field.to_string(), + AttributeValue::String(targeting_key.clone()), + ); + } + Ok(result) +} + +fn context_value(value: &EvaluationContextFieldValue) -> EvaluationResult { + match value { + EvaluationContextFieldValue::Bool(value) => Ok(AttributeValue::Boolean(*value)), + EvaluationContextFieldValue::Int(value) => Ok(AttributeValue::Integer(*value)), + EvaluationContextFieldValue::Float(value) => Ok(AttributeValue::Double(*value)), + EvaluationContextFieldValue::String(value) => Ok(AttributeValue::String(value.clone())), + EvaluationContextFieldValue::DateTime(value) => value + .format(&Rfc3339) + .map(AttributeValue::String) + .map_err(|error| { + evaluation_error(EvaluationErrorCode::InvalidContext, error.to_string()) + }), + EvaluationContextFieldValue::Struct(value) => { + if let Ok(value) = Arc::clone(value).downcast::() { + return Ok(AttributeValue::from_json((*value).clone())); + } + if let Ok(value) = Arc::clone(value).downcast::() { + return Ok(AttributeValue::from_json(struct_to_json(&value))); + } + Err(evaluation_error( + EvaluationErrorCode::InvalidContext, + "OpenFeature structure context values must use StructValue or serde_json::Value", + )) + } + } +} + +fn struct_to_json(value: &StructValue) -> serde_json::Value { + serde_json::Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), openfeature_value_to_json(value))) + .collect(), + ) +} + +fn openfeature_value_to_json(value: &Value) -> serde_json::Value { + match value { + Value::Bool(value) => serde_json::Value::Bool(*value), + Value::Int(value) => serde_json::Value::Number((*value).into()), + Value::Float(value) => serde_json::Number::from_f64(*value) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Value::String(value) => serde_json::Value::String(value.clone()), + Value::Array(value) => { + serde_json::Value::Array(value.iter().map(openfeature_value_to_json).collect()) + } + Value::Struct(value) => struct_to_json(value), + } +} + +fn variable_object_to_struct( + values: std::collections::HashMap, +) -> StructValue { + StructValue { + fields: values + .into_iter() + .filter_map(|(key, value)| variable_to_openfeature(value).map(|value| (key, value))) + .collect(), + } +} + +fn variable_to_openfeature(value: VariableValue) -> Option { + match value { + VariableValue::String(value) => Some(Value::String(value)), + VariableValue::Integer(value) => Some(Value::Int(value)), + VariableValue::Double(value) if value.is_finite() => Some(Value::Float(value)), + VariableValue::Double(_) | VariableValue::Null => None, + VariableValue::Boolean(value) => Some(Value::Bool(value)), + VariableValue::Array(values) => Some(Value::Array( + values + .into_iter() + .filter_map(variable_to_openfeature) + .collect(), + )), + VariableValue::Object(values) => Some(Value::Struct(variable_object_to_struct(values))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_every_current_featurevisor_reason() { + for reason in [ + FeaturevisorReason::Required, + FeaturevisorReason::Forced, + FeaturevisorReason::Sticky, + FeaturevisorReason::Rule, + FeaturevisorReason::VariableOverrideVariation, + FeaturevisorReason::VariableOverrideRule, + ] { + assert_eq!(reason_for(reason), EvaluationReason::TargetingMatch); + } + + assert_eq!( + reason_for(FeaturevisorReason::Allocated), + EvaluationReason::Split + ); + + for reason in [ + FeaturevisorReason::Disabled, + FeaturevisorReason::VariationDisabled, + FeaturevisorReason::VariableDisabled, + FeaturevisorReason::RequiredFeaturesUnmet, + ] { + assert_eq!(reason_for(reason), EvaluationReason::Disabled); + } + + for reason in [ + FeaturevisorReason::FeatureNotFound, + FeaturevisorReason::VariableNotFound, + FeaturevisorReason::NoVariations, + FeaturevisorReason::Error, + ] { + assert_eq!(reason_for(reason), EvaluationReason::Error); + } + + for reason in [ + FeaturevisorReason::OutOfRange, + FeaturevisorReason::NoMatch, + FeaturevisorReason::VariableDefault, + ] { + assert_eq!(reason_for(reason), EvaluationReason::Default); + } + } +} diff --git a/openfeature/tests/provider.rs b/openfeature/tests/provider.rs new file mode 100644 index 0000000..8693b15 --- /dev/null +++ b/openfeature/tests/provider.rs @@ -0,0 +1,461 @@ +use featurevisor::{ + create_featurevisor, DatafileInput, FeaturevisorModule, FeaturevisorOptions, LogLevel, +}; +use featurevisor_openfeature::{FeaturevisorProvider, FeaturevisorProviderOptions}; +use open_feature::{ + provider::FeatureProvider, EvaluationContext, EvaluationErrorCode, EvaluationReason, + FlagMetadataValue, OpenFeature, +}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +const DATAFILE: &str = r#"{ + "schemaVersion":"2", + "revision":"openfeature-test", + "segments":{}, + "features":{ + "checkout":{ + "bucketBy":"userId", + "variations":[{"value":"on","variables":{"title":"Hello","count":3,"ratio":1.5,"visible":true,"config":{"colour":"blue"},"json":"{\"nested\":true}","invalidJson":"not-json"}}], + "variablesSchema":{ + "title":{"type":"string","defaultValue":"Default"}, + "count":{"type":"integer","defaultValue":0}, + "ratio":{"type":"double","defaultValue":0}, + "visible":{"type":"boolean","defaultValue":false}, + "config":{"type":"object","defaultValue":{}}, + "json":{"type":"json","defaultValue":"{}"}, + "invalidJson":{"type":"json","defaultValue":"{}"} + }, + "force":[ + {"conditions":{"attribute":"userId","operator":"equals","value":"forced-user"},"enabled":true,"variation":"on"}, + {"conditions":{"attribute":"userId","operator":"equals","value":""},"enabled":true,"variation":"on"} + ], + "traffic":[{"key":"all","segments":"*","percentage":100000,"variation":"on"}] + }, + "empty":{"bucketBy":"userId","variations":[],"traffic":[{"key":"all","segments":"*","percentage":100000,"allocation":[]}]}, + "disabled":{ + "bucketBy":"userId", + "disabledVariationValue":"off", + "variations":[{"value":"on"}], + "force":[{"conditions":{"attribute":"blocked","operator":"equals","value":true},"enabled":false}], + "traffic":[{"key":"all","segments":"*","percentage":100000,"variation":"on"}] + }, + "allocated":{ + "bucketBy":"userId", + "variations":[{"value":"on"}], + "traffic":[{"key":"all","segments":"*","percentage":100000,"allocation":[{"variation":"on","range":[0,100000]}]}] + } + }, + "variables":{ + "supportEmail":{"type":"string","defaultValue":"support@example.com","overrides":[{"key":"nl","conditions":{"attribute":"country","operator":"equals","value":"nl"},"value":"nl@example.com"}]}, + "settings":{"type":"object","defaultValue":{"enabled":true,"limits":[1,2]}}, + "globalJson":{"type":"json","defaultValue":"{\"source\":\"global\"}"} + } +}"#; + +fn provider() -> FeaturevisorProvider { + FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(DATAFILE.to_string())), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }, + ..Default::default() + }) + .expect("valid provider") +} + +#[tokio::test] +async fn resolves_flags_variations_variables_and_global_variables() { + let provider = provider(); + let context = EvaluationContext::default().with_targeting_key("forced-user"); + + let flag = provider + .resolve_bool_value("checkout", &context) + .await + .expect("flag"); + assert!(flag.value); + assert_eq!(flag.reason, Some(EvaluationReason::TargetingMatch)); + + let variation = provider + .resolve_string_value("checkout:variation", &context) + .await + .expect("variation"); + assert_eq!(variation.value, "on"); + assert_eq!(variation.variant.as_deref(), Some("on")); + + assert_eq!( + provider + .resolve_string_value("checkout:title", &context) + .await + .expect("string") + .value, + "Hello" + ); + assert_eq!( + provider + .resolve_int_value("checkout:count", &context) + .await + .expect("integer") + .value, + 3 + ); + assert_eq!( + provider + .resolve_float_value("checkout:ratio", &context) + .await + .expect("float") + .value, + 1.5 + ); + assert!( + provider + .resolve_bool_value("checkout:visible", &context) + .await + .expect("boolean variable") + .value + ); + + let object = provider + .resolve_struct_value("checkout:config", &context) + .await + .expect("object"); + assert_eq!( + object.value.fields.get("colour"), + Some(&open_feature::Value::String("blue".to_string())) + ); + let json = provider + .resolve_struct_value("checkout:json", &context) + .await + .expect("json object"); + assert_eq!( + json.value.fields.get("nested"), + Some(&open_feature::Value::Bool(true)) + ); + + assert_eq!( + provider + .resolve_string_value("variable:supportEmail", &context) + .await + .expect("global string") + .value, + "support@example.com" + ); + assert!(provider + .resolve_struct_value("variable:settings", &context) + .await + .expect("global object") + .value + .fields + .contains_key("limits")); + assert_eq!( + provider + .resolve_struct_value("variable:globalJson", &context) + .await + .expect("global json") + .value + .fields + .get("source"), + Some(&open_feature::Value::String("global".to_string())) + ); +} + +#[tokio::test] +async fn maps_context_targeting_key_and_custom_configuration() { + let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(DATAFILE.to_string())), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }, + targeting_key_field: "accountId".to_string(), + key_separator: "/".to_string(), + variation_key: "$variation".to_string(), + global_variable_prefix: "$variable".to_string(), + ..Default::default() + }) + .expect("valid provider"); + let context = EvaluationContext::default() + .with_targeting_key("forced-user") + .with_custom_field("country", "nl"); + + assert_eq!( + provider + .resolve_string_value("checkout/$variation", &context) + .await + .expect("custom variation") + .value, + "on" + ); + assert_eq!( + provider + .resolve_string_value("$variable/supportEmail", &context) + .await + .expect("custom global") + .value, + "nl@example.com" + ); + + let empty = provider + .resolve_bool_value( + "checkout", + &EvaluationContext::default().with_targeting_key(""), + ) + .await + .expect("empty targeting key remains valid"); + assert!(empty.value); +} + +#[tokio::test] +async fn reports_standard_errors_and_type_mismatches() { + let provider = provider(); + let context = EvaluationContext::default(); + + let missing = provider + .resolve_bool_value("missing", &context) + .await + .expect_err("missing feature"); + assert_eq!(missing.code, EvaluationErrorCode::FlagNotFound); + assert_eq!( + missing.message.as_deref(), + Some("Feature \"missing\" was not found") + ); + + assert_eq!( + provider + .resolve_string_value("checkout", &context) + .await + .expect_err("flag is not string") + .code, + EvaluationErrorCode::TypeMismatch + ); + assert_eq!( + provider + .resolve_bool_value("checkout:title", &context) + .await + .expect_err("string is not boolean") + .code, + EvaluationErrorCode::TypeMismatch + ); + assert_eq!( + provider + .resolve_int_value("checkout:ratio", &context) + .await + .expect_err("double is not integer") + .code, + EvaluationErrorCode::TypeMismatch + ); + assert_eq!( + provider + .resolve_struct_value("checkout:invalidJson", &context) + .await + .expect_err("invalid json is not structure") + .code, + EvaluationErrorCode::TypeMismatch + ); + assert_eq!( + provider + .resolve_string_value("empty:variation", &context) + .await + .expect_err("no variations") + .code, + EvaluationErrorCode::FlagNotFound + ); + assert_eq!( + provider + .resolve_string_value("checkout:missing", &context) + .await + .expect_err("missing variable") + .code, + EvaluationErrorCode::FlagNotFound + ); + let empty_global = provider + .resolve_string_value("variable:", &context) + .await + .expect_err("empty global variable key"); + assert_eq!(empty_global.code, EvaluationErrorCode::FlagNotFound); + assert_eq!( + empty_global.message.as_deref(), + Some("Global variable \"\" was not found") + ); +} + +#[tokio::test] +async fn maps_targeting_split_and_disabled_reasons() { + let provider = provider(); + + let allocated = provider + .resolve_string_value( + "allocated:variation", + &EvaluationContext::default().with_targeting_key("allocated-user"), + ) + .await + .expect("allocated variation"); + assert_eq!(allocated.value, "on"); + assert_eq!(allocated.reason, Some(EvaluationReason::Split)); + + let overridden = provider + .resolve_string_value( + "variable:supportEmail", + &EvaluationContext::default().with_custom_field("country", "nl"), + ) + .await + .expect("overridden global variable"); + assert_eq!(overridden.value, "nl@example.com"); + assert_eq!(overridden.reason, Some(EvaluationReason::TargetingMatch)); + + let blocked_context = EvaluationContext::default().with_custom_field("blocked", true); + let disabled = provider + .resolve_bool_value("disabled", &blocked_context) + .await + .expect("forced disabled flag"); + assert!(!disabled.value); + assert_eq!(disabled.reason, Some(EvaluationReason::TargetingMatch)); + + let disabled_variation = provider + .resolve_string_value("disabled:variation", &blocked_context) + .await + .expect("disabled variation"); + assert_eq!(disabled_variation.value, "off"); + assert_eq!(disabled_variation.reason, Some(EvaluationReason::Disabled)); +} + +#[tokio::test] +async fn exposes_metadata_and_works_through_openfeature_client() { + let provider = provider(); + assert_eq!(provider.metadata().name, "Featurevisor"); + + let mut api = OpenFeature::default(); + api.set_provider(provider).await; + let client = api.create_client(); + let context = EvaluationContext::default().with_targeting_key("forced-user"); + let details = client + .get_bool_details("checkout", Some(&context), None) + .await + .expect("client evaluation"); + assert!(details.value); + assert_eq!(details.reason, Some(EvaluationReason::TargetingMatch)); + assert_eq!( + details.flag_metadata.values.get("revision"), + Some(&FlagMetadataValue::String("openfeature-test".to_string())) + ); + assert_eq!( + details.flag_metadata.values.get("featurevisorReason"), + Some(&FlagMetadataValue::String("forced".to_string())) + ); + api.shutdown().await; +} + +#[tokio::test] +async fn reports_parse_errors_and_recovers_after_valid_datafile() { + let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json("{".to_string())), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }, + ..Default::default() + }) + .expect("provider configuration"); + + assert_eq!( + provider + .resolve_bool_value("checkout", &EvaluationContext::default()) + .await + .expect_err("parse error") + .code, + EvaluationErrorCode::ParseError + ); + provider + .featurevisor() + .set_datafile(DatafileInput::Json(DATAFILE.to_string()), true); + assert!( + provider + .resolve_bool_value( + "checkout", + &EvaluationContext::default().with_targeting_key("forced-user") + ) + .await + .expect("recovered") + .value + ); + + provider + .featurevisor() + .set_datafile(DatafileInput::Json("{".to_string()), true); + assert_eq!( + provider + .resolve_bool_value("checkout", &EvaluationContext::default()) + .await + .expect_err("later parse error") + .code, + EvaluationErrorCode::ParseError + ); +} + +struct CloseModule(Arc); + +impl FeaturevisorModule for CloseModule { + fn close(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +#[test] +fn closes_owned_instances_but_not_borrowed_instances() { + let owned_closed = Arc::new(AtomicUsize::new(0)); + { + let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + modules: vec![Arc::new(CloseModule(Arc::clone(&owned_closed)))], + ..Default::default() + }, + ..Default::default() + }) + .expect("owned provider"); + provider.close(); + provider.close(); + } + assert_eq!(owned_closed.load(Ordering::SeqCst), 1); + + let borrowed_closed = Arc::new(AtomicUsize::new(0)); + let featurevisor = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(DATAFILE.to_string())), + modules: vec![Arc::new(CloseModule(Arc::clone(&borrowed_closed)))], + ..Default::default() + }); + { + let provider = FeaturevisorProvider::from_featurevisor(featurevisor.clone()) + .expect("borrowed provider"); + provider.close(); + } + assert_eq!(borrowed_closed.load(Ordering::SeqCst), 0); + assert!(featurevisor.is_enabled("checkout", None)); + featurevisor.close(); + assert_eq!(borrowed_closed.load(Ordering::SeqCst), 1); +} + +#[test] +fn rejects_ambiguous_or_empty_key_grammar() { + let error = FeaturevisorProvider::new(FeaturevisorProviderOptions { + global_variable_prefix: "global:variable".to_string(), + ..Default::default() + }) + .err() + .expect("invalid prefix"); + assert_eq!( + error.to_string(), + "globalVariablePrefix cannot contain keySeparator" + ); + + assert_eq!( + FeaturevisorProvider::new(FeaturevisorProviderOptions { + key_separator: String::new(), + ..Default::default() + }) + .err() + .expect("empty separator") + .to_string(), + "keySeparator cannot be empty" + ); +}