Current section
Files
Jump to
Current section
Files
lib/bamboo/elastic_email/utilities.ex
defmodule Bamboo.ElasticEmail.Utilities do
@moduledoc """
Utilities for working with the Elastic Email API.
The decode_query/{1,2} and encode_query/{1,2} functions are based heavily
on Plug.Conn.Query, but the Elastic Email API accepts repeated values for
list values instead of requiring `[]` be appended to the key name. Nested
key names are not supported.
"""
@doc """
Decode an Elastic Email API query string. Because the decoded query is
returned as a map, a list is always returned.
iex> decode_query("foo=bar")["foo"]
["bar"]
If a value is given more than once, a list is returned:
iex> decode_query("foo=bar&foo=baz")["foo"]
["bar", "baz"]
Decoding an empty string returns an empty map.
iex> decode_query("")
%{}
"""
def decode_query(query, initial \\ %{})
def decode_query("", initial), do: initial
def decode_query(query, initial) do
parts = :binary.split(query, "&", [:global])
Enum.reduce(Enum.reverse(parts), initial, &decode_www_pair(&1, &2))
end
@doc """
Encodes an Elastic Email API query string. Maps can be encoded:
iex> encode_query(%{foo: "bar", baz: "bat"})
"baz=bat&foo=bar"
Encoding keyword lists preserves the order of the fields:
iex> encode_query([foo: "bar", baz: "bat"])
"foo=bar&baz=bat"
When encoding keyword lists with duplicate keys, the keys are repeated:
iex> encode_query([foo: "bar", foo: "bat"])
"foo=bar&foo=bat"
Encoding maps or keys with simple lists will have the same result as
repeated keys in a keyword list:
iex> encode_query(%{foo: ["bar", "bat"]})
"foo=bar&foo=bat"
Encoding a list of maps works the same way:
iex> encode_query([%{foo: "bar"}, %{foo: "bat"}])
"foo=bar&foo=bat"
Nested maps and keyword lists are not supported and raise exceptions:
iex> encode_query(%{foo: %{bar: "baz"}})
** (ArgumentError) cannot encode nested structures for foo
iex> encode_query(%{foo: [bar: "baz"]})
** (ArgumentError) cannot encode nested structures for foo
Structs work as well as maps:
iex> encode_query(%Point{x: 1, y: 1})
"x=1&y=1"
Other structures raise an exception:
iex> encode_query(3)
** (ArgumentError) can only encode maps, keyword lists, or lists of maps, got: 3
"""
def encode_query(kv, encoder \\ &to_string/1) do
kv
|> encode_pair(encoder)
|> IO.iodata_to_binary()
|> String.trim_leading("&")
|> String.replace("&&", "&", global: true)
end
defp decode_www_pair(binary, acc) do
current =
case :binary.split(binary, "=") do
[key, value] ->
{decode_www_form(key), decode_www_form(value)}
[key] ->
{decode_www_form(key), nil}
end
decode_pair(current, acc)
end
defp decode_www_form(value) do
URI.decode_www_form(value)
rescue
ArgumentError ->
# credo:disable-for-lines:1 Credo.Check.Warning.RaiseInsideRescue
raise Plug.Conn.InvalidQueryError,
message: "invalid www-form encoding on query-string, got #{value}"
end
# Decodes the given tuple and stores it in the accumulator. It parses the key
# and stores the value into the current accumulator. Parameter lists are
# added to the accumulator in reverse order, so be sure # to pass the
# parameters in reverse order.
defp decode_pair({key, value}, acc), do: assign_map(acc, key, value)
defp assign_map(acc, key, value) do
case acc do
%{^key => values} -> Map.put(acc, key, [value | values])
%{} -> Map.put(acc, key, [value])
end
end
# covers structs
defp encode_pair(%{__struct__: struct} = map, encoder) when is_atom(struct) do
encode_pair(Map.from_struct(map), encoder)
end
defp encode_pair(%{} = map, encoder), do: encode_kv(map, encoder)
defp encode_pair([], _encoder), do: []
defp encode_pair([head | _] = list, encoder) when is_list(list) and is_tuple(head) do
encode_kv(list, encoder)
end
defp encode_pair([head | _] = list, encoder) when is_list(list) and is_map(head) do
list
|> Enum.flat_map(&[?&, encode_pair(&1, encoder)])
|> prune()
end
# covers nil
defp encode_pair(nil, _encoder), do: []
defp encode_pair(value, _encoder) do
raise ArgumentError,
"can only encode maps, keyword lists, or lists of maps, got: #{inspect(value)}"
end
defp encode_kv(kv, encoder) do
mapper = fn
{_, value} when value in [%{}, []] ->
[]
{field, value} when is_map(value) ->
raise ArgumentError, "cannot encode nested structures for #{field}"
{field, [head | _] = value} when is_list(value) and (is_tuple(head) or is_map(head)) ->
raise ArgumentError, "cannot encode nested structures for #{field}"
{field, value} when is_list(value) ->
field = encode_key(field)
[?&, Enum.map(value, &[?&, field, ?= | encode_value(&1, encoder)])]
{field, value} ->
[?& | [encode_key(field), ?=, encode_value(value, encoder)]]
end
kv
|> Enum.flat_map(mapper)
|> prune()
end
defp encode_key(item), do: URI.encode_www_form(to_string(item))
defp encode_value(item, encoder), do: URI.encode_www_form(encoder.(item))
defp prune([?& | t]), do: t
defp prune([]), do: []
end