lib/tesla/middleware/json.ex

defmodule Tesla.Middleware.JSON do
  @moduledoc """
  Encode requests and decode responses as JSON.

  This middleware requires [Jason](https://hex.pm/packages/jason) (or other engine) as dependency.

  Remember to add `{:jason, ">= 1.0"}` to dependencies.
  Also, you need to recompile Tesla after adding `:jason` dependency:

  ```
  mix deps.clean tesla
  mix deps.compile tesla
  ```

  If you only need to encode the request body or decode the response body,
  you can use `Tesla.Middleware.EncodeJson` or `Tesla.Middleware.DecodeJson` directly instead.

  ## Examples

  ```
  defmodule MyClient do
    def client do
      Tesla.client([
        # use jason engine
        Tesla.Middleware.JSON,
        # or
        {Tesla.Middleware.JSON, engine: JSX, engine_opts: [strict: [:comments]]},
        # or
        {Tesla.Middleware.JSON, engine: Poison, engine_opts: [keys: :atoms]},
        # or
        {Tesla.Middleware.JSON, decode: &JSX.decode/1, encode: &JSX.encode/1}
      ])
    end
  end
  ```

  ## Options

  - `:decode` - decoding function
  - `:encode` - encoding function
  - `:encode_content_type` - content-type to be used in request header
  - `:engine` - encode/decode engine, e.g `Jason`, `Poison` or `JSX`  (defaults to Jason)
  - `:engine_opts` - optional engine options
  - `:decode_content_types` - list of additional decodable content-types
  """

  @behaviour Tesla.Middleware

  # NOTE: text/javascript added to support Facebook Graph API.
  #       see https://github.com/teamon/tesla/pull/13
  @default_content_types ["application/json", "text/javascript"]
  @default_encode_content_type "application/json"
  @default_engine Jason

  @impl Tesla.Middleware
  def call(env, next, opts) do
    opts = opts || []

    with {:ok, env} <- encode(env, opts),
         {:ok, env} <- Tesla.run(env, next) do
      decode(env, opts)
    end
  end

  @doc """
  Encode request body as JSON.

  It is used by `Tesla.Middleware.EncodeJson`.
  """
  @spec encode(Tesla.Env.t(), keyword()) :: Tesla.Env.result()
  def encode(env, opts) do
    with true <- encodable?(env),
         {:ok, body} <- encode_body(env.body, opts) do
      {:ok,
       env
       |> Tesla.put_body(body)
       |> Tesla.put_headers([{"content-type", encode_content_type(opts)}])}
    else
      false -> {:ok, env}
      error -> error
    end
  end

  defp encode_body(%Stream{} = body, opts), do: {:ok, encode_stream(body, opts)}
  defp encode_body(body, opts) when is_function(body), do: {:ok, encode_stream(body, opts)}
  defp encode_body(body, opts), do: process(body, :encode, opts)

  defp encode_content_type(opts),
    do: Keyword.get(opts, :encode_content_type, @default_encode_content_type)

  defp encode_stream(body, opts) do
    Stream.map(body, fn item ->
      {:ok, body} = encode_body(item, opts)
      body <> "\n"
    end)
  end

  defp encodable?(%{body: nil}), do: false
  defp encodable?(%{body: body}) when is_binary(body), do: false
  defp encodable?(%{body: %Tesla.Multipart{}}), do: false
  defp encodable?(_), do: true

  @doc """
  Decode response body as JSON.

  It is used by `Tesla.Middleware.DecodeJson`.
  """
  @spec decode(Tesla.Env.t(), keyword()) :: Tesla.Env.result()
  def decode(env, opts) do
    with true <- decodable?(env, opts),
         {:ok, body} <- decode_body(env.body, opts) do
      {:ok, %{env | body: body}}
    else
      false -> {:ok, env}
      error -> error
    end
  end

  defp decode_body(body, opts) when is_struct(body, Stream) or is_function(body),
    do: {:ok, decode_stream(body, opts)}

  defp decode_body(body, opts), do: process(body, :decode, opts)

  defp decodable?(env, opts), do: decodable_body?(env) && decodable_content_type?(env, opts)

  defp decodable_body?(env) do
    (is_binary(env.body) && env.body != "") ||
      (is_list(env.body) && env.body != []) ||
      is_function(env.body) ||
      is_struct(env.body, Stream)
  end

  defp decodable_content_type?(env, opts) do
    case Tesla.get_header(env, "content-type") do
      nil ->
        false

      content_type ->
        content_type = String.downcase(content_type)

        opts
        |> content_types()
        |> Enum.any?(&String.starts_with?(content_type, &1))
    end
  end

  defp decode_stream(body, opts) do
    Stream.map(body, fn chunk ->
      case decode_body(chunk, opts) do
        {:ok, item} -> item
        _ -> chunk
      end
    end)
  end

  defp content_types(opts),
    do: @default_content_types ++ Keyword.get(opts, :decode_content_types, [])

  defp process(data, op, opts) do
    case do_process(data, op, opts) do
      {:ok, data} -> {:ok, data}
      {:error, reason} -> {:error, {__MODULE__, op, reason}}
      {:error, reason, _pos} -> {:error, {__MODULE__, op, reason}}
    end
  rescue
    ex in Protocol.UndefinedError ->
      {:error, {__MODULE__, op, ex}}
  end

  defp do_process(data, op, opts) do
    # :encode/:decode
    if fun = opts[op] do
      fun.(data)
    else
      engine = Keyword.get(opts, :engine, @default_engine)
      opts = Keyword.get(opts, :engine_opts, [])

      apply(engine, op, [data, opts])
    end
  end
end

defmodule Tesla.Middleware.DecodeJson do
  @moduledoc """
  Decodes response body as JSON.

  Only decodes the body if the `Content-Type` header suggests
  that the body is JSON.
  """
  @moduledoc since: "1.8.0"

  @behaviour Tesla.Middleware

  @impl Tesla.Middleware
  def call(env, next, opts) do
    opts = opts || []

    with {:ok, env} <- Tesla.run(env, next) do
      Tesla.Middleware.JSON.decode(env, opts)
    end
  end
end

defmodule Tesla.Middleware.EncodeJson do
  @moduledoc """
  Encodes request body as JSON.
  """
  @moduledoc since: "1.8.0"

  @behaviour Tesla.Middleware

  @impl Tesla.Middleware
  def call(env, next, opts) do
    opts = opts || []

    with {:ok, env} <- Tesla.Middleware.JSON.encode(env, opts) do
      Tesla.run(env, next)
    end
  end
end