Packages
ConfigCat SDK for Elixir. Feature Flags created by developers for developers with ❤️. ConfigCat lets you manage feature flags across frontend, backend, mobile, and desktop apps without (re)deploying code. % rollouts, user targeting, segmentation. Feature toggle SDKs for all main languages.
Current section
Files
Jump to
Current section
Files
lib/config_cat/config/user_comparator.ex
defmodule ConfigCat.Config.ComparatorMetadata do
@moduledoc false
use TypedStruct
@type value_type :: :double | :string | :string_list
typedstruct enforce: true do
field :description, String.t()
field :value_type, value_type()
end
end
defmodule ConfigCat.Config.ComparisonContext do
@moduledoc false
use TypedStruct
alias ConfigCat.Config
alias ConfigCat.Config.UserCondition
typedstruct enforce: true do
field :condition, UserCondition.t()
field :context_salt, Config.salt()
field :key, Config.key()
field :salt, Config.salt()
end
end
defmodule ConfigCat.Config.UserComparator do
@moduledoc false
alias ConfigCat.Config
alias ConfigCat.Config.ComparatorMetadata, as: Metadata
alias ConfigCat.Config.ComparisonContext
alias ConfigCat.Config.UserCondition
require ConfigCat.ConfigCatLogger, as: ConfigCatLogger
@is_one_of 0
@is_not_one_of 1
@contains_any_of 2
@not_contains_any_of 3
@is_one_of_semver 4
@is_not_one_of_semver 5
@less_than_semver 6
@less_than_equal_semver 7
@greater_than_semver 8
@greater_than_equal_semver 9
@equals_number 10
@not_equals_number 11
@less_than_number 12
@less_than_equal_number 13
@greater_than_number 14
@greater_than_equal_number 15
@is_one_of_hashed 16
@is_not_one_of_hashed 17
@before_datetime 18
@after_datetime 19
@equals_hashed 20
@not_equals_hashed 21
@starts_with_any_of_hashed 22
@not_starts_with_any_of_hashed 23
@ends_with_any_of_hashed 24
@not_ends_with_any_of_hashed 25
@array_contains_any_of_hashed 26
@array_not_contains_any_of_hashed 27
@equals 28
@not_equals 29
@starts_with_any_of 30
@not_starts_with_any_of 31
@ends_with_any_of 32
@not_ends_with_any_of 33
@array_contains_any_of 34
@array_not_contains_any_of 35
@metadata %{
@is_one_of => %Metadata{description: "IS ONE OF", value_type: :string_list},
@is_not_one_of => %Metadata{description: "IS NOT ONE OF", value_type: :string_list},
@contains_any_of => %Metadata{description: "CONTAINS ANY OF", value_type: :string_list},
@not_contains_any_of => %Metadata{description: "NOT CONTAINS ANY OF", value_type: :string_list},
@is_one_of_semver => %Metadata{description: "IS ONE OF", value_type: :string_list},
@is_not_one_of_semver => %Metadata{description: "IS NOT ONE OF", value_type: :string_list},
@less_than_semver => %Metadata{description: "<", value_type: :string},
@less_than_equal_semver => %Metadata{description: "<=", value_type: :string},
@greater_than_semver => %Metadata{description: ">", value_type: :string},
@greater_than_equal_semver => %Metadata{description: ">=", value_type: :string},
@equals_number => %Metadata{description: "=", value_type: :double},
@not_equals_number => %Metadata{description: "!=", value_type: :double},
@less_than_number => %Metadata{description: "<", value_type: :double},
@less_than_equal_number => %Metadata{description: "<=", value_type: :double},
@greater_than_number => %Metadata{description: ">", value_type: :double},
@greater_than_equal_number => %Metadata{description: ">=", value_type: :double},
@is_one_of_hashed => %Metadata{description: "IS ONE OF", value_type: :string_list},
@is_not_one_of_hashed => %Metadata{description: "IS NOT ONE OF", value_type: :string_list},
@before_datetime => %Metadata{description: "BEFORE", value_type: :double},
@after_datetime => %Metadata{description: "AFTER", value_type: :double},
@equals_hashed => %Metadata{description: "EQUALS", value_type: :string},
@not_equals_hashed => %Metadata{description: "NOT EQUALS", value_type: :string},
@starts_with_any_of_hashed => %Metadata{description: "STARTS WITH ANY OF", value_type: :string_list},
@not_starts_with_any_of_hashed => %Metadata{description: "NOT STARTS WITH ANY OF", value_type: :string_list},
@ends_with_any_of_hashed => %Metadata{description: "ENDS WITH ANY OF", value_type: :string_list},
@not_ends_with_any_of_hashed => %Metadata{description: "NOT ENDS WITH ANY OF", value_type: :string_list},
@array_contains_any_of_hashed => %Metadata{description: "ARRAY CONTAINS ANY OF", value_type: :string_list},
@array_not_contains_any_of_hashed => %Metadata{description: "ARRAY NOT CONTAINS ANY OF", value_type: :string_list},
@equals => %Metadata{description: "EQUALS", value_type: :string},
@not_equals => %Metadata{description: "NOT EQUALS", value_type: :string},
@starts_with_any_of => %Metadata{description: "STARTS WITH ANY OF", value_type: :string_list},
@not_starts_with_any_of => %Metadata{description: "NOT STARTS WITH ANY OF", value_type: :string_list},
@ends_with_any_of => %Metadata{description: "ENDS WITH ANY OF", value_type: :string_list},
@not_ends_with_any_of => %Metadata{description: "NOT ENDS WITH ANY OF", value_type: :string_list},
@array_contains_any_of => %Metadata{description: "ARRAY CONTAINS ANY OF", value_type: :string_list},
@array_not_contains_any_of => %Metadata{description: "ARRAY NOT CONTAINS ANY OF", value_type: :string_list}
}
@type result ::
{:ok, boolean()} | {:error, :invalid_datetime | :invalid_float | :invalid_string_list | :invalid_version}
@type t :: non_neg_integer()
@type value_type :: Metadata.value_type()
defguard is_for_datetime(comparator) when comparator in [@before_datetime, @after_datetime]
defguard is_for_hashed(comparator)
when comparator in [
@is_one_of_hashed,
@is_not_one_of_hashed,
@equals_hashed,
@not_equals_hashed,
@starts_with_any_of_hashed,
@not_starts_with_any_of_hashed,
@ends_with_any_of_hashed,
@not_ends_with_any_of_hashed,
@array_contains_any_of_hashed,
@array_not_contains_any_of_hashed
]
@spec description(t()) :: String.t()
def description(comparator) do
case Map.fetch(@metadata, comparator) do
{:ok, %Metadata{} = metadata} -> metadata.description
:error -> "Unsupported comparator"
end
end
@spec value_type(t()) :: value_type()
def value_type(comparator) do
case Map.fetch(@metadata, comparator) do
{:ok, %Metadata{} = metadata} ->
metadata.value_type
:error ->
:string
end
end
@spec compare(
t(),
Config.value(),
UserCondition.comparison_value(),
ComparisonContext.t()
) :: result()
def compare(@is_one_of, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
{:ok, text in comparison_values}
end
end
def compare(@is_not_one_of, user_value, comparison_values, %ComparisonContext{} = context) do
@is_one_of |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@contains_any_of, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result = Enum.any?(comparison_values, &String.contains?(text, &1))
{:ok, result}
end
end
def compare(@not_contains_any_of, user_value, comparison_values, %ComparisonContext{} = context) do
@contains_any_of |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@is_one_of_semver, user_value, comparison_values, %ComparisonContext{} = _context) do
with {:ok, user_version} <- to_version(user_value),
{:ok, comparison_versions} <- to_versions(comparison_values) do
result = Enum.any?(comparison_versions, &(Version.compare(user_version, &1) == :eq))
{:ok, result}
end
end
def compare(@is_not_one_of_semver, user_value, comparison_values, %ComparisonContext{} = context) do
@is_one_of_semver |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@less_than_semver, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_semver(user_value, comparison_value, [:lt])
end
def compare(@less_than_equal_semver, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_semver(user_value, comparison_value, [:lt, :eq])
end
def compare(@greater_than_semver, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_semver(user_value, comparison_value, [:gt])
end
def compare(@greater_than_equal_semver, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_semver(user_value, comparison_value, [:gt, :eq])
end
def compare(@equals_number, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_numbers(user_value, comparison_value, &==/2)
end
def compare(@not_equals_number, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_numbers(user_value, comparison_value, &!==/2)
end
def compare(@less_than_number, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_numbers(user_value, comparison_value, &</2)
end
def compare(@less_than_equal_number, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_numbers(user_value, comparison_value, &<=/2)
end
def compare(@greater_than_number, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_numbers(user_value, comparison_value, &>/2)
end
def compare(@greater_than_equal_number, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_numbers(user_value, comparison_value, &>=/2)
end
def compare(@is_one_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result = hash_value(text, context) in comparison_values
{:ok, result}
end
end
def compare(@is_not_one_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
@is_one_of_hashed |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@before_datetime, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_datetimes(user_value, comparison_value, [:lt])
end
def compare(@after_datetime, user_value, comparison_value, %ComparisonContext{} = _context) do
compare_datetimes(user_value, comparison_value, [:gt])
end
def compare(@equals_hashed, user_value, comparison_value, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result = hash_value(text, context) == comparison_value
{:ok, result}
end
end
def compare(@not_equals_hashed, user_value, comparison_value, %ComparisonContext{} = context) do
@equals_hashed |> compare(user_value, comparison_value, context) |> negate()
end
def compare(@starts_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result =
Enum.any?(
comparison_values,
fn comparison ->
{length, comparison_string} = parse_comparison(comparison)
if byte_size(text) >= length do
hashed = text |> binary_part(0, length) |> hash_value(context)
hashed == comparison_string
else
false
end
end
)
{:ok, result}
end
end
def compare(@not_starts_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
@starts_with_any_of_hashed |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@ends_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result =
Enum.any?(
comparison_values,
fn comparison ->
{length, comparison_string} = parse_comparison(comparison)
if byte_size(text) >= length do
hashed = text |> binary_part(byte_size(text), -length) |> hash_value(context)
hashed == comparison_string
else
false
end
end
)
{:ok, result}
end
end
def compare(@not_ends_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
@ends_with_any_of_hashed |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@array_contains_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, user_values} <- to_string_list(user_value) do
hashed_user_values = Enum.map(user_values, &hash_value(&1, context))
result = Enum.any?(comparison_values, &(&1 in hashed_user_values))
{:ok, result}
end
end
def compare(@array_not_contains_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do
@array_contains_any_of_hashed |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@equals, user_value, comparison_value, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result = text == comparison_value
{:ok, result}
end
end
def compare(@not_equals, user_value, comparison_value, %ComparisonContext{} = context) do
@equals |> compare(user_value, comparison_value, context) |> negate()
end
def compare(@starts_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result = Enum.any?(comparison_values, &String.starts_with?(text, &1))
{:ok, result}
end
end
def compare(@not_starts_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do
@starts_with_any_of |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@ends_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do
with {:ok, text} <- as_text(user_value, context) do
result = Enum.any?(comparison_values, &String.ends_with?(text, &1))
{:ok, result}
end
end
def compare(@not_ends_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do
@ends_with_any_of |> compare(user_value, comparison_values, context) |> negate()
end
def compare(@array_contains_any_of, user_value, comparison_values, %ComparisonContext{} = _context) do
with {:ok, user_values} <- to_string_list(user_value) do
result = Enum.any?(comparison_values, &(&1 in user_values))
{:ok, result}
end
end
def compare(@array_not_contains_any_of, user_value, comparison_values, %ComparisonContext{} = context) do
@array_contains_any_of |> compare(user_value, comparison_values, context) |> negate()
end
def compare(_comparator, _user_value, _comparison_value, %ComparisonContext{} = _context) do
{:ok, false}
end
defp compare_semver(user_value, comparison_value, valid_comparisons) do
with {:ok, user_version} <- to_version(user_value),
{:ok, comparison_version} <- to_version(comparison_value) do
result = Version.compare(user_version, comparison_version)
{:ok, result in valid_comparisons}
end
end
defp compare_numbers(user_value, comparison_value, operator) do
with {:ok, user_float} <- to_float(user_value),
{:ok, comparison_float} <- to_float(comparison_value) do
{:ok, operator.(user_float, comparison_float)}
end
end
defp compare_datetimes(user_value, comparison_value, valid_comparisons) do
with {:ok, user_seconds} <- to_unix_seconds(user_value),
{:ok, comparison_seconds} <- to_float(comparison_value) do
result =
cond do
user_seconds < comparison_seconds -> :lt
user_seconds > comparison_seconds -> :gt
true -> :eq
end
{:ok, result in valid_comparisons}
else
{:error, :invalid_float} -> {:error, :invalid_datetime}
error -> error
end
end
defp hash_value(value, %ComparisonContext{} = context) do
salted = value <> context.salt <> context.context_salt
:sha256
|> :crypto.hash(salted)
|> Base.encode16()
|> String.downcase()
end
defp parse_comparison(value) do
[length_string, comparison_string] = String.split(value, "_", parts: 2)
length = String.trim(length_string)
{String.to_integer(length), comparison_string}
end
defp as_text(value, _context) when is_binary(value), do: {:ok, value}
defp as_text(value, %ComparisonContext{} = context) do
%ComparisonContext{condition: condition, key: key} = context
attribute_name = UserCondition.comparison_attribute(condition)
condition_text = UserCondition.description(condition)
ConfigCatLogger.warning(
"Evaluation of condition (#{condition_text}) for setting '#{key}' may not produce the expected result " <>
"(the User.#{attribute_name} attribute is not a string value, thus it was automatically converted to " <>
"the string value '#{value}'). Please make sure that using a non-string value was intended.",
event_id: 3005
)
user_value_to_string(value)
end
@spec user_value_to_string(Config.value() | DateTime.t() | NaiveDateTime.t() | [String.t()]) ::
{:ok, String.t() | nil} | {:error, :invalid_datetime | :invalid_float | :invalid_string_list}
def user_value_to_string(nil), do: {:ok, nil}
def user_value_to_string(%DateTime{} = dt) do
with {:ok, seconds} <- to_unix_seconds(dt) do
{:ok, to_string(seconds)}
end
end
def user_value_to_string(%NaiveDateTime{} = naive) do
naive |> DateTime.from_naive!("Etc/UTC") |> user_value_to_string()
end
def user_value_to_string(value) when is_list(value) do
with {:ok, list} <- to_string_list(value) do
{:ok, Jason.encode!(list)}
end
end
# Per the spec, we need to match JavaScript formatting of floats, which is
# different from the way Elixir does it.
# - Float.to_string/1 doesn't include a `+` for a positive exponent.
# :erlang.float_to_binary() does, but we'd have to specify a number of
# decimal places which we don't know. Instead, we detect an `e` followed by
# digits and replace it with `e+` and the digits.
# - Float.to_string/1 preserves the `.0` for values that would otherwise be
# integers; JavaScript does not, converting e.g. 125.0 -> "125". We need to
# handle that case specially. Note that we have to perform this check AFTER
# checking for an exponent, because for very large floating point values,
# `trunc(value) == value` will be true.
def user_value_to_string(value) when is_float(value) do
result = to_string(value)
cond do
String.contains?(result, "e") ->
{:ok, String.replace(result, ~r/e([\d+])/, "e+\\1")}
trunc(value) == value ->
{:ok, value |> trunc() |> to_string()}
true ->
{:ok, to_string(value)}
end
end
def user_value_to_string(value), do: {:ok, to_string(value)}
defp to_float(value) when is_float(value), do: {:ok, value}
defp to_float(value) when is_integer(value), do: {:ok, value * 1.0}
defp to_float(value) when is_binary(value) do
value
|> String.trim()
|> String.replace(",", ".")
|> Float.parse()
|> case do
{float, ""} -> {:ok, float}
_ -> {:error, :invalid_float}
end
end
defp to_float(_value), do: {:error, :invalid_float}
defp to_string_list(value) when is_list(value) do
ensure_all_strings(value)
end
defp to_string_list(value) when is_binary(value) do
case Jason.decode(value) do
{:ok, decoded} when is_list(decoded) ->
ensure_all_strings(decoded)
_ ->
{:error, :invalid_string_list}
end
end
defp to_string_list(_value), do: {:error, :invalid_string_list}
defp ensure_all_strings(list) do
if Enum.all?(list, &is_binary/1) do
{:ok, list}
else
{:error, :invalid_string_list}
end
end
@spec to_unix_seconds(DateTime.t() | NaiveDateTime.t() | number() | String.t()) ::
{:ok, float()} | {:error, :invalid_float}
def to_unix_seconds(%DateTime{} = value) do
{:ok, DateTime.to_unix(value, :millisecond) / 1000.0}
end
def to_unix_seconds(%NaiveDateTime{} = value) do
value |> DateTime.from_naive!("Etc/UTC") |> to_unix_seconds()
end
def to_unix_seconds(value) do
case to_float(value) do
{:ok, float} -> {:ok, float}
{:error, :invalid_float} -> {:error, :invalid_datetime}
end
end
defp to_versions(values) do
values
|> Enum.reject(&(&1 == ""))
|> Enum.reduce_while({:ok, []}, fn value, {:ok, versions} ->
case to_version(value) do
{:ok, version} -> {:cont, {:ok, [version | versions]}}
error -> {:halt, error}
end
end)
|> case do
{:ok, versions} -> {:ok, Enum.reverse(versions)}
error -> error
end
end
defp to_version(value) do
value
|> to_string()
|> String.trim()
|> Version.parse()
|> case do
{:ok, version} -> {:ok, version}
:error -> {:error, :invalid_version}
end
end
defp negate({:ok, result}), do: {:ok, !result}
defp negate(error), do: error
end