lib/ex_chargebee/interface/parameter_serializer.ex

defmodule ExChargebee.Interface.ParameterSerializer do
  @moduledoc """
  A custom serializer for Chargebee API get and post parameters
  
  Behavior resembles x-www-form-urlencoded at first glance, but diverges sharply
  in handling of nested arrays and maps.
  """
  # serialize/3 is a 1:1 adaptation of Chargebee-Ruby `Chargebee::Util.serialize/3`
  # from https://github.com/chargebee/chargebee-ruby/blob/42f4aa5e58d5760d9f66d3aff02f8389faa6e68f/lib/chargebee/util.rb#L5
  def serialize(value, prefix \\ nil, index \\ nil)

  def serialize(value, prefix, index) when is_map(value) do
    value
    |> Enum.flat_map(&do_serialize(&1, prefix, index))
    |> Map.new()
  end

  def serialize(value, prefix, nil) when is_list(value) do
    value
    |> Enum.with_index()
    |> Enum.flat_map(fn {item, i} -> serialize(item, prefix, i) end)
    |> Map.new()
  end

  # Apparently Second Degree nested arrays are just encoded as json values
  def serialize(value, prefix, index) when is_list(value) do
    value = Jason.encode!(value)

    key = "#{prefix}[#{index}]"

    [{key, value}]
  end

  def serialize(_value, nil, nil) do
    raise ArgumentError, "Only hash or arrays are allowed as value"
  end

  def serialize(value, prefix, index) do
    [{prefix(index, prefix), value}]
  end

  def do_serialize({_k, nil}, _prefix, _index), do: []

  def do_serialize({k, v}, _prefix, _index) when k in ["metadata", :metadata] and is_map(v),
    do: [{as_string(k), Jason.encode!(v)}]

  def do_serialize({k, v}, prefix, index)
      when k in ["in", :in, :not_in, "not_in", "between", :between] and is_list(v) do
    value = v |> Enum.map(&as_string/1) |> Enum.intersperse(",")

    pre = if is_nil(prefix), do: as_string(k), else: "#{prefix}[#{k}]"

    do_serialize("[#{value}]", pre, index)
  end

  def do_serialize({k, %struct{} = v}, prefix, index)
      when struct in [Date, DateTime, NaiveDateTime] do
    do_serialize(as_string(v), prefix(k, prefix), index)
  end

  def do_serialize({k, v}, prefix, index) when is_map(v) or is_list(v) do
    pre = if is_nil(prefix), do: as_string(k), else: "#{prefix}[#{k}]"

    serialize(v, pre, index)
  end

  def do_serialize({k, v}, prefix, index) do
    do_serialize(v, prefix(k, prefix), index)
  end

  def do_serialize(value, prefix, nil) do
    [{as_string(prefix), as_string(value)}]
  end

  def do_serialize(value, prefix, index) do
    prefix = "#{prefix}[#{index}]"

    do_serialize(value, prefix, nil)
  end

  defp date_to_unix(%DateTime{} = dt), do: DateTime.to_unix(dt)

  defp date_to_unix(%NaiveDateTime{} = dt),
    do: dt |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix()

  defp date_to_unix(%Date{} = dt),
    do: dt |> DateTime.new!(~T[00:00:00], "Etc/UTC") |> DateTime.to_unix()

  # as_string is a helper to convert datelike objects into unix timestamps
  defp as_string(%struct{} = v) when struct in [Date, DateTime, NaiveDateTime] do
    date_to_unix(v) |> to_string()
  end

  defp as_string(v), do: to_string(v)

  defp prefix(key, nil), do: to_string(key)
  defp prefix(key, prefix), do: "#{prefix}[#{key}]"
end