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 12 commits into
base: main
Choose a base branch
from
Open
Changes from all 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
217 changes: 217 additions & 0 deletions openfeature/providers/elixir-provider/.credo.exs
Original file line number Diff line number Diff line change
@@ -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 <name>`. 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`.
#
]
}
}
]
}
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>.

7 changes: 7 additions & 0 deletions openfeature/providers/elixir-provider/config/config.exs
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions openfeature/providers/elixir-provider/config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
1 change: 1 addition & 0 deletions openfeature/providers/elixir-provider/config/prod.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
6 changes: 6 additions & 0 deletions openfeature/providers/elixir-provider/config/test.exs
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions openfeature/providers/elixir-provider/lib/elixir_provider.ex
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions openfeature/providers/elixir-provider/lib/provider/application.ex
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
defmodule ElixirProvider.CacheController do
@moduledoc """
Controller for caching flag evaluations to avoid redundant API calls.
"""

use GenServer
@flag_table :flag_cache

@spec start_link(any()) :: GenServer.on_start()
def start_link(_args) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end

def get(flag_key, evaluation_hash) do
cache_key = build_cache_key(flag_key, evaluation_hash)

case :ets.lookup(@flag_table, cache_key) do
[{^cache_key, cached_value}] -> {:ok, cached_value}
[] -> :miss
end
end

def set(flag_key, evaluation_hash, value) do
cache_key = build_cache_key(flag_key, evaluation_hash)
:ets.insert(@flag_table, {cache_key, value})
:ok
end

def clear do
GenServer.stop(__MODULE__)
:ets.delete_all_objects(@flag_table)
:ets.insert(@flag_table, {:context, %{}})
:ok
end

defp build_cache_key(flag_key, evaluation_hash) do
"#{flag_key}-#{evaluation_hash}"
end

@impl true
def init(:ok) do
:ets.new(@flag_table, [:named_table, :set, :public])
:ets.insert(@flag_table, {:context, %{}})
{:ok, nil, :hibernate}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
defmodule ElixirProvider.ContextTransformer do
@moduledoc """
Converts an OpenFeature EvaluationContext into a GO Feature Flag context.
"""
alias ElixirProvider.GofEvaluationContext
alias OpenFeature.Types

@doc """
Extracts other key value pairs after the targeting key
"""
def get_any_value(map) when is_map(map) do
map
|> Enum.reject(fn {key, _value} -> key === :targetingKey end)
|> Enum.into(%{})
end

@doc """
Converts an EvaluationContext map into a ElixirProvider.GofEvaluationContext struct.
Returns `{:ok, context}` on success, or `{:error, reason}` on failure.
"""
@spec transform_context(Types.context()) ::
{:ok, GofEvaluationContext.t()} | {:error, String.t()}
def transform_context(ctx) do
case Map.fetch(ctx, :targetingKey) do
{:ok, value} ->
{:ok,
%GofEvaluationContext{
key: value,
custom: get_any_value(ctx)
}}

:error ->
{:error, "targeting key not found"}
end
end
end
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
defmodule ElixirProvider.DataCollectorHook do
@moduledoc """
Data collector hook
"""
use GenServer
require Logger

alias OpenFeature.Hook
alias ElixirProvider.{FeatureEvent, HttpClient, RequestDataCollector}

@default_targeting_key "undefined-targetingKey"

defstruct [
:base_hook,
:http_client,
:data_collector_endpoint,
:disable_data_collection,
:data_flush_interval,
:event_queue
]

@type t :: %__MODULE__{
base_hook: Hook.t(),
http_client: HttpClient.t(),
data_collector_endpoint: String.t(),
disable_data_collection: boolean(),
data_flush_interval: non_neg_integer(),
event_queue: list(FeatureEvent.t())
}

def start(options, http_client) do
state = %__MODULE__{
base_hook: %Hook{
before: &before_hook/2,
after: &after_hook/4,
error: &error_hook/3,
finally: &finally_hook/2
},
http_client: http_client,
data_collector_endpoint: options.endpoint <> "/v1/data/collector",
disable_data_collection: options.disable_data_collection || false,
data_flush_interval: options.data_flush_interval || 60_000,
event_queue: []
}

schedule_collect_data(state.data_flush_interval)
{:ok, state}
end

# Starts the GenServer and initializes with options
@spec start_link(any()) :: GenServer.on_start()
def start_link(_args) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end

def stop(state) do
GenServer.stop(__MODULE__)
collect_data(state.data_flush_interval)

%__MODULE__{
http_client: state.http_client,
data_collector_endpoint: state.data_collector_endpoint,
disable_data_collection: state.disable_data_collection,
data_flush_interval: state.data_flush_interval,
event_queue: []
}
end

@impl true
def init([]) do
{:ok, %__MODULE__{}}
end

### Hook Functions
defp before_hook(_hook_context, _hook_hints) do
# Define your `before` hook logic, if any
nil
end

def after_hook(%__MODULE__{} = hook, hook_context, flag_evaluation_details, _hints) do
if hook.disable_data_collection or flag_evaluation_details.reason != :CACHED do
:ok
else
feature_event = %FeatureEvent{
context_kind:
if(Map.get(hook_context.context, "anonymous"), do: "anonymousUser", else: "user"),
creation_date: DateTime.utc_now() |> DateTime.to_unix(:millisecond),
default: false,
key: hook_context.flag_key,
value: flag_evaluation_details.value,
variation: flag_evaluation_details.variant || "SdkDefault",
user_key:
Map.get(hook_context.evaluation_context, "targeting_key") || @default_targeting_key
}

GenServer.cast(__MODULE__, {:add_event, feature_event})
end
end

defp error_hook(hook_context, any, _hints) do
# Logger.info("Data sent successfully: #{inspect(hook_context)}")
Logger.info("Data sent successfully: #{inspect(any)}")
# Logger.info("Data sent successfully: #{inspect(hints)}")
# if hook.disable_data_collection do
# :ok
# else
feature_event = %FeatureEvent{
context_kind:
if(Map.get(hook_context.context, "anonymous"), do: "anonymousUser", else: "user"),
creation_date: DateTime.utc_now() |> DateTime.to_unix(:millisecond),
default: true,
key: hook_context.flag_key,
value: Map.get(hook_context.context, "default_value"),
variation: "SdkDefault",
user_key: Map.get(hook_context.context, "targeting_key") || @default_targeting_key
}

GenServer.call(__MODULE__, {:add_event, feature_event})
# end
end

defp finally_hook(_hook_context, _hook_hints) do
# Define your `finally` hook logic, if any
:ok
end

# Schedule periodic data collection based on the interval
defp schedule_collect_data(interval) do
Process.send_after(self(), :collect_data, interval)
end

### GenServer Callbacks
@impl true
def handle_call({:add_event, feature_event}, _from, state) do
{:reply, :ok, %{state | event_queue: [feature_event | state.event_queue]}}
end

# Handle the periodic flush
@impl true
def handle_info(:collect_data, state) do
case collect_data(state) do
:ok -> Logger.info("Data collected and sent successfully.")
{:error, reason} -> Logger.error("Failed to send data: #{inspect(reason)}")
end

schedule_collect_data(state.data_flush_interval)
{:noreply, %{state | event_queue: []}}
end

defp collect_data(%__MODULE__{
event_queue: event_queue,
http_client: http_client,
data_collector_endpoint: endpoint
}) do
Logger.info("Data sent successfully: #{inspect(event_queue)}")

if Enum.empty?(event_queue) do
:ok
else
body = %RequestDataCollector{
meta: %{"provider" => "open-feature-elixir-sdk"},
events: event_queue
}

case HttpClient.post(http_client, endpoint, body) do
{:ok, response} ->
Logger.info("Data sent successfully: #{inspect(response)}")
:ok

{:error, reason} ->
Logger.error("Error sending data: #{inspect(reason)}")
{:error, reason}
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule ElixirProvider.GofEvaluationContext do
@moduledoc """
GoFeatureFlagEvaluationContext is an object representing a user context for evaluation.
"""
alias Jason
@derive Jason.Encoder
defstruct key: "", custom: %{}

@type t :: %__MODULE__{
key: String.t(),
custom: map() | nil
}

@doc """
Generates an MD5 hash based on the `key` and `custom` fields.
"""
def hash(%__MODULE__{key: key, custom: custom}) do
data = %{"key" => key, "custom" => custom}
encoded = Jason.encode!(data, pretty: true)
:crypto.hash(:md5, encoded) |> Base.encode16(case: :lower)
end
end
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions openfeature/providers/elixir-provider/lib/provider/flag_options.ex
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions openfeature/providers/elixir-provider/lib/provider/http_client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule ElixirProvider.HttpClient do
@moduledoc """
Implements HttpClientBehaviour using Mint for HTTP requests.
"""

# Define a struct to store HTTP connection, endpoint, and other configuration details
defstruct [:conn, :endpoint, :headers]

@type t :: %__MODULE__{
conn: Mint.HTTP.t() | nil,
endpoint: String.t(),
headers: list()
}

def start_http_connection(options) do
uri = URI.parse(options.endpoint)
scheme = if uri.scheme == "https", do: :https, else: :http

case Mint.HTTP.connect(scheme, uri.host, uri.port) do
{:ok, conn} ->
{:ok,
%{
conn: conn,
endpoint: options.endpoint,
headers: [{"content-type", "application/json"}]
}}

{:error, reason} ->
{:error, reason}
end
end

def post(%{conn: conn, endpoint: endpoint, headers: headers}, path, data) do
url = URI.merge(endpoint, path) |> URI.to_string()
body = Jason.encode!(data)

with {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "POST", url, headers, body),
{:ok, response} <- read_response(conn, request_ref) do
Jason.decode(response)
else
{:error, _conn, reason} -> {:error, reason}
{:error, reason} -> {:error, reason}
end
end

defp read_response(conn, request_ref) do
receive do
message ->
case Mint.HTTP.stream(conn, message) do
{:ok, _conn, responses} ->
Enum.reduce_while(responses, {:ok, ""}, fn
{:status, ^request_ref, status}, _acc ->
if status == 200, do: {:cont, {:ok, ""}}, else: {:halt, {:error, :bad_status}}

{:headers, ^request_ref, _headers}, acc ->
{:cont, acc}

{:data, ^request_ref, data}, {:ok, acc} ->
{:cont, {:ok, acc <> data}}

{:done, ^request_ref}, {:ok, acc} ->
{:halt, {:ok, acc}}

_other, acc ->
{:cont, acc}
end)

:unknown ->
{:error, :unknown_response}
end
after
5_000 -> {:error, :timeout}
end
end
end
11 changes: 11 additions & 0 deletions openfeature/providers/elixir-provider/lib/provider/metadata.ex
Original file line number Diff line number Diff line change
@@ -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
156 changes: 156 additions & 0 deletions openfeature/providers/elixir-provider/lib/provider/provider.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
defmodule ElixirProvider.Provider do
@behaviour OpenFeature.Provider

require Logger
alias ElixirProvider.CacheController
alias ElixirProvider.ContextTransformer
alias ElixirProvider.DataCollectorHook
alias ElixirProvider.GoFeatureFlagOptions
alias ElixirProvider.GofEvaluationContext
alias ElixirProvider.GoFWebSocketClient
alias ElixirProvider.HttpClient
alias ElixirProvider.RequestFlagEvaluation
alias ElixirProvider.ResponseFlagEvaluation
alias OpenFeature.Hook
alias OpenFeature.ResolutionDetails

@moduledoc """
The GO Feature Flag provider for OpenFeature, managing HTTP requests, caching, and flag evaluation.
"""

defstruct [
:options,
:http_client,
:hooks,
:ws,
:domain,
name: "ElixirProvider"
]

@type t :: %__MODULE__{
name: String.t(),
options: GoFeatureFlagOptions.t(),
http_client: HttpClient.t(),
hooks: [Hook.t()] | nil,
ws: GoFWebSocketClient.t(),
domain: String.t()
}

@impl true
def initialize(%__MODULE__{} = provider, domain, _context) do
{:ok, http_client} = HttpClient.start_http_connection(provider.options)
{:ok, hooks} = DataCollectorHook.start(provider.options, http_client)
{:ok, ws} = GoFWebSocketClient.connect(provider.options.endpoint)

updated_provider = %__MODULE__{
provider
| domain: domain,
http_client: http_client,
hooks: [hooks.base_hook],
ws: ws
}

{:ok, updated_provider}
end

@impl true
def shutdown(%__MODULE__{ws: ws} = provider) do
Process.exit(ws, :normal)
CacheController.clear()
if(GenServer.whereis(GoFWebSocketClient), do: GoFWebSocketClient.stop())

if(GenServer.whereis(DataCollectorHook),
do: DataCollectorHook.stop(provider.hooks)
)

:ok
end

@impl true
def resolve_boolean_value(provider, key, default, context) do
generic_resolve(provider, :boolean, key, default, context)
end

@impl true
def resolve_string_value(provider, key, default, context) do
generic_resolve(provider, :string, key, default, context)
end

@impl true
def resolve_number_value(provider, key, default, context) do
generic_resolve(provider, :number, key, default, context)
end

@impl true
def resolve_map_value(provider, key, default, context) do
generic_resolve(provider, :map, key, default, context)
end

defp generic_resolve(provider, type, flag_key, default_value, context) do
{:ok, goff_context} = ContextTransformer.transform_context(context)

goff_request = %RequestFlagEvaluation{user: goff_context, default_value: default_value}
eval_context_hash = GofEvaluationContext.hash(goff_context)
http_client = provider.http_client
Logger.debug("Unexpected frame received: #{inspect("fires")}")

response_body =
case CacheController.get(flag_key, eval_context_hash) do
{:ok, cached_response} ->
cached_response

:miss ->
# Fetch from HTTP if cache miss
case HttpClient.post(http_client, "/v1/feature/#{flag_key}/eval", goff_request) do
{:ok, response} ->
handle_response(flag_key, eval_context_hash, response)

{:error, reason} ->
{:error, {:unexpected_error, reason}}
end
end

handle_flag_resolution(response_body, type, flag_key, default_value)
end

defp handle_response(flag_key, eval_context_hash, response) do
# Build the flag evaluation struct directly from the response map
flag_eval = ResponseFlagEvaluation.decode(response)

# Cache the response if it's marked as cacheable
if flag_eval.cacheable do
CacheController.set(flag_key, eval_context_hash, response)
end

{:ok, flag_eval}
end

defp handle_flag_resolution(response, type, flag_key, _default_value) do
Logger.debug("Unexpected frame received: #{inspect(response)}")

case response do
{:ok, %ResponseFlagEvaluation{value: value, reason: reason}} ->
case {type, value} do
{:boolean, val} when is_boolean(val) ->
{:ok, %ResolutionDetails{value: val, reason: reason}}

{:string, val} when is_binary(val) ->
{:ok, %ResolutionDetails{value: val, reason: reason}}

{:number, val} when is_number(val) ->
{:ok, %ResolutionDetails{value: val, reason: reason}}

{:map, val} when is_map(val) ->
{:ok, %ResolutionDetails{value: val, reason: reason}}

_ ->
{:error,
{:variant_not_found,
"Expected #{type} but got #{inspect(value)} for flag #{flag_key}"}}
end

_ ->
{:error, {:flag_not_found, "Flag #{flag_key} not found"}}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule ElixirProvider.RequestFlagEvaluation do
@moduledoc """
RequestFlagEvaluation is an object representing a user context for evaluation.
"""
alias ElixirProvider.GofEvaluationContext

@enforce_keys [:user]
@derive Jason.Encoder
defstruct [:default_value, :user]

@type t :: %__MODULE__{
user: GofEvaluationContext.t(),
default_value: any()
}
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
defmodule ElixirProvider.ResponseFlagEvaluation do
@moduledoc """
Represents the evaluation response of a feature flag.
"""
alias ElixirProvider.Types

@enforce_keys [:value, :failed, :reason]
@derive Jason.Encoder
defstruct [
:value,
error_code: nil,
failed: false,
reason: "",
track_events: nil,
variation_type: nil,
version: nil,
metadata: nil,
cacheable: nil
]

@type t :: %__MODULE__{
error_code: String.t() | nil,
failed: boolean(),
reason: String.t(),
track_events: boolean() | nil,
value: Types.json_type(),
variation_type: String.t() | nil,
version: String.t() | nil,
metadata: map() | nil,
cacheable: boolean() | nil
}

@spec decode(map()) :: t()
def decode(response) when is_map(response) do
%__MODULE__{
failed: response["failed"] || false,
value: response["value"],
variation_type: response["variationType"],
reason: response["reason"] || "",
error_code: response["errorCode"],
metadata: response["metadata"] || %{},
cacheable: Map.get(response, "cacheable", false),
track_events: response["track_events"],
version: response["version"]
}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule ElixirProvider.ServerSupervisor do
@moduledoc """
Supervisor
"""
use Supervisor

def start_link(args) do
Supervisor.start_link(__MODULE__, [args], name: __MODULE__)
end

@impl true
def init([_args]) do
children = [
ElixirProvider.CacheController,
ElixirProvider.DataCollectorHook
]

Supervisor.init(children, strategy: :one_for_one)
end
end
8 changes: 8 additions & 0 deletions openfeature/providers/elixir-provider/lib/provider/types.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
defmodule ElixirProvider.Types do
@moduledoc """
ElixirProvider types.
"""

@type json_type :: boolean() | integer() | float() | String.t() | list() | map()

end
182 changes: 182 additions & 0 deletions openfeature/providers/elixir-provider/lib/provider/web_socket.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
defmodule ElixirProvider.GoFWebSocketClient do
use GenServer

require Logger
require Mint.HTTP

alias ElixirProvider.CacheController

@moduledoc """
A minimal WebSocket client for listening to configuration changes from the GO Feature Flag relay proxy.
Clears the cache on receiving change notifications.
"""

defstruct [:conn, :websocket, :request_ref, :status, :caller, :resp_headers, :closing?]

@type t :: %__MODULE__{
conn: Mint.HTTP.t() | nil,
websocket: Mint.WebSocket.t() | nil,
request_ref: reference() | nil,
caller: {pid(), GenServer.from()} | nil,
status: integer() | nil,
resp_headers: list({String.t(), String.t()}) | nil,
closing?: boolean()
}

@websocket_uri "ws/v1/flag/change"

def connect(url) do
with {:ok, socket} <- GenServer.start_link(__MODULE__, [], name: __MODULE__),
{:ok, :connected} <- GenServer.call(socket, {:connect, url}) do
{:ok, socket}
end
end

def stop do
GenServer.stop(__MODULE__)
end

@impl true
def init([]) do
{:ok, %__MODULE__{}}
end

@impl true
def handle_call({:connect, url}, from, state) do
uri = URI.parse(url)

{http_scheme, ws_scheme} =
case uri.scheme do
"ws" -> {:http, :ws}
"wss" -> {:https, :wss}
"http" -> {:http, :ws}
"https" -> {:https, :wss}
_ -> {:reply, {:error, :invalid_scheme}, state}
end

# Ensure the path is not nil
path = (uri.path || "/") <> @websocket_uri

with {:ok, conn} <-
Mint.HTTP.connect(http_scheme, uri.host, uri.port || default_port(http_scheme)),
{:ok, conn, ref} <- Mint.WebSocket.upgrade(ws_scheme, conn, path, []) do
state = %{state | conn: conn, request_ref: ref, caller: from}

{:noreply, state}
else
{:error, reason} ->
Logger.info("Parsed URI path: #{inspect("hi")}")
{:reply, {:error, reason}, state}

{:error, conn, reason} ->
Logger.info("Parsed URI path: #{inspect(reason)}")
{:reply, {:error, reason}, put_in(state.conn, conn)}
end
end

defp default_port(:http), do: 80
defp default_port(:https), do: 443

@impl GenServer
def handle_info(message, state) do
Logger.info("Received message: #{inspect(message)}")

case Mint.WebSocket.stream(state.conn, message) do
{:ok, conn, responses} ->
state = put_in(state.conn, conn) |> handle_responses(responses)
if state.closing?, do: do_close(state), else: {:noreply, state}

{:error, conn, reason, _responses} ->
state = put_in(state.conn, conn) |> reply({:error, reason})
{:noreply, state}

:unknown ->
{:noreply, state}
end
end

defp handle_responses(state, responses)

defp handle_responses(%{request_ref: ref} = state, [{:status, ref, status} | rest]) do
put_in(state.status, status)
|> handle_responses(rest)
end

defp handle_responses(%{request_ref: ref} = state, [{:headers, ref, resp_headers} | rest]) do
put_in(state.resp_headers, resp_headers)
|> handle_responses(rest)
end

defp handle_responses(%{request_ref: ref} = state, [{:done, ref} | rest]) do
case Mint.WebSocket.new(state.conn, ref, state.status, state.resp_headers) do
{:ok, conn, websocket} ->
%{state | conn: conn, websocket: websocket, status: nil, resp_headers: nil}
|> reply({:ok, :connected})
|> handle_responses(rest)

{:error, conn, reason} ->
put_in(state.conn, conn)
|> reply({:error, reason})
end
end

defp handle_responses(%{request_ref: ref, websocket: websocket} = state, [
{:data, ref, data} | rest
])
when websocket != nil do
case Mint.WebSocket.decode(websocket, data) do
{:ok, websocket, frames} ->
put_in(state.websocket, websocket)
|> handle_frames(frames)
|> handle_responses(rest)

{:error, websocket, reason} ->
put_in(state.websocket, websocket)
|> reply({:error, reason})
end
end

defp handle_responses(state, [_response | rest]) do
handle_responses(state, rest)
end

defp handle_responses(state, []), do: state

def handle_frames(state, frames) do
Enum.reduce(frames, state, fn
{:close, _code, reason}, state ->
Logger.debug("Closing connection: #{inspect(reason)}")
%{state | closing?: true}

{:text, text}, state ->
response = Jason.decode!(text)

case Map.get(response, "type") do
"change" ->
# Clear the cache when a change message is received
CacheController.clear()
Logger.info("Cache cleared due to configuration change notification.")

_ ->
nil
end

state

frame, state ->
Logger.debug("Unexpected frame received: #{inspect(frame)}")
state
end)
end

defp do_close(state) do
Mint.HTTP.close(state.conn)
Logger.info("Websocket closed")
{:stop, :normal, state}
end

defp reply(state, response) do
if state.caller, do: GenServer.reply(state.caller, response)
put_in(state.caller, nil)
end
end
35 changes: 35 additions & 0 deletions openfeature/providers/elixir-provider/mix.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule ElixirProvider.MixProject do
use Mix.Project

def project do
[
app: :elixir_provider,
version: "0.1.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end

# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end

# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:open_feature, git: "https://github.com/open-feature/elixir-sdk.git"},
{:jason, "~> 1.4"},
{:mint, "~> 1.6"},
{:mint_web_socket, "~> 1.0"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:bypass, "~> 2.1", only: :test},
{:plug, "~> 1.16", only: :test},
{:mox, "~> 1.2", only: :test},
{:mimic, "~> 1.7", only: :test}
]
end
end
27 changes: 27 additions & 0 deletions openfeature/providers/elixir-provider/mix.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
%{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
"credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"},
"elixir_sdk": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []},
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
"ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"},
"hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
"mimic": {:hex, :mimic, "1.10.2", "0d7e67ba09b1e8fe21a61a91f4cb2b876151c2d7e1c9bf6fc325195dd33075dd", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "21a50eddbdee1e9bad93cb8738bd4e224913d0d25a06692d34fb19881dba7292"},
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"},
"open_feature": {:git, "https://github.com/open-feature/elixir-sdk.git", "8e08041085aedec5d661b9a9a942cdf9f2606422", []},
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"websocket_client": {:hex, :websocket_client, "1.5.0", "e825f23c51a867681a222148ed5200cc4a12e4fb5ff0b0b35963e916e2b5766b", [:rebar3], [], "hexpm", "2b9b201cc5c82b9d4e6966ad8e605832eab8f4ddb39f57ac62f34cb208b68de9"},
"websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"},
}
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions openfeature/providers/elixir-provider/test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Mimic.copy(Mint.HTTP)

ExUnit.start()
Empty file.
Original file line number Diff line number Diff line change
@@ -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

Empty file.