defmodule SplitClient do @moduledoc """ A Split.io client for Elixir. See [Glossary](README.md#split-io-glossary) for explanations about function arguments and return values """ alias SplitClient.Boundary.Treatments @doc """ Get the treatment for a specific split. ## Args * `key` * `split_name` ## Options * `:attributes` * `:bucketing_key` * `:cache` - (defaults to true) By default the SplitClient caches evaluation results. To ignore the cache and make a direct request to the split-evaluator pass `false` here. This only affects reads. Results will still added to the cache. ## Examples iex> SplitClient.get_treatment("unique-customer-id", "shiny-feature") %Treatment{split_name: "shiny-feature", treatment: "on"} """ @spec get_treatment(key :: String.t(), split_name :: String.t(), options :: keyword()) :: Treatment.t() def get_treatment(key, split_name, opts \\ []) do validate_params([ &validate_key(&1, key, "key"), &validate_key(&1, split_name, "split_name"), &validate_optional(&1, opts, :bucketing_key), &validate_optional(&1, opts, :attributes) ]) treatments_server().get_treatment(key, split_name, opts) end @doc """ Get the treatments for multiple splits. ## Args * `key` * `split_name` ## Options * `:attributes` * `:bucketing_key` * `:cache` - (defaults to true) By default the SplitClient caches evaluation results. To ignore the cache and make a direct request to the split-evaluator pass `false` here. This only affects reads. Results will still added to the cache. ## Examples iex> SplitClient.get_treatments("unique-customer-id", ["shiny-feature", "my-experiment"]) %{"shiny-feature" => %Treatment{treatment: "on"}, "my-experiment" => %Treatment{split_name: "my-experiment", treatment: "off"}} """ @spec get_treatments(key :: String.t(), split_names :: [String.t()], options :: keyword()) :: map() def get_treatments(key, split_names, opts \\ []) do validate_params([ &validate_key(&1, key, "key"), &validate_keys(&1, split_names, "split_name"), &validate_optional(&1, opts, :bucketing_key), &validate_optional(&1, opts, :attributes) ]) treatments_server().get_treatments(key, split_names, opts) end @doc """ Get all the treatments for one or more traffic types. ## Args * `keys` - A list of maps containing the following: - `:matching_key` - `:traffic_type` - `:bucketing_key` - Optional ## Options * `:attributes` ## Examples iex> SplitClient.get_all_treatments(%{matching_key: "unique-customer-id", target_type: "customer"}, %{matching_key: "unique-user-id", target_type: "user"}) %{"customer" => %{"shiny-feature" => %Treatment{treatment: "on"}}, "user" => %{"my-experiment" => %Treatment{treatment: "off"}}} """ @spec get_all_treatments(keys :: [map()], options :: keyword()) :: map() def get_all_treatments(keys, opts \\ []) do validate_params([ &validate_composite_keys(&1, keys), &validate_optional(&1, opts, :attributes) ]) treatments_server().get_all_treatments(keys, opts) end @doc """ Determine if a particular split is turned on. This is syntax sugar around the `get_treatment/3` function. If your treatment has a meaningful config that could be returned then use `get_treatment/3` instead ## Args * `key` * `split_name` ## Options * `:attributes` * `:bucketing_key` * `:cache` - (defaults to true) By default the SplitClient caches evaluation results. To ignore the cache and make a direct request to the split-evaluator pass `false` here. This only affects reads. Results will still added to the cache. ## Examples iex> SplitClient.on?("unique-customer-id", "shiny-feature") true """ @spec on?(key :: String.t(), split_name :: String.t(), options :: keyword()) :: boolean() def on?(key, split_name, opts \\ []) do treatment = get_treatment(key, split_name, opts) treatment.treatment == "on" end @doc """ Determine if a particular split is turned off. This is syntax sugar around the `get_treatment/3` function. If your treatment has a meaningful config that could be returned then use `get_treatment/3` instead ## Args * `key` * `split_name` ## Options * `:attributes` * `:bucketing_key` * `:cache` - (defaults to true) By default the SplitClient caches evaluation results. To ignore the cache and make a direct request to the split-evaluator pass `false` here. This only affects reads. Results will still added to the cache. ## Examples iex> SplitClient.off?("unique-customer-id", "shiny-feature") true """ @spec off?(key :: String.t(), split_name :: String.t(), options :: keyword()) :: boolean() def off?(key, split_name, opts \\ []) do treatment = get_treatment(key, split_name, opts) treatment.treatment != "on" end defp validate_params(validators) do errors = Enum.reduce(validators, [], fn validator, acc -> validator.(acc) end) if !Enum.empty?(errors) do raise __MODULE__.InvalidParamsException, errors end end defp validate_optional(errors, opts, option) do case Keyword.has_key?(opts, option) do true -> validate_option(errors, Keyword.get(opts, option), option) false -> errors end end defp validate_option(errors, bucketing_key, :bucketing_key) do validate_key(errors, bucketing_key, "bucketing_key") end defp validate_option(errors, attributes, :attributes) when is_list(attributes), do: errors defp validate_option(errors, attributes, :attributes) when is_map(attributes), do: errors defp validate_option(errors, attributes, :attributes) do add_error(errors, "`attributes` must be a map or keyword list, got `#{inspect(attributes)}`") end defp validate_composite_keys(errors, keys) when is_list(keys) and length(keys) > 0 do Enum.reduce(keys, errors, fn key, acc -> validate_composite_key(acc, key) end) end defp validate_composite_keys(errors, keys) do add_error(errors, "`keys` must be a list with at least 1 item, got #{inspect(keys)}") end defp validate_composite_key(errors, %{matching_key: matching, traffic_type: traffic_type} = key) do validate_optional(errors, Enum.into(key, Keyword.new()), :bucketing_key) |> validate_key(matching, "matching_key") |> validate_key(traffic_type, "traffic_type") end defp validate_composite_key(errors, key) do add_error( errors, "`key` must be a map with a `matching_key` and `traffic_type`, got #{inspect(key)}" ) end defp validate_keys(errors, keys, key_name) when is_list(keys) do Enum.reduce(keys, errors, fn key, acc -> validate_key(acc, key, key_name) end) end defp validate_key(errors, nil, key_name), do: [non_empty_string_msg(key_name, nil) | errors] defp validate_key(errors, key, key_name) when is_binary(key) do errors = case String.trim(key) do "" -> add_error(errors, non_empty_string_msg(key_name, key)) _key -> errors end case String.length(key) > 250 do true -> add_error(errors, "`#{key_name}` must be less than 250 characters, got `#{inspect(key)}`") false -> errors end end defp validate_key(errors, key, key_name) when is_atom(key) do validate_key(errors, Atom.to_string(key), key_name) end defp validate_key(errors, key, key_name) when is_integer(key) do validate_key(errors, Integer.to_string(key), key_name) end defp validate_key(errors, key, key_name) do add_error(errors, non_empty_string_msg(key_name, key)) end defp add_error(errors, error) do [error | errors] end defp non_empty_string_msg(key_name, key) do "`#{key_name}` must be a non-empty string, got `#{inspect(key)}`" end defp treatments_server do Application.get_env(:split_client, :treatments_server, Treatments) end defmodule InvalidParamsException do @moduledoc false defexception [:message] @impl true def exception(errors) do msg = Enum.join(errors, "\n") %__MODULE__{message: msg} end end end