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..6cb49973eea --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/elixir_provider.ex @@ -0,0 +1,7 @@ +defmodule ElixirProvider do + @moduledoc """ + `ElixirProvider` is a feature flag manager for controlling feature availability in Go applications. + + It allows toggling features dynamically based on configurations from sources like databases and APIs, enabling flexible, real-time control over application behavior. + """ +end diff --git a/openfeature/providers/elixir-provider/lib/provider/application.ex b/openfeature/providers/elixir-provider/lib/provider/application.ex new file mode 100644 index 00000000000..b81153a4f3b --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/application.ex @@ -0,0 +1,20 @@ +defmodule ElixirProvider.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + ExSd.ServerSupervisor + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: ExSd.Supervisor] + Supervisor.start_link(children, opts) + end + +end diff --git a/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex b/openfeature/providers/elixir-provider/lib/provider/cache_controller.ex new file mode 100644 index 00000000000..3b63367db95 --- /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() :: GenServer.on_start() + 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 + end + end + + def set(flag_key, evaluation_hash, value) do + cache_key = build_cache_key(flag_key, evaluation_hash) + :ets.insert(@flag_table, {cache_key, value}) + :ok + end + + def clear do + GenServer.stop(__MODULE__) + :ets.delete_all_objects(@flag_table) + :ets.insert(@flag_table, {:context, %{}}) + :ok + end + + defp build_cache_key(flag_key, evaluation_hash) do + "#{flag_key}-#{evaluation_hash}" + end + + @impl true + def init(:ok) do + :ets.new(@flag_table, [:named_table, :set, :public]) + :ets.insert(@flag_table, {:context, %{}}) + {:ok, nil, :hibernate} + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex b/openfeature/providers/elixir-provider/lib/provider/context_transformer.ex new file mode 100644 index 00000000000..23543d4f0cd --- /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.GofEvaluationContext + 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.GofEvaluationContext struct. + Returns `{:ok, context}` on success, or `{:error, reason}` on failure. + """ + @spec transform_context(Types.context()) :: {:ok, GofEvaluationContext.t()} | {:error, String.t()} + def transform_context(ctx) do + case get_any_value(ctx) do + {:ok, {key, value}} -> + {:ok, %GofEvaluationContext{ + 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/data_collector_hook.ex b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex new file mode 100644 index 00000000000..10722424e72 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/data_collector_hook.ex @@ -0,0 +1,143 @@ +defmodule ElixirProvider.DataCollectorHook do + + use GenServer + require Logger + + alias ElixirProvider.HttpClient + alias ElixirProvider.{FeatureEvent, RequestDataCollector} + + @default_targeting_key "undefined-targetingKey" + + defstruct [ + :http_client, + :data_collector_endpoint, + :disable_data_collection, + data_flush_interval: 60_000, + event_queue: [] + ] + + @type t :: %__MODULE__{ + http_client: HttpClient.t(), + data_collector_endpoint: String.t(), + disable_data_collection: boolean(), + data_flush_interval: non_neg_integer(), + event_queue: list(FeatureEvent.t()) + } + + # Starts the GenServer and initializes with options + 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, + disable_data_collection: state.disable_data_collection, + data_flush_interval: state.data_flush_interval, + event_queue: [] + } + end + + @impl true + def init([]) do + {:ok, %__MODULE__{}} + end + + # 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) + 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 + else + feature_event = %FeatureEvent{ + context_kind: if(Map.get(hook_context.context, "anonymous"), do: "anonymousUser", else: "user"), + creation_date: DateTime.utc_now() |> DateTime.to_unix(:millisecond), + default: false, + key: hook_context.flag_key, + value: flag_evaluation_details.value, + variation: flag_evaluation_details.variant || "SdkDefault", + user_key: Map.get(hook_context.evaluation_context, "targeting_key") || @default_targeting_key + } + + GenServer.cast(__MODULE__, {:add_event, feature_event}) + end + end + + 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 + } + + GenServer.call(__MODULE__, {:add_event, feature_event}) + end + end + + ### GenServer Callbacks + @impl true + def handle_call({:add_event, feature_event}, _from, state) do + {:reply, :ok, %{state | event_queue: [feature_event | state.event_queue]}} + end + + # Handle the periodic flush + @impl true + def handle_info(:collect_data, state) do + case collect_data(state) do + :ok -> Logger.info("Data collected and sent successfully.") + {:error, reason} -> Logger.error("Failed to send data: #{inspect(reason)}") + end + + schedule_collect_data(state.data_flush_interval) + {:noreply, %{state | event_queue: []}} + end + + defp collect_data(%__MODULE__{event_queue: event_queue, http_client: http_client, data_collector_endpoint: endpoint}) do + if Enum.empty?(event_queue) do + :ok + else + body = %RequestDataCollector{ + meta: %{"provider" => "open-feature-elixir-sdk"}, + events: event_queue + } + + case HttpClient.post(http_client, endpoint, body) do + {:ok, response} -> + Logger.info("Data sent successfully: #{inspect(response)}") + :ok + + {:error, reason} -> + Logger.error("Error sending data: #{inspect(reason)}") + {:error, reason} + end + end + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex new file mode 100644 index 00000000000..5a7ae148b98 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/evaluation_context.ex @@ -0,0 +1,22 @@ +defmodule ElixirProvider.GofEvaluationContext do + @moduledoc """ + GoFeatureFlagEvaluationContext is an object representing a user context for evaluation. + """ + alias Jason + + 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..d631a9183ac --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/feature_event.ex @@ -0,0 +1,27 @@ +defmodule ElixirProvider.FeatureEvent do + @moduledoc """ + Represents a feature event with details about the feature flag evaluation. + """ + @enforce_keys [:context_kind, :user_key, :creation_date, :key, :variation] + defstruct [kind: "feature", + context_kind: "", + user_key: "", + creation_date: 0, + key: "", + variation: "", + value: nil, + default: false, + source: "PROVIDER_CACHE"] + + @type t :: %__MODULE__{ + kind: String.t(), + context_kind: String.t(), + user_key: String.t(), + creation_date: integer(), + key: String.t(), + variation: String.t(), + value: any(), + default: boolean(), + source: String.t() + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/flag_options.ex b/openfeature/providers/elixir-provider/lib/provider/flag_options.ex new file mode 100644 index 00000000000..871d67f7148 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/flag_options.ex @@ -0,0 +1,22 @@ +defmodule ElixirProvider.GoFeatureFlagOptions do + @moduledoc """ + Configuration options for the Go Feature Flag. + """ + + @enforce_keys [:endpoint] + defstruct [:endpoint, + cache_size: 10_000, + data_flush_interval: 60_000, + disable_data_collection: false, + reconnect_interval: 60, + disable_cache_invalidation: false] + + @type t :: %__MODULE__{ + endpoint: String.t(), + cache_size: integer() | nil, + data_flush_interval: integer() | nil, + disable_data_collection: boolean(), + reconnect_interval: integer() | nil, + disable_cache_invalidation: boolean() | nil + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/http_client.ex b/openfeature/providers/elixir-provider/lib/provider/http_client.ex new file mode 100644 index 00000000000..265c7b273db --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/http_client.ex @@ -0,0 +1,105 @@ +defmodule ElixirProvider.HttpClient do + @moduledoc """ + 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] + + @type t :: %__MODULE__{ + conn: Mint.HTTP.t() | nil, + endpoint: String.t(), + 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} + + {: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 + 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) + else + {:error, _conn, reason} -> {:error, reason} + {:error, reason} -> {:error, reason} + end + end + + defp read_response(conn, request_ref) do + receive do + message -> + case Mint.HTTP.stream(conn, message) do + {:ok, _conn, responses} -> + Enum.reduce_while(responses, {:ok, ""}, fn + {:status, ^request_ref, status}, _acc -> + if status == 200, do: {:cont, {:ok, ""}}, else: {:halt, {:error, :bad_status}} + + {:headers, ^request_ref, _headers}, acc -> + {:cont, acc} + + {:data, ^request_ref, data}, {:ok, acc} -> + {:cont, {:ok, acc <> data}} + + {:done, ^request_ref}, {:ok, acc} -> + {:halt, {:ok, acc}} + + _other, acc -> + {:cont, acc} + end) + + :unknown -> + {:error, :unknown_response} + end + after + 5_000 -> {:error, :timeout} + end + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/metadata.ex b/openfeature/providers/elixir-provider/lib/provider/metadata.ex new file mode 100644 index 00000000000..436648b40c4 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/metadata.ex @@ -0,0 +1,11 @@ +defmodule ElixirProvider.GoFeatureFlagMetadata do + @moduledoc """ + Metadata for the Go Feature Flag. + """ + + defstruct [name: "Go Feature Flag"] + + @type t :: %__MODULE__{ + name: String.t() + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/provider.ex b/openfeature/providers/elixir-provider/lib/provider/provider.ex new file mode 100644 index 00000000000..106592af709 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/provider.ex @@ -0,0 +1,139 @@ +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) + {: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/request_flag_evaluation.ex b/openfeature/providers/elixir-provider/lib/provider/request_flag_evaluation.ex new file mode 100644 index 00000000000..ddf8a7fccd3 --- /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.GofEvaluationContext + + @enforce_keys [:user] + defstruct [:default_value, :user] + + @type t :: %__MODULE__{ + user: GofEvaluationContext.t(), + default_value: any() + } +end diff --git a/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex new file mode 100644 index 00000000000..4202b703782 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/response_flag_evalution.ex @@ -0,0 +1,44 @@ +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 + } + + @spec decode(map()) :: t() + def decode(response) when is_map(response) do + %__MODULE__{ + failed: response["failed"] || false, + value: response["value"], + variation_type: response["variationType"], + reason: response["reason"] || "", + error_code: response["errorCode"], + metadata: response["metadata"] || %{}, + cacheable: Map.get(response, "cacheable", false), + track_events: response["track_events"], + version: response["version"] + } + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex new file mode 100644 index 00000000000..66105582c45 --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/server_supervisor.ex @@ -0,0 +1,19 @@ +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.HttpClient, + ElixirProvider.GoFWebSocketClient, + ElixirProvider.CacheController, + ElixirProvider.DataCollectorHook + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/openfeature/providers/elixir-provider/lib/provider/types.ex b/openfeature/providers/elixir-provider/lib/provider/types.ex new file mode 100644 index 00000000000..db0db1ada6a --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/types.ex @@ -0,0 +1,8 @@ +defmodule ElixirProvider.Types do + @moduledoc """ + ElixirProvider types. + """ + + @type json_type :: boolean() | integer() | float() | String.t() | list() | map() + +end diff --git a/openfeature/providers/elixir-provider/lib/provider/web_socket.ex b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex new file mode 100644 index 00000000000..90fafecfc5f --- /dev/null +++ b/openfeature/providers/elixir-provider/lib/provider/web_socket.ex @@ -0,0 +1,176 @@ +defmodule ElixirProvider.GoFWebSocketClient do + use GenServer + + require Logger + require Mint.HTTP + + alias ElixirProvider.CacheController + + @moduledoc """ + A minimal WebSocket client for listening to configuration changes from the GO Feature Flag relay proxy. + Clears the cache on receiving change notifications. + """ + + defstruct [:conn, :websocket, :request_ref, :status, :caller, :resp_headers, :closing?] + + @type t :: %__MODULE__{ + conn: Mint.HTTP.t() | nil, + websocket: Mint.WebSocket.t() | nil, + request_ref: reference() | nil, + caller: {pid(), GenServer.from()} | nil, + status: integer() | nil, + resp_headers: list({String.t(), String.t()}) | nil, + closing?: boolean() + } + + @websocket_uri "/ws/v1/flag/change" + + def connect(url) do + with {:ok, socket} <- GenServer.start_link(__MODULE__, [], name: __MODULE__), + {:ok, :connected} <- GenServer.call(socket, {:connect, url}) do + {:ok, socket} + end + end + + def stop() do + GenServer.stop(__MODULE__) + end + + @impl true + def init([]) do + {:ok, %__MODULE__{}} + end + + @impl true + def handle_call({:connect, url}, from, state) do + uri = URI.parse(url) + + http_scheme = + case uri.scheme do + "ws" -> :http + "wss" -> :https + end + + 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} -> + {:reply, {:error, reason}, state} + + {:error, conn, reason} -> + {:reply, {:error, reason}, put_in(state.conn, conn)} + end + end + + @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} + + {:error, conn, reason, _responses} -> + state = put_in(state.conn, conn) |> reply({:error, reason}) + {:noreply, state} + + :unknown -> + {:noreply, state} + end + end + + defp handle_responses(state, responses) + + defp handle_responses(%{request_ref: ref} = state, [{:status, ref, status} | rest]) do + put_in(state.status, status) + |> handle_responses(rest) + end + + defp handle_responses(%{request_ref: ref} = state, [{:headers, ref, resp_headers} | rest]) do + put_in(state.resp_headers, resp_headers) + |> handle_responses(rest) + end + + defp handle_responses(%{request_ref: ref} = state, [{:done, ref} | rest]) do + case Mint.WebSocket.new(state.conn, ref, state.status, state.resp_headers) do + {:ok, conn, websocket} -> + %{state | conn: conn, websocket: websocket, status: nil, resp_headers: nil} + |> reply({:ok, :connected}) + |> handle_responses(rest) + + {:error, conn, reason} -> + put_in(state.conn, conn) + |> reply({:error, reason}) + end + end + + defp handle_responses(%{request_ref: ref, websocket: websocket} = state, [ + {:data, ref, data} | rest + ]) + when websocket != nil do + case Mint.WebSocket.decode(websocket, data) do + {:ok, websocket, frames} -> + put_in(state.websocket, websocket) + |> handle_frames(frames) + |> handle_responses(rest) + + {:error, websocket, reason} -> + put_in(state.websocket, websocket) + |> reply({:error, reason}) + end + end + + defp handle_responses(state, [_response | rest]) do + handle_responses(state, rest) + end + + defp handle_responses(state, []), do: state + + def handle_frames(state, frames) do + Enum.reduce(frames, state, fn + {:close, _code, reason}, state -> + Logger.debug("Closing connection: #{inspect(reason)}") + %{state | closing?: true} + + {:text, text}, state -> + + response = Jason.decode!(text) + + case Map.get(response, "type") do + "change" -> + # Clear the cache when a change message is received + CacheController.clear() + Logger.info("Cache cleared due to configuration change notification.") + + _ -> nil + end + + state + + frame, state -> + Logger.debug("Unexpected frame received: #{inspect(frame)}") + state + end) + end + + defp do_close(state) do + Mint.HTTP.close(state.conn) + Logger.info("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 diff --git a/openfeature/providers/elixir-provider/mix.exs b/openfeature/providers/elixir-provider/mix.exs new file mode 100644 index 00000000000..ba862851eef --- /dev/null +++ b/openfeature/providers/elixir-provider/mix.exs @@ -0,0 +1,30 @@ +defmodule ElixirProvider.MixProject do + use Mix.Project + + def project do + [ + app: :elixir_provider, + version: "0.1.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps(), + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:open_feature, git: "https://github.com/open-feature/elixir-sdk.git"}, + {:jason, "~> 1.4"}, + {:mint, "~> 1.6"}, + {:mint_web_socket, "~> 1.0"} + ] + end +end diff --git a/openfeature/providers/elixir-provider/mix.lock b/openfeature/providers/elixir-provider/mix.lock new file mode 100644 index 00000000000..66e2e8b26ff --- /dev/null +++ b/openfeature/providers/elixir-provider/mix.lock @@ -0,0 +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"}, +} 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()