lib/maxwell/middleware/json.ex

defmodule Maxwell.Middleware.Json do
  @moduledoc """
  Encode request's body to json when request's body is not nil
  Decode response's body to json when reponse's header contain `{'Content-Type', "application/json"}` and body is binary
  or Reponse's body is list

  It will auto add `%{'Content-Type': 'application/json'}` to request's headers

  Default json_lib is Poison

  ## Examples

        # Client.ex
        use Maxwell.Builder ~(get)a
        middleware Maxwell.Middleware.Json

        # OR

        middleware Maxwell.Middleware.Json,
          encode_content_type: "application/json",
          encode_func: &other_json_lib.encode/1,
          decode_content_types: ["yourowntype"],
          decode_func: &other_json_lib.decode/1

  """
  use Maxwell.Middleware

  def init(opts) do
    check_opts(opts)
    encode_func = opts[:encode_func] || (&Poison.encode/1)
    decode_content_type = opts[:encode_content_type] || "application/json"
    decode_func = opts[:decode_func] || (&Poison.decode/1)
    decode_content_types = opts[:decode_content_types] || []
    {{encode_func, decode_content_type}, {decode_func, decode_content_types}}
  end

  def request(%Maxwell.Conn{} = conn, {encode_opts, _decode_opts}) do
    Maxwell.Middleware.EncodeJson.request(conn, encode_opts)
  end

  def response(%Maxwell.Conn{} = conn, {_encode_opts, decode_opts}) do
    Maxwell.Middleware.DecodeJson.response(conn, decode_opts)
  end

  defp check_opts(opts) do
    for {key, value} <- opts do
      case key do
        :encode_func ->
          unless is_function(value, 1),
            do: raise(ArgumentError, "Json Middleware :encode_func only accepts function/1")

        :encode_content_type ->
          unless is_binary(value),
            do: raise(ArgumentError, "Json Middleware :encode_content_types only accepts string")

        :decode_func ->
          unless is_function(value, 1),
            do: raise(ArgumentError, "Json Middleware :decode_func only accepts function/1")

        :decode_content_types ->
          unless is_list(value),
            do: raise(ArgumentError, "Json Middleware :decode_content_types only accepts lists")

        _ ->
          raise(ArgumentError, "Json Middleware Options doesn't accept #{key}")
      end
    end
  end
end

defmodule Maxwell.Middleware.EncodeJson do
  @moduledoc """
  Encode request's body to json when request's body is not nil

  It will auto add `%{'Content-Type': 'application/json'}` to request's headers

  Default json_lib is Poison

  ## Examples

        # Client.ex
        use Maxwell.Builder ~(get)a
        middleware Maxwell.Middleware.EncodeJson

        # OR

        middleware Maxwell.Middleware.EncodeJson,
          encode_content_type: "application/json",
          encode_func: &other_json_lib.encode/1

  """
  use Maxwell.Middleware
  alias Maxwell.Conn

  def init(opts) do
    check_opts(opts)
    encode_func = opts[:encode_func] || (&Poison.encode/1)
    content_type = opts[:encode_content_type] || "application/json"
    {encode_func, content_type}
  end

  def request(conn = %Conn{req_body: req_body}, _opts)
      when is_nil(req_body) or is_tuple(req_body) or is_atom(req_body),
      do: conn

  def request(conn = %Conn{req_body: %Stream{}}, _opts), do: conn

  def request(conn = %Conn{req_body: req_body}, {encode_func, content_type}) do
    {:ok, req_body} = encode_func.(req_body)

    conn
    |> Conn.put_req_body(req_body)
    |> Conn.put_req_header("content-type", content_type)
  end

  defp check_opts(opts) do
    for {key, value} <- opts do
      case key do
        :encode_func ->
          unless is_function(value, 1),
            do: raise(ArgumentError, "EncodeJson :encode_func only accepts function/1")

        :encode_content_type ->
          unless is_binary(value),
            do: raise(ArgumentError, "EncodeJson :encode_content_types only accepts string")

        _ ->
          raise(
            ArgumentError,
            "EncodeJson Options doesn't accept #{key} (:encode_func and :encode_content_type)"
          )
      end
    end
  end
end

defmodule Maxwell.Middleware.DecodeJson do
  @moduledoc """
  Decode response's body to json when

  1. The reponse headers contain a content type of `application/json` and body is binary.
  2. The response is a list

  Default json decoder is Poison

  ## Examples

        # Client.ex
        use Maxwell.Builder ~(get)a
        middleware Maxwell.Middleware.DecodeJson

        # OR

        middleware Maxwell.Middleware.DecodeJson,
          decode_content_types: ["text/javascript"],
          decode_func: &other_json_lib.decode/1

  """
  use Maxwell.Middleware

  def init(opts) do
    check_opts(opts)
    {opts[:decode_func] || (&Poison.decode/1), opts[:decode_content_types] || []}
  end

  def response(%Maxwell.Conn{} = conn, {decode_fun, valid_content_types}) do
    with {:ok, content_type} <- fetch_resp_content_type(conn),
         true <- valid_content?(content_type, conn.resp_body, valid_content_types),
         {:ok, resp_body} <- decode_fun.(conn.resp_body) do
      %{conn | resp_body: resp_body}
    else
      :error -> conn
      false -> conn
      {:error, reason} -> {:error, {:decode_json_error, reason}, conn}
      {:error, reason, pos} -> {:error, {:decode_json_error, reason, pos}, conn}
    end
  end

  defp valid_content?(content_type, body, valid_types) do
    (present?(body) &&
       String.starts_with?(content_type, "application/json")) ||
      String.starts_with?(content_type, "text/javascript") ||
      Enum.any?(valid_types, &String.starts_with?(content_type, &1))
  end

  defp fetch_resp_content_type(conn) do
    if content_type = Maxwell.Conn.get_resp_header(conn, "content-type") do
      {:ok, content_type}
    else
      :error
    end
  end

  defp present?(""), do: false
  defp present?([]), do: false
  defp present?(term) when is_binary(term) or is_list(term), do: true
  defp present?(_), do: false

  defp check_opts(opts) do
    for {key, value} <- opts do
      case key do
        :decode_func ->
          unless is_function(value, 1),
            do: raise(ArgumentError, "DecodeJson :decode_func only accepts function/1")

        :decode_content_types ->
          unless is_list(value),
            do: raise(ArgumentError, "DecodeJson :decode_content_types only accepts lists")

        _ ->
          raise(
            ArgumentError,
            "DecodeJson Options doesn't accept #{key} (:decode_func and :decode_content_types)"
          )
      end
    end
  end
end