diff --git a/openfeature/providers/elixir-provider/.credo.exs b/openfeature/providers/elixir-provider/.credo.exs new file mode 100644 index 00000000000..baa1ea24410 --- /dev/null +++ b/openfeature/providers/elixir-provider/.credo.exs @@ -0,0 +1,217 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/openfeature/providers/elixir-provider/.formatter.exs b/openfeature/providers/elixir-provider/.formatter.exs new file mode 100644 index 00000000000..d2cda26eddc --- /dev/null +++ b/openfeature/providers/elixir-provider/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/openfeature/providers/elixir-provider/.gitignore b/openfeature/providers/elixir-provider/.gitignore new file mode 100644 index 00000000000..564a52082a4 --- /dev/null +++ b/openfeature/providers/elixir-provider/.gitignore @@ -0,0 +1,28 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +elixir_provider-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +.elixir_ls \ No newline at end of file diff --git a/openfeature/providers/elixir-provider/README.md b/openfeature/providers/elixir-provider/README.md new file mode 100644 index 00000000000..a55f11d7a38 --- /dev/null +++ b/openfeature/providers/elixir-provider/README.md @@ -0,0 +1,21 @@ +# ElixirProvider + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `elixir_provider` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:elixir_provider, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/openfeature/providers/elixir-provider/config/config.exs b/openfeature/providers/elixir-provider/config/config.exs new file mode 100644 index 00000000000..17739f2bd81 --- /dev/null +++ b/openfeature/providers/elixir-provider/config/config.exs @@ -0,0 +1,7 @@ +import Config + +config :elixir_provider, + max_wait_time: 5000, + hackney_options: [timeout: :infinity, recv_timeout: :infinity] + +import_config "#{config_env()}.exs" diff --git a/openfeature/providers/elixir-provider/config/dev.exs b/openfeature/providers/elixir-provider/config/dev.exs new file mode 100644 index 00000000000..becde76932f --- /dev/null +++ b/openfeature/providers/elixir-provider/config/dev.exs @@ -0,0 +1 @@ +import Config diff --git a/openfeature/providers/elixir-provider/config/prod.exs b/openfeature/providers/elixir-provider/config/prod.exs new file mode 100644 index 00000000000..becde76932f --- /dev/null +++ b/openfeature/providers/elixir-provider/config/prod.exs @@ -0,0 +1 @@ +import Config diff --git a/openfeature/providers/elixir-provider/config/test.exs b/openfeature/providers/elixir-provider/config/test.exs new file mode 100644 index 00000000000..b6ab92fda62 --- /dev/null +++ b/openfeature/providers/elixir-provider/config/test.exs @@ -0,0 +1,6 @@ +import Config + +# Prevents timeouts in ExUnit +config :elixir_provider, + hackney_options: [timeout: 10_000, recv_timeout: 10_000], + tmp_dir_prefix: "wallaby_test" diff --git a/openfeature/providers/elixir-provider/lib/elixir_provider.ex b/openfeature/providers/elixir-provider/lib/elixir_provider.ex new file mode 100644 index 00000000000..6cb49973eea --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/elixir_provider.ex @@ -0,0 +1,7 @@ +defmodule ElixirProvider do + @moduledoc """ + `ElixirProvider` is a feature flag manager for controlling feature availability in Go applications. + + It allows toggling features dynamically based on configurations from sources like databases and APIs, enabling flexible, real-time control over application behavior. + """ +end diff --git a/openfeature/providers/elixir-provider/lib/provider/application.ex b/openfeature/providers/elixir-provider/lib/provider/application.ex new file mode 100644 index 00000000000..b81153a4f3b --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/application.ex @@ -0,0 +1,20 @@ +defmodule ElixirProvider.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + ExSd.ServerSupervisor + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: ExSd.Supervisor] + Supervisor.start_link(children, opts) + end + +end diff --git a/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex new file mode 100644 index 00000000000..07b59f0ef8d --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex @@ -0,0 +1,46 @@ +defmodule ElixirProvider.CacheController do + @moduledoc """ + Controller for caching flag evaluations to avoid redundant API calls. + """ + + use GenServer + @flag_table :flag_cache + + @spec start_link(any()) :: GenServer.on_start() + def start_link(_args) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def get(flag_key, evaluation_hash) do + cache_key = build_cache_key(flag_key, evaluation_hash) + + case :ets.lookup(@flag_table, cache_key) do + [{^cache_key, cached_value}] -> {:ok, cached_value} + [] -> :miss + end + end + + def set(flag_key, evaluation_hash, value) do + cache_key = build_cache_key(flag_key, evaluation_hash) + :ets.insert(@flag_table, {cache_key, value}) + :ok + end + + def clear do + GenServer.stop(__MODULE__) + :ets.delete_all_objects(@flag_table) + :ets.insert(@flag_table, {:context, %{}}) + :ok + end + + defp build_cache_key(flag_key, evaluation_hash) do + "#{flag_key}-#{evaluation_hash}" + end + + @impl true + def init(:ok) do + :ets.new(@flag_table, [:named_table, :set, :public]) + :ets.insert(@flag_table, {:context, %{}}) + {:ok, nil, :hibernate} + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex new file mode 100644 index 00000000000..87bc1d272ea --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex @@ -0,0 +1,36 @@ +defmodule ElixirProvider.ContextTransformer do + @moduledoc """ + Converts an OpenFeature EvaluationContext into a GO Feature Flag context. + """ + alias ElixirProvider.GofEvaluationContext + alias OpenFeature.Types + + @doc """ + Extracts other key value pairs after the targeting key + """ + def get_any_value(map) when is_map(map) do + map + |> Enum.reject(fn {key, _value} -> key === :targetingKey end) + |> Enum.into(%{}) + end + + @doc """ + Converts an EvaluationContext map into a ElixirProvider.GofEvaluationContext struct. + Returns `{:ok, context}` on success, or `{:error, reason}` on failure. + """ + @spec transform_context(Types.context()) :: + {:ok, GofEvaluationContext.t()} | {:error, String.t()} + def transform_context(ctx) do + case Map.fetch(ctx, :targetingKey) do + {:ok, value} -> + {:ok, + %GofEvaluationContext{ + key: value, + custom: get_any_value(ctx) + }} + + :error -> + {:error, "targeting key not found"} + end + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/data_collector.ex b/openfeature/providers/elixir-provider/lib/provider/data_collector.ex new file mode 100644 index 00000000000..901f5e4aba7 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector.ex @@ -0,0 +1,13 @@ +defmodule ElixirProvider.RequestDataCollector do + @moduledoc """ + Represents the data collected in a request, including meta information and events. + """ + alias ElixirProvider.FeatureEvent + + defstruct [:meta, events: []] + + @type t :: %__MODULE__{ + meta: %{optional(String.t()) => String.t()}, + events: [FeatureEvent.t()] + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex new file mode 100644 index 00000000000..28c5a543a0a --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -0,0 +1,176 @@ +defmodule ElixirProvider.DataCollectorHook do + @moduledoc """ + Data collector hook + """ + use GenServer + require Logger + + alias OpenFeature.Hook + alias ElixirProvider.{FeatureEvent, HttpClient, RequestDataCollector} + + @default_targeting_key "undefined-targetingKey" + + defstruct [ + :base_hook, + :http_client, + :data_collector_endpoint, + :disable_data_collection, + :data_flush_interval, + :event_queue + ] + + @type t :: %__MODULE__{ + base_hook: Hook.t(), + http_client: HttpClient.t(), + data_collector_endpoint: String.t(), + disable_data_collection: boolean(), + data_flush_interval: non_neg_integer(), + event_queue: list(FeatureEvent.t()) + } + + def start(options, http_client) do + state = %__MODULE__{ + base_hook: %Hook{ + before: &before_hook/2, + after: &after_hook/4, + error: &error_hook/3, + finally: &finally_hook/2 + }, + http_client: http_client, + data_collector_endpoint: options.endpoint <> "/v1/data/collector", + disable_data_collection: options.disable_data_collection || false, + data_flush_interval: options.data_flush_interval || 60_000, + event_queue: [] + } + + schedule_collect_data(state.data_flush_interval) + {:ok, state} + end + + # Starts the GenServer and initializes with options + @spec start_link(any()) :: GenServer.on_start() + def start_link(_args) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def stop(state) do + GenServer.stop(__MODULE__) + collect_data(state.data_flush_interval) + + %__MODULE__{ + http_client: state.http_client, + data_collector_endpoint: state.data_collector_endpoint, + disable_data_collection: state.disable_data_collection, + data_flush_interval: state.data_flush_interval, + event_queue: [] + } + end + + @impl true + def init([]) do + {:ok, %__MODULE__{}} + end + + ### Hook Functions + defp before_hook(_hook_context, _hook_hints) do + # Define your `before` hook logic, if any + nil + end + + def after_hook(%__MODULE__{} = hook, hook_context, flag_evaluation_details, _hints) do + if hook.disable_data_collection or flag_evaluation_details.reason != :CACHED do + :ok + else + feature_event = %FeatureEvent{ + context_kind: + if(Map.get(hook_context.context, "anonymous"), do: "anonymousUser", else: "user"), + creation_date: DateTime.utc_now() |> DateTime.to_unix(:millisecond), + default: false, + key: hook_context.flag_key, + value: flag_evaluation_details.value, + variation: flag_evaluation_details.variant || "SdkDefault", + user_key: + Map.get(hook_context.evaluation_context, "targeting_key") || @default_targeting_key + } + + GenServer.cast(__MODULE__, {:add_event, feature_event}) + end + end + + defp error_hook(hook_context, any, _hints) do + # Logger.info("Data sent successfully: #{inspect(hook_context)}") + Logger.info("Data sent successfully: #{inspect(any)}") + # Logger.info("Data sent successfully: #{inspect(hints)}") + # if hook.disable_data_collection do + # :ok + # else + feature_event = %FeatureEvent{ + context_kind: + if(Map.get(hook_context.context, "anonymous"), do: "anonymousUser", else: "user"), + creation_date: DateTime.utc_now() |> DateTime.to_unix(:millisecond), + default: true, + key: hook_context.flag_key, + value: Map.get(hook_context.context, "default_value"), + variation: "SdkDefault", + user_key: Map.get(hook_context.context, "targeting_key") || @default_targeting_key + } + + GenServer.call(__MODULE__, {:add_event, feature_event}) + # end + end + + defp finally_hook(_hook_context, _hook_hints) do + # Define your `finally` hook logic, if any + :ok + end + + # Schedule periodic data collection based on the interval + defp schedule_collect_data(interval) do + Process.send_after(self(), :collect_data, interval) + end + + ### GenServer Callbacks + @impl true + def handle_call({:add_event, feature_event}, _from, state) do + {:reply, :ok, %{state | event_queue: [feature_event | state.event_queue]}} + end + + # Handle the periodic flush + @impl true + def handle_info(:collect_data, state) do + case collect_data(state) do + :ok -> Logger.info("Data collected and sent successfully.") + {:error, reason} -> Logger.error("Failed to send data: #{inspect(reason)}") + end + + schedule_collect_data(state.data_flush_interval) + {:noreply, %{state | event_queue: []}} + end + + defp collect_data(%__MODULE__{ + event_queue: event_queue, + http_client: http_client, + data_collector_endpoint: endpoint + }) do + Logger.info("Data sent successfully: #{inspect(event_queue)}") + + if Enum.empty?(event_queue) do + :ok + else + body = %RequestDataCollector{ + meta: %{"provider" => "open-feature-elixir-sdk"}, + events: event_queue + } + + case HttpClient.post(http_client, endpoint, body) do + {:ok, response} -> + Logger.info("Data sent successfully: #{inspect(response)}") + :ok + + {:error, reason} -> + Logger.error("Error sending data: #{inspect(reason)}") + {:error, reason} + end + end + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex new file mode 100644 index 00000000000..8a890c1caa2 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex @@ -0,0 +1,22 @@ +defmodule ElixirProvider.GofEvaluationContext do + @moduledoc """ + GoFeatureFlagEvaluationContext is an object representing a user context for evaluation. + """ + alias Jason + @derive Jason.Encoder + defstruct key: "", custom: %{} + + @type t :: %__MODULE__{ + key: String.t(), + custom: map() | nil + } + + @doc """ + Generates an MD5 hash based on the `key` and `custom` fields. + """ + def hash(%__MODULE__{key: key, custom: custom}) do + data = %{"key" => key, "custom" => custom} + encoded = Jason.encode!(data, pretty: true) + :crypto.hash(:md5, encoded) |> Base.encode16(case: :lower) + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/feature_event.ex b/openfeature/providers/elixir-provider/lib/provider/feature_event.ex new file mode 100644 index 00000000000..b3f91dea5cd --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/feature_event.ex @@ -0,0 +1,27 @@ +defmodule ElixirProvider.FeatureEvent do + @moduledoc """ + Represents a feature event with details about the feature flag evaluation. + """ + @enforce_keys [:context_kind, :user_key, :creation_date, :key, :variation] + defstruct kind: "feature", + context_kind: "", + user_key: "", + creation_date: 0, + key: "", + variation: "", + value: nil, + default: false, + source: "PROVIDER_CACHE" + + @type t :: %__MODULE__{ + kind: String.t(), + context_kind: String.t(), + user_key: String.t(), + creation_date: integer(), + key: String.t(), + variation: String.t(), + value: any(), + default: boolean(), + source: String.t() + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/flag_options.ex b/openfeature/providers/elixir-provider/lib/provider/flag_options.ex new file mode 100644 index 00000000000..871d67f7148 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/flag_options.ex @@ -0,0 +1,22 @@ +defmodule ElixirProvider.GoFeatureFlagOptions do + @moduledoc """ + Configuration options for the Go Feature Flag. + """ + + @enforce_keys [:endpoint] + defstruct [:endpoint, + cache_size: 10_000, + data_flush_interval: 60_000, + disable_data_collection: false, + reconnect_interval: 60, + disable_cache_invalidation: false] + + @type t :: %__MODULE__{ + endpoint: String.t(), + cache_size: integer() | nil, + data_flush_interval: integer() | nil, + disable_data_collection: boolean(), + reconnect_interval: integer() | nil, + disable_cache_invalidation: boolean() | nil + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/http_client.ex b/openfeature/providers/elixir-provider/lib/provider/http_client.ex new file mode 100644 index 00000000000..32db352fcd5 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -0,0 +1,75 @@ +defmodule ElixirProvider.HttpClient do + @moduledoc """ + Implements HttpClientBehaviour using Mint for HTTP requests. + """ + + # Define a struct to store HTTP connection, endpoint, and other configuration details + defstruct [:conn, :endpoint, :headers] + + @type t :: %__MODULE__{ + conn: Mint.HTTP.t() | nil, + endpoint: String.t(), + headers: list() + } + + def start_http_connection(options) do + uri = URI.parse(options.endpoint) + scheme = if uri.scheme == "https", do: :https, else: :http + + case Mint.HTTP.connect(scheme, uri.host, uri.port) do + {:ok, conn} -> + {:ok, + %{ + conn: conn, + endpoint: options.endpoint, + headers: [{"content-type", "application/json"}] + }} + + {:error, reason} -> + {:error, reason} + end + end + + def post(%{conn: conn, endpoint: endpoint, headers: headers}, path, data) do + url = URI.merge(endpoint, path) |> URI.to_string() + body = Jason.encode!(data) + + with {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "POST", url, headers, body), + {:ok, response} <- read_response(conn, request_ref) do + Jason.decode(response) + else + {:error, _conn, reason} -> {:error, reason} + {:error, reason} -> {:error, reason} + end + end + + defp read_response(conn, request_ref) do + receive do + message -> + case Mint.HTTP.stream(conn, message) do + {:ok, _conn, responses} -> + Enum.reduce_while(responses, {:ok, ""}, fn + {:status, ^request_ref, status}, _acc -> + if status == 200, do: {:cont, {:ok, ""}}, else: {:halt, {:error, :bad_status}} + + {:headers, ^request_ref, _headers}, acc -> + {:cont, acc} + + {:data, ^request_ref, data}, {:ok, acc} -> + {:cont, {:ok, acc <> data}} + + {:done, ^request_ref}, {:ok, acc} -> + {:halt, {:ok, acc}} + + _other, acc -> + {:cont, acc} + end) + + :unknown -> + {:error, :unknown_response} + end + after + 5_000 -> {:error, :timeout} + end + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/metadata.ex b/openfeature/providers/elixir-provider/lib/provider/metadata.ex new file mode 100644 index 00000000000..436648b40c4 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/metadata.ex @@ -0,0 +1,11 @@ +defmodule ElixirProvider.GoFeatureFlagMetadata do + @moduledoc """ + Metadata for the Go Feature Flag. + """ + + defstruct [name: "Go Feature Flag"] + + @type t :: %__MODULE__{ + name: String.t() + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/provider.ex b/openfeature/providers/elixir-provider/lib/provider/provider.ex new file mode 100644 index 00000000000..c5a760cd425 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/provider.ex @@ -0,0 +1,156 @@ +defmodule ElixirProvider.Provider do + @behaviour OpenFeature.Provider + + require Logger + alias ElixirProvider.CacheController + alias ElixirProvider.ContextTransformer + alias ElixirProvider.DataCollectorHook + alias ElixirProvider.GoFeatureFlagOptions + alias ElixirProvider.GofEvaluationContext + alias ElixirProvider.GoFWebSocketClient + alias ElixirProvider.HttpClient + alias ElixirProvider.RequestFlagEvaluation + alias ElixirProvider.ResponseFlagEvaluation + alias OpenFeature.Hook + alias OpenFeature.ResolutionDetails + + @moduledoc """ + The GO Feature Flag provider for OpenFeature, managing HTTP requests, caching, and flag evaluation. + """ + + defstruct [ + :options, + :http_client, + :hooks, + :ws, + :domain, + name: "ElixirProvider" + ] + + @type t :: %__MODULE__{ + name: String.t(), + options: GoFeatureFlagOptions.t(), + http_client: HttpClient.t(), + hooks: [Hook.t()] | nil, + ws: GoFWebSocketClient.t(), + domain: String.t() + } + + @impl true + def initialize(%__MODULE__{} = provider, domain, _context) do + {:ok, http_client} = HttpClient.start_http_connection(provider.options) + {:ok, hooks} = DataCollectorHook.start(provider.options, http_client) + {:ok, ws} = GoFWebSocketClient.connect(provider.options.endpoint) + + updated_provider = %__MODULE__{ + provider + | domain: domain, + http_client: http_client, + hooks: [hooks.base_hook], + ws: ws + } + + {:ok, updated_provider} + end + + @impl true + def shutdown(%__MODULE__{ws: ws} = provider) do + Process.exit(ws, :normal) + CacheController.clear() + if(GenServer.whereis(GoFWebSocketClient), do: GoFWebSocketClient.stop()) + + if(GenServer.whereis(DataCollectorHook), + do: DataCollectorHook.stop(provider.hooks) + ) + + :ok + end + + @impl true + def resolve_boolean_value(provider, key, default, context) do + generic_resolve(provider, :boolean, key, default, context) + end + + @impl true + def resolve_string_value(provider, key, default, context) do + generic_resolve(provider, :string, key, default, context) + end + + @impl true + def resolve_number_value(provider, key, default, context) do + generic_resolve(provider, :number, key, default, context) + end + + @impl true + def resolve_map_value(provider, key, default, context) do + generic_resolve(provider, :map, key, default, context) + end + + defp generic_resolve(provider, type, flag_key, default_value, context) do + {:ok, goff_context} = ContextTransformer.transform_context(context) + + goff_request = %RequestFlagEvaluation{user: goff_context, default_value: default_value} + eval_context_hash = GofEvaluationContext.hash(goff_context) + http_client = provider.http_client + Logger.debug("Unexpected frame received: #{inspect("fires")}") + + response_body = + case CacheController.get(flag_key, eval_context_hash) do + {:ok, cached_response} -> + cached_response + + :miss -> + # Fetch from HTTP if cache miss + case HttpClient.post(http_client, "/v1/feature/#{flag_key}/eval", goff_request) do + {:ok, response} -> + handle_response(flag_key, eval_context_hash, response) + + {:error, reason} -> + {:error, {:unexpected_error, reason}} + end + end + + handle_flag_resolution(response_body, type, flag_key, default_value) + end + + defp handle_response(flag_key, eval_context_hash, response) do + # Build the flag evaluation struct directly from the response map + flag_eval = ResponseFlagEvaluation.decode(response) + + # Cache the response if it's marked as cacheable + if flag_eval.cacheable do + CacheController.set(flag_key, eval_context_hash, response) + end + + {:ok, flag_eval} + end + + defp handle_flag_resolution(response, type, flag_key, _default_value) do + Logger.debug("Unexpected frame received: #{inspect(response)}") + + case response do + {:ok, %ResponseFlagEvaluation{value: value, reason: reason}} -> + case {type, value} do + {:boolean, val} when is_boolean(val) -> + {:ok, %ResolutionDetails{value: val, reason: reason}} + + {:string, val} when is_binary(val) -> + {:ok, %ResolutionDetails{value: val, reason: reason}} + + {:number, val} when is_number(val) -> + {:ok, %ResolutionDetails{value: val, reason: reason}} + + {:map, val} when is_map(val) -> + {:ok, %ResolutionDetails{value: val, reason: reason}} + + _ -> + {:error, + {:variant_not_found, + "Expected #{type} but got #{inspect(value)} for flag #{flag_key}"}} + end + + _ -> + {:error, {:flag_not_found, "Flag #{flag_key} not found"}} + end + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex new file mode 100644 index 00000000000..2c0a413493a --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex @@ -0,0 +1,15 @@ +defmodule ElixirProvider.RequestFlagEvaluation do + @moduledoc """ + RequestFlagEvaluation is an object representing a user context for evaluation. + """ + alias ElixirProvider.GofEvaluationContext + + @enforce_keys [:user] + @derive Jason.Encoder + defstruct [:default_value, :user] + + @type t :: %__MODULE__{ + user: GofEvaluationContext.t(), + default_value: any() + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex new file mode 100644 index 00000000000..354351eaaf8 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex @@ -0,0 +1,47 @@ +defmodule ElixirProvider.ResponseFlagEvaluation do + @moduledoc """ + Represents the evaluation response of a feature flag. + """ + alias ElixirProvider.Types + + @enforce_keys [:value, :failed, :reason] + @derive Jason.Encoder + defstruct [ + :value, + error_code: nil, + failed: false, + reason: "", + track_events: nil, + variation_type: nil, + version: nil, + metadata: nil, + cacheable: nil + ] + + @type t :: %__MODULE__{ + error_code: String.t() | nil, + failed: boolean(), + reason: String.t(), + track_events: boolean() | nil, + value: Types.json_type(), + variation_type: String.t() | nil, + version: String.t() | nil, + metadata: map() | nil, + cacheable: boolean() | nil + } + + @spec decode(map()) :: t() + def decode(response) when is_map(response) do + %__MODULE__{ + failed: response["failed"] || false, + value: response["value"], + variation_type: response["variationType"], + reason: response["reason"] || "", + error_code: response["errorCode"], + metadata: response["metadata"] || %{}, + cacheable: Map.get(response, "cacheable", false), + track_events: response["track_events"], + version: response["version"] + } + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex new file mode 100644 index 00000000000..74beb87103e --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex @@ -0,0 +1,20 @@ +defmodule ElixirProvider.ServerSupervisor do + @moduledoc """ + Supervisor + """ + use Supervisor + + def start_link(args) do + Supervisor.start_link(__MODULE__, [args], name: __MODULE__) + end + + @impl true + def init([_args]) do + children = [ + ElixirProvider.CacheController, + ElixirProvider.DataCollectorHook + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/types.ex b/openfeature/providers/elixir-provider/lib/provider/types.ex new file mode 100644 index 00000000000..db0db1ada6a --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/types.ex @@ -0,0 +1,8 @@ +defmodule ElixirProvider.Types do + @moduledoc """ + ElixirProvider types. + """ + + @type json_type :: boolean() | integer() | float() | String.t() | list() | map() + +end diff --git a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex new file mode 100644 index 00000000000..c5e297d855e --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex @@ -0,0 +1,182 @@ +defmodule ElixirProvider.GoFWebSocketClient do + use GenServer + + require Logger + require Mint.HTTP + + alias ElixirProvider.CacheController + + @moduledoc """ + A minimal WebSocket client for listening to configuration changes from the GO Feature Flag relay proxy. + Clears the cache on receiving change notifications. + """ + + defstruct [:conn, :websocket, :request_ref, :status, :caller, :resp_headers, :closing?] + + @type t :: %__MODULE__{ + conn: Mint.HTTP.t() | nil, + websocket: Mint.WebSocket.t() | nil, + request_ref: reference() | nil, + caller: {pid(), GenServer.from()} | nil, + status: integer() | nil, + resp_headers: list({String.t(), String.t()}) | nil, + closing?: boolean() + } + + @websocket_uri "ws/v1/flag/change" + + def connect(url) do + with {:ok, socket} <- GenServer.start_link(__MODULE__, [], name: __MODULE__), + {:ok, :connected} <- GenServer.call(socket, {:connect, url}) do + {:ok, socket} + end + end + + def stop do + GenServer.stop(__MODULE__) + end + + @impl true + def init([]) do + {:ok, %__MODULE__{}} + end + + @impl true + def handle_call({:connect, url}, from, state) do + uri = URI.parse(url) + + {http_scheme, ws_scheme} = + case uri.scheme do + "ws" -> {:http, :ws} + "wss" -> {:https, :wss} + "http" -> {:http, :ws} + "https" -> {:https, :wss} + _ -> {:reply, {:error, :invalid_scheme}, state} + end + + # Ensure the path is not nil + path = (uri.path || "/") <> @websocket_uri + + with {:ok, conn} <- + Mint.HTTP.connect(http_scheme, uri.host, uri.port || default_port(http_scheme)), + {:ok, conn, ref} <- Mint.WebSocket.upgrade(ws_scheme, conn, path, []) do + state = %{state | conn: conn, request_ref: ref, caller: from} + + {:noreply, state} + else + {:error, reason} -> + Logger.info("Parsed URI path: #{inspect("hi")}") + {:reply, {:error, reason}, state} + + {:error, conn, reason} -> + Logger.info("Parsed URI path: #{inspect(reason)}") + {:reply, {:error, reason}, put_in(state.conn, conn)} + end + end + + defp default_port(:http), do: 80 + defp default_port(:https), do: 443 + + @impl GenServer + def handle_info(message, state) do + Logger.info("Received message: #{inspect(message)}") + + case Mint.WebSocket.stream(state.conn, message) do + {:ok, conn, responses} -> + state = put_in(state.conn, conn) |> handle_responses(responses) + if state.closing?, do: do_close(state), else: {:noreply, state} + + {:error, conn, reason, _responses} -> + state = put_in(state.conn, conn) |> reply({:error, reason}) + {:noreply, state} + + :unknown -> + {:noreply, state} + end + end + + defp handle_responses(state, responses) + + defp handle_responses(%{request_ref: ref} = state, [{:status, ref, status} | rest]) do + put_in(state.status, status) + |> handle_responses(rest) + end + + defp handle_responses(%{request_ref: ref} = state, [{:headers, ref, resp_headers} | rest]) do + put_in(state.resp_headers, resp_headers) + |> handle_responses(rest) + end + + defp handle_responses(%{request_ref: ref} = state, [{:done, ref} | rest]) do + case Mint.WebSocket.new(state.conn, ref, state.status, state.resp_headers) do + {:ok, conn, websocket} -> + %{state | conn: conn, websocket: websocket, status: nil, resp_headers: nil} + |> reply({:ok, :connected}) + |> handle_responses(rest) + + {:error, conn, reason} -> + put_in(state.conn, conn) + |> reply({:error, reason}) + end + end + + defp handle_responses(%{request_ref: ref, websocket: websocket} = state, [ + {:data, ref, data} | rest + ]) + when websocket != nil do + case Mint.WebSocket.decode(websocket, data) do + {:ok, websocket, frames} -> + put_in(state.websocket, websocket) + |> handle_frames(frames) + |> handle_responses(rest) + + {:error, websocket, reason} -> + put_in(state.websocket, websocket) + |> reply({:error, reason}) + end + end + + defp handle_responses(state, [_response | rest]) do + handle_responses(state, rest) + end + + defp handle_responses(state, []), do: state + + def handle_frames(state, frames) do + Enum.reduce(frames, state, fn + {:close, _code, reason}, state -> + Logger.debug("Closing connection: #{inspect(reason)}") + %{state | closing?: true} + + {:text, text}, state -> + response = Jason.decode!(text) + + case Map.get(response, "type") do + "change" -> + # Clear the cache when a change message is received + CacheController.clear() + Logger.info("Cache cleared due to configuration change notification.") + + _ -> + nil + end + + state + + frame, state -> + Logger.debug("Unexpected frame received: #{inspect(frame)}") + state + end) + end + + defp do_close(state) do + Mint.HTTP.close(state.conn) + Logger.info("Websocket closed") + {:stop, :normal, state} + end + + defp reply(state, response) do + if state.caller, do: GenServer.reply(state.caller, response) + put_in(state.caller, nil) + end +end diff --git a/openfeature/providers/elixir-provider/mix.exs b/openfeature/providers/elixir-provider/mix.exs new file mode 100644 index 00000000000..1daa1beb8a9 --- /dev/null +++ b/openfeature/providers/elixir-provider/mix.exs @@ -0,0 +1,35 @@ +defmodule ElixirProvider.MixProject do + use Mix.Project + + def project do + [ + app: :elixir_provider, + version: "0.1.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:open_feature, git: "https://github.com/open-feature/elixir-sdk.git"}, + {:jason, "~> 1.4"}, + {:mint, "~> 1.6"}, + {:mint_web_socket, "~> 1.0"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:bypass, "~> 2.1", only: :test}, + {:plug, "~> 1.16", only: :test}, + {:mox, "~> 1.2", only: :test}, + {:mimic, "~> 1.7", only: :test} + ] + end +end diff --git a/openfeature/providers/elixir-provider/mix.lock b/openfeature/providers/elixir-provider/mix.lock new file mode 100644 index 00000000000..a34fa9e7c46 --- /dev/null +++ b/openfeature/providers/elixir-provider/mix.lock @@ -0,0 +1,27 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, + "credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [: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", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"}, + "elixir_sdk": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, + "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mimic": {:hex, :mimic, "1.10.2", "0d7e67ba09b1e8fe21a61a91f4cb2b876151c2d7e1c9bf6fc325195dd33075dd", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "21a50eddbdee1e9bad93cb8738bd4e224913d0d25a06692d34fb19881dba7292"}, + "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, + "open_feature": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "websocket_client": {:hex, :websocket_client, "1.5.0", "e825f23c51a867681a222148ed5200cc4a12e4fb5ff0b0b35963e916e2b5766b", [:rebar3], [], "hexpm", "2b9b201cc5c82b9d4e6966ad8e605832eab8f4ddb39f57ac62f34cb208b68de9"}, + "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, +} diff --git a/openfeature/providers/elixir-provider/test/elixir_provider/elixir_provider_test.exs b/openfeature/providers/elixir-provider/test/elixir_provider/elixir_provider_test.exs new file mode 100644 index 00000000000..510d14245ef --- /dev/null +++ b/openfeature/providers/elixir-provider/test/elixir_provider/elixir_provider_test.exs @@ -0,0 +1,231 @@ +defmodule ElixirProviderTest do + @moduledoc """ + Test file + """ + use ExUnit.Case, async: true + require Logger + + doctest ElixirProvider + alias OpenFeature + alias OpenFeature.Client + + @endpoint "http://localhost:1031" + + @default_evaluation_ctx %{ + targetingKey: "d45e303a-38c2-11ed-a261-0242ac120002", + email: "john.doe@gofeatureflag.org", + firstname: "john", + lastname: "doe", + anonymous: false, + professional: true, + rate: 3.14, + age: 30, + company_info: %{name: "my_company", size: 120}, + labels: ["pro", "beta"] + } + + setup do + _ = start_supervised!(ElixirProvider.ServerSupervisor) + + provider = %ElixirProvider.Provider{ + options: %ElixirProvider.GoFeatureFlagOptions{ + endpoint: @endpoint, + data_flush_interval: 100, + disable_cache_invalidation: true + } + } + + OpenFeature.set_provider(provider) + client = OpenFeature.get_client() + {:ok, client: client} + end + + ## TEST CONTEXT TRANSFORMER + + test "should use the targetingKey as user key" do + got = + ElixirProvider.ContextTransformer.transform_context(%{ + targetingKey: "user-key" + }) + + want( + = / + {:ok, + %ElixirProvider.GofEvaluationContext{ + key: "user-key", + custom: %{} + }} + ) + + assert got == want + end + + test "should specify the anonymous field base on the attributes" do + got = + ElixirProvider.ContextTransformer.transform_context(%{ + targetingKey: "user-key", + anonymous: true + }) + + want = + {:ok, + %ElixirProvider.GofEvaluationContext{ + key: "user-key", + custom: %{ + anonymous: true + } + }} + + assert got == want + end + + test "should fail if no targeting field is provided" do + got = + ElixirProvider.ContextTransformer.transform_context(%{ + anonymous: true, + firstname: "John", + lastname: "Doe", + email: "john.doe@gofeatureflag.org" + }) + + want = {:error, "targeting key not found"} + + assert got == want + end + + test "should fill custom fields if extra fields are present" do + got = + ElixirProvider.ContextTransformer.transform_context(%{ + targetingKey: "user-key", + anonymous: true, + firstname: "John", + lastname: "Doe", + email: "john.doe@gofeatureflag.org" + }) + + want = + {:ok, + %ElixirProvider.GofEvaluationContext{ + key: "user-key", + custom: %{ + firstname: "John", + lastname: "Doe", + email: "john.doe@gofeatureflag.org", + anonymous: true + } + }} + + assert got == want + end + + ## PROVIDER TESTS + + test "should provide an error if flag does not exist", %{client: client} do + flag_key = "flag_not_found" + default = false + ctx = @default_evaluation_ctx + + ElixirProvider.HttpClientMock + |> expect(:post, fn _client, path, _data -> + if path == "/v1/feature/#{flag_key}/eval" do + {:error, {:http_error, 404, "Not Found"}} + else + {:error, {:unexpected_path, path}} + end + end) + + # Make the client call + response = Client.get_boolean_details(client, flag_key, default, context: ctx) + + # # Define the expected response structure + # expected_response = %{ + # error_code: :provider_not_ready, + # error_message: + # "impossible to call go-feature-flag relay proxy on #{@endpoint}#{path}: Error: Request failed with status code 404", + # key: flag_key, + # reason: :error, + # value: false, + # flag_metadata: %{} + # } + + # # Assert the response matches the expected structure + assert response == "?" + end + + # test "should provide an error if flag does not exist", %{client: client} do + # flag_key = "flag_not_found" + # default = false + # ctx = @default_evaluation_ctx + # path = "/v1/feature/#{flag_key}/eval" + + # # Mock the Mint.HTTP.request/5 function + # Mimic.expect(Mint.HTTP, :request, fn _conn, "POST", url, headers, body -> + # assert url == "#{@endpoint}#{path}" + # assert headers == [{"content-type", "application/json"}] + # assert body == Jason.encode!(%{context: ctx, default: default}) + # {:ok, :mocked_conn, :mocked_request_ref} + # end) + + # # Mock the Mint.HTTP.stream/2 function to simulate a 404 error response + # Mimic.expect(Mint.HTTP, :stream, fn _conn, _message -> + # {:ok, :mocked_conn, + # [ + # {:status, :mocked_request_ref, 404}, + # {:headers, :mocked_request_ref, []}, + # {:data, :mocked_request_ref, ~s<{"error":"flag_not_found"}>}, + # {:done, :mocked_request_ref} + # ]} + # end) + + # # Call the function being tested + # response = Client.get_boolean_details(client, flag_key, default, context: ctx) + + # # Define the expected response + # # expected_response = %{ + # # error_code: :provider_not_ready, + # # error_message: + # # "impossible to call go-feature-flag relay proxy on #{endpoint}#{path}: Error: Request failed with status code 404", + # # key: flag_key, + # # reason: :error, + # # value: false, + # # flag_metadata: %{} + # # } + + # # Assert the response matches the expected response + # # assert response == "?" + # end + + test "post/3 sends a POST request and processes the response" do + # Mock the Mint.HTTP.request/5 function + Mimic.expect(Mint.HTTP, :request, fn _conn, "POST", url, headers, body -> + assert url == "https://api.example.com/v1/test/path" + assert headers == [{"content-type", "application/json"}] + assert body == ~s<{"key":"value"}> + {:ok, :mocked_conn, :mocked_request_ref} + end) + + # Mock the Mint.HTTP.stream/2 function to simulate a 200 OK response + Mimic.expect(Mint.HTTP, :stream, fn _conn, _message -> + {:ok, :mocked_conn, + [ + {:status, :mocked_request_ref, 200}, + {:headers, :mocked_request_ref, []}, + {:data, :mocked_request_ref, ~s<{"message":"success"}>}, + {:done, :mocked_request_ref} + ]} + end) + + # Prepare the connection struct + client = %ElixirProvider.HttpClient{ + conn: :mocked_conn, + endpoint: "https://api.example.com", + headers: [{"content-type", "application/json"}] + } + + # Call the post/3 function + response = ElixirProvider.HttpClient.post(client, "/v1/test/path", %{"key" => "value"}) + + # Assert the decoded response + assert {:ok, %{"message" => "success"}} == response + end +end diff --git a/openfeature/providers/elixir-provider/test/test_helper.exs b/openfeature/providers/elixir-provider/test/test_helper.exs new file mode 100644 index 00000000000..9264bb7105a --- /dev/null +++ b/openfeature/providers/elixir-provider/test/test_helper.exs @@ -0,0 +1,3 @@ +Mimic.copy(Mint.HTTP) + +ExUnit.start() diff --git a/openfeature/providers/elixir-provider3/config/config.exs b/openfeature/providers/elixir-provider3/config/config.exs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openfeature/providers/elixir-provider3/lib/org/gofeatureflag/module.ex b/openfeature/providers/elixir-provider3/lib/org/gofeatureflag/module.ex new file mode 100644 index 00000000000..56e80d193e4 --- /dev/null +++ b/openfeature/providers/elixir-provider3/lib/org/gofeatureflag/module.ex @@ -0,0 +1,7 @@ +defmodule Org.Gofeatureflag do + @moduledoc """ + `Org.Gofeatureflag` is a provider to use in combination with the OpenFeature SDK to use + GO Feature Flag in your Elixir application. + """ +end + diff --git a/openfeature/providers/elixir-provider3/lib/org/gofeatureflag/provider/model/ofrep_response.ex b/openfeature/providers/elixir-provider3/lib/org/gofeatureflag/provider/model/ofrep_response.ex new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openfeature/providers/elixir-provider3/lib/org/gofeatureflag/provider/provider.ex b/openfeature/providers/elixir-provider3/lib/org/gofeatureflag/provider/provider.ex new file mode 100644 index 00000000000..e69de29bb2d