From d10e49623b7b53f57471a42e57eb8853951b1476 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 9 Apr 2024 11:56:06 +0100 Subject: [PATCH] feat: Identity overrides in local evaluation mode (#34) * feat: Add Environment.identity_overrides, remove integrations config attributes * feat: Identity overrides in local evaluation mode - Rename `Identity.flags` to `Identity.identity_features` - Store identity overrides by identifier - Use stored identities when evaluating identity flags - Use a JSON file fixture for tests * chore: Bump version --- lib/flagsmith_client.ex | 5 +- lib/flagsmith_client_poller.ex | 37 +++++-- lib/flagsmith_engine.ex | 2 +- lib/schemas/environment.ex | 10 +- lib/schemas/identity.ex | 10 +- mix.exs | 2 +- test/data/environment.json | 172 +++++++++++++++++++++++++++++++++ test/flagsmith_engine_test.exs | 6 +- test/flagsmith_poller_test.exs | 31 ++++++ test/support/generators.ex | 10 +- 10 files changed, 248 insertions(+), 37 deletions(-) create mode 100644 test/data/environment.json diff --git a/lib/flagsmith_client.ex b/lib/flagsmith_client.ex index b73e862..4c25ac9 100644 --- a/lib/flagsmith_client.ex +++ b/lib/flagsmith_client.ex @@ -177,8 +177,9 @@ defmodule Flagsmith.Client do case Tesla.post(http_client(config), @api_paths.identities, query) do {:ok, %{status: status, body: body}} when status >= 200 and status < 300 -> - with %Schemas.Identity{flags: flags} <- Schemas.Identity.from_response(body), - flags <- build_flags(flags, config) do + with %Schemas.Identity{identity_features: identity_features} <- + Schemas.Identity.from_response(body), + flags <- build_flags(identity_features, config) do {:ok, flags} else error -> diff --git a/lib/flagsmith_client_poller.ex b/lib/flagsmith_client_poller.ex index 81de65d..8878cf3 100644 --- a/lib/flagsmith_client_poller.ex +++ b/lib/flagsmith_client_poller.ex @@ -18,7 +18,8 @@ defmodule Flagsmith.Client.Poller do :configuration, :environment, :refresh, - :refresh_monitor + :refresh_monitor, + identities_with_overrides: %{} ] ################################# @@ -114,9 +115,17 @@ defmodule Flagsmith.Client.Poller do def handle_event({:call, from}, {:get_identity_flags, identifier, traits}, _, %__MODULE__{ environment: env, - configuration: config + configuration: config, + identities_with_overrides: overrides }) do - identity = Schemas.Identity.from_id_traits(identifier, traits, env.api_key) + identity = + case Map.get(overrides, identifier) do + nil -> + Schemas.Identity.from_id_traits(identifier, traits, env.api_key) + + existing -> + %Schemas.Identity{existing | traits: Flagsmith.Schemas.Traits.Trait.from(traits)} + end flags = env @@ -148,7 +157,7 @@ defmodule Flagsmith.Client.Poller do def handle_event(:internal, :initial_load, :loading, %__MODULE__{configuration: config} = data) do case Flagsmith.Client.get_environment_request(config) do {:ok, environment} -> - {:next_state, :on, %__MODULE__{data | environment: environment}, + {:next_state, :on, update_data(data, environment), [{:next_event, :internal, :set_refresh}]} error -> @@ -209,7 +218,7 @@ defmodule Flagsmith.Client.Poller do # a process other than the one we have stored under the `:refresh_monitor` key # we still make sure it's matching. # - # Then we just check if the response is an `:ok` tuple with an `Environment.t` + # Then we just check if the response is an `:ok` tuple with an `Environment.t` # we replace the `:environment` key on our statem data and following user queries # will receive the new env or flags. If not we let it stay as is. # @@ -222,8 +231,7 @@ defmodule Flagsmith.Client.Poller do ) do case result do {:ok, %Schemas.Environment{} = env} -> - {:keep_state, %{data | refresh_monitor: nil, environment: env}, - [{:next_event, :internal, :set_refresh}]} + {:keep_state, update_data(data, env), [{:next_event, :internal, :set_refresh}]} error -> Logger.error( @@ -252,6 +260,21 @@ defmodule Flagsmith.Client.Poller do %__MODULE__{configuration: config, refresh: refresh_milliseconds} end + # Update identities with overrides along with the environment. + defp update_data(data, environment) do + %__MODULE__{ + data + | refresh_monitor: nil, + environment: environment, + identities_with_overrides: + Enum.reduce( + environment.identity_overrides, + %{}, + fn identity, acc -> Map.put(acc, identity.identifier, identity) end + ) + } + end + @doc false # this function is just so we can spawn a proper function with an MFA tuple def get_environment(pid, config) do diff --git a/lib/flagsmith_engine.ex b/lib/flagsmith_engine.ex index dece5f4..388103e 100644 --- a/lib/flagsmith_engine.ex +++ b/lib/flagsmith_engine.ex @@ -67,7 +67,7 @@ defmodule Flagsmith.Engine do feature_states: fs, project: %Environment.Project{segments: segments} } = env, - %Identity{flags: identity_features} = identity, + %Identity{identity_features: identity_features} = identity, override_traits \\ [] ) do with identity <- Identity.set_env_key(identity, env), diff --git a/lib/schemas/environment.ex b/lib/schemas/environment.ex index 088b8cb..007637d 100644 --- a/lib/schemas/environment.ex +++ b/lib/schemas/environment.ex @@ -10,11 +10,8 @@ defmodule Flagsmith.Schemas.Environment do typed_embedded_schema do field(:api_key, :string) embeds_many(:feature_states, __MODULE__.FeatureState) + embeds_many(:identity_overrides, Flagsmith.Schemas.Identity) embeds_one(:project, __MODULE__.Project) - embeds_one(:amplitude_config, __MODULE__.Integration) - embeds_one(:segment_config, __MODULE__.Integration) - embeds_one(:mixpanel_config, __MODULE__.Integration) - embeds_one(:heap_config, __MODULE__.Integration) field(:__configuration__, :map) end @@ -26,11 +23,8 @@ defmodule Flagsmith.Schemas.Environment do struct |> cast(params, [:api_key, :id]) |> cast_embed(:feature_states) + |> cast_embed(:identity_overrides) |> cast_embed(:project) - |> cast_embed(:amplitude_config) - |> cast_embed(:segment_config) - |> cast_embed(:mixpanel_config) - |> cast_embed(:heap_config) end @doc false diff --git a/lib/schemas/identity.ex b/lib/schemas/identity.ex index 4981d63..0e4529b 100644 --- a/lib/schemas/identity.ex +++ b/lib/schemas/identity.ex @@ -12,7 +12,7 @@ defmodule Flagsmith.Schemas.Identity do field(:django_id, :integer) field(:identifier, :string) field(:environment_key, :string) - embeds_many(:flags, Flagsmith.Schemas.Features.FeatureState) + embeds_many(:identity_features, Flagsmith.Schemas.Features.FeatureState) embeds_many(:traits, Flagsmith.Schemas.Traits.Trait) end @@ -24,7 +24,7 @@ defmodule Flagsmith.Schemas.Identity do |> cast(params, [:identifier, :environment_key, :django_id]) |> validate_required([:identifier]) |> cast_embed(:traits) - |> cast_embed(:flags) + |> cast_embed(:identity_features) end @doc false @@ -43,16 +43,14 @@ defmodule Flagsmith.Schemas.Identity do @doc false @spec from_response(element :: map() | list(map())) :: __MODULE__.t() | any() def from_response(element) when is_map(element) do - element + Map.put(element, "identity_features", Map.get(element, "flags")) |> changeset() |> apply_changes() end def from_response(elements) when is_list(elements) do Enum.map(elements, fn element -> - element - |> changeset() - |> apply_changes() + from_response(element) end) end diff --git a/mix.exs b/mix.exs index 5b39473..3f0ed5b 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule FlagsmithEngine.MixProject do def project do [ app: :flagsmith_engine, - version: "2.0.0", + version: "2.1.0", elixir: "~> 1.12", start_permanent: Mix.env() == :prod, deps: deps(), diff --git a/test/data/environment.json b/test/data/environment.json new file mode 100644 index 0000000..02c46e1 --- /dev/null +++ b/test/data/environment.json @@ -0,0 +1,172 @@ +{ + "api_key": "cU3oztxgvRgZifpLepQJTX", + "feature_states": [ + { + "django_id": 72267, + "enabled": false, + "feature": { + "id": 13534, + "name": "header_size", + "type": "MULTIVARIATE" + }, + "feature_state_value": "24px", + "featurestate_uuid": "16c5a45c-1d9c-4f44-bebe-5b73d60f897d", + "multivariate_feature_state_values": [ + { + "id": 2915, + "multivariate_feature_option": { + "id": 849, + "value": "34px" + }, + "mv_fs_value_uuid": "448a7777-91cf-47b0-bf16-a4d566ef7745", + "percentage_allocation": 60.0 + } + ] + }, + { + "django_id": 72269, + "enabled": false, + "feature": { + "id": 13535, + "name": "body_size", + "type": "STANDARD" + }, + "feature_state_value": "18px", + "featurestate_uuid": "c3c61a9a-f153-46b2-8e9e-dd80d6529201", + "multivariate_feature_state_values": [] + }, + { + "django_id": 92461, + "enabled": true, + "feature": { + "id": 17985, + "name": "secret_button", + "type": "STANDARD" + }, + "feature_state_value": "{\"colour\": \"#ababab\"}", + "featurestate_uuid": "d6bbf961-1752-4548-97d1-02d60cc1ab44", + "multivariate_feature_state_values": [] + }, + { + "django_id": 94235, + "enabled": true, + "feature": { + "id": 18382, + "name": "test_identity", + "type": "STANDARD" + }, + "feature_state_value": "very_yes", + "featurestate_uuid": "aa1a4512-b1c7-44d3-a263-c21676852a52", + "multivariate_feature_state_values": [] + } + ], + "id": 11278, + "identity_overrides": [ + { + "identifier": "overridden-id", + "identity_uuid": "0f21cde8-63c5-4e50-baca-87897fa6cd01", + "created_date": "2019-08-27T14:53:45.698555Z", + "updated_at": "2023-07-14 16:12:00.000000", + "environment_api_key": "cU3oztxgvRgZifpLepQJTX", + "identity_features": [ + { + "feature": { + "id": 18382, + "name": "test_identity", + "type": "STANDARD" + }, + "feature_state_value": "some-overridden-value", + "enabled": false + } + ] + } + ], + "project": { + "hide_disabled_flags": false, + "id": 4732, + "name": "testing-api", + "organisation": { + "feature_analytics": false, + "id": 4131, + "name": "Mr. Bojangles Inc", + "persist_trait_data": true, + "stop_serving_flags": false + }, + "segments": [ + { + "feature_states": [ + { + "django_id": 95632, + "enabled": true, + "feature": { + "id": 17985, + "name": "secret_button", + "type": "STANDARD" + }, + "feature_state_value": "", + "featurestate_uuid": "3b58d149-fdb3-4815-b537-6583291523dd", + "multivariate_feature_state_values": [] + } + ], + "id": 5241, + "name": "test_segment", + "rules": [ + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "operator": "EQUAL", + "property_": "show_popup", + "value": "false" + } + ], + "rules": [], + "type": "ANY" + } + ], + "type": "ALL" + } + ] + }, + { + "feature_states": [ + { + "django_id": 95631, + "enabled": true, + "feature": { + "id": 17985, + "name": "secret_button", + "type": "STANDARD" + }, + "feature_state_value": "", + "featurestate_uuid": "adb486aa-563d-4b1d-9f72-bf5b210bf94f", + "multivariate_feature_state_values": [] + } + ], + "id": 5243, + "name": "test_perc", + "rules": [ + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "operator": "PERCENTAGE_SPLIT", + "property_": "", + "value": "20" + } + ], + "rules": [], + "type": "ANY" + } + ], + "type": "ALL" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/test/flagsmith_engine_test.exs b/test/flagsmith_engine_test.exs index 657f19d..3a66b8e 100644 --- a/test/flagsmith_engine_test.exs +++ b/test/flagsmith_engine_test.exs @@ -20,7 +20,6 @@ defmodule Flagsmith.EngineTest do assert {:ok, %Environment{ __configuration__: nil, - amplitude_config: nil, api_key: "cU3oztxgvRgZifpLepQJTX", feature_states: [ %Environment.FeatureState{ @@ -82,9 +81,7 @@ defmodule Flagsmith.EngineTest do multivariate_feature_state_values: [] } ], - heap_config: nil, id: 11278, - mixpanel_config: nil, project: %Environment.Project{ hide_disabled_flags: false, id: 4732, @@ -172,8 +169,7 @@ defmodule Flagsmith.EngineTest do ] } ] - }, - segment_config: nil + } } = parsed} = Flagsmith.Engine.parse_environment(env_map) assert env_map_2 = Test.Generators.json_env() diff --git a/test/flagsmith_poller_test.exs b/test/flagsmith_poller_test.exs index 4bbe3c9..ded329b 100644 --- a/test/flagsmith_poller_test.exs +++ b/test/flagsmith_poller_test.exs @@ -456,6 +456,37 @@ defmodule Flagsmith.Client.Poller.Test do %{trait_key: "show_popup", trait_value: false} ]) + # finally, verify that identity overrides work correctly + assert {:ok, + %Flagsmith.Schemas.Flags{ + __configuration__: %Flagsmith.Configuration{}, + flags: %{ + "body_size" => %Flagsmith.Schemas.Flag{ + enabled: false, + feature_name: "body_size", + value: "18px" + }, + "header_size" => %Flagsmith.Schemas.Flag{ + enabled: false, + feature_name: "header_size", + value: "34px" + }, + "secret_button" => %Flagsmith.Schemas.Flag{ + enabled: true, + feature_name: "secret_button", + value: nil + }, + "test_identity" => %Flagsmith.Schemas.Flag{ + enabled: false, + feature_name: "test_identity", + value: "some-overridden-value" + } + } + }} = + Flagsmith.Client.get_identity_flags(config, "overridden-id", [ + %{trait_key: "show_popup", trait_value: false} + ]) + # sanity check that nowhere did the poller process exit/crash assert ^pid = Flagsmith.Client.Poller.whereis(config.environment_key) end diff --git a/test/support/generators.ex b/test/support/generators.ex index f56c0ae..18f4203 100644 --- a/test/support/generators.ex +++ b/test/support/generators.ex @@ -3,14 +3,13 @@ defmodule Flagsmith.Engine.Test.Generators do alias Flagsmith.Schemas.Traits.Trait.Value def json_env() do - "{\"api_key\":\"cU3oztxgvRgZifpLepQJTX\",\"feature_states\":[{\"django_id\":72267,\"enabled\":false,\"feature\":{\"id\":13534,\"name\":\"header_size\",\"type\":\"MULTIVARIATE\"},\"feature_state_value\":\"24px\",\"featurestate_uuid\":\"16c5a45c-1d9c-4f44-bebe-5b73d60f897d\",\"multivariate_feature_state_values\":[{\"id\":2915,\"multivariate_feature_option\":{\"id\":849,\"value\":\"34px\"},\"mv_fs_value_uuid\":\"448a7777-91cf-47b0-bf16-a4d566ef7745\",\"percentage_allocation\":60.0}]},{\"django_id\":72269,\"enabled\":false,\"feature\":{\"id\":13535,\"name\":\"body_size\",\"type\":\"STANDARD\"},\"feature_state_value\":\"18px\",\"featurestate_uuid\":\"c3c61a9a-f153-46b2-8e9e-dd80d6529201\",\"multivariate_feature_state_values\":[]},{\"django_id\":92461,\"enabled\": true,\"feature\":{\"id\":17985,\"name\":\"secret_button\",\"type\":\"STANDARD\"},\"feature_state_value\":\"{\\\"colour\\\": \\\"#ababab\\\"}\",\"featurestate_uuid\":\"d6bbf961-1752-4548-97d1-02d60cc1ab44\",\"multivariate_feature_state_values\":[]},{\"django_id\":94235,\"enabled\":true,\"feature\":{\"id\":18382,\"name\":\"test_identity\",\"type\":\"STANDARD\"},\"feature_state_value\":\"very_yes\",\"featurestate_uuid\":\"aa1a4512-b1c7-44d3-a263-c21676852a52\",\"multivariate_feature_state_values\":[]}],\"id\":11278,\"project\":{\"hide_disabled_flags\":false,\"id\":4732,\"name\":\"testing-api\",\"organisation\":{\"feature_analytics\":false,\"id\":4131,\"name\":\"Mr. Bojangles Inc\",\"persist_trait_data\":true,\"stop_serving_flags\":false},\"segments\":[{\"feature_states\":[{\"django_id\":95632,\"enabled\":true,\"feature\":{\"id\":17985,\"name\":\"secret_button\",\"type\":\"STANDARD\"},\"feature_state_value\":\"\",\"featurestate_uuid\":\"3b58d149-fdb3-4815-b537-6583291523dd\",\"multivariate_feature_state_values\":[]}],\"id\":5241,\"name\":\"test_segment\",\"rules\":[{\"conditions\":[],\"rules\":[{\"conditions\":[{\"operator\":\"EQUAL\",\"property_\":\"show_popup\",\"value\":\"false\"}],\"rules\":[],\"type\":\"ANY\"}],\"type\":\"ALL\"}]},{\"feature_states\":[{\"django_id\":95631,\"enabled\":true,\"feature\":{\"id\":17985,\"name\":\"secret_button\",\"type\":\"STANDARD\"},\"feature_state_value\":\"\",\"featurestate_uuid\":\"adb486aa-563d-4b1d-9f72-bf5b210bf94f\",\"multivariate_feature_state_values\":[]}],\"id\":5243,\"name\":\"test_perc\",\"rules\":[{\"conditions\":[],\"rules\":[{\"conditions\":[{\"operator\":\"PERCENTAGE_SPLIT\",\"property_\":\"\",\"value\":\"20\"}],\"rules\":[],\"type\":\"ANY\"}],\"type\":\"ALL\"}]}]}}" + File.read!("test/data/environment.json") end def map_env(), do: Jason.decode!(json_env()) def full_env() do %Environment{ - amplitude_config: nil, api_key: "cU3oztxgvRgZifpLepQJTX", feature_states: [ %Environment.FeatureState{ @@ -72,9 +71,7 @@ defmodule Flagsmith.Engine.Test.Generators do multivariate_feature_state_values: [] } ], - heap_config: nil, id: 11278, - mixpanel_config: nil, project: %Environment.Project{ hide_disabled_flags: false, id: 4732, @@ -162,8 +159,7 @@ defmodule Flagsmith.Engine.Test.Generators do ] } ] - }, - segment_config: nil + } } end @@ -175,7 +171,7 @@ defmodule Flagsmith.Engine.Test.Generators do def full_identity() do %Identity{ - flags: [ + identity_features: [ %Features.FeatureState{ enabled: false, environment: 11278,