defmodule Unleash.Propagation.Serialization do @moduledoc """ Module handling the (de)serialization of Unleash objects for propagation. See `Unleash.Propagation`. """ defmodule ContextDeserializationError do @moduledoc false defexception [:message, :details] @type t :: %__MODULE__{message: String.t(), details: term()} end defmodule OverridesDeserializationError do @moduledoc false defexception [:message, :details] @type t :: %__MODULE__{message: String.t(), details: term()} end @propagated_context_schema %{ "userId" => :string, "sessionId" => :string, "properties" => {:custom, &__MODULE__.validate_context_properties/1} } @spec deserialize_context(binary()) :: {:ok, Unleash.context()} | {:error, ContextDeserializationError.t()} def deserialize_context(serialized_context) do with {:ok, payload} <- Jason.decode(serialized_context), {:ok, validated} <- Peri.validate(@propagated_context_schema, payload) do context = Map.reject( %{ user_id: validated["userId"], session_id: validated["sessionId"], properties: validated["properties"] }, fn {_key, val} -> is_nil(val) end ) {:ok, context} else {:error, %Jason.DecodeError{} = jason_error} -> {:error, %ContextDeserializationError{message: "invalid JSON", details: jason_error}} {:error, [%Peri.Error{} | _]} = peri_errors -> {:error, %ContextDeserializationError{message: "invalid context object", details: peri_errors}} end end @spec deserialize_context!(binary()) :: Unleash.context() def deserialize_context!(serialized_context) do case deserialize_context(serialized_context) do {:ok, context} -> context {:error, exception} -> raise exception end end @doc """ Serializes context for further propagation to downstream services. Note that only propagation-friendly context fields are serialized. """ @spec serialize_context!(Unleash.context()) :: String.t() def serialize_context!(context) do %{ userId: context[:user_id], sessionId: context[:session_id], properties: context[:properties] } |> Map.update(:properties, nil, &stringify_properties/1) |> Map.reject(fn {_key, val} -> is_nil(val) end) |> Jason.encode!() end @propagated_overrides_schema %{ "name" => {:required, :string}, "isEnabled" => {:required, {:either, {:boolean, :string}}} } @spec deserialize_overrides(binary()) :: {:ok, Unleash.context()} | {:error, OverridesDeserializationError.t()} def deserialize_overrides(serialized_overrides) do with {:ok, payload} <- Jason.decode(serialized_overrides), {_tag, payload} when is_list(payload) <- {:check_list, payload}, {:ok, overrides} <- parse_overrides(payload) do {:ok, overrides} else {:error, %Jason.DecodeError{} = jason_error} -> {:error, %OverridesDeserializationError{message: "invalid JSON", details: jason_error}} {:check_list, _not_a_list} -> {:error, %OverridesDeserializationError{message: "not a list of overrides objects"}} {:error, [%Peri.Error{} | _] = peri_errors} -> {:error, %OverridesDeserializationError{message: "invalid override object", details: peri_errors}} end end @spec deserialize_overrides!(binary()) :: Unleash.overrides() def deserialize_overrides!(serialized_overrides) do case deserialize_overrides(serialized_overrides) do {:ok, overrides} -> overrides {:error, exception} -> raise exception end end @doc """ Serializes overrides for further propagation to downstream services. """ @spec serialize_overrides!(Unleash.overrides()) :: String.t() def serialize_overrides!(overrides) do overrides |> Enum.map(&objectify_override_entry/1) |> Jason.encode!() end defp stringify_properties(nil), do: nil defp stringify_properties(%{} = props) do Map.new( props, fn {key, val} when is_atom(key) -> {Unleash.stringify_context_key(key), val} {key, val} when is_binary(key) -> {key, val} end ) end defp objectify_override_entry({feature_name, state}) do state = case state do :off -> false :on -> true variant when is_binary(variant) -> variant end %{"name" => feature_name, "isEnabled" => state} end @spec parse_overrides(list(term())) :: {:ok, Unleash.overrides()} | {:error, Peri.Error.t()} defp parse_overrides(overrides_payload) do overrides_payload |> Enum.reduce_while( %{}, fn override, acc -> case Peri.validate(@propagated_overrides_schema, override) do {:ok, %{"name" => name, "isEnabled" => enabled}} -> state = case enabled do true -> :on false -> :off variant when is_binary(variant) -> variant end {:cont, Map.put(acc, name, state)} {:error, error} -> {:halt, {:error, error}} end end ) |> case do {:error, error} -> {:error, error} overrides -> {:ok, overrides} end end def validate_context_properties(properties) when is_map(properties) do properties |> Enum.all?(fn {key, val} -> is_binary(key) and is_binary(val) end) |> case do true -> :ok false -> {:error, "properties should be a flat object with string values", []} end end def validate_context_properties(_properties), do: {:error, "invalid properties", []} end