Skip to content
Merged
Show file tree
Hide file tree
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
4 changes: 4 additions & 0 deletions .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}"]
]
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
# ConfigCat SDK for Elixir
https://configcat.com

https://configcat.com
ConfigCat SDK for Elixir provides easy integration for your application to ConfigCat.

ConfigCat is a feature flag and configuration management service that lets you separate releases from deployments. You can turn your features ON/OFF using <a href="http://app.configcat.com" target="_blank">ConfigCat Dashboard</a> even after they are deployed. ConfigCat lets you target specific groups of users based on region, email or any other custom user attribute.

ConfigCat is a <a href="https://configcat.com" target="_blank">hosted feature flag service</a>. Manage feature toggles across frontend, backend, mobile, desktop apps. <a href="https://configcat.com" target="_blank">Alternative to LaunchDarkly</a>. Management app + feature flag SDKs.

## Installation

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `config_cat` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:config_cat, "~> 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/config_cat](https://hexdocs.pm/config_cat).
187 changes: 187 additions & 0 deletions lib/config_cat.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
defmodule ConfigCat do
use GenServer

alias ConfigCat.{FetchPolicy, Rollout}
alias HTTPoison.Response

require Logger

@base_url "https://cdn.configcat.com"
@base_path "configuration-files"
@config_filename "config_v4.json"

def start_link(sdk_key, options \\ [])

def start_link(nil, _options), do: {:error, :missing_sdk_key}

def start_link(sdk_key, options) do
with {name, options} <- Keyword.pop(options, :name, __MODULE__),
{initial_config, options} <- Keyword.pop(options, :initial_config) do
initial_state = %{
config: initial_config,
etag: nil,
last_update: nil,
options: Keyword.merge(default_options(), options),
sdk_key: sdk_key
}

GenServer.start_link(__MODULE__, initial_state, name: name)
end
end

defp default_options, do: [api: ConfigCat.API, fetch_policy: FetchPolicy.auto()]

def get_value(key, default_value, user_or_options \\ []) do
if Keyword.keyword?(user_or_options) do
get_value(key, default_value, nil, user_or_options)
else
get_value(key, default_value, user_or_options, [])
end
end

def get_value(key, default_value, user, options) do
client = Keyword.get(options, :client, __MODULE__)
GenServer.call(client, {:get_value, key, default_value, user})
end

def force_refresh(client \\ __MODULE__) do
GenServer.call(client, :force_refresh)
end

@impl GenServer
def init(state) do
{:ok, state, {:continue, :maybe_init_fetch}}
end

@impl GenServer
def handle_call({:get_value, key, default_value, user}, _from, state) do
with {:ok, new_state} <- maybe_refresh(state),
value <- Rollout.evaluate(key, user, default_value, new_state.config) do
{:reply, value, new_state}
else
error -> {:reply, error, state}
end
end

@impl GenServer
def handle_call(:force_refresh, _from, state) do
with {:ok, new_state} <- refresh(state) do
{:reply, :ok, new_state}
else
error -> {:reply, error, state}
end
end

defp schedule_initial_fetch?(%{options: options}) do
options
|> Keyword.get(:fetch_policy)
|> FetchPolicy.schedule_initial_fetch?()
end

defp maybe_refresh(%{options: options} = state) do
options
|> Keyword.get(:fetch_policy)
|> maybe_refresh(state)
end

defp maybe_refresh(fetch_policy, %{last_update: last_update} = state) do
if FetchPolicy.needs_fetch?(fetch_policy, last_update) do
refresh(state)
else
{:ok, state}
end
end

defp refresh(%{options: options, etag: etag} = state) do
Logger.info("Fetching configuration from ConfigCat")

with api <- Keyword.get(options, :api),
{:ok, response} <- api.get(url(state), headers(etag)) do
response
|> log_response()
|> handle_response(state)
else
error ->
log_error(error)
end
end

defp handle_response(%Response{status_code: code, body: body, headers: headers}, state)
when code >= 200 and code < 300 do
with {:ok, config} = Jason.decode(body),
etag <- extract_etag(headers) do
{:ok, %{state | config: config, etag: etag, last_update: now()}}
end
end

defp handle_response(%Response{status_code: 304}, state) do
{:ok, %{state | last_update: now()}}
end

defp handle_response(response, _state) do
{:error, response}
end

defp headers(nil), do: []
defp headers(etag), do: [{"If-None-Match", etag}]

defp extract_etag(headers) do
headers |> Enum.into(%{}) |> Map.get("ETag")
end

defp url(%{options: options, sdk_key: sdk_key}) do
base_url = Keyword.get(options, :base_url, @base_url)

base_url
|> URI.parse()
|> URI.merge("#{@base_path}/#{sdk_key}/#{@config_filename}")
|> URI.to_string()
end

defp now, do: DateTime.utc_now()

defp log_response(%Response{headers: headers, status_code: status_code} = response) do
Logger.info(
"ConfigCat configuration json fetch response code: #{status_code} Cached: #{
extract_etag(headers)
}"
)

response
end

defp log_error(error) do
Logger.warn("Failed to fetch configuration from ConfigCat: #{inspect(error)}")
error
end

defp schedule_and_refresh(%{options: options} = state) do
options
|> Keyword.get(:fetch_policy)
|> FetchPolicy.schedule_next_fetch(self())

case refresh(state) do
{:ok, new_state} -> new_state
_error -> state
end
end

@impl GenServer
# Work around leaking messages from hackney (see https://github.com/benoitc/hackney/issues/464#issuecomment-495731612)
# Seems to be an issue in OTP 21 and later.
def handle_info({:ssl_closed, _msg}, state), do: {:noreply, state}

@impl GenServer
def handle_info(:refresh, state) do
{:noreply, schedule_and_refresh(state)}
end

@impl GenServer
def handle_continue(:maybe_init_fetch, state) do
if schedule_initial_fetch?(state) do
{:noreply, schedule_and_refresh(state)}
else
{:noreply, state}
end
end
end
11 changes: 11 additions & 0 deletions lib/config_cat/api.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule ConfigCat.API do
use HTTPoison.Base

def process_request_headers(headers) do
[
{"User-Agent", "ConfigCat-Elixir/m-0.0.1"},
{"X-ConfigCat-UserAgent", "ConfigCat-Elixir/m-0.0.1"},
{"Content-Type", "application/json"} | headers
]
end
end
51 changes: 51 additions & 0 deletions lib/config_cat/fetch_policy.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
defmodule ConfigCat.FetchPolicy do
defstruct [
:type,
cache_expiry_seconds: 0,
poll_interval_seconds: 0
]

def manual do
%__MODULE__{type: :manual}
end

def lazy(cache_expiry_seconds: seconds) do
%__MODULE__{type: :lazy, cache_expiry_seconds: seconds}
end

def auto(options \\ []) do
seconds = options |> Keyword.get(:poll_interval_seconds, 60) |> max(1)

%__MODULE__{
type: :auto,
poll_interval_seconds: seconds
}
end

def needs_fetch?(%__MODULE__{type: :lazy}, nil), do: true

def needs_fetch?(
%__MODULE__{type: :lazy, cache_expiry_seconds: expiry_seconds},
last_update_time
) do
cache_expired?(last_update_time, expiry_seconds)
end

def needs_fetch?(_policy, _last_update_time), do: false

def schedule_initial_fetch?(%__MODULE__{type: :auto}), do: true
def schedule_initial_fetch?(_policy), do: false

def schedule_next_fetch(%__MODULE__{type: :auto, poll_interval_seconds: seconds}, pid) do
Process.send_after(pid, :refresh, seconds * 1000)
end

def schedule_next_fetch(_policy, _pid), do: nil

defp cache_expired?(last_update_time, expiry_seconds) do
:gt !==
last_update_time
|> DateTime.add(expiry_seconds, :second)
|> DateTime.compare(DateTime.utc_now())
end
end
Loading