Skip to content
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