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
Prev Previous commit
Next Next commit
add provider
JoE11-y committed Nov 3, 2024
commit 11e53eab8b5164d8983e6a9e2b982104c0f8e1fb
139 changes: 123 additions & 16 deletions openfeature/providers/elixir-provider/lib/elixir_provider.ex
Original file line number Diff line number Diff line change
@@ -1,32 +1,139 @@
defmodule ElixirProvider do
alias OpenFeature.EvaluationDetails
alias ElixirProvider.ResponseFlagEvaluation
alias ElixirProvider.GoFeatureFlagMetadata
alias ElixirProvider.ContextTransformer
alias ElixirProvider.RequestFlagEvaluation
@behaviour OpenFeature.Provider

alias OpenFeature.ResolutionDetails
alias ElixirProvider.GoFeatureFlagOptions
alias ElixirProvider.Types
alias ElixirProvider.HttpClient
alias ElixirProvider.DataCollectorHook
alias ElixirProvider.CacheController
alias ElixirProvider.ResponseFlagEvaluation
alias ElixirProvider.GoFWebSocketClient
alias ElixirProvider.HttpClient
alias ElixirProvider.RequestFlagEvaluation
alias ElixirProvider.ContextTransformer
alias ElixirProvider.GofEvaluationContext

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

defstruct [
:options,
:_http_client,
_data_collector_hook: nil,
_ws: nil,
:http_client,
:data_collector_hook,
:ws,
:domain
]

@type t :: %__MODULE__{
options: GoFeatureFlagOptions.t(),
_http_client: HttpClient.t(),
_data_collector_hook: any(),
_ws: GoFWebSocketClient.t(),
}
options: GoFeatureFlagOptions.t(),
http_client: HttpClient.t(),
data_collector_hook: DataCollectorHook.t() | nil,
ws: GoFWebSocketClient.t(),
domain: String.t()
}

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

updated_provider = %__MODULE__{
provider
| domain: domain,
http_client: http_client,
data_collector_hook: data_collector_hook,
ws: ws
}

{:ok, updated_provider}
end

@impl true
def shutdown(%__MODULE__{ws: ws} = provider) do
Process.exit(ws, :normal)
CacheController.clear()
if provider.data_collector_hook, do: DataCollectorHook.shutdown(provider.data_collector_hook)
:ok
end

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

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

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

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

defp generic_resolve(provider, type, flag_key, default_value, context) do
{:ok, goff_context} = ContextTransformer.transform_context(context)
goff_request = %RequestFlagEvaluation{user: goff_context, default_value: default_value}
eval_context_hash = GofEvaluationContext.hash(goff_context)

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

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

handle_flag_resolution(response_body, type, flag_key, default_value)
end

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

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

{:ok, flag_eval}
end

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

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

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

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

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

_ ->
{:error, {:flag_not_found, "Flag #{flag_key} not found"}}
end
end

end
16 changes: 0 additions & 16 deletions openfeature/providers/elixir-provider/lib/provider/application.ex
Original file line number Diff line number Diff line change
@@ -1,16 +0,0 @@
defmodule OpenFeature.Application do
@moduledoc false

use Application

@impl true
def start(_type, _args) do
children = [
OpenFeature.Store,
OpenFeature.EventEmitter
]

opts = [strategy: :one_for_one, name: OpenFeature.Supervisor]
Supervisor.start_link(children, opts)
end
end
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ defmodule ElixirProvider.ContextTransformer do
@moduledoc """
Converts an OpenFeature EvaluationContext into a GO Feature Flag context.
"""
alias ElixirProvider.EvaluationContext
alias ElixirProvider.GofEvaluationContext
alias OpenFeature.Types

@doc """
@@ -16,14 +16,14 @@ defmodule ElixirProvider.ContextTransformer do
end

@doc """
Converts an EvaluationContext map into a ElixirProvider.EvaluationContext struct.
Converts an EvaluationContext map into a ElixirProvider.GofEvaluationContext struct.
Returns `{:ok, context}` on success, or `{:error, reason}` on failure.
"""
@spec transform_context(Types.context()) :: {:ok, EvaluationContext.t()} | {:error, String.t()}
@spec transform_context(Types.context()) :: {:ok, GofEvaluationContext.t()} | {:error, String.t()}
def transform_context(ctx) do
case get_any_value(ctx) do
{:ok, {key, value}} ->
{:ok, %EvaluationContext{
{:ok, %GofEvaluationContext{
key: key,
custom: value
}}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule DataCollectorHook do
defmodule ElixirProvider.DataCollectorHook do
use GenServer
require Logger

@@ -25,12 +25,32 @@ defmodule DataCollectorHook do
}

# Starts the GenServer and initializes with options
def start_link(state) do
GenServer.start_link(__MODULE__, state, name: __MODULE__)
def start_link(options, http_client) do
GenServer.start_link(__MODULE__, {options, http_client: http_client}, name: __MODULE__)
end

def shutdown(state) do
GenServer.stop(__MODULE__)
collect_data(state.data_flush_interval)
%__MODULE__{
http_client: state.http_client,
data_collector_endpoint: state.data_collector_endpoint,
disable_data_collection: state.disable_data_collection,
data_flush_interval: state.data_flush_interval,
event_queue: []
}
end

# Initializes GenServer state and schedules the first flush
def init(state) do
def init(args) do
state = %__MODULE__{
http_client: args.http_client,
data_collector_endpoint: args.options.endpoint,
disable_data_collection: args.options.disable_data_collection || false,
data_flush_interval: args.options.data_flush_interval || 60_000,
event_queue: []
}

schedule_collect_data(state.data_flush_interval)
{:ok, state}
end
@@ -40,6 +60,8 @@ defmodule DataCollectorHook do
Process.send_after(self(), :collect_data, interval)
end

### Hook Implementations

def after_hook(hook, hook_context, flag_evaluation_details, _hints) do
if hook.disable_data_collection or flag_evaluation_details.reason != :CACHED do
:ok
@@ -103,9 +125,8 @@ defmodule DataCollectorHook do
meta: %{"provider" => "open-feature-elixir-sdk"},
events: event_queue
}
|> Jason.encode!()

case http_client.post(http_client, endpoint, body) do
case HttpClient.post(http_client, endpoint, body) do
{:ok, response} ->
Logger.info("Data sent successfully: #{inspect(response)}")
:ok
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule ElixirProvider.EvaluationContext do
defmodule ElixirProvider.GofEvaluationContext do
@moduledoc """
GoFeatureFlagEvaluationContext is an object representing a user context for evaluation.
"""
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ defmodule ElixirProvider.GoFeatureFlagOptions do
endpoint: String.t(),
cache_size: integer() | nil,
data_flush_interval: integer() | nil,
disable_data_collection: integer() | nil,
disable_data_collection: boolean(),
reconnect_interval: integer() | nil,
disable_cache_invalidation: boolean() | nil
}
Original file line number Diff line number Diff line change
@@ -12,17 +12,16 @@ defmodule ElixirProvider.HttpClient do
headers: list()
}

@spec start_http_connection(client :: t()) :: {:ok, t()} | {:error, any()}
def start_http_connection(client) do
uri = URI.parse(client.endpoint)
def start_http_connection(options) do
uri = URI.parse(options.endpoint)
scheme = if uri.scheme == "https", do: :https, else: :http

case Mint.HTTP.connect(scheme, uri.host, uri.port) do
{:ok, conn} ->
# Create the struct with the connection, endpoint, and default headers
config = %__MODULE__{
conn: conn,
endpoint: client.endpoint,
endpoint: options.endpoint,
headers: [{"content-type", "application/json"}]
}

Original file line number Diff line number Diff line change
@@ -2,13 +2,13 @@ defmodule ElixirProvider.RequestFlagEvaluation do
@moduledoc """
RequestFlagEvaluation is an object representing a user context for evaluation.
"""
alias ElixirProvider.EvaluationContext
alias ElixirProvider.GofEvaluationContext

@enforce_keys [:user]
defstruct [:default_value, :user]

@type t :: %__MODULE__{
user: EvaluationContext.t(),
user: GofEvaluationContext.t(),
default_value: any()
}
end
Original file line number Diff line number Diff line change
@@ -26,4 +26,19 @@ defmodule ElixirProvider.ResponseFlagEvaluation do
metadata: map() | nil,
cacheable: boolean() | nil
}

@spec decode(map()) :: t()
def decode(response) when is_map(response) do
%__MODULE__{
failed: response["failed"] || false,
value: response["value"],
variation_type: response["variationType"],
reason: response["reason"] || "",
error_code: response["errorCode"],
metadata: response["metadata"] || %{},
cacheable: Map.get(response, "cacheable", false),
track_events: response["track_events"],
version: response["version"]
}
end
end