defmodule Flagsmith.Client do
alias Flagsmith.Schemas
alias Flagsmith.Configuration
@api_paths Flagsmith.Configuration.api_paths()
@environment_header Flagsmith.Configuration.environment_header()
@type tesla_header_list :: [{String.t(), String.t()}]
@type config_or_env :: Configuration.t() | Keyword.t() | Schemas.Environment.t()
@doc """
Create a `t:Flagsmith.Configuration.t/0` struct with the desired settings to use
in requests.
All settings are optional with exception of the `:environment_key` if not configured
at the application level.
Non specified options will assume defaults, or if set at the application level use
that.
"""
@spec new(Keyword.t()) :: Configuration.t() | no_return()
def new(opts \\ []),
do: Configuration.build(opts)
@spec http_client(Configuration.t()) :: Tesla.Client.t()
defp http_client(%Configuration{
environment_key: environment_key,
api_url: api_url,
request_timeout_milliseconds: timeout,
custom_headers: custom_headers,
retries: retries
}) do
Tesla.client([
base_url_middleware(api_url),
auth_middleware(environment_key),
Tesla.Middleware.JSON,
{Tesla.Middleware.Retry, max_retries: retries},
{Tesla.Middleware.Headers, custom_headers},
{Tesla.Middleware.FollowRedirects, max_redirects: 5},
{Tesla.Middleware.Timeout, timeout: timeout}
])
end
@doc """
Returns an `:ok` tuple containing a `t:Flagsmith.Schemas.Environment.t/0` struct,
either from the local evaluation or API depending on the configuration used, or an
`:error` tuple if unable to.
Passing a `t:Flagsmith.Configuration.t/0` or options with `:enable_local_evaluation`
as `true` will start a local process for the given api key used, if one is not
started yet, which requires you to be running the `Flagsmith.Supervisor`.
"""
@spec get_environment(Configuration.t() | Keyword.t()) ::
{:ok, Schemas.Environment.t()} | {:error, term()}
def get_environment(configuration_or_opts \\ [])
def get_environment(%Configuration{enable_local_evaluation: local?} = config) do
case local? do
true -> Flagsmith.Client.Poller.get_environment(config)
false -> get_environment_request(config)
end
end
def get_environment(opts) when is_list(opts),
do: get_environment(new(opts))
@doc false
def get_environment_request(%Configuration{} = config) do
case Tesla.get(http_client(config), @api_paths.environment) do
{:ok, %{status: status, body: body}} when status >= 200 and status < 300 ->
{:ok,
body
|> Schemas.Environment.from_response()
|> Schemas.Environment.add_client_config(config)}
error_resp ->
return_error(error_resp)
end
end
@doc """
Returns an `:ok` tuple containing a list of `t:Flagsmith.Schemas.Flag.t/0` structs,
either from the local evaluation or API depending on the configuration used, or an
`:error` tuple if unable to.
Passing a `t:Flagsmith.Configuration.t/0` or options with `:enable_local_evaluation`
as `true` will start a local process for the given api key used, if one is not
started yet, which requires you to be running the `Flagsmith.Supervisor`.
"""
@spec get_environment_flags(config_or_env()) ::
{:ok, Schemas.Flags.t()} | {:error, term()}
def get_environment_flags(configuration_or_env_or_opts \\ [])
def get_environment_flags(%Configuration{enable_local_evaluation: local?} = config) do
case local? do
true -> Flagsmith.Client.Poller.get_environment_flags(config)
false -> get_environment_flags_request(config)
end
end
def get_environment_flags(%Schemas.Environment{} = env),
do: {:ok, build_flags(env)}
def get_environment_flags(opts) when is_list(opts),
do: get_environment_flags(new(opts))
@doc false
defp get_environment_flags_request(%Configuration{} = config) do
case get_environment_request(config) do
{:ok, %Schemas.Environment{} = env} ->
{:ok, build_flags(env, config)}
error ->
error
end
end
@doc false
def build_flags(%Schemas.Environment{__configuration__: %Configuration{} = config} = env),
do: build_flags(env, config)
@doc false
def build_flags(%Schemas.Environment{} = env, %Configuration{} = config) do
env
|> extract_flags()
|> Schemas.Flags.new(config)
end
def build_flags(flags, %Configuration{} = config) when is_map(flags),
do: Schemas.Flags.new(flags, config)
def build_flags(flags, %Configuration{} = config) when is_list(flags) do
flags
|> extract_flags()
|> Schemas.Flags.new(config)
end
@doc """
Returns an `:ok` tuple containing a list of `t:Flagsmith.Schemas.Flag.t/0` structs,
either from the local evaluation or API depending on the configuration used, or an
`:error` tuple if unable to. The flags are retrieved based on a user identifier
so take into account segments and traits.
Note: when using local evaluation there's no way to update the
traits, the traits passed on to this function are used to check any segment rule
specified on the `t:Flagsmith.Schemas.Environment.t/0` you're accessing. On the other
hand, when using the live API evaluation the traits you pass will be used to update
the traits associated with the identity you're specifying.
Passing a `t:Flagsmith.Configuration.t/0` or options with `:enable_local_evaluation`
as `true` will start a local process for the given api key used, if one is not
started yet, which requires you to be running the `Flagsmith.Supervisor`.
"""
@spec get_identity_flags(
Configuration.t() | Keyword.t(),
String.t(),
list(map() | Schemas.Traits.Trait.t())
) ::
{:ok, Schemas.Flags.t()} | {:error, term()}
def get_identity_flags(configuration_or_opts \\ [], identifier, traits)
def get_identity_flags(
%Configuration{enable_local_evaluation: local?} = config,
identifier,
traits
) do
case local? do
true -> Flagsmith.Client.Poller.get_identity_flags(config, identifier, traits)
false -> get_identity_flags_request(config, identifier, traits)
end
end
def get_identity_flags(opts, identifier, traits) when is_list(opts),
do: get_identity_flags(new(opts), identifier, traits)
@doc false
def get_identity_flags_request(%Configuration{} = config, identifier, traits) do
query = build_identity_params(identifier, traits)
case Tesla.post(http_client(config), @api_paths.identities, query) do
{:ok, %{status: status, body: body}} when status >= 200 and status < 300 ->
with %Schemas.Identity{flags: flags} <- Schemas.Identity.from_response(body),
flags <- build_flags(flags, config) do
{:ok, flags}
else
error ->
{:error, error}
end
error_resp ->
return_error(error_resp)
end
end
@doc """
Return all flags from an environment as a list.
If a `t:Flagsmith.Schemas.Environment.t/0` is passed instead of a
`t:Flagsmith.Configuration.t/0` or list of options, then the flags are extracted
from it.
"""
@spec all_flags(config_or_env()) :: list(Schemas.Flag.t())
def all_flags(config_or_opts_or_env_or_flags \\ [])
def all_flags(%Schemas.Environment{} = env) do
env
|> Flagsmith.Engine.get_environment_feature_states()
|> Enum.map(&Schemas.Flag.from(&1))
end
def all_flags(%Schemas.Flags{flags: flags}),
do: Enum.map(flags, fn {_, flag} -> flag end)
def all_flags(%Configuration{} = config) do
case get_environment(config) do
{:ok, %Schemas.Environment{} = env} -> all_flags(env)
error -> error
end
end
def all_flags(opts) when is_list(opts),
do: all_flags(new(opts))
@doc """
Returns the `:enabled` status of a feature by name, or `:not_found` if the feature
doesn't exist.
If a `t:Flagsmith.Schemas.Environment.t/0` is passed instead of a
`t:Flagsmith.Configuration.t/0` or list of options, then the feature is evaluated
from that environment, otherwise a local evaluation or api call is executed
according to the configuration or passed options.
"""
@spec is_feature_enabled(config_or_env(), feature_name :: String.t()) ::
boolean() | :not_found | term()
def is_feature_enabled(configuration_or_env_or_opts \\ [], feature_name) do
case get_flag(configuration_or_env_or_opts, feature_name) do
%Schemas.Flag{enabled: enabled?} ->
enabled?
error ->
error
end
end
@doc """
Returns a `t:Flagsmith.Schemas.Flag.t/0` by name, or `:not_found` if the feature
doesn't exist.
If a `t:Flagsmith.Schemas.Environment.t/0` is passed instead of a
`t:Flagsmith.Configuration.t/0` or list of options, then the feature is looked up
in that environment, otherwise a local evaluation or api call is executed
according to the configuration or passed options.
"""
@spec get_feature_value(config_or_env(), feature_name :: String.t()) ::
:not_found | term()
def get_feature_value(configuration_or_env_or_opts \\ [], feature_name) do
case get_flag(configuration_or_env_or_opts, feature_name) do
%Schemas.Flag{value: value} ->
value
error ->
error
end
end
@doc """
Returns a `t:Flagsmith.Schemas.Flag.t/0` by name. If the feature doesn't exist,
it returns `:not_found` by default or in case `default_flag_handler` has been set
returns what the call to that function with the feature name returns.
If a `t:Flagsmith.Schemas.Environment.t/0` is passed instead of a
`t:Flagsmith.Configuration.t/0` or list of options, then the feature is looked up
in that environment, otherwise a local evaluation or api call is executed
according to the configuration or passed options.
"""
@spec get_flag(config_or_env() | Schemas.Flags.t(), feature_name :: String.t()) ::
Schemas.Flag.t() | :not_found | term()
def get_flag(configuration_or_env_or_opts \\ [], feature_name)
def get_flag(
%Schemas.Environment{__configuration__: %{default_flag_handler: handler}} = env,
feature_name
) do
env
|> extract_flags()
|> Map.get(feature_name)
|> case do
%Schemas.Flag{} = flag ->
maybe_track(flag, env)
_ when is_function(handler, 1) ->
handler.(feature_name)
_ ->
:not_found
end
end
def get_flag(
%Schemas.Flags{
__configuration__: %{default_flag_handler: handler} = config,
flags: flags
},
feature_name
) do
case Map.get(flags, feature_name) do
%Schemas.Flag{} = flag ->
maybe_track(flag, config)
_ when is_function(handler, 1) ->
handler.(feature_name)
_ ->
:not_found
end
end
def get_flag(%Configuration{default_flag_handler: handler} = config, feature_name) do
case get_environment(config) do
{:ok, %Schemas.Environment{} = env} -> get_flag(env, feature_name)
_error when is_function(handler, 1) -> handler.(feature_name)
error -> error
end
end
def get_flag(opts, feature_name) when is_list(opts),
do: get_flag(new(opts), feature_name)
@doc false
# Submits a map of `feature_id => number_of_accesses` to the Flagsmith analytics
# endpoint for usage tracking.
@spec analytics_track(Configuration.t() | Keyword.t(), map()) :: {:ok, map()} | {:error, term}
def analytics_track(configuration_or_env_or_opts \\ [], tracking)
def analytics_track(%Configuration{} = config, tracking) do
case Tesla.post(http_client(config), @api_paths.analytics, tracking) do
{:ok, %{status: status, body: body}} when status >= 200 and status < 300 ->
{:ok, body}
error_resp ->
return_error(error_resp)
end
end
def analytics_track(opts, tracking)
when is_list(opts) and is_map(tracking) and not is_struct(tracking),
do: analytics_track(new(opts), tracking)
@doc false
# Given an `t:Flagsmith.Schemas.Environment.t/0` or a list composed of
# `t:Flagsmith.Schemas.Environment.FeatureState.t/0` or
# `t:Flagsmith.Schemas.Features.FeatureState.t/0` return a map composed of the
# features names as keys and the features as `t:Flagsmith.Schemas.Flag.t/0`.
@spec extract_flags(
Schemas.Environment.t()
| list(Schemas.Features.FeatureState.t() | Schemas.Environment.FeatureState.t())
) :: %{String.t() => Schemas.Flag.t()}
defp extract_flags(%Schemas.Environment{} = env) do
env
|> Flagsmith.Engine.get_environment_feature_states()
|> Enum.reduce(%{}, fn feature_state, acc ->
%Schemas.Flag{feature_name: name} = flag = Schemas.Flag.from(feature_state)
Map.put(acc, name, flag)
end)
end
defp extract_flags(feature_states) when is_list(feature_states) do
Enum.reduce(feature_states, %{}, fn feature_state, acc ->
%Schemas.Flag{feature_name: name} = flag = Schemas.Flag.from(feature_state)
Map.put(acc, name, flag)
end)
end
defp maybe_track(feature_flag, environment) do
Flagsmith.Client.Analytics.Processor.track(feature_flag, environment)
feature_flag
end
defp build_identity_params(identifier, [_ | _] = traits) do
%{
identifier: identifier,
traits: Schemas.Traits.Trait.from(traits)
}
end
defp build_identity_params(identifier, _),
do: %{identifier: identifier}
@doc false
@spec auth_middleware(environment_key :: String.t()) ::
{Tesla.Middleware.Headers, tesla_header_list()}
def auth_middleware(environment_key),
do: {Tesla.Middleware.Headers, auth_header(environment_key)}
@doc false
@spec base_url_middleware(base_url :: String.t()) ::
{Tesla.Middleware.BaseUrl, String.t()}
def base_url_middleware(base_url),
do: {Tesla.Middleware.BaseUrl, base_url}
@spec auth_header(environment_key :: String.t()) :: tesla_header_list()
defp auth_header(environment_key), do: [{@environment_header, environment_key}]
defp return_error({:ok, %{body: body}}), do: {:error, body}
defp return_error({:error, _} = error), do: error
end