lib/req/response.ex

defmodule Req.Response do
  @moduledoc """
  The response struct.

  Fields:

    * `:status` - the HTTP status code.

    * `:headers` - the HTTP response headers. The header names should be downcased.
      See also "Header Names" section in `Req` module documentation.

    * `:body` - the HTTP response body.

    * `:trailers` - the HTTP response trailers. The trailer names must be downcased.

    * `:private` - a map reserved for libraries and frameworks to use.
      Prefix the keys with the name of your project to avoid any future
      conflicts. Only accepts `t:atom/0` keys.
  """

  @type t() :: %__MODULE__{
          status: non_neg_integer(),
          headers: %{optional(binary()) => [binary()]},
          body: binary() | term(),
          trailers: %{optional(binary()) => [binary()]},
          private: map()
        }

  @derive {Inspect, except: [:async]}

  defstruct status: 200,
            headers: if(Req.MixProject.legacy_headers_as_lists?(), do: [], else: %{}),
            body: "",
            trailers: %{},
            private: %{},
            async: nil

  @doc """
  Returns a new response.

  Expects a keyword list, map, or struct containing the response keys.

  ## Example

      iex> Req.Response.new(status: 200, body: "body")
      %Req.Response{status: 200, headers: %{}, body: "body"}

      iex> finch_response = %Finch.Response{status: 200, headers: [{"content-type", "text/html"}]}
      iex> Req.Response.new(finch_response)
      %Req.Response{status: 200, headers: %{"content-type" => ["text/html"]}, body: ""}

  """
  @spec new(options :: keyword() | map() | struct()) :: t()
  def new(options \\ [])

  def new(options) when is_list(options), do: new(Map.new(options))

  if Req.MixProject.legacy_headers_as_lists?() do
    def new(options) do
      options = Map.take(options, [:status, :headers, :body])
      struct!(__MODULE__, options)
    end
  else
    def new(options) do
      options =
        Map.take(options, [:status, :headers, :body, :trailers])
        |> Map.update(:headers, %{}, fn headers ->
          Enum.reduce(headers, %{}, fn {name, value}, acc ->
            Map.update(acc, name, List.wrap(value), &(&1 ++ List.wrap(value)))
          end)
        end)
        |> Map.update(:trailers, %{}, fn trailers ->
          Enum.reduce(trailers, %{}, fn {name, value}, acc ->
            Map.update(acc, name, List.wrap(value), &(&1 ++ List.wrap(value)))
          end)
        end)

      struct!(__MODULE__, options)
    end
  end

  @doc """
  Builds or updates a response with JSON body.

  ## Example

      iex> Req.Response.json(%{hello: 42})
      %Req.Response{
        status: 200,
        headers: %{"content-type" => ["application/json"]},
        body: ~s|{"hello":42}|
      }

      iex> resp = Req.Response.new()
      iex> Req.Response.json(resp, %{hello: 42})
      %Req.Response{
        status: 200,
        headers: %{"content-type" => ["application/json"]},
        body: ~s|{"hello":42}|
      }

  If the request already contains a 'content-type' header, it is kept as is:

      iex> Req.Response.new()
      iex> |> Req.Response.put_header("content-type", "application/vnd.api+json; charset=utf-8")
      iex> |> Req.Response.json(%{hello: 42})
      %Req.Response{
        status: 200,
        headers: %{"content-type" => ["application/vnd.api+json; charset=utf-8"]},
        body: ~s|{"hello":42}|
      }
  """
  @spec json(t(), body :: term()) :: t()
  def json(response \\ new(), body) do
    response =
      case get_header(response, "content-type") do
        [] ->
          put_header(response, "content-type", "application/json")

        _ ->
          response
      end

    Map.replace!(response, :body, Jason.encode!(body))
  end

  @doc """
  Gets the value for a specific private `key`.
  """
  @spec get_private(t(), key :: atom(), default :: term()) :: term()
  def get_private(%Req.Response{} = response, key, default \\ nil) when is_atom(key) do
    Map.get(response.private, key, default)
  end

  @doc """
  Assigns a private `key` to `value`.
  """
  @spec put_private(t(), key :: atom(), value :: term()) :: t()
  def put_private(%Req.Response{} = response, key, value) when is_atom(key) do
    put_in(response.private[key], value)
  end

  @doc """
  Updates private `key` with the given function.

  If `key` is present in request private map then the existing value is passed to `fun` and its
  result is used as the updated value of `key`. If `key` is not present, `default` is inserted
  as the value of `key`. The default value will not be passed through the update function.

  ## Examples

      iex> resp = %Req.Response{private: %{a: 1}}
      iex> Req.Response.update_private(resp, :a, 11, & &1 + 1).private
      %{a: 2}
      iex> Req.Response.update_private(resp, :b, 11, & &1 + 1).private
      %{a: 1, b: 11}
  """
  @spec update_private(t(), key :: atom(), default :: term(), (atom() -> term())) :: t()
  def update_private(%Req.Response{} = response, key, initial, fun)
      when is_atom(key) and is_function(fun, 1) do
    update_in(response.private, &Map.update(&1, key, initial, fun))
  end

  @doc """
  Returns the values of the header specified by `name`.

  See also "Header Names" section in `Req` module documentation.

  ## Examples

      iex> Req.Response.get_header(response, "content-type")
      ["application/json"]
  """
  @spec get_header(t(), binary()) :: [binary()]
  if Req.MixProject.legacy_headers_as_lists?() do
    def get_header(%Req.Response{} = response, name) when is_binary(name) do
      name = Req.__ensure_header_downcase__(name)

      for {^name, value} <- response.headers do
        value
      end
    end
  else
    def get_header(%Req.Response{} = response, name) when is_binary(name) do
      name = Req.__ensure_header_downcase__(name)
      Map.get(response.headers, name, [])
    end
  end

  @doc """
  Adds a new response header `name` if not present, otherwise replaces the
  previous value of that header with `value`.

  See also "Header Names" section in `Req` module documentation.

  ## Examples

      iex> resp = Req.Response.put_header(%Req.Response{}, "content-type", "application/json")
      iex> resp.headers
      %{"content-type" => ["application/json"]}
  """
  @spec put_header(t(), binary(), binary()) :: t()
  if Req.MixProject.legacy_headers_as_lists?() do
    def put_header(%Req.Response{} = response, name, value)
        when is_binary(name) and is_binary(value) do
      name = Req.__ensure_header_downcase__(name)
      %{response | headers: List.keystore(response.headers, name, 0, {name, value})}
    end
  else
    def put_header(%Req.Response{} = response, name, value)
        when is_binary(name) and is_binary(value) do
      name = Req.__ensure_header_downcase__(name)
      put_in(response.headers[name], List.wrap(value))
    end
  end

  @doc """
  Deletes the header given by `name`.

  All occurrences of the header are deleted, in case the header is repeated multiple times.

  See also "Header Names" section in `Req` module documentation.

  ## Examples

      iex> Req.Response.get_header(resp, "cache-control")
      ["max-age=600", "no-transform"]
      iex> resp = Req.Response.delete_header(resp, "cache-control")
      iex> Req.Response.get_header(resp, "cache-control")
      []

  """
  if Req.MixProject.legacy_headers_as_lists?() do
    def delete_header(%Req.Response{} = response, name) when is_binary(name) do
      name_to_delete = Req.__ensure_header_downcase__(name)

      %Req.Response{
        response
        | headers:
            for(
              {name, value} <- response.headers,
              name != name_to_delete,
              do: {name, value}
            )
      }
    end
  else
    def delete_header(%Req.Response{} = response, name) when is_binary(name) do
      name = Req.__ensure_header_downcase__(name)
      update_in(response.headers, &Map.delete(&1, name))
    end
  end

  @doc """
  Returns the `retry-after` header delay value or nil if not found.
  """
  @spec get_retry_after(t()) :: integer() | nil
  def get_retry_after(response) do
    case get_header(response, "retry-after") do
      [delay] ->
        retry_delay_in_ms(delay)

      [] ->
        nil
    end
  end

  defp retry_delay_in_ms(delay_value) do
    case Integer.parse(delay_value) do
      {seconds, ""} ->
        :timer.seconds(seconds)

      :error ->
        delay_value
        |> parse_http_datetime()
        |> DateTime.diff(DateTime.utc_now(), :millisecond)
        |> max(0)
    end
  end

  @month_numbers %{
    "Jan" => "01",
    "Feb" => "02",
    "Mar" => "03",
    "Apr" => "04",
    "May" => "05",
    "Jun" => "06",
    "Jul" => "07",
    "Aug" => "08",
    "Sep" => "09",
    "Oct" => "10",
    "Nov" => "11",
    "Dec" => "12"
  }

  defp parse_http_datetime(datetime) do
    [_day_of_week, day, month, year, time, "GMT"] = String.split(datetime, " ")
    date = year <> "-" <> @month_numbers[month] <> "-" <> day

    case DateTime.from_iso8601(date <> " " <> time <> "Z") do
      {:ok, valid_datetime, 0} ->
        valid_datetime

      {:error, reason} ->
        raise "cannot parse \"retry-after\" header value #{inspect(datetime)} as datetime, reason: #{reason}"
    end
  end
end