From 3ab2b538948d60f0ed11c8c31e2ef2d9a6689af3 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Mon, 31 Aug 2026 22:55:31 +0200 Subject: [PATCH] feat: OpenFeature provider for Elixir --- .github/workflows/checks.yml | 38 ++ .github/workflows/publish.yml | 61 ++- Makefile | 27 +- README.md | 83 +++- mix.exs | 2 +- openfeature/.formatter.exs | 3 + openfeature/.gitignore | 20 + openfeature/LICENSE | 21 + openfeature/README.md | 76 +++ .../lib/featurevisor_openfeature/provider.ex | 413 +++++++++++++++++ openfeature/mix.exs | 59 +++ openfeature/mix.lock | 16 + openfeature/test/provider_test.exs | 437 ++++++++++++++++++ openfeature/test/test_helper.exs | 1 + 14 files changed, 1246 insertions(+), 11 deletions(-) create mode 100644 openfeature/.formatter.exs create mode 100644 openfeature/.gitignore create mode 100644 openfeature/LICENSE create mode 100644 openfeature/README.md create mode 100644 openfeature/lib/featurevisor_openfeature/provider.ex create mode 100644 openfeature/mix.exs create mode 100644 openfeature/mix.lock create mode 100644 openfeature/test/provider_test.exs create mode 100644 openfeature/test/test_helper.exs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e25a575..7fd3393 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -47,6 +47,44 @@ jobs: mix docs --warnings-as-errors mix hex.build + openfeature: + name: OpenFeature with Elixir ${{ matrix.elixir }} and OTP ${{ matrix.otp }} + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - elixir: "1.15.8" + otp: "26.2" + - elixir: "1.20.3" + otp: "29.0" + + steps: + - uses: actions/checkout@v7 + - uses: erlef/setup-beam@v1.24.1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + - name: Install provider dependencies + working-directory: openfeature + run: FEATUREVISOR_OPENFEATURE_PATH=.. mix deps.get + - name: Compile provider without warnings + working-directory: openfeature + run: FEATUREVISOR_OPENFEATURE_PATH=.. mix compile --warnings-as-errors + - name: Test provider + working-directory: openfeature + run: FEATUREVISOR_OPENFEATURE_PATH=.. mix test + - name: Format, static analysis, documentation, and package + if: matrix.elixir == '1.20.3' + working-directory: openfeature + run: | + mix format --check-formatted + FEATUREVISOR_OPENFEATURE_PATH=.. mix credo --strict + FEATUREVISOR_OPENFEATURE_PATH=.. mix dialyzer + FEATUREVISOR_OPENFEATURE_PATH=.. mix docs --warnings-as-errors + env -u FEATUREVISOR_OPENFEATURE_PATH mix hex.build + example: name: Featurevisor example 1 runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a30f5d8..5c5edc3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,9 +24,17 @@ jobs: node-version: 24 package-manager-cache: false - name: Install dependencies - run: mix deps.get - - name: Verify tag matches package version - run: test "${GITHUB_REF_NAME#v}" = "$(mix run --no-start --no-compile -e 'IO.write(Mix.Project.config()[:version])')" + run: | + mix deps.get + cd openfeature + FEATUREVISOR_OPENFEATURE_PATH=.. mix deps.get + - name: Verify tag matches package versions + run: | + version="${GITHUB_REF_NAME#v}" + base_version=$(mix run --no-start --no-compile -e 'IO.write(Mix.Project.config()[:version])') + provider_version=$(cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix run --no-start --no-compile -e 'IO.write(Mix.Project.config()[:version])') + test "$version" = "$base_version" + test "$base_version" = "$provider_version" - name: Run checks run: make check - name: Set up Featurevisor example 1 project @@ -48,7 +56,50 @@ jobs: tar -tf featurevisor-*.tar | grep '^contents.tar.gz$' env: HEX_API_KEY: ${{ secrets.HEX_API_KEY }} - - name: Publish to Hex.pm - run: mix hex.publish --yes + - name: Publish base package to Hex.pm + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + if curl --fail --silent --show-error "https://hex.pm/api/packages/featurevisor/releases/$version" >/dev/null; then + echo "featurevisor $version is already published" + else + mix hex.publish --yes + fi + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + - name: Wait for base package + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + for attempt in {1..18}; do + if curl --fail --silent --show-error "https://hex.pm/api/packages/featurevisor/releases/$version" >/dev/null; then + exit 0 + fi + sleep 10 + done + echo "featurevisor $version did not become available on Hex.pm" >&2 + exit 1 + - name: Publish OpenFeature provider package to Hex.pm + shell: bash + working-directory: openfeature + run: | + version="${GITHUB_REF_NAME#v}" + if curl --fail --silent --show-error "https://hex.pm/api/packages/featurevisor_openfeature/releases/$version" >/dev/null; then + echo "featurevisor_openfeature $version is already published" + else + resolved=false + for attempt in {1..18}; do + if env -u FEATUREVISOR_OPENFEATURE_PATH mix deps.get; then + resolved=true + break + fi + sleep 10 + done + test "$resolved" = true + env -u FEATUREVISOR_OPENFEATURE_PATH mix hex.publish --dry-run --yes + env -u FEATUREVISOR_OPENFEATURE_PATH mix hex.build + tar -tf featurevisor_openfeature-*.tar | grep '^contents.tar.gz$' + env -u FEATUREVISOR_OPENFEATURE_PATH mix hex.publish --yes + fi env: HEX_API_KEY: ${{ secrets.HEX_API_KEY }} diff --git a/Makefile b/Makefile index e71b145..0cf0e5c 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ FEATUREVISOR_PROJECT ?= ../featurevisor/examples/example-1 -.PHONY: deps format format-check compile test credo dialyzer docs package check escript test-example-1 +.PHONY: deps deps-openfeature format format-check compile test test-openfeature credo dialyzer docs docs-openfeature package package-openfeature check check-base check-openfeature escript test-example-1 deps: mix deps.get +deps-openfeature: + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix deps.get + format: mix format @@ -17,6 +20,9 @@ compile: test: mix test +test-openfeature: + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix test + credo: mix credo --strict @@ -26,13 +32,30 @@ dialyzer: docs: mix docs --warnings-as-errors +docs-openfeature: + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix docs --warnings-as-errors + package: mix hex.build +package-openfeature: + cd openfeature && env -u FEATUREVISOR_OPENFEATURE_PATH mix hex.build + escript: mix escript.build -check: format-check compile test credo dialyzer docs package +check-base: format-check compile test credo dialyzer docs package + +check-openfeature: deps-openfeature + cd openfeature && mix format --check-formatted + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix compile --warnings-as-errors + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix test + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix credo --strict + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix dialyzer + cd openfeature && FEATUREVISOR_OPENFEATURE_PATH=.. mix docs --warnings-as-errors + cd openfeature && env -u FEATUREVISOR_OPENFEATURE_PATH mix hex.build + +check: check-base check-openfeature test-example-1: test escript ./featurevisor test --projectDirectoryPath=$(FEATUREVISOR_PROJECT) --onlyFailures diff --git a/README.md b/README.md index 6dfd28b..58f191d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The SDK supports concurrent evaluations, structured diagnostics, lifecycle modul - [Modules](#modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -52,7 +53,7 @@ Add `featurevisor` to your dependencies in `mix.exs`: ```elixir def deps do [ - {:featurevisor, "~> 1.0"} + {:featurevisor, "~> 1.2"} ] end ``` @@ -446,6 +447,78 @@ Close invokes module close callbacks and removes listeners, diagnostic subscript Do not call `close/1` directly for a supervised instance. Stop its supervisor or remove its child instead. A closed handle evaluates against an empty datafile, so flags return `false`, variations and variables return `nil`, and the revision returns `"unknown"`. +## OpenFeature + +The OpenFeature provider is published as a separate Hex package. Applications +that only use the Featurevisor SDK do not install or compile OpenFeature or the +provider code. + +```elixir +def deps do + [ + {:featurevisor, "~> 1.2"}, + {:featurevisor_openfeature, "~> 1.2"}, + {:open_feature, "~> 0.1.3"} + ] +end +``` + +Create a provider that owns its Featurevisor instance: + +```elixir +provider = + FeaturevisorOpenFeature.Provider.new( + featurevisor_options: %{datafile: datafile} + ) + +{:ok, provider} = OpenFeature.set_provider(provider) +client = OpenFeature.get_client() + +enabled = + OpenFeature.Client.get_boolean_value(client, "checkout", false, + context: %{"targetingKey" => "user-123"} + ) +``` + +You can also pass an existing Featurevisor instance. The provider borrows it +and does not close it: + +```elixir +provider = FeaturevisorOpenFeature.Provider.new(featurevisor: f) +``` + +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 official OpenFeature Elixir SDK represents evaluation context as a map. The +provider accepts `"targetingKey"`, `"targeting_key"`, or `:targeting_key` and +copies its string value to the configured Featurevisor targeting field. + +The provider implements boolean, string, number, and map resolution. +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, and invalid datafiles use standard OpenFeature errors. Replacing an +invalid datafile with a valid one recovers the provider. + +The OpenFeature Elixir SDK does not currently expose provider tracking. +Featurevisor modules and diagnostics continue to run 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 Build the escript from this repository: @@ -522,7 +595,7 @@ make test-example-1 The integration target executes all expanded `example-1` assertions, including Target datafiles, through the Elixir evaluator. -`make check` remains the fast package gate. The separate `make test-example-1` target requires the sibling Featurevisor monorepo, so checks and publishing workflows run that integration explicitly after the package gate. +`make check` verifies both the base SDK and OpenFeature provider. The separate `make test-example-1` target requires the sibling Featurevisor monorepo, so checks and publishing workflows run that integration explicitly after the package gate. ## Publishing @@ -533,7 +606,11 @@ make check mix hex.publish --dry-run ``` -The release workflow verifies the tag, package contents, documentation, and `example-1` integration before publishing to Hex.pm. +The repository publishes `featurevisor` and `featurevisor_openfeature` with the same version. The release workflow verifies the tag, package contents, documentation, and `example-1` integration, then publishes the base package before the provider package. + +Hex can only resolve the provider's exact base package dependency after the +matching `featurevisor` release is visible. The workflow waits for that release +before it performs the final provider dry run and publication. The published Hex package intentionally includes runtime source, `mix.exs`, the README, and the licence. Tests and the conformance fixture remain repository verification assets and are not shipped to consumers. diff --git a/mix.exs b/mix.exs index dc0e22b..84d4431 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Featurevisor.MixProject do use Mix.Project - @version "1.1.0" + @version "1.2.0" @source_url "https://github.com/featurevisor/featurevisor-elixir" def project do diff --git a/openfeature/.formatter.exs b/openfeature/.formatter.exs new file mode 100644 index 0000000..682fc6c --- /dev/null +++ b/openfeature/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"] +] diff --git a/openfeature/.gitignore b/openfeature/.gitignore new file mode 100644 index 0000000..059ca0a --- /dev/null +++ b/openfeature/.gitignore @@ -0,0 +1,20 @@ +/_build/ +/cover/ +/deps/ +/doc/ +/erl_crash.dump +*.ez +/featurevisor_openfeature-*.tar +/.elixir_ls/ +/.lexical/ +/.vscode/ +/.idea/ +/.DS_Store +/.env +/.env.* +!/.env.example +*.pem +*.key +*.p12 +*.pfx +*.secret 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..b5e74dd --- /dev/null +++ b/openfeature/README.md @@ -0,0 +1,76 @@ +# Featurevisor OpenFeature provider for Elixir + +This Hex package adapts the Featurevisor Elixir SDK to the official OpenFeature Elixir SDK. + +## Installation + +```elixir +def deps do + [ + {:featurevisor, "~> 1.2"}, + {:featurevisor_openfeature, "~> 1.2"}, + {:open_feature, "~> 0.1.3"} + ] +end +``` + +The provider is a separate package, so applications that only use +`featurevisor` do not install or compile OpenFeature. + +## Usage + +Create a provider that owns its Featurevisor instance: + +```elixir +provider = + FeaturevisorOpenFeature.Provider.new( + featurevisor_options: %{datafile: datafile} + ) + +{:ok, provider} = OpenFeature.set_provider(provider) +client = OpenFeature.get_client() + +enabled = + OpenFeature.Client.get_boolean_value(client, "checkout", false, + context: %{"targetingKey" => "user-123"} + ) +``` + +You can also pass an existing Featurevisor instance. The provider borrows it +and does not close it: + +```elixir +provider = FeaturevisorOpenFeature.Provider.new(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 accepts `"targetingKey"`, +`"targeting_key"`, and `:targeting_key` in the OpenFeature context. + +The provider implements boolean, string, number, and map resolution. +Featurevisor reasons and evaluation metadata are mapped to OpenFeature +resolution details. Missing definitions, type mismatches, and invalid +datafiles use standard OpenFeature errors. Replacing an invalid datafile with +a valid one recovers the provider. + +Calling `shutdown/1` releases provider subscriptions. It also closes a +Featurevisor instance created by the provider, but never closes a borrowed +instance. + +The current OpenFeature Elixir SDK does not expose provider tracking. +Featurevisor modules and diagnostics continue to run inside the Featurevisor +instance. + +See the [Featurevisor Elixir SDK documentation](https://featurevisor.com/docs/sdks/elixir/#openfeature) +and the [shared OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) +for more details. diff --git a/openfeature/lib/featurevisor_openfeature/provider.ex b/openfeature/lib/featurevisor_openfeature/provider.ex new file mode 100644 index 0000000..4fb4a87 --- /dev/null +++ b/openfeature/lib/featurevisor_openfeature/provider.ex @@ -0,0 +1,413 @@ +defmodule FeaturevisorOpenFeature.Provider do + @moduledoc """ + OpenFeature provider backed by a Featurevisor Elixir SDK instance. + + The provider can create and own a Featurevisor instance or borrow an existing + instance supplied through `new/1`. Borrowed instances are never closed by the + provider. + """ + + @behaviour OpenFeature.Provider + + alias OpenFeature.ResolutionDetails + + @type option :: + {:featurevisor, Featurevisor.t()} + | {:featurevisor_options, Featurevisor.options() | keyword()} + | {:targeting_key_field, String.t()} + | {:key_separator, String.t()} + | {:variation_key, String.t()} + | {:global_variable_prefix, String.t()} + + @type t :: %__MODULE__{} + defstruct name: "Featurevisor", + domain: nil, + state: :not_ready, + hooks: [], + featurevisor: nil, + owns_featurevisor: false, + targeting_key_field: "userId", + key_separator: ":", + variation_key: "variation", + global_variable_prefix: "variable", + lifecycle: nil + + @doc "Creates a Featurevisor OpenFeature provider." + @spec new([option()] | map()) :: t() + def new(options \\ []) do + options = Map.new(options) + separator = Map.get(options, :key_separator, ":") + prefix = Map.get(options, :global_variable_prefix, "variable") + + if separator == "", do: raise(ArgumentError, "keySeparator cannot be empty") + if prefix == "", do: raise(ArgumentError, "globalVariablePrefix cannot be empty") + + if String.contains?(prefix, separator) do + raise ArgumentError, "globalVariablePrefix cannot contain keySeparator" + end + + {f, owns?, initial_error} = featurevisor_instance(options) + + {:ok, lifecycle} = + Agent.start(fn -> %{closed: false, error: initial_error, subscriptions: []} end) + + error_unsubscribe = + Featurevisor.on(f, :error, fn + %{diagnostic: %{code: "invalid_datafile", message: message}} -> + update_lifecycle(lifecycle, &%{&1 | error: message}) + + _details -> + :ok + end) + + datafile_unsubscribe = + Featurevisor.on(f, :datafile_set, fn _details -> + update_lifecycle(lifecycle, &%{&1 | error: nil}) + end) + + update_lifecycle(lifecycle, fn state -> + %{state | subscriptions: [error_unsubscribe, datafile_unsubscribe]} + end) + + %__MODULE__{ + featurevisor: f, + owns_featurevisor: owns?, + targeting_key_field: Map.get(options, :targeting_key_field, "userId"), + key_separator: separator, + variation_key: Map.get(options, :variation_key, "variation"), + global_variable_prefix: prefix, + lifecycle: lifecycle + } + end + + @doc "Initializes the provider for an OpenFeature domain." + @impl true + def initialize(provider, domain, _context) do + {:ok, %{provider | domain: domain, state: :ready}} + end + + @doc "Releases subscriptions and closes an owned Featurevisor instance." + @impl true + def shutdown(%__MODULE__{} = provider) do + case close_lifecycle(provider.lifecycle) do + {:close, subscriptions} -> + Enum.each(subscriptions, & &1.()) + if provider.owns_featurevisor, do: Featurevisor.close(provider.featurevisor) + if Process.alive?(provider.lifecycle), do: Agent.stop(provider.lifecycle, :normal) + :ok + + :already_closed -> + :ok + end + end + + @doc "Resolves a boolean feature flag or variable." + @impl true + def resolve_boolean_value(provider, key, default, context), + do: resolve_as(provider, key, default, context, :boolean) + + @doc "Resolves a string variation or variable." + @impl true + def resolve_string_value(provider, key, default, context), + do: resolve_as(provider, key, default, context, :string) + + @doc "Resolves an integer or floating point variable." + @impl true + def resolve_number_value(provider, key, default, context), + do: resolve_as(provider, key, default, context, :number) + + @doc "Resolves an object or JSON object variable." + @impl true + def resolve_map_value(provider, key, default, context), + do: resolve_as(provider, key, default, context, :map) + + defp featurevisor_instance(options) do + case Map.fetch(options, :featurevisor) do + {:ok, %Featurevisor{} = f} -> + {f, false, nil} + + :error -> + featurevisor_options = options |> Map.get(:featurevisor_options, %{}) |> Map.new() + error = initial_datafile_error(Map.get(featurevisor_options, :datafile)) + {Featurevisor.create_featurevisor(featurevisor_options), true, error} + end + end + + defp initial_datafile_error(nil), do: nil + + defp initial_datafile_error(datafile) when is_binary(datafile) do + case Jason.decode(datafile) do + {:ok, %{"revision" => revision}} when is_binary(revision) and revision != "" -> nil + _error -> "Could not parse datafile" + end + end + + defp initial_datafile_error(%{"revision" => revision}) + when is_binary(revision) and revision != "", + do: nil + + defp initial_datafile_error(_datafile), do: "Could not parse datafile" + + defp resolve_as(provider, key, default, context, expected) do + case datafile_error(provider.lifecycle) do + nil -> + {evaluation, value} = evaluate(provider, key, context) + details = details(provider, evaluation, default) + + cond do + error = evaluation_error(evaluation) -> + {:ok, struct(details, error)} + + is_nil(value) -> + {:ok, details} + + type_matches?(value, expected) -> + {:ok, %{details | value: value}} + + true -> + {:ok, + %{ + details + | reason: :error, + error_code: :type_mismatch, + error_message: + "Flag \"#{key}\" did not resolve to a #{expected_name(expected)} value" + }} + end + + message -> + {:ok, + %ResolutionDetails{ + value: default, + reason: :error, + error_code: :parse_error, + error_message: message + }} + end + rescue + error -> {:error, :unexpected_error, error} + end + + defp evaluate(provider, key, context) do + {feature_key, selector} = split_key(key, provider.key_separator) + context = featurevisor_context(context, provider.targeting_key_field) + + cond do + feature_key == provider.global_variable_prefix and not is_nil(selector) -> + evaluation = + Featurevisor.evaluate_global_variable(provider.featurevisor, selector, context) + + {evaluation, + normalize_variable( + evaluation.variable_value, + get_in(evaluation.variable || %{}, ["type"]) + )} + + is_nil(selector) -> + evaluation = Featurevisor.evaluate_flag(provider.featurevisor, feature_key, context) + {evaluation, evaluation.enabled} + + selector == provider.variation_key -> + evaluation = Featurevisor.evaluate_variation(provider.featurevisor, feature_key, context) + {evaluation, evaluation.variation_value || get_in(evaluation.variation || %{}, ["value"])} + + true -> + evaluation = + Featurevisor.evaluate_variable(provider.featurevisor, feature_key, selector, context) + + {evaluation, + normalize_variable( + evaluation.variable_value, + get_in(evaluation.variable_schema || %{}, ["type"]) + )} + end + end + + defp split_key(key, separator) do + case String.split(key, separator, parts: 2) do + [feature] -> {feature, nil} + [feature, selector] -> {feature, selector} + end + end + + defp featurevisor_context(context, targeting_key_field) when is_map(context) do + normalized = + Map.new(context, fn {key, value} -> {normalize_key(key), normalize_value(value)} end) + + case targeting_key(context) do + {:ok, targeting_key} -> + normalized + |> Map.put("targetingKey", targeting_key) + |> Map.put(targeting_key_field, targeting_key) + + :error -> + normalized + end + end + + defp targeting_key(context) do + Enum.find_value(["targetingKey", "targeting_key", :targeting_key], :error, fn key -> + case Map.fetch(context, key) do + {:ok, value} when is_binary(value) -> {:ok, value} + _other -> false + end + end) + end + + defp normalize_key(key) when is_binary(key), do: key + defp normalize_key(key) when is_atom(key), do: Atom.to_string(key) + defp normalize_key(key), do: to_string(key) + + defp normalize_value(%DateTime{} = value), do: DateTime.to_iso8601(value) + + defp normalize_value(%NaiveDateTime{} = value) do + value |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601() + end + + defp normalize_value(%Date{} = value), do: Date.to_iso8601(value) + defp normalize_value(value) when is_list(value), do: Enum.map(value, &normalize_value/1) + + defp normalize_value(value) when is_map(value) do + Map.new(value, fn {key, item} -> {normalize_key(key), normalize_value(item)} end) + end + + defp normalize_value(value), do: value + + defp normalize_variable(value, "json") when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> decoded + _error -> value + end + end + + defp normalize_variable(value, _type), do: value + + defp details(provider, evaluation, default) do + %ResolutionDetails{ + value: default, + reason: reason_for(evaluation.reason), + variant: evaluation.variation_value || get_in(evaluation.variation || %{}, ["value"]), + flag_metadata: metadata(provider, evaluation) + } + end + + defp reason_for(reason) + when reason in [ + :required, + :forced, + :sticky, + :rule, + :variable_override_variation, + :variable_override_rule + ], + do: :targeting_match + + defp reason_for(:allocated), do: :split + + defp reason_for(reason) + when reason in [ + :disabled, + :variation_disabled, + :variable_disabled, + :required_features_unmet + ], + do: :disabled + + defp reason_for(reason) + when reason in [:feature_not_found, :variable_not_found, :no_variations, :error], + do: :error + + defp reason_for(_reason), do: :default + + defp evaluation_error(%{reason: :feature_not_found, feature_key: feature_key}) do + %{ + reason: :error, + error_code: :flag_not_found, + error_message: "Feature \"#{feature_key}\" was not found" + } + end + + defp evaluation_error(%{reason: :variable_not_found} = evaluation) do + message = + if evaluation.feature_key in [nil, ""] do + "Global variable \"#{evaluation.variable_key}\" was not found" + else + "Variable \"#{evaluation.variable_key}\" was not found for feature \"#{evaluation.feature_key}\"" + end + + %{reason: :error, error_code: :flag_not_found, error_message: message} + end + + defp evaluation_error(%{reason: :no_variations, feature_key: feature_key}) do + %{ + reason: :error, + error_code: :flag_not_found, + error_message: "Feature \"#{feature_key}\" has no variations" + } + end + + defp evaluation_error(%{reason: :error, error: error}) do + %{ + reason: :error, + error_code: :general, + error_message: error_message(error) + } + end + + defp evaluation_error(_evaluation), do: nil + + defp error_message(%{__exception__: true} = error), do: Exception.message(error) + defp error_message(error) when is_binary(error), do: error + defp error_message(_error), do: "Featurevisor evaluation failed" + + defp metadata(provider, evaluation) do + %{ + "featurevisorReason" => Atom.to_string(evaluation.reason), + "schemaVersion" => Featurevisor.get_schema_version(provider.featurevisor), + "revision" => Featurevisor.get_revision(provider.featurevisor), + "featureKey" => evaluation.feature_key, + "variableKey" => evaluation.variable_key, + "ruleKey" => evaluation.rule_key, + "bucketKey" => evaluation.bucket_key, + "bucketValue" => evaluation.bucket_value, + "forceIndex" => evaluation.force_index, + "variableOverrideIndex" => evaluation.variable_override_index, + "variableOverrideKey" => evaluation.variable_override_key + } + |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end) + |> Map.new() + end + + defp type_matches?(value, :boolean), do: is_boolean(value) + defp type_matches?(value, :string), do: is_binary(value) + defp type_matches?(value, :number), do: is_number(value) + defp type_matches?(value, :map), do: is_map(value) + + defp expected_name(:boolean), do: "boolean" + defp expected_name(:string), do: "string" + defp expected_name(:number), do: "number" + defp expected_name(:map), do: "map" + + defp datafile_error(lifecycle) do + if Process.alive?(lifecycle), do: Agent.get(lifecycle, & &1.error), else: nil + end + + defp update_lifecycle(lifecycle, function) do + if Process.alive?(lifecycle), do: Agent.update(lifecycle, function) + catch + :exit, _reason -> :ok + end + + defp close_lifecycle(lifecycle) do + if Process.alive?(lifecycle) do + Agent.get_and_update(lifecycle, fn + %{closed: true} = state -> {:already_closed, state} + state -> {{:close, state.subscriptions}, %{state | closed: true, subscriptions: []}} + end) + else + :already_closed + end + catch + :exit, _reason -> :already_closed + end +end diff --git a/openfeature/mix.exs b/openfeature/mix.exs new file mode 100644 index 0000000..85f700b --- /dev/null +++ b/openfeature/mix.exs @@ -0,0 +1,59 @@ +defmodule FeaturevisorOpenFeature.MixProject do + use Mix.Project + + @version "1.2.0" + @source_url "https://github.com/featurevisor/featurevisor-elixir" + + def project do + [ + app: :featurevisor_openfeature, + version: @version, + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps(), + description: "OpenFeature provider for the Featurevisor Elixir SDK", + package: package(), + docs: docs(), + test_coverage: [tool: ExCoveralls] + ] + end + + def application do + [extra_applications: [:logger]] + end + + defp deps do + [ + featurevisor_dependency(), + {:open_feature, "~> 0.1.3"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, + {:ex_doc, "~> 0.38", only: :dev, runtime: false}, + {:excoveralls, "~> 0.18", only: :test} + ] + end + + defp featurevisor_dependency do + case System.get_env("FEATUREVISOR_OPENFEATURE_PATH") do + nil -> {:featurevisor, "== #{@version}"} + path -> {:featurevisor, "== #{@version}", path: path} + end + end + + defp package do + [ + licenses: ["MIT"], + links: %{"GitHub" => @source_url, "Featurevisor" => "https://featurevisor.com"}, + files: ["lib", "mix.exs", "README.md", "LICENSE"] + ] + end + + defp docs do + [ + main: "readme", + source_ref: "v#{@version}", + source_url: @source_url, + extras: ["README.md", "LICENSE"] + ] + end +end diff --git a/openfeature/mix.lock b/openfeature/mix.lock new file mode 100644 index 0000000..24f649a --- /dev/null +++ b/openfeature/mix.lock @@ -0,0 +1,16 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "open_feature": {:hex, :open_feature, "0.1.3", "b03ea38818010dd04c3e690059a8baf14a797c9322af06eb40ac3f81c733474e", [:mix], [], "hexpm", "2dafd79e94384328469c655ef2ddc66647c2531a53b57634e012f952b6afcaf9"}, +} diff --git a/openfeature/test/provider_test.exs b/openfeature/test/provider_test.exs new file mode 100644 index 0000000..b18dce7 --- /dev/null +++ b/openfeature/test/provider_test.exs @@ -0,0 +1,437 @@ +defmodule FeaturevisorOpenFeature.ProviderTest do + use ExUnit.Case, async: false + + alias FeaturevisorOpenFeature.Provider + alias OpenFeature.ResolutionDetails + + @datafile %{ + "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" => ~s({"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" => 100_000, + "variation" => "on" + } + ] + }, + "empty" => %{ + "bucketBy" => "userId", + "variations" => [], + "traffic" => [ + %{ + "key" => "all", + "segments" => "*", + "percentage" => 100_000, + "allocation" => [] + } + ] + }, + "disabled" => %{ + "bucketBy" => "userId", + "disabledVariationValue" => "off", + "variations" => [%{"value" => "on"}], + "force" => [ + %{ + "conditions" => %{ + "attribute" => "blocked", + "operator" => "equals", + "value" => true + }, + "enabled" => false + } + ], + "traffic" => [ + %{ + "key" => "all", + "segments" => "*", + "percentage" => 100_000, + "variation" => "on" + } + ] + }, + "allocated" => %{ + "bucketBy" => "userId", + "variations" => [%{"value" => "on"}], + "traffic" => [ + %{ + "key" => "all", + "segments" => "*", + "percentage" => 100_000, + "allocation" => [%{"variation" => "on", "range" => [0, 100_000]}] + } + ] + } + }, + "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" => ~s({"source":"global"}) + } + } + } + + setup do + OpenFeature.shutdown() + OpenFeature.clear_providers() + + on_exit(fn -> + OpenFeature.shutdown() + OpenFeature.clear_providers() + end) + end + + defp provider(options \\ []) do + Provider.new( + Keyword.merge( + [featurevisor_options: %{datafile: @datafile, log_level: :fatal}], + options + ) + ) + end + + test "resolves every OpenFeature type and global variables" do + provider = provider() + context = %{"targetingKey" => "forced-user"} + + assert {:ok, %ResolutionDetails{value: true, reason: :targeting_match}} = + Provider.resolve_boolean_value(provider, "checkout", false, context) + + assert {:ok, %ResolutionDetails{value: "on", variant: "on"}} = + Provider.resolve_string_value(provider, "checkout:variation", "fallback", context) + + assert {:ok, %ResolutionDetails{value: "Hello"}} = + Provider.resolve_string_value(provider, "checkout:title", "fallback", context) + + assert {:ok, %ResolutionDetails{value: 3}} = + Provider.resolve_number_value(provider, "checkout:count", 0, context) + + assert {:ok, %ResolutionDetails{value: 1.5}} = + Provider.resolve_number_value(provider, "checkout:ratio", 0.0, context) + + assert {:ok, %ResolutionDetails{value: true}} = + Provider.resolve_boolean_value(provider, "checkout:visible", false, context) + + assert {:ok, %ResolutionDetails{value: %{"colour" => "blue"}}} = + Provider.resolve_map_value(provider, "checkout:config", %{}, context) + + assert {:ok, %ResolutionDetails{value: %{"nested" => true}}} = + Provider.resolve_map_value(provider, "checkout:json", %{}, context) + + assert {:ok, %ResolutionDetails{value: "support@example.com"}} = + Provider.resolve_string_value( + provider, + "variable:supportEmail", + "fallback", + context + ) + + assert {:ok, %ResolutionDetails{value: %{"enabled" => true, "limits" => [1, 2]}}} = + Provider.resolve_map_value(provider, "variable:settings", %{}, context) + + assert {:ok, %ResolutionDetails{value: %{"source" => "global"}}} = + Provider.resolve_map_value(provider, "variable:globalJson", %{}, context) + + assert :ok = Provider.shutdown(provider) + end + + test "supports custom key grammar and targeting key spellings" do + provider = + provider( + targeting_key_field: "accountId", + key_separator: "/", + variation_key: "$variation", + global_variable_prefix: "$variable" + ) + + assert {:ok, %ResolutionDetails{value: "on"}} = + Provider.resolve_string_value( + provider, + "checkout/$variation", + "fallback", + %{"targeting_key" => "forced-user"} + ) + + assert {:ok, %ResolutionDetails{value: "nl@example.com"}} = + Provider.resolve_string_value( + provider, + "$variable/supportEmail", + "fallback", + %{targeting_key: "forced-user", country: "nl"} + ) + + assert {:ok, %ResolutionDetails{value: true}} = + Provider.resolve_boolean_value(provider, "checkout", false, %{"targetingKey" => ""}) + + Provider.shutdown(provider) + end + + test "maps errors, defaults, reasons, and metadata" do + provider = provider() + + assert {:ok, + %ResolutionDetails{ + value: true, + reason: :error, + error_code: :flag_not_found, + error_message: ~s(Feature "missing" was not found) + }} = Provider.resolve_boolean_value(provider, "missing", true, %{}) + + assert {:ok, %ResolutionDetails{value: "fallback", error_code: :flag_not_found}} = + Provider.resolve_string_value(provider, "checkout:missing", "fallback", %{}) + + assert {:ok, + %ResolutionDetails{ + value: "fallback", + error_code: :flag_not_found, + error_message: ~s(Global variable "" was not found) + }} = Provider.resolve_string_value(provider, "variable:", "fallback", %{}) + + assert {:ok, %ResolutionDetails{value: "fallback", error_code: :flag_not_found}} = + Provider.resolve_string_value(provider, "empty:variation", "fallback", %{}) + + assert {:ok, %ResolutionDetails{value: "fallback", error_code: :type_mismatch}} = + Provider.resolve_string_value(provider, "checkout", "fallback", %{}) + + assert {:ok, %ResolutionDetails{value: false, error_code: :type_mismatch}} = + Provider.resolve_boolean_value(provider, "checkout:title", false, %{}) + + assert {:ok, %ResolutionDetails{value: 0, error_code: :type_mismatch}} = + Provider.resolve_number_value(provider, "checkout:invalidJson", 0, %{}) + + assert {:ok, + %ResolutionDetails{ + value: true, + flag_metadata: %{ + "revision" => "openfeature-test", + "schemaVersion" => "2", + "featurevisorReason" => "forced" + } + }} = + Provider.resolve_boolean_value( + provider, + "checkout", + false, + %{"targetingKey" => "forced-user"} + ) + + Provider.shutdown(provider) + end + + test "maps targeting, split, and disabled reasons" do + provider = provider() + + assert {:ok, %ResolutionDetails{value: "on", reason: :split}} = + Provider.resolve_string_value( + provider, + "allocated:variation", + "fallback", + %{"targetingKey" => "allocated-user"} + ) + + assert {:ok, %ResolutionDetails{value: "nl@example.com", reason: :targeting_match}} = + Provider.resolve_string_value( + provider, + "variable:supportEmail", + "fallback", + %{"country" => "nl"} + ) + + assert {:ok, %ResolutionDetails{value: false, reason: :targeting_match}} = + Provider.resolve_boolean_value(provider, "disabled", true, %{"blocked" => true}) + + assert {:ok, %ResolutionDetails{value: "off", reason: :disabled}} = + Provider.resolve_string_value( + provider, + "disabled:variation", + "fallback", + %{"blocked" => true} + ) + + Provider.shutdown(provider) + end + + test "reports malformed datafiles and recovers after replacement" do + provider = provider(featurevisor_options: %{datafile: "{", log_level: :fatal}) + + assert {:ok, %ResolutionDetails{value: false, error_code: :parse_error}} = + Provider.resolve_boolean_value(provider, "checkout", false, %{}) + + assert :ok = Featurevisor.set_datafile(provider.featurevisor, @datafile, true) + + assert {:ok, %ResolutionDetails{value: true}} = + Provider.resolve_boolean_value( + provider, + "checkout", + false, + %{"targetingKey" => "forced-user"} + ) + + assert {:error, _reason} = Featurevisor.set_datafile(provider.featurevisor, "{", true) + + assert {:ok, %ResolutionDetails{value: false, error_code: :parse_error}} = + Provider.resolve_boolean_value(provider, "checkout", false, %{}) + + Provider.shutdown(provider) + end + + test "works through the OpenFeature client" do + {:ok, initialized} = provider() |> OpenFeature.set_provider() + assert initialized.state == :ready + client = OpenFeature.get_client() + + details = + OpenFeature.Client.get_boolean_details(client, "checkout", false, + context: %{"targetingKey" => "forced-user"} + ) + + assert details.value + assert details.reason == :targeting_match + assert details.flag_metadata["revision"] == "openfeature-test" + end + + test "closes owned instances and leaves borrowed instances open" do + {:ok, owned_count} = Agent.start_link(fn -> 0 end) + + owned = + provider( + featurevisor_options: %{ + modules: [ + %Featurevisor.Module{ + name: "owned", + close: fn -> Agent.update(owned_count, &(&1 + 1)) end + } + ] + } + ) + + assert :ok = Provider.shutdown(owned) + assert :ok = Provider.shutdown(owned) + assert Agent.get(owned_count, & &1) == 1 + + {:ok, borrowed_count} = Agent.start_link(fn -> 0 end) + + f = + Featurevisor.create_featurevisor(%{ + datafile: @datafile, + modules: [ + %Featurevisor.Module{ + name: "borrowed", + close: fn -> Agent.update(borrowed_count, &(&1 + 1)) end + } + ] + }) + + borrowed = Provider.new(featurevisor: f) + assert :ok = Provider.shutdown(borrowed) + assert Agent.get(borrowed_count, & &1) == 0 + assert Featurevisor.enabled?(f, "checkout") + Featurevisor.close(f) + assert Agent.get(borrowed_count, & &1) == 1 + end + + test "rejects invalid key grammar" do + assert_raise ArgumentError, "globalVariablePrefix cannot contain keySeparator", fn -> + Provider.new(global_variable_prefix: "global:variable") + end + + assert_raise ArgumentError, "keySeparator cannot be empty", fn -> + Provider.new(key_separator: "") + end + end + + test "normalizes nested dates without mutating the OpenFeature context" do + parent = self() + + module = %Featurevisor.Module{ + name: "capture", + before_evaluation: fn options -> + send(parent, {:context, options.context}) + options + end + } + + provider = provider(featurevisor_options: %{datafile: @datafile, modules: [module]}) + date = ~U[2026-01-02 03:04:05.123Z] + context = %{"targetingKey" => "forced-user", "nested" => %{"dates" => [date]}} + + Provider.resolve_boolean_value(provider, "checkout", false, context) + + assert_receive {:context, + %{ + "targetingKey" => "forced-user", + "userId" => "forced-user", + "nested" => %{"dates" => ["2026-01-02T03:04:05.123Z"]} + }} + + assert context["nested"]["dates"] == [date] + Provider.shutdown(provider) + end +end diff --git a/openfeature/test/test_helper.exs b/openfeature/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/openfeature/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()