From 16379e48eb4eab4d8db0d8007ca1f641d854a0ee Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Sun, 3 Nov 2024 11:59:37 +0100 Subject: [PATCH 01/10] add elixir-provider --- .../providers/elixir-provider/.formatter.exs | 4 ++ .../providers/elixir-provider/.gitignore | 28 ++++++++++++ .../providers/elixir-provider/README.md | 21 +++++++++ .../elixir-provider/lib/elixir_provider.ex | 34 ++++++++++++++ .../lib/provider/application.ex | 16 +++++++ .../lib/provider/cache_controller.ex | 45 +++++++++++++++++++ .../lib/provider/context_transformer.ex | 34 ++++++++++++++ .../lib/provider/data_collector.ex | 13 ++++++ .../lib/provider/evaluation_context.ex | 22 +++++++++ .../lib/provider/feature_event.ex | 27 +++++++++++ .../lib/provider/flag_options.ex | 22 +++++++++ .../elixir-provider/lib/provider/metadata.ex | 11 +++++ .../lib/provider/request_flag_evaluation.ex | 14 ++++++ .../lib/provider/response_flag_evalution.ex | 29 ++++++++++++ .../elixir-provider/lib/provider/types.ex | 8 ++++ openfeature/providers/elixir-provider/mix.exs | 31 +++++++++++++ .../providers/elixir-provider/mix.lock | 6 +++ .../test/elixir_provider_test.exs | 8 ++++ .../elixir-provider/test/test_helper.exs | 1 + 19 files changed, 374 insertions(+) create mode 100644 openfeature/providers/elixir-provider/.formatter.exs create mode 100644 openfeature/providers/elixir-provider/.gitignore create mode 100644 openfeature/providers/elixir-provider/README.md create mode 100644 openfeature/providers/elixir-provider/lib/elixir_provider.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/application.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/cache_controller.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/context_transformer.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/data_collector.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/feature_event.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/flag_options.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/metadata.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/types.ex create mode 100644 openfeature/providers/elixir-provider/mix.exs create mode 100644 openfeature/providers/elixir-provider/mix.lock create mode 100644 openfeature/providers/elixir-provider/test/elixir_provider_test.exs create mode 100644 openfeature/providers/elixir-provider/test/test_helper.exs 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/lib/elixir_provider.ex b/openfeature/providers/elixir-provider/lib/elixir_provider.ex new file mode 100644 index 00000000000..3eb29f4a61b --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/elixir_provider.ex @@ -0,0 +1,34 @@ +defmodule ElixirProvider do + use WebSockex + + alias OpenFeature.EvaluationDetails + alias ElixirProvider.ResponseFlagEvaluation + alias ElixirProvider.GoFeatureFlagMetadata + alias ElixirProvider.ContextTransformer + alias ElixirProvider.RequestFlagEvaluation + alias ElixirProvider.GoFeatureFlagOptions + alias ElixirProvider.Types + alias ElixirProvider.CacheController + + @moduledoc """ + The provider for GO Feature Flag, managing HTTP requests, caching, and flag evaluation. + """ + + defstruct [ + :options, + :_http_client, + _cache_controller: nil, + _data_collector_hook: nil, + _ws: nil, + ] + + @type t :: %__MODULE__{ + options: GoFeatureFlagOptions, + _cache_controller: CacheController, + _http_client: any(), + _data_collector_hook: any(), + _ws: WebSockex, + } + + +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..5a6faa7b2e4 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/application.ex @@ -0,0 +1,16 @@ +defmodule OpenFeature.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + OpenFeature.Store, + OpenFeature.EventEmitter + ] + + opts = [strategy: :one_for_one, name: OpenFeature.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..52cd35c8e30 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex @@ -0,0 +1,45 @@ +defmodule ElixirProvider.CacheController do + @moduledoc """ + Controller for caching flag evaluations to avoid redundant API calls. + """ + + use GenServer + @flag_table :flag_cache + + @spec start_link(Keyword.t()) :: GenServer.on_start() + def start_link(opts) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, :ok, name: name) + 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 + :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..457961358d4 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex @@ -0,0 +1,34 @@ +defmodule ElixirProvider.ContextTransformer do + @moduledoc """ + Converts an OpenFeature EvaluationContext into a GO Feature Flag context. + """ + alias ElixirProvider.EvaluationContext + alias OpenFeature.Types + + @doc """ + Finds any key-value pair with a non-nil value. + """ + def get_any_value(map) when is_map(map) do + case Enum.find(map, fn {_key, value} -> value != nil end) do + {key, value} -> {:ok, {key, value}} + nil -> {:error, "No keys found with a value"} + end + end + + @doc """ + Converts an EvaluationContext map into a ElixirProvider.EvaluationContext struct. + Returns `{:ok, context}` on success, or `{:error, reason}` on failure. + """ + @spec transform_context(Types.context()) :: {:ok, EvaluationContext.t()} | {:error, String.t()} + def transform_context(ctx) do + case get_any_value(ctx) do + {:ok, {key, value}} -> + {:ok, %EvaluationContext{ + key: key, + custom: value + }} + {:error, reason} -> + {:error, reason} + 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/evaluation_context.ex b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex new file mode 100644 index 00000000000..500ee82796c --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex @@ -0,0 +1,22 @@ +defmodule ElixirProvider.EvaluationContext do + @moduledoc """ + GoFeatureFlagEvaluationContext is an object representing a user context for evaluation. + """ + alias Jason + + 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..0f994ffdea3 --- /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 [:kind, :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..879b0fd6f99 --- /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: integer() | nil, + reconnect_interval: integer() | nil, + disable_cache_invalidation: boolean() | nil + } +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/request_flag_evaluation.ex b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex new file mode 100644 index 00000000000..141b4ade49e --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex @@ -0,0 +1,14 @@ +defmodule ElixirProvider.RequestFlagEvaluation do + @moduledoc """ + RequestFlagEvaluation is an object representing a user context for evaluation. + """ + alias ElixirProvider.EvaluationContext + + @enforce_keys [:user] + defstruct [:default_value, :user] + + @type t :: %__MODULE__{ + user: EvaluationContext.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..92347efa746 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex @@ -0,0 +1,29 @@ +defmodule ElixirProvider.ResponseFlagEvaluation do + @moduledoc """ + Represents the evaluation response of a feature flag. + """ + alias ElixirProvider.Types + + @enforce_keys [:value, :failed, :reason] + 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 + } +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/mix.exs b/openfeature/providers/elixir-provider/mix.exs new file mode 100644 index 00000000000..1205abe7bfe --- /dev/null +++ b/openfeature/providers/elixir-provider/mix.exs @@ -0,0 +1,31 @@ +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"}, + {:websockex, "~> 0.4.3"} + # {:dep_from_hexpm, "~> 0.3.0"},, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/openfeature/providers/elixir-provider/mix.lock b/openfeature/providers/elixir-provider/mix.lock new file mode 100644 index 00000000000..1202cc324dd --- /dev/null +++ b/openfeature/providers/elixir-provider/mix.lock @@ -0,0 +1,6 @@ +%{ + "elixir_sdk": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "open_feature": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []}, + "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, +} diff --git a/openfeature/providers/elixir-provider/test/elixir_provider_test.exs b/openfeature/providers/elixir-provider/test/elixir_provider_test.exs new file mode 100644 index 00000000000..35b6a5568bb --- /dev/null +++ b/openfeature/providers/elixir-provider/test/elixir_provider_test.exs @@ -0,0 +1,8 @@ +defmodule ElixirProviderTest do + use ExUnit.Case + doctest ElixirProvider + + test "greets the world" do + assert ElixirProvider.hello() == :world + 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..869559e709e --- /dev/null +++ b/openfeature/providers/elixir-provider/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() From a50afbc2c0950462f607cb7f24f719318ad9cfc2 Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Sun, 3 Nov 2024 12:48:57 +0100 Subject: [PATCH 02/10] add web clients --- .../elixir-provider/lib/elixir_provider.ex | 12 +-- .../lib/provider/data_collector_hook.ex | 27 ++++++ .../lib/provider/http_client.ex | 60 ++++++++++++ .../lib/provider/web_socket.ex | 93 +++++++++++++++++++ openfeature/providers/elixir-provider/mix.exs | 5 +- .../providers/elixir-provider/mix.lock | 4 + 6 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/http_client.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/web_socket.ex diff --git a/openfeature/providers/elixir-provider/lib/elixir_provider.ex b/openfeature/providers/elixir-provider/lib/elixir_provider.ex index 3eb29f4a61b..523bc901a8f 100644 --- a/openfeature/providers/elixir-provider/lib/elixir_provider.ex +++ b/openfeature/providers/elixir-provider/lib/elixir_provider.ex @@ -1,6 +1,4 @@ defmodule ElixirProvider do - use WebSockex - alias OpenFeature.EvaluationDetails alias ElixirProvider.ResponseFlagEvaluation alias ElixirProvider.GoFeatureFlagMetadata @@ -9,6 +7,8 @@ defmodule ElixirProvider do alias ElixirProvider.GoFeatureFlagOptions alias ElixirProvider.Types alias ElixirProvider.CacheController + alias ElixirProvider.GoFWebSocketClient + alias ElixirProvider.HttpClient @moduledoc """ The provider for GO Feature Flag, managing HTTP requests, caching, and flag evaluation. @@ -17,17 +17,15 @@ defmodule ElixirProvider do defstruct [ :options, :_http_client, - _cache_controller: nil, _data_collector_hook: nil, _ws: nil, ] @type t :: %__MODULE__{ - options: GoFeatureFlagOptions, - _cache_controller: CacheController, - _http_client: any(), + options: GoFeatureFlagOptions.t(), + _http_client: HttpClient.t(), _data_collector_hook: any(), - _ws: WebSockex, + _ws: GoFWebSocketClient.t(), } 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..aa139e87f2a --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -0,0 +1,27 @@ +defmodule ElixirProvider.DataCollectorHook do + + require Logger + + alias ElixirProvider.HttpClient + + @moduledoc """ + Handles data collection by buffering events and sending them to the relay proxy at intervals. + """ + + defstruct [ + :goff_api_controller, + :data_flush_interval, + :data_collector_metadata, + :collect_uncached_evaluation, + event_queue: [] + ] + + @type t :: %__MODULE__{ + goff_api_controller: HttpClient.t(), + data_flush_interval: non_neg_integer(), + data_collector_metadata: map(), + collect_uncached_evaluation: boolean(), + event_queue: list() + } + + 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..166c0a4f968 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -0,0 +1,60 @@ +defmodule ElixirProvider.HttpClient do + + @moduledoc """ + Handles HTTP requests to the GO Feature Flag API. + """ + + @type t :: Mint.HTTP.t() + + @spec start_http_connection(String.t()) :: {:ok, Mint.HTTP.t()} | {:error, any()} + def start_http_connection(endpoint) do + uri = URI.parse(endpoint) + scheme = if uri.scheme == "https", do: :https, else: :http + Mint.HTTP.connect(scheme, uri.host, uri.port) + end + + @spec post(Mint.HTTP.t(), String.t(), map()) :: {:ok, map()} | {:error, any()} + def post(conn, path, data) do + headers = [{"content-type", "application/json"}] + body = Jason.encode!(data) + + with {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "POST", path, 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/web_socket.ex b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex new file mode 100644 index 00000000000..e15137e0b0c --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex @@ -0,0 +1,93 @@ +defmodule ElixirProvider.GoFWebSocketClient do + use GenServer + require Logger + + 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. + """ + + @type t :: Mint.WebSocket.t() + + @websocket_uri "/ws/v1/flag/change" + + # Public API + + # Start the WebSocket client with a URL + def start_link(url) do + GenServer.start_link(__MODULE__, url, name: __MODULE__) + end + + # GenServer Callbacks + + def init(url) do + state = %{} + + # Connect to the WebSocket server + case connect(url) do + {:ok, conn, websocket} -> + Logger.info("Connected to WebSocket at #{url}") + {:ok, %{conn: conn, websocket: websocket, url: url}} + + {:error, reason} -> + Logger.error("Failed to connect to WebSocket: #{inspect(reason)}") + {:stop, reason, state} + end + end + + # Handle incoming messages and check for change notifications + def handle_info({:websocket, {:text, message}}, state) do + case Jason.decode(message) do + {:ok, %{"type" => "change"}} -> + # Clear the cache when a change message is received + CacheController.clear() + Logger.info("Cache cleared due to configuration change notification.") + + _other -> + Logger.debug("Received non-change message: #{message}") + end + + {:noreply, state} + end + + # Handle WebSocket disconnection and attempt reconnection + def handle_info({:websocket, :closed}, %{url: url} = state) do + Logger.warning("WebSocket disconnected. Attempting to reconnect...") + case connect(url) do + {:ok, conn, websocket} -> + {:noreply, %{state | conn: conn, websocket: websocket}} + + {:error, reason} -> + Logger.error("Failed to reconnect: #{inspect(reason)}") + {:stop, reason, state} + end + end + + # Private Helper Functions + + defp connect(url) do + uri = URI.parse(url) + http_scheme = if uri.scheme == "ws", do: :http, else: :https + websocket_scheme = if uri.scheme == "ws", do: :ws, else: :wss + + # Construct the WebSocket path + path = uri.path <> @websocket_uri + + with {:ok, conn} <- Mint.HTTP.connect(http_scheme, uri.host, uri.port), + {:ok, conn, ref} <- Mint.WebSocket.upgrade(websocket_scheme, conn, path, []) do + {:ok, conn, ref} + else + {:error, %Mint.HTTPError{} = error} -> + {:error, {:http_error, error.reason}} + + {:error, %Mint.TransportError{} = error} -> + {:error, {:transport_error, error.reason}} + + {:error, conn, reason} -> + {:error, {:websocket_upgrade_error, reason, conn}} + end + end + +end diff --git a/openfeature/providers/elixir-provider/mix.exs b/openfeature/providers/elixir-provider/mix.exs index 1205abe7bfe..ba862851eef 100644 --- a/openfeature/providers/elixir-provider/mix.exs +++ b/openfeature/providers/elixir-provider/mix.exs @@ -23,9 +23,8 @@ defmodule ElixirProvider.MixProject do [ {:open_feature, git: "https://github.com/open-feature/elixir-sdk.git"}, {:jason, "~> 1.4"}, - {:websockex, "~> 0.4.3"} - # {:dep_from_hexpm, "~> 0.3.0"},, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:mint, "~> 1.6"}, + {:mint_web_socket, "~> 1.0"} ] end end diff --git a/openfeature/providers/elixir-provider/mix.lock b/openfeature/providers/elixir-provider/mix.lock index 1202cc324dd..66e2e8b26ff 100644 --- a/openfeature/providers/elixir-provider/mix.lock +++ b/openfeature/providers/elixir-provider/mix.lock @@ -1,6 +1,10 @@ %{ "elixir_sdk": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []}, + "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"}, + "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"}, "open_feature": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []}, + "websocket_client": {:hex, :websocket_client, "1.5.0", "e825f23c51a867681a222148ed5200cc4a12e4fb5ff0b0b35963e916e2b5766b", [:rebar3], [], "hexpm", "2b9b201cc5c82b9d4e6966ad8e605832eab8f4ddb39f57ac62f34cb208b68de9"}, "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, } From 8bfd768a7ecea29e9068d3b489ea4eef28e645ea Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Sun, 3 Nov 2024 15:11:00 +0100 Subject: [PATCH 03/10] update data collector hook --- .../lib/provider/data_collector_hook.ex | 118 ++++++++++++++++-- .../lib/provider/feature_event.ex | 2 +- .../lib/provider/http_client.ex | 43 +++++-- 3 files changed, 138 insertions(+), 25 deletions(-) diff --git a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex index aa139e87f2a..c28375fdeef 100644 --- a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -1,27 +1,119 @@ -defmodule ElixirProvider.DataCollectorHook do - +defmodule DataCollectorHook do + use GenServer require Logger alias ElixirProvider.HttpClient + # alias OpenFeature.{EvaluationDetails, HookContext} + alias ElixirProvider.{FeatureEvent, RequestDataCollector} - @moduledoc """ - Handles data collection by buffering events and sending them to the relay proxy at intervals. - """ + @default_targeting_key "undefined-targetingKey" defstruct [ - :goff_api_controller, - :data_flush_interval, - :data_collector_metadata, - :collect_uncached_evaluation, + :http_client, + :data_collector_endpoint, + :disable_data_collection, + data_flush_interval: 60_000, event_queue: [] ] @type t :: %__MODULE__{ - goff_api_controller: HttpClient.t(), + http_client: HttpClient.t(), + data_collector_endpoint: String.t(), + disable_data_collection: boolean(), data_flush_interval: non_neg_integer(), - data_collector_metadata: map(), - collect_uncached_evaluation: boolean(), - event_queue: list() + event_queue: list(FeatureEvent.t()) } + # Starts the GenServer and initializes with options + def start_link(state) do + GenServer.start_link(__MODULE__, state, name: __MODULE__) + end + + # Initializes GenServer state and schedules the first flush + def init(state) do + schedule_collect_data(state.data_flush_interval) + {:ok, state} + end + + # Schedule periodic flush based on the interval + defp schedule_collect_data(interval) do + Process.send_after(self(), :collect_data, interval) + end + + def after_hook(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 + } + + # Send event to GenServer process to append to queue + GenServer.cast(__MODULE__, {:add_event, feature_event}) + end + end + + def error_hook(hook, hook_context, _hints) do + 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 + } + + # Send error event to GenServer process to append to queue + GenServer.call(__MODULE__, {:add_event, feature_event}) + end + end + + ### GenServer Callbacks + def handle_call({:add_event, feature_event}, state) do + {:noreply, %{state | event_queue: [feature_event | state.event_queue]}} + end + + # Handle the periodic flush + 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 the next flush + 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 + if Enum.empty?(event_queue) do + :ok + else + body = %RequestDataCollector{ + meta: %{"provider" => "open-feature-elixir-sdk"}, + events: event_queue + } + |> Jason.encode!() + + case http_client.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/feature_event.ex b/openfeature/providers/elixir-provider/lib/provider/feature_event.ex index 0f994ffdea3..d631a9183ac 100644 --- a/openfeature/providers/elixir-provider/lib/provider/feature_event.ex +++ b/openfeature/providers/elixir-provider/lib/provider/feature_event.ex @@ -2,7 +2,7 @@ defmodule ElixirProvider.FeatureEvent do @moduledoc """ Represents a feature event with details about the feature flag evaluation. """ - @enforce_keys [:kind, :context_kind, :user_key, :creation_date, :key, :variation] + @enforce_keys [:context_kind, :user_key, :creation_date, :key, :variation] defstruct [kind: "feature", context_kind: "", user_key: "", diff --git a/openfeature/providers/elixir-provider/lib/provider/http_client.ex b/openfeature/providers/elixir-provider/lib/provider/http_client.ex index 166c0a4f968..c663bd57502 100644 --- a/openfeature/providers/elixir-provider/lib/provider/http_client.ex +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -1,24 +1,46 @@ defmodule ElixirProvider.HttpClient do - @moduledoc """ Handles HTTP requests to the GO Feature Flag API. """ - @type t :: Mint.HTTP.t() + # 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() + } - @spec start_http_connection(String.t()) :: {:ok, Mint.HTTP.t()} | {:error, any()} - def start_http_connection(endpoint) do - uri = URI.parse(endpoint) + @spec start_http_connection(client :: t()) :: {:ok, t()} | {:error, any()} + def start_http_connection(client) do + uri = URI.parse(client.endpoint) scheme = if uri.scheme == "https", do: :https, else: :http - Mint.HTTP.connect(scheme, uri.host, uri.port) + + case Mint.HTTP.connect(scheme, uri.host, uri.port) do + {:ok, conn} -> + # Create the struct with the connection, endpoint, and default headers + config = %__MODULE__{ + conn: conn, + endpoint: client.endpoint, + headers: [{"content-type", "application/json"}] + } + + {:ok, config} + + {:error, reason} -> + {:error, reason} + end end - @spec post(Mint.HTTP.t(), String.t(), map()) :: {:ok, map()} | {:error, any()} - def post(conn, path, data) do - headers = [{"content-type", "application/json"}] + @spec post(t(), String.t(), map()) :: {:ok, map()} | {:error, any()} + def post(%__MODULE__{conn: conn, endpoint: endpoint, headers: headers}, path, data) do + # Full URL path + url = URI.merge(endpoint, path) |> URI.to_string() body = Jason.encode!(data) - with {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "POST", path, headers, body), + # Make the POST request using the existing connection + 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 @@ -56,5 +78,4 @@ defmodule ElixirProvider.HttpClient do 5_000 -> {:error, :timeout} end end - end From 11e53eab8b5164d8983e6a9e2b982104c0f8e1fb Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Sun, 3 Nov 2024 19:18:01 +0100 Subject: [PATCH 04/10] add provider --- .../elixir-provider/lib/elixir_provider.ex | 139 ++++++++++++++++-- .../lib/provider/application.ex | 16 -- .../lib/provider/context_transformer.ex | 8 +- .../lib/provider/data_collector_hook.ex | 33 ++++- .../lib/provider/evaluation_context.ex | 2 +- .../lib/provider/flag_options.ex | 2 +- .../lib/provider/http_client.ex | 7 +- .../lib/provider/request_flag_evaluation.ex | 4 +- .../lib/provider/response_flag_evalution.ex | 15 ++ 9 files changed, 176 insertions(+), 50 deletions(-) diff --git a/openfeature/providers/elixir-provider/lib/elixir_provider.ex b/openfeature/providers/elixir-provider/lib/elixir_provider.ex index 523bc901a8f..96648f51f84 100644 --- a/openfeature/providers/elixir-provider/lib/elixir_provider.ex +++ b/openfeature/providers/elixir-provider/lib/elixir_provider.ex @@ -1,32 +1,139 @@ defmodule ElixirProvider do - alias OpenFeature.EvaluationDetails - alias ElixirProvider.ResponseFlagEvaluation - alias ElixirProvider.GoFeatureFlagMetadata - alias ElixirProvider.ContextTransformer - alias ElixirProvider.RequestFlagEvaluation + @behaviour OpenFeature.Provider + + alias OpenFeature.ResolutionDetails alias ElixirProvider.GoFeatureFlagOptions - alias ElixirProvider.Types + alias ElixirProvider.HttpClient + alias ElixirProvider.DataCollectorHook alias ElixirProvider.CacheController + alias ElixirProvider.ResponseFlagEvaluation alias ElixirProvider.GoFWebSocketClient - alias ElixirProvider.HttpClient + alias ElixirProvider.RequestFlagEvaluation + alias ElixirProvider.ContextTransformer + alias ElixirProvider.GofEvaluationContext @moduledoc """ - The provider for GO Feature Flag, managing HTTP requests, caching, and flag evaluation. + The GO Feature Flag provider for OpenFeature, managing HTTP requests, caching, and flag evaluation. """ defstruct [ :options, - :_http_client, - _data_collector_hook: nil, - _ws: nil, + :http_client, + :data_collector_hook, + :ws, + :domain ] @type t :: %__MODULE__{ - options: GoFeatureFlagOptions.t(), - _http_client: HttpClient.t(), - _data_collector_hook: any(), - _ws: GoFWebSocketClient.t(), - } + options: GoFeatureFlagOptions.t(), + http_client: HttpClient.t(), + data_collector_hook: DataCollectorHook.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) + CacheController.start_link(provider.options) + {:ok, data_collector_hook} = DataCollectorHook.start_link(provider.options, http_client) + {:ok, ws} = GoFWebSocketClient.start_link(provider.options.endpoint) + + updated_provider = %__MODULE__{ + provider + | domain: domain, + http_client: http_client, + data_collector_hook: data_collector_hook, + ws: ws + } + + {:ok, updated_provider} + end + + @impl true + def shutdown(%__MODULE__{ws: ws} = provider) do + Process.exit(ws, :normal) + CacheController.clear() + if provider.data_collector_hook, do: DataCollectorHook.shutdown(provider.data_collector_hook) + :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) + + 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(provider.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 + 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/application.ex b/openfeature/providers/elixir-provider/lib/provider/application.ex index 5a6faa7b2e4..e69de29bb2d 100644 --- a/openfeature/providers/elixir-provider/lib/provider/application.ex +++ b/openfeature/providers/elixir-provider/lib/provider/application.ex @@ -1,16 +0,0 @@ -defmodule OpenFeature.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [ - OpenFeature.Store, - OpenFeature.EventEmitter - ] - - opts = [strategy: :one_for_one, name: OpenFeature.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex index 457961358d4..23543d4f0cd 100644 --- a/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex +++ b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex @@ -2,7 +2,7 @@ defmodule ElixirProvider.ContextTransformer do @moduledoc """ Converts an OpenFeature EvaluationContext into a GO Feature Flag context. """ - alias ElixirProvider.EvaluationContext + alias ElixirProvider.GofEvaluationContext alias OpenFeature.Types @doc """ @@ -16,14 +16,14 @@ defmodule ElixirProvider.ContextTransformer do end @doc """ - Converts an EvaluationContext map into a ElixirProvider.EvaluationContext struct. + 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, EvaluationContext.t()} | {:error, String.t()} + @spec transform_context(Types.context()) :: {:ok, GofEvaluationContext.t()} | {:error, String.t()} def transform_context(ctx) do case get_any_value(ctx) do {:ok, {key, value}} -> - {:ok, %EvaluationContext{ + {:ok, %GofEvaluationContext{ key: key, custom: value }} diff --git a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex index c28375fdeef..cc7436033fd 100644 --- a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -1,4 +1,4 @@ -defmodule DataCollectorHook do +defmodule ElixirProvider.DataCollectorHook do use GenServer require Logger @@ -25,12 +25,32 @@ defmodule DataCollectorHook do } # Starts the GenServer and initializes with options - def start_link(state) do - GenServer.start_link(__MODULE__, state, name: __MODULE__) + def start_link(options, http_client) do + GenServer.start_link(__MODULE__, {options, http_client: http_client}, name: __MODULE__) + end + + def shutdown(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 # Initializes GenServer state and schedules the first flush - def init(state) do + def init(args) do + state = %__MODULE__{ + http_client: args.http_client, + data_collector_endpoint: args.options.endpoint, + disable_data_collection: args.options.disable_data_collection || false, + data_flush_interval: args.options.data_flush_interval || 60_000, + event_queue: [] + } + schedule_collect_data(state.data_flush_interval) {:ok, state} end @@ -40,6 +60,8 @@ defmodule DataCollectorHook do Process.send_after(self(), :collect_data, interval) end + ### Hook Implementations + def after_hook(hook, hook_context, flag_evaluation_details, _hints) do if hook.disable_data_collection or flag_evaluation_details.reason != :CACHED do :ok @@ -103,9 +125,8 @@ defmodule DataCollectorHook do meta: %{"provider" => "open-feature-elixir-sdk"}, events: event_queue } - |> Jason.encode!() - case http_client.post(http_client, endpoint, body) do + case HttpClient.post(http_client, endpoint, body) do {:ok, response} -> Logger.info("Data sent successfully: #{inspect(response)}") :ok diff --git a/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex index 500ee82796c..5a7ae148b98 100644 --- a/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex +++ b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex @@ -1,4 +1,4 @@ -defmodule ElixirProvider.EvaluationContext do +defmodule ElixirProvider.GofEvaluationContext do @moduledoc """ GoFeatureFlagEvaluationContext is an object representing a user context for evaluation. """ diff --git a/openfeature/providers/elixir-provider/lib/provider/flag_options.ex b/openfeature/providers/elixir-provider/lib/provider/flag_options.ex index 879b0fd6f99..871d67f7148 100644 --- a/openfeature/providers/elixir-provider/lib/provider/flag_options.ex +++ b/openfeature/providers/elixir-provider/lib/provider/flag_options.ex @@ -15,7 +15,7 @@ defmodule ElixirProvider.GoFeatureFlagOptions do endpoint: String.t(), cache_size: integer() | nil, data_flush_interval: integer() | nil, - disable_data_collection: integer() | nil, + disable_data_collection: boolean(), reconnect_interval: integer() | nil, disable_cache_invalidation: boolean() | nil } diff --git a/openfeature/providers/elixir-provider/lib/provider/http_client.ex b/openfeature/providers/elixir-provider/lib/provider/http_client.ex index c663bd57502..df60b45a951 100644 --- a/openfeature/providers/elixir-provider/lib/provider/http_client.ex +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -12,9 +12,8 @@ defmodule ElixirProvider.HttpClient do headers: list() } - @spec start_http_connection(client :: t()) :: {:ok, t()} | {:error, any()} - def start_http_connection(client) do - uri = URI.parse(client.endpoint) + 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 @@ -22,7 +21,7 @@ defmodule ElixirProvider.HttpClient do # Create the struct with the connection, endpoint, and default headers config = %__MODULE__{ conn: conn, - endpoint: client.endpoint, + endpoint: options.endpoint, headers: [{"content-type", "application/json"}] } diff --git a/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex index 141b4ade49e..ddf8a7fccd3 100644 --- a/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex +++ b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex @@ -2,13 +2,13 @@ defmodule ElixirProvider.RequestFlagEvaluation do @moduledoc """ RequestFlagEvaluation is an object representing a user context for evaluation. """ - alias ElixirProvider.EvaluationContext + alias ElixirProvider.GofEvaluationContext @enforce_keys [:user] defstruct [:default_value, :user] @type t :: %__MODULE__{ - user: EvaluationContext.t(), + 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 index 92347efa746..4202b703782 100644 --- a/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex +++ b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex @@ -26,4 +26,19 @@ defmodule ElixirProvider.ResponseFlagEvaluation do 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 From 28f898358ac8e7cb988267555bf9f0fd16d15990 Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Wed, 6 Nov 2024 19:46:00 +0100 Subject: [PATCH 05/10] code refactor --- .../elixir-provider/lib/elixir_provider.ex | 138 +------------ .../lib/provider/application.ex | 20 ++ .../lib/provider/cache_controller.ex | 8 +- .../lib/provider/data_collector_hook.ex | 39 ++-- .../elixir-provider/lib/provider/provider.ex | 140 +++++++++++++ .../lib/provider/server_supervisor.ex | 18 ++ .../lib/provider/web_socket.ex | 185 +++++++++++++----- 7 files changed, 340 insertions(+), 208 deletions(-) create mode 100644 openfeature/providers/elixir-provider/lib/provider/provider.ex create mode 100644 openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex diff --git a/openfeature/providers/elixir-provider/lib/elixir_provider.ex b/openfeature/providers/elixir-provider/lib/elixir_provider.ex index 96648f51f84..6cb49973eea 100644 --- a/openfeature/providers/elixir-provider/lib/elixir_provider.ex +++ b/openfeature/providers/elixir-provider/lib/elixir_provider.ex @@ -1,139 +1,7 @@ defmodule ElixirProvider do - @behaviour OpenFeature.Provider - - alias OpenFeature.ResolutionDetails - alias ElixirProvider.GoFeatureFlagOptions - alias ElixirProvider.HttpClient - alias ElixirProvider.DataCollectorHook - alias ElixirProvider.CacheController - alias ElixirProvider.ResponseFlagEvaluation - alias ElixirProvider.GoFWebSocketClient - alias ElixirProvider.RequestFlagEvaluation - alias ElixirProvider.ContextTransformer - alias ElixirProvider.GofEvaluationContext - @moduledoc """ - The GO Feature Flag provider for OpenFeature, managing HTTP requests, caching, and flag evaluation. - """ - - defstruct [ - :options, - :http_client, - :data_collector_hook, - :ws, - :domain - ] - - @type t :: %__MODULE__{ - options: GoFeatureFlagOptions.t(), - http_client: HttpClient.t(), - data_collector_hook: DataCollectorHook.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) - CacheController.start_link(provider.options) - {:ok, data_collector_hook} = DataCollectorHook.start_link(provider.options, http_client) - {:ok, ws} = GoFWebSocketClient.start_link(provider.options.endpoint) - - updated_provider = %__MODULE__{ - provider - | domain: domain, - http_client: http_client, - data_collector_hook: data_collector_hook, - ws: ws - } - - {:ok, updated_provider} - end - - @impl true - def shutdown(%__MODULE__{ws: ws} = provider) do - Process.exit(ws, :normal) - CacheController.clear() - if provider.data_collector_hook, do: DataCollectorHook.shutdown(provider.data_collector_hook) - :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) - - 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(provider.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 - 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 + `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 index e69de29bb2d..b81153a4f3b 100644 --- a/openfeature/providers/elixir-provider/lib/provider/application.ex +++ 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 index 52cd35c8e30..3b63367db95 100644 --- a/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex +++ b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex @@ -6,10 +6,9 @@ defmodule ElixirProvider.CacheController do use GenServer @flag_table :flag_cache - @spec start_link(Keyword.t()) :: GenServer.on_start() - def start_link(opts) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, :ok, name: name) + @spec start_link() :: GenServer.on_start() + def start_link() do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end def get(flag_key, evaluation_hash) do @@ -27,6 +26,7 @@ defmodule ElixirProvider.CacheController do end def clear do + GenServer.stop(__MODULE__) :ets.delete_all_objects(@flag_table) :ets.insert(@flag_table, {:context, %{}}) :ok diff --git a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex index cc7436033fd..10722424e72 100644 --- a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -1,9 +1,9 @@ defmodule ElixirProvider.DataCollectorHook do + use GenServer require Logger alias ElixirProvider.HttpClient - # alias OpenFeature.{EvaluationDetails, HookContext} alias ElixirProvider.{FeatureEvent, RequestDataCollector} @default_targeting_key "undefined-targetingKey" @@ -25,11 +25,11 @@ defmodule ElixirProvider.DataCollectorHook do } # Starts the GenServer and initializes with options - def start_link(options, http_client) do - GenServer.start_link(__MODULE__, {options, http_client: http_client}, name: __MODULE__) + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) end - def shutdown(state) do + def stop(state) do GenServer.stop(__MODULE__) collect_data(state.data_flush_interval) %__MODULE__{ @@ -41,13 +41,18 @@ defmodule ElixirProvider.DataCollectorHook do } end - # Initializes GenServer state and schedules the first flush - def init(args) do + @impl true + def init([]) do + {:ok, %__MODULE__{}} + end + + # Initializes the state with the provided options + def start(options, http_client) do state = %__MODULE__{ - http_client: args.http_client, - data_collector_endpoint: args.options.endpoint, - disable_data_collection: args.options.disable_data_collection || false, - data_flush_interval: args.options.data_flush_interval || 60_000, + http_client: http_client, + data_collector_endpoint: options.endpoint, + disable_data_collection: options.disable_data_collection || false, + data_flush_interval: options.data_flush_interval || 60_000, event_queue: [] } @@ -55,13 +60,12 @@ defmodule ElixirProvider.DataCollectorHook do {:ok, state} end - # Schedule periodic flush based on the interval + # Schedule periodic data collection based on the interval defp schedule_collect_data(interval) do Process.send_after(self(), :collect_data, interval) end ### Hook Implementations - def after_hook(hook, hook_context, flag_evaluation_details, _hints) do if hook.disable_data_collection or flag_evaluation_details.reason != :CACHED do :ok @@ -76,12 +80,11 @@ defmodule ElixirProvider.DataCollectorHook do user_key: Map.get(hook_context.evaluation_context, "targeting_key") || @default_targeting_key } - # Send event to GenServer process to append to queue GenServer.cast(__MODULE__, {:add_event, feature_event}) end end - def error_hook(hook, hook_context, _hints) do + def error(hook, hook_context, _hints) do if hook.disable_data_collection do :ok else @@ -95,24 +98,24 @@ defmodule ElixirProvider.DataCollectorHook do user_key: Map.get(hook_context.context, "targeting_key") || @default_targeting_key } - # Send error event to GenServer process to append to queue GenServer.call(__MODULE__, {:add_event, feature_event}) end end ### GenServer Callbacks - def handle_call({:add_event, feature_event}, state) do - {:noreply, %{state | event_queue: [feature_event | state.event_queue]}} + @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 the next flush schedule_collect_data(state.data_flush_interval) {:noreply, %{state | event_queue: []}} 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..97f979c3ba1 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/provider.ex @@ -0,0 +1,140 @@ +defmodule ElixirProvider.Provider do + @behaviour OpenFeature.Provider + + alias OpenFeature.ResolutionDetails + alias ElixirProvider.GoFeatureFlagOptions + alias ElixirProvider.HttpClient + alias ElixirProvider.DataCollectorHook + alias ElixirProvider.CacheController + alias ElixirProvider.ResponseFlagEvaluation + alias ElixirProvider.GoFWebSocketClient + alias ElixirProvider.RequestFlagEvaluation + alias ElixirProvider.ContextTransformer + alias ElixirProvider.GofEvaluationContext + + @moduledoc """ + The GO Feature Flag provider for OpenFeature, managing HTTP requests, caching, and flag evaluation. + """ + + defstruct [ + :options, + :http_client, + :data_collector_hook, + :ws, + :domain + ] + + @type t :: %__MODULE__{ + options: GoFeatureFlagOptions.t(), + http_client: HttpClient.t(), + data_collector_hook: DataCollectorHook.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) + CacheController.start_link() + {:ok, data_collector_hook} = DataCollectorHook.start(provider.options, http_client) + {:ok, ws} = GoFWebSocketClient.connect(provider.options.endpoint) + + updated_provider = %__MODULE__{ + provider + | domain: domain, + http_client: http_client, + data_collector_hook: data_collector_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.data_collector_hook)) + :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) + + 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(provider.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 + 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/server_supervisor.ex b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex new file mode 100644 index 00000000000..c4411f575a5 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex @@ -0,0 +1,18 @@ +defmodule ElixirProvider.ServerSupervisor do + use Supervisor + + def start_link(args) do + Supervisor.start_link(__MODULE__, [args], name: __MODULE__) + end + + @impl true + def init([_args]) do + children = [ + ElixirProvider.GoFWebSocketClient, + ElixirProvider.CacheController, + ElixirProvider.DataCollectorHook + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex index e15137e0b0c..90fafecfc5f 100644 --- a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex +++ b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex @@ -1,6 +1,8 @@ defmodule ElixirProvider.GoFWebSocketClient do use GenServer + require Logger + require Mint.HTTP alias ElixirProvider.CacheController @@ -9,85 +11,166 @@ defmodule ElixirProvider.GoFWebSocketClient do Clears the cache on receiving change notifications. """ - @type t :: Mint.WebSocket.t() + 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" - # Public API + 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 - # Start the WebSocket client with a URL - def start_link(url) do - GenServer.start_link(__MODULE__, url, name: __MODULE__) + @impl true + def init([]) do + {:ok, %__MODULE__{}} end - # GenServer Callbacks + @impl true + def handle_call({:connect, url}, from, state) do + uri = URI.parse(url) - def init(url) do - state = %{} + http_scheme = + case uri.scheme do + "ws" -> :http + "wss" -> :https + end - # Connect to the WebSocket server - case connect(url) do - {:ok, conn, websocket} -> - Logger.info("Connected to WebSocket at #{url}") - {:ok, %{conn: conn, websocket: websocket, url: url}} + ws_scheme = + case uri.scheme do + "ws" -> :ws + "wss" -> :wss + end + # Construct the WebSocket path + path = uri.path <> @websocket_uri + + with {:ok, conn} <- Mint.HTTP.connect(http_scheme, uri.host, uri.port), + {: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.error("Failed to connect to WebSocket: #{inspect(reason)}") - {:stop, reason, state} + {:reply, {:error, reason}, state} + + {:error, conn, reason} -> + {:reply, {:error, reason}, put_in(state.conn, conn)} end end - # Handle incoming messages and check for change notifications - def handle_info({:websocket, {:text, message}}, state) do - case Jason.decode(message) do - {:ok, %{"type" => "change"}} -> - # Clear the cache when a change message is received - CacheController.clear() - Logger.info("Cache cleared due to configuration change notification.") + @impl GenServer + def handle_info(message, state) do + 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} - _other -> - Logger.debug("Received non-change message: #{message}") + {: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 - {:noreply, state} + defp handle_responses(%{request_ref: ref} = state, [{:headers, ref, resp_headers} | rest]) do + put_in(state.resp_headers, resp_headers) + |> handle_responses(rest) end - # Handle WebSocket disconnection and attempt reconnection - def handle_info({:websocket, :closed}, %{url: url} = state) do - Logger.warning("WebSocket disconnected. Attempting to reconnect...") - case connect(url) do + 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} -> - {:noreply, %{state | conn: conn, websocket: websocket}} + %{state | conn: conn, websocket: websocket, status: nil, resp_headers: nil} + |> reply({:ok, :connected}) + |> handle_responses(rest) - {:error, reason} -> - Logger.error("Failed to reconnect: #{inspect(reason)}") - {:stop, reason, state} + {:error, conn, reason} -> + put_in(state.conn, conn) + |> reply({:error, reason}) end end - # Private Helper Functions + 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 connect(url) do - uri = URI.parse(url) - http_scheme = if uri.scheme == "ws", do: :http, else: :https - websocket_scheme = if uri.scheme == "ws", do: :ws, else: :wss + defp handle_responses(state, [_response | rest]) do + handle_responses(state, rest) + end - # Construct the WebSocket path - path = uri.path <> @websocket_uri + defp handle_responses(state, []), do: state - with {:ok, conn} <- Mint.HTTP.connect(http_scheme, uri.host, uri.port), - {:ok, conn, ref} <- Mint.WebSocket.upgrade(websocket_scheme, conn, path, []) do - {:ok, conn, ref} - else - {:error, %Mint.HTTPError{} = error} -> - {:error, {:http_error, error.reason}} + def handle_frames(state, frames) do + Enum.reduce(frames, state, fn + {:close, _code, reason}, state -> + Logger.debug("Closing connection: #{inspect(reason)}") + %{state | closing?: true} - {:error, %Mint.TransportError{} = error} -> - {:error, {:transport_error, error.reason}} + {:text, text}, state -> - {:error, conn, reason} -> - {:error, {:websocket_upgrade_error, reason, conn}} - end + 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("Comfy 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 From c534b2545dcb4371aee57da46d6b44e1836aac83 Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Wed, 6 Nov 2024 20:02:36 +0100 Subject: [PATCH 06/10] update super server --- .../lib/provider/http_client.ex | 25 +++++++++++++++++++ .../elixir-provider/lib/provider/provider.ex | 1 - .../lib/provider/server_supervisor.ex | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/openfeature/providers/elixir-provider/lib/provider/http_client.ex b/openfeature/providers/elixir-provider/lib/provider/http_client.ex index df60b45a951..265c7b273db 100644 --- a/openfeature/providers/elixir-provider/lib/provider/http_client.ex +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -3,6 +3,8 @@ defmodule ElixirProvider.HttpClient do Handles HTTP requests to the GO Feature Flag API. """ + use GenServer + # Define a struct to store HTTP connection, endpoint, and other configuration details defstruct [:conn, :endpoint, :headers] @@ -12,6 +14,29 @@ defmodule ElixirProvider.HttpClient do headers: list() } + @spec start_link() :: GenServer.on_start() + def start_link() do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def stop() do + GenServer.stop(__MODULE__) + end + + @impl true + def init([]) do + {:ok, %__MODULE__{}} + end + + @spec start_http_connection(any()) :: + {:error, + %{ + :__exception__ => true, + :__struct__ => Mint.HTTPError | Mint.TransportError, + :reason => any(), + optional(:module) => any() + }} + | {:ok, ElixirProvider.HttpClient.t()} def start_http_connection(options) do uri = URI.parse(options.endpoint) scheme = if uri.scheme == "https", do: :https, else: :http diff --git a/openfeature/providers/elixir-provider/lib/provider/provider.ex b/openfeature/providers/elixir-provider/lib/provider/provider.ex index 97f979c3ba1..106592af709 100644 --- a/openfeature/providers/elixir-provider/lib/provider/provider.ex +++ b/openfeature/providers/elixir-provider/lib/provider/provider.ex @@ -35,7 +35,6 @@ defmodule ElixirProvider.Provider do @impl true def initialize(%__MODULE__{} = provider, domain, _context) do {:ok, http_client} = HttpClient.start_http_connection(provider.options) - CacheController.start_link() {:ok, data_collector_hook} = DataCollectorHook.start(provider.options, http_client) {:ok, ws} = GoFWebSocketClient.connect(provider.options.endpoint) diff --git a/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex index c4411f575a5..66105582c45 100644 --- a/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex +++ b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex @@ -8,6 +8,7 @@ defmodule ElixirProvider.ServerSupervisor do @impl true def init([_args]) do children = [ + ElixirProvider.HttpClient, ElixirProvider.GoFWebSocketClient, ElixirProvider.CacheController, ElixirProvider.DataCollectorHook From f5dc8251f44b4c097b9049baf7c43fba403dc03a Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Thu, 5 Dec 2024 18:04:08 +0100 Subject: [PATCH 07/10] add context transformer tests --- .../providers/elixir-provider/.credo.exs | 217 ++++++++++++++++++ .../lib/provider/cache_controller.ex | 5 +- .../lib/provider/context_transformer.ex | 30 +-- .../lib/provider/data_collector_hook.ex | 21 +- .../lib/provider/http_client.ex | 6 +- .../elixir-provider/lib/provider/provider.ex | 25 +- .../lib/provider/server_supervisor.ex | 3 + .../lib/provider/web_socket.ex | 42 ++-- openfeature/providers/elixir-provider/mix.exs | 5 +- .../providers/elixir-provider/mix.lock | 3 + .../test/elixir_provider_test.exs | 81 ++++++- 11 files changed, 379 insertions(+), 59 deletions(-) create mode 100644 openfeature/providers/elixir-provider/.credo.exs 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/lib/provider/cache_controller.ex b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex index 3b63367db95..27889fb1f79 100644 --- a/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex +++ b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex @@ -7,12 +7,13 @@ defmodule ElixirProvider.CacheController do @flag_table :flag_cache @spec start_link() :: GenServer.on_start() - def start_link() do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + def start_link 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 diff --git a/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex index 23543d4f0cd..87bc1d272ea 100644 --- a/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex +++ b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex @@ -6,29 +6,31 @@ defmodule ElixirProvider.ContextTransformer do alias OpenFeature.Types @doc """ - Finds any key-value pair with a non-nil value. + Extracts other key value pairs after the targeting key """ def get_any_value(map) when is_map(map) do - case Enum.find(map, fn {_key, value} -> value != nil end) do - {key, value} -> {:ok, {key, value}} - nil -> {:error, "No keys found with a value"} - end + 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()} + @spec transform_context(Types.context()) :: + {:ok, GofEvaluationContext.t()} | {:error, String.t()} def transform_context(ctx) do - case get_any_value(ctx) do - {:ok, {key, value}} -> - {:ok, %GofEvaluationContext{ - key: key, - custom: value - }} - {:error, reason} -> - {:error, reason} + 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_hook.ex b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex index 10722424e72..722e85b5849 100644 --- a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -1,4 +1,7 @@ defmodule ElixirProvider.DataCollectorHook do + @moduledoc """ + Data collector hook + """ use GenServer require Logger @@ -25,13 +28,14 @@ defmodule ElixirProvider.DataCollectorHook do } # Starts the GenServer and initializes with options - def start_link() do + def start_link 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, @@ -71,13 +75,15 @@ defmodule ElixirProvider.DataCollectorHook do :ok else feature_event = %FeatureEvent{ - context_kind: if(Map.get(hook_context.context, "anonymous"), do: "anonymousUser", else: "user"), + 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 + user_key: + Map.get(hook_context.evaluation_context, "targeting_key") || @default_targeting_key } GenServer.cast(__MODULE__, {:add_event, feature_event}) @@ -89,7 +95,8 @@ defmodule ElixirProvider.DataCollectorHook do :ok else feature_event = %FeatureEvent{ - context_kind: if(Map.get(hook_context.context, "anonymous"), do: "anonymousUser", else: "user"), + 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, @@ -120,7 +127,11 @@ defmodule ElixirProvider.DataCollectorHook do {:noreply, %{state | event_queue: []}} end - defp collect_data(%__MODULE__{event_queue: event_queue, http_client: http_client, data_collector_endpoint: endpoint}) do + defp collect_data(%__MODULE__{ + event_queue: event_queue, + http_client: http_client, + data_collector_endpoint: endpoint + }) do if Enum.empty?(event_queue) do :ok else diff --git a/openfeature/providers/elixir-provider/lib/provider/http_client.ex b/openfeature/providers/elixir-provider/lib/provider/http_client.ex index 265c7b273db..0786da26de0 100644 --- a/openfeature/providers/elixir-provider/lib/provider/http_client.ex +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -15,11 +15,11 @@ defmodule ElixirProvider.HttpClient do } @spec start_link() :: GenServer.on_start() - def start_link() do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + def start_link do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end - def stop() do + def stop do GenServer.stop(__MODULE__) end diff --git a/openfeature/providers/elixir-provider/lib/provider/provider.ex b/openfeature/providers/elixir-provider/lib/provider/provider.ex index 106592af709..2eee0405177 100644 --- a/openfeature/providers/elixir-provider/lib/provider/provider.ex +++ b/openfeature/providers/elixir-provider/lib/provider/provider.ex @@ -1,16 +1,16 @@ defmodule ElixirProvider.Provider do @behaviour OpenFeature.Provider - alias OpenFeature.ResolutionDetails - alias ElixirProvider.GoFeatureFlagOptions - alias ElixirProvider.HttpClient - alias ElixirProvider.DataCollectorHook alias ElixirProvider.CacheController - alias ElixirProvider.ResponseFlagEvaluation - alias ElixirProvider.GoFWebSocketClient - alias ElixirProvider.RequestFlagEvaluation 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.ResolutionDetails @moduledoc """ The GO Feature Flag provider for OpenFeature, managing HTTP requests, caching, and flag evaluation. @@ -54,7 +54,11 @@ defmodule ElixirProvider.Provider do Process.exit(ws, :normal) CacheController.clear() if(GenServer.whereis(GoFWebSocketClient), do: GoFWebSocketClient.stop()) - if(GenServer.whereis(DataCollectorHook), do: DataCollectorHook.stop(provider.data_collector_hook)) + + if(GenServer.whereis(DataCollectorHook), + do: DataCollectorHook.stop(provider.data_collector_hook) + ) + :ok end @@ -128,12 +132,13 @@ defmodule ElixirProvider.Provider do {:ok, %ResolutionDetails{value: val, reason: reason}} _ -> - {:error, {:variant_not_found, "Expected #{type} but got #{inspect(value)} for flag #{flag_key}"}} + {: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/server_supervisor.ex b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex index 66105582c45..7d579ff653d 100644 --- a/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex +++ b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex @@ -1,4 +1,7 @@ defmodule ElixirProvider.ServerSupervisor do + @moduledoc """ + Supervisor + """ use Supervisor def start_link(args) do diff --git a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex index 90fafecfc5f..00fc74ea217 100644 --- a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex +++ b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex @@ -14,14 +14,14 @@ defmodule ElixirProvider.GoFWebSocketClient do 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() - } + 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" @@ -32,7 +32,7 @@ defmodule ElixirProvider.GoFWebSocketClient do end end - def stop() do + def stop do GenServer.stop(__MODULE__) end @@ -118,16 +118,16 @@ defmodule ElixirProvider.GoFWebSocketClient do {: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 + 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 @@ -143,7 +143,6 @@ defmodule ElixirProvider.GoFWebSocketClient do %{state | closing?: true} {:text, text}, state -> - response = Jason.decode!(text) case Map.get(response, "type") do @@ -152,7 +151,8 @@ defmodule ElixirProvider.GoFWebSocketClient do CacheController.clear() Logger.info("Cache cleared due to configuration change notification.") - _ -> nil + _ -> + nil end state diff --git a/openfeature/providers/elixir-provider/mix.exs b/openfeature/providers/elixir-provider/mix.exs index ba862851eef..b44b7091c00 100644 --- a/openfeature/providers/elixir-provider/mix.exs +++ b/openfeature/providers/elixir-provider/mix.exs @@ -7,7 +7,7 @@ defmodule ElixirProvider.MixProject do version: "0.1.0", elixir: "~> 1.17", start_permanent: Mix.env() == :prod, - deps: deps(), + deps: deps() ] end @@ -24,7 +24,8 @@ defmodule ElixirProvider.MixProject do {:open_feature, git: "https://github.com/open-feature/elixir-sdk.git"}, {:jason, "~> 1.4"}, {:mint, "~> 1.6"}, - {:mint_web_socket, "~> 1.0"} + {:mint_web_socket, "~> 1.0"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false} ] end end diff --git a/openfeature/providers/elixir-provider/mix.lock b/openfeature/providers/elixir-provider/mix.lock index 66e2e8b26ff..d145ceabf6b 100644 --- a/openfeature/providers/elixir-provider/mix.lock +++ b/openfeature/providers/elixir-provider/mix.lock @@ -1,5 +1,8 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "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"}, "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"}, "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"}, diff --git a/openfeature/providers/elixir-provider/test/elixir_provider_test.exs b/openfeature/providers/elixir-provider/test/elixir_provider_test.exs index 35b6a5568bb..23131f98d15 100644 --- a/openfeature/providers/elixir-provider/test/elixir_provider_test.exs +++ b/openfeature/providers/elixir-provider/test/elixir_provider_test.exs @@ -1,8 +1,85 @@ defmodule ElixirProviderTest do + @moduledoc """ + Test file + """ use ExUnit.Case doctest ElixirProvider - test "greets the world" do - assert ElixirProvider.hello() == :world + ## 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 end From 50859b4f135481cfef8ffe5630db318a62b3b8c2 Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Fri, 6 Dec 2024 22:44:58 +0100 Subject: [PATCH 08/10] add updates --- .../elixir-provider/lib/provider/provider.ex | 16 +- .../lib/provider/web_socket.ex | 34 +-- openfeature/providers/elixir-provider/mix.exs | 4 +- .../providers/elixir-provider/mix.lock | 10 + .../test/elixir_provider_test.exs | 195 ++++++++++++------ 5 files changed, 172 insertions(+), 87 deletions(-) diff --git a/openfeature/providers/elixir-provider/lib/provider/provider.ex b/openfeature/providers/elixir-provider/lib/provider/provider.ex index 2eee0405177..98dc79abf00 100644 --- a/openfeature/providers/elixir-provider/lib/provider/provider.ex +++ b/openfeature/providers/elixir-provider/lib/provider/provider.ex @@ -1,6 +1,7 @@ defmodule ElixirProvider.Provider do @behaviour OpenFeature.Provider + require Logger alias ElixirProvider.CacheController alias ElixirProvider.ContextTransformer alias ElixirProvider.DataCollectorHook @@ -19,15 +20,17 @@ defmodule ElixirProvider.Provider do defstruct [ :options, :http_client, - :data_collector_hook, + :hooks, :ws, - :domain + :domain, + name: "ElixirProvider" ] @type t :: %__MODULE__{ + name: String.t(), options: GoFeatureFlagOptions.t(), http_client: HttpClient.t(), - data_collector_hook: DataCollectorHook.t() | nil, + hooks: DataCollectorHook.t() | nil, ws: GoFWebSocketClient.t(), domain: String.t() } @@ -35,14 +38,14 @@ defmodule ElixirProvider.Provider do @impl true def initialize(%__MODULE__{} = provider, domain, _context) do {:ok, http_client} = HttpClient.start_http_connection(provider.options) - {:ok, data_collector_hook} = DataCollectorHook.start(provider.options, http_client) + {: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, - data_collector_hook: data_collector_hook, + hooks: hooks, ws: ws } @@ -56,7 +59,7 @@ defmodule ElixirProvider.Provider do if(GenServer.whereis(GoFWebSocketClient), do: GoFWebSocketClient.stop()) if(GenServer.whereis(DataCollectorHook), - do: DataCollectorHook.stop(provider.data_collector_hook) + do: DataCollectorHook.stop(provider.hooks) ) :ok @@ -104,6 +107,7 @@ defmodule ElixirProvider.Provider do end defp handle_response(flag_key, eval_context_hash, response) do + Logger.debug("Unexpected frame received: #{inspect("here")}") # Build the flag evaluation struct directly from the response map flag_eval = ResponseFlagEvaluation.decode(response) diff --git a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex index 00fc74ea217..c5e297d855e 100644 --- a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex +++ b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex @@ -23,7 +23,7 @@ defmodule ElixirProvider.GoFWebSocketClient do closing?: boolean() } - @websocket_uri "/ws/v1/flag/change" + @websocket_uri "ws/v1/flag/change" def connect(url) do with {:ok, socket} <- GenServer.start_link(__MODULE__, [], name: __MODULE__), @@ -45,36 +45,42 @@ defmodule ElixirProvider.GoFWebSocketClient do def handle_call({:connect, url}, from, state) do uri = URI.parse(url) - http_scheme = + {http_scheme, ws_scheme} = case uri.scheme do - "ws" -> :http - "wss" -> :https + "ws" -> {:http, :ws} + "wss" -> {:https, :wss} + "http" -> {:http, :ws} + "https" -> {:https, :wss} + _ -> {:reply, {:error, :invalid_scheme}, state} end - ws_scheme = - case uri.scheme do - "ws" -> :ws - "wss" -> :wss - end - - # Construct the WebSocket path - path = uri.path <> @websocket_uri + # Ensure the path is not nil + path = (uri.path || "/") <> @websocket_uri - with {:ok, conn} <- Mint.HTTP.connect(http_scheme, uri.host, uri.port), + 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) @@ -165,7 +171,7 @@ defmodule ElixirProvider.GoFWebSocketClient do defp do_close(state) do Mint.HTTP.close(state.conn) - Logger.info("Comfy websocket closed") + Logger.info("Websocket closed") {:stop, :normal, state} end diff --git a/openfeature/providers/elixir-provider/mix.exs b/openfeature/providers/elixir-provider/mix.exs index b44b7091c00..07c61da8be7 100644 --- a/openfeature/providers/elixir-provider/mix.exs +++ b/openfeature/providers/elixir-provider/mix.exs @@ -25,7 +25,9 @@ defmodule ElixirProvider.MixProject do {:jason, "~> 1.4"}, {:mint, "~> 1.6"}, {:mint_web_socket, "~> 1.0"}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:bypass, "~> 2.1", only: :test}, + {:plug, "~> 1.16", only: :test} ] end end diff --git a/openfeature/providers/elixir-provider/mix.lock b/openfeature/providers/elixir-provider/mix.lock index d145ceabf6b..9c006dbd850 100644 --- a/openfeature/providers/elixir-provider/mix.lock +++ b/openfeature/providers/elixir-provider/mix.lock @@ -1,13 +1,23 @@ %{ "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"}, "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"}, "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"}, "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_test.exs b/openfeature/providers/elixir-provider/test/elixir_provider_test.exs index 23131f98d15..df083306cf0 100644 --- a/openfeature/providers/elixir-provider/test/elixir_provider_test.exs +++ b/openfeature/providers/elixir-provider/test/elixir_provider_test.exs @@ -4,82 +4,145 @@ defmodule ElixirProviderTest do """ use ExUnit.Case doctest ElixirProvider + alias OpenFeature + alias OpenFeature.Client - ## TEST CONTEXT TRANSFORMER + @endpoint "http://localhost:1031" - test "should use the targetingKey as user key" do - got = - ElixirProvider.ContextTransformer.transform_context(%{ - targetingKey: "user-key" - }) + @default_evaluation_ctx %{ + targeting_key: "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"] + } - want = - {:ok, - %ElixirProvider.GofEvaluationContext{ - key: "user-key", - custom: %{} - }} + setup do + provider = %ElixirProvider.Provider{ + options: %ElixirProvider.GoFeatureFlagOptions{ + endpoint: @endpoint, + data_flush_interval: 100, + disable_cache_invalidation: true + } + } - assert got == want + bypass = Bypass.open() + OpenFeature.set_provider(provider) + client = OpenFeature.get_client() + {:ok, bypass: bypass, client: client} 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 CONTEXT TRANSFORMER - 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" - }) + # test "should use the targetingKey as user key" do + # got = + # ElixirProvider.ContextTransformer.transform_context(%{ + # targetingKey: "user-key" + # }) - want = {:error, "targeting key not found"} + # want =/ + # {:ok, + # %ElixirProvider.GofEvaluationContext{ + # key: "user-key", + # custom: %{} + # }} - assert got == want - end + # 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 + # 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", %{bypass: bypass, client: client} do + flag_key = "flag_not_found" + default = false + ctx = @default_evaluation_ctx + + # Corrected path (only the path, not the full URL) + path = "/v1/feature/#{flag_key}/eval" + + # Set up Bypass to handle the POST request + Bypass.expect_once(bypass, "POST", path, fn conn -> + Plug.Conn.resp(conn, 404, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>) + 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 == expected_response + end end From ff6b53b8b9e3ed63ae7dae4f3afa75a28bc1dc5a Mon Sep 17 00:00:00 2001 From: JoE11-y Date: Wed, 11 Dec 2024 06:17:23 +0100 Subject: [PATCH 09/10] add http mock to tests and update code --- .../elixir-provider/config/config.exs | 7 + .../providers/elixir-provider/config/dev.exs | 1 + .../providers/elixir-provider/config/prod.exs | 1 + .../providers/elixir-provider/config/test.exs | 6 + .../lib/provider/cache_controller.ex | 4 +- .../lib/provider/data_collector_hook.ex | 104 ++++---- .../lib/provider/evaluation_context.ex | 4 +- .../lib/provider/feature_event.ex | 4 +- .../lib/provider/http_client.ex | 46 +--- .../elixir-provider/lib/provider/provider.ex | 20 +- .../lib/provider/request_flag_evaluation.ex | 1 + .../lib/provider/response_flag_evalution.ex | 21 +- .../lib/provider/server_supervisor.ex | 2 - openfeature/providers/elixir-provider/mix.exs | 4 +- .../providers/elixir-provider/mix.lock | 4 + .../elixir_provider/elixir_provider_test.exs | 231 ++++++++++++++++++ .../test/elixir_provider_test.exs | 148 ----------- .../elixir-provider/test/test_helper.exs | 2 + 18 files changed, 359 insertions(+), 251 deletions(-) create mode 100644 openfeature/providers/elixir-provider/config/config.exs create mode 100644 openfeature/providers/elixir-provider/config/dev.exs create mode 100644 openfeature/providers/elixir-provider/config/prod.exs create mode 100644 openfeature/providers/elixir-provider/config/test.exs create mode 100644 openfeature/providers/elixir-provider/test/elixir_provider/elixir_provider_test.exs delete mode 100644 openfeature/providers/elixir-provider/test/elixir_provider_test.exs 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/provider/cache_controller.ex b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex index 27889fb1f79..07b59f0ef8d 100644 --- a/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex +++ b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex @@ -6,8 +6,8 @@ defmodule ElixirProvider.CacheController do use GenServer @flag_table :flag_cache - @spec start_link() :: GenServer.on_start() - def start_link do + @spec start_link(any()) :: GenServer.on_start() + def start_link(_args) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 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 index 722e85b5849..28c5a543a0a 100644 --- a/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -2,24 +2,25 @@ defmodule ElixirProvider.DataCollectorHook do @moduledoc """ Data collector hook """ - use GenServer require Logger - alias ElixirProvider.HttpClient - alias ElixirProvider.{FeatureEvent, RequestDataCollector} + 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: 60_000, - event_queue: [] + :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(), @@ -27,8 +28,28 @@ defmodule ElixirProvider.DataCollectorHook do 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 - def start_link do + @spec start_link(any()) :: GenServer.on_start() + def start_link(_args) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @@ -50,27 +71,13 @@ defmodule ElixirProvider.DataCollectorHook do {:ok, %__MODULE__{}} end - # Initializes the state with the provided options - def start(options, http_client) do - state = %__MODULE__{ - http_client: http_client, - data_collector_endpoint: options.endpoint, - 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 - - # Schedule periodic data collection based on the interval - defp schedule_collect_data(interval) do - Process.send_after(self(), :collect_data, interval) + ### Hook Functions + defp before_hook(_hook_context, _hook_hints) do + # Define your `before` hook logic, if any + nil end - ### Hook Implementations - def after_hook(hook, hook_context, flag_evaluation_details, _hints) do + 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 @@ -90,23 +97,36 @@ defmodule ElixirProvider.DataCollectorHook do end end - def error(hook, hook_context, _hints) do - 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 - } + 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 + 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 @@ -132,6 +152,8 @@ defmodule ElixirProvider.DataCollectorHook do 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 diff --git a/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex index 5a7ae148b98..8a890c1caa2 100644 --- a/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex +++ b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex @@ -3,8 +3,8 @@ defmodule ElixirProvider.GofEvaluationContext do GoFeatureFlagEvaluationContext is an object representing a user context for evaluation. """ alias Jason - - defstruct [key: "", custom: %{}] + @derive Jason.Encoder + defstruct key: "", custom: %{} @type t :: %__MODULE__{ key: String.t(), diff --git a/openfeature/providers/elixir-provider/lib/provider/feature_event.ex b/openfeature/providers/elixir-provider/lib/provider/feature_event.ex index d631a9183ac..b3f91dea5cd 100644 --- a/openfeature/providers/elixir-provider/lib/provider/feature_event.ex +++ b/openfeature/providers/elixir-provider/lib/provider/feature_event.ex @@ -3,7 +3,7 @@ defmodule ElixirProvider.FeatureEvent do Represents a feature event with details about the feature flag evaluation. """ @enforce_keys [:context_kind, :user_key, :creation_date, :key, :variation] - defstruct [kind: "feature", + defstruct kind: "feature", context_kind: "", user_key: "", creation_date: 0, @@ -11,7 +11,7 @@ defmodule ElixirProvider.FeatureEvent do variation: "", value: nil, default: false, - source: "PROVIDER_CACHE"] + source: "PROVIDER_CACHE" @type t :: %__MODULE__{ kind: String.t(), diff --git a/openfeature/providers/elixir-provider/lib/provider/http_client.ex b/openfeature/providers/elixir-provider/lib/provider/http_client.ex index 0786da26de0..32db352fcd5 100644 --- a/openfeature/providers/elixir-provider/lib/provider/http_client.ex +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -1,10 +1,8 @@ defmodule ElixirProvider.HttpClient do @moduledoc """ - Handles HTTP requests to the GO Feature Flag API. + Implements HttpClientBehaviour using Mint for HTTP requests. """ - use GenServer - # Define a struct to store HTTP connection, endpoint, and other configuration details defstruct [:conn, :endpoint, :headers] @@ -14,56 +12,28 @@ defmodule ElixirProvider.HttpClient do headers: list() } - @spec start_link() :: GenServer.on_start() - def start_link do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - def stop do - GenServer.stop(__MODULE__) - end - - @impl true - def init([]) do - {:ok, %__MODULE__{}} - end - - @spec start_http_connection(any()) :: - {:error, - %{ - :__exception__ => true, - :__struct__ => Mint.HTTPError | Mint.TransportError, - :reason => any(), - optional(:module) => any() - }} - | {:ok, ElixirProvider.HttpClient.t()} 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} -> - # Create the struct with the connection, endpoint, and default headers - config = %__MODULE__{ - conn: conn, - endpoint: options.endpoint, - headers: [{"content-type", "application/json"}] - } - - {:ok, config} + {:ok, + %{ + conn: conn, + endpoint: options.endpoint, + headers: [{"content-type", "application/json"}] + }} {:error, reason} -> {:error, reason} end end - @spec post(t(), String.t(), map()) :: {:ok, map()} | {:error, any()} - def post(%__MODULE__{conn: conn, endpoint: endpoint, headers: headers}, path, data) do - # Full URL path + def post(%{conn: conn, endpoint: endpoint, headers: headers}, path, data) do url = URI.merge(endpoint, path) |> URI.to_string() body = Jason.encode!(data) - # Make the POST request using the existing connection with {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "POST", url, headers, body), {:ok, response} <- read_response(conn, request_ref) do Jason.decode(response) diff --git a/openfeature/providers/elixir-provider/lib/provider/provider.ex b/openfeature/providers/elixir-provider/lib/provider/provider.ex index 98dc79abf00..c5a760cd425 100644 --- a/openfeature/providers/elixir-provider/lib/provider/provider.ex +++ b/openfeature/providers/elixir-provider/lib/provider/provider.ex @@ -11,6 +11,7 @@ defmodule ElixirProvider.Provider do alias ElixirProvider.HttpClient alias ElixirProvider.RequestFlagEvaluation alias ElixirProvider.ResponseFlagEvaluation + alias OpenFeature.Hook alias OpenFeature.ResolutionDetails @moduledoc """ @@ -30,7 +31,7 @@ defmodule ElixirProvider.Provider do name: String.t(), options: GoFeatureFlagOptions.t(), http_client: HttpClient.t(), - hooks: DataCollectorHook.t() | nil, + hooks: [Hook.t()] | nil, ws: GoFWebSocketClient.t(), domain: String.t() } @@ -45,7 +46,7 @@ defmodule ElixirProvider.Provider do provider | domain: domain, http_client: http_client, - hooks: hooks, + hooks: [hooks.base_hook], ws: ws } @@ -87,8 +88,11 @@ defmodule ElixirProvider.Provider do 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 @@ -97,9 +101,12 @@ defmodule ElixirProvider.Provider do :miss -> # Fetch from HTTP if cache miss - case HttpClient.post(provider.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}} + 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 @@ -107,7 +114,6 @@ defmodule ElixirProvider.Provider do end defp handle_response(flag_key, eval_context_hash, response) do - Logger.debug("Unexpected frame received: #{inspect("here")}") # Build the flag evaluation struct directly from the response map flag_eval = ResponseFlagEvaluation.decode(response) @@ -120,6 +126,8 @@ defmodule ElixirProvider.Provider do 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 diff --git a/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex index ddf8a7fccd3..2c0a413493a 100644 --- a/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex +++ b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex @@ -5,6 +5,7 @@ defmodule ElixirProvider.RequestFlagEvaluation do alias ElixirProvider.GofEvaluationContext @enforce_keys [:user] + @derive Jason.Encoder defstruct [:default_value, :user] @type t :: %__MODULE__{ diff --git a/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex index 4202b703782..354351eaaf8 100644 --- a/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex +++ b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex @@ -5,15 +5,18 @@ defmodule ElixirProvider.ResponseFlagEvaluation do alias ElixirProvider.Types @enforce_keys [:value, :failed, :reason] - defstruct [:value, error_code: nil, - failed: false, - reason: "", - track_events: nil, - variation_type: nil, - version: nil, - metadata: nil, - cacheable: nil - ] + @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, diff --git a/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex index 7d579ff653d..74beb87103e 100644 --- a/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex +++ b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex @@ -11,8 +11,6 @@ defmodule ElixirProvider.ServerSupervisor do @impl true def init([_args]) do children = [ - ElixirProvider.HttpClient, - ElixirProvider.GoFWebSocketClient, ElixirProvider.CacheController, ElixirProvider.DataCollectorHook ] diff --git a/openfeature/providers/elixir-provider/mix.exs b/openfeature/providers/elixir-provider/mix.exs index 07c61da8be7..1daa1beb8a9 100644 --- a/openfeature/providers/elixir-provider/mix.exs +++ b/openfeature/providers/elixir-provider/mix.exs @@ -27,7 +27,9 @@ defmodule ElixirProvider.MixProject do {:mint_web_socket, "~> 1.0"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:bypass, "~> 2.1", only: :test}, - {:plug, "~> 1.16", 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 index 9c006dbd850..a34fa9e7c46 100644 --- a/openfeature/providers/elixir-provider/mix.lock +++ b/openfeature/providers/elixir-provider/mix.lock @@ -7,11 +7,15 @@ "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"}, 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/elixir_provider_test.exs b/openfeature/providers/elixir-provider/test/elixir_provider_test.exs deleted file mode 100644 index df083306cf0..00000000000 --- a/openfeature/providers/elixir-provider/test/elixir_provider_test.exs +++ /dev/null @@ -1,148 +0,0 @@ -defmodule ElixirProviderTest do - @moduledoc """ - Test file - """ - use ExUnit.Case - doctest ElixirProvider - alias OpenFeature - alias OpenFeature.Client - - @endpoint "http://localhost:1031" - - @default_evaluation_ctx %{ - targeting_key: "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 - provider = %ElixirProvider.Provider{ - options: %ElixirProvider.GoFeatureFlagOptions{ - endpoint: @endpoint, - data_flush_interval: 100, - disable_cache_invalidation: true - } - } - - bypass = Bypass.open() - OpenFeature.set_provider(provider) - client = OpenFeature.get_client() - {:ok, bypass: bypass, 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", %{bypass: bypass, client: client} do - flag_key = "flag_not_found" - default = false - ctx = @default_evaluation_ctx - - # Corrected path (only the path, not the full URL) - path = "/v1/feature/#{flag_key}/eval" - - # Set up Bypass to handle the POST request - Bypass.expect_once(bypass, "POST", path, fn conn -> - Plug.Conn.resp(conn, 404, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>) - 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 == expected_response - end -end diff --git a/openfeature/providers/elixir-provider/test/test_helper.exs b/openfeature/providers/elixir-provider/test/test_helper.exs index 869559e709e..9264bb7105a 100644 --- a/openfeature/providers/elixir-provider/test/test_helper.exs +++ b/openfeature/providers/elixir-provider/test/test_helper.exs @@ -1 +1,3 @@ +Mimic.copy(Mint.HTTP) + ExUnit.start() From 9cdd2d604c3f988d7d9e3f27dfe2f6a7426b6ec1 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 24 Dec 2024 08:42:53 +0100 Subject: [PATCH 10/10] WIP Signed-off-by: Thomas Poignant --- openfeature/providers/elixir-provider3/config/config.exs | 0 .../elixir-provider3/lib/org/gofeatureflag/module.ex | 7 +++++++ .../lib/org/gofeatureflag/provider/model/ofrep_response.ex | 0 .../lib/org/gofeatureflag/provider/provider.ex | 0 4 files changed, 7 insertions(+) create mode 100644 openfeature/providers/elixir-provider3/config/config.exs create mode 100644 openfeature/providers/elixir-provider3/lib/org/gofeatureflag/module.ex create mode 100644 openfeature/providers/elixir-provider3/lib/org/gofeatureflag/provider/model/ofrep_response.ex create mode 100644 openfeature/providers/elixir-provider3/lib/org/gofeatureflag/provider/provider.ex 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