Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add elixir-provider #2610

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions openfeature/providers/elixir-provider/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
28 changes: 28 additions & 0 deletions openfeature/providers/elixir-provider/.gitignore
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions openfeature/providers/elixir-provider/README.md
Original file line number Diff line number Diff line change
@@ -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 <https://hexdocs.pm/elixir_provider>.

139 changes: 139 additions & 0 deletions openfeature/providers/elixir-provider/lib/elixir_provider.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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

end
Empty file.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading