core/web/conn.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule AntikytheraCore.Conn do
  alias Antikythera.{Http.Method, Conn, Request, Context, GearName}
  alias Antikythera.G2gRequest, as: GReq
  alias Antikythera.G2gResponse, as: GRes
  alias AntikytheraCore.Request, as: CoreReq
  alias AntikytheraCore.Context, as: CoreContext
  alias AntikytheraCore.Cookies, as: CoreCookies
  alias AntikytheraCore.Handler.GearError

  def make_from_cowboy_req(
        req,
        {gear_name, entry_point, method, path_info, path_matches},
        qparams,
        body_pair
      ) do
    antikythera_req =
      CoreReq.make_from_cowboy_req(req, method, path_info, path_matches, qparams, body_pair)

    # context_id is generated; epool_id will be filled afterward
    context = CoreContext.make(gear_name, entry_point)
    make_conn(antikythera_req, context)
  end

  def make_from_g2g_req_and_context(
        %GReq{
          method: method,
          query_params: query_params,
          headers: headers,
          cookies: cookies,
          body: body
        },
        %Context{gear_name: sender_name, context_id: context_id, executor_pool_id: epool_id},
        receiver_name,
        entry_point,
        path_info,
        path_matches
      ) do
    {raw_body, normalized_body, default_headers} =
      if method in [:put, :post, :patch] do
        get_normalized_body_and_default_headers(body)
      else
        {"", body, %{}}
      end

    headers_with_default = Map.merge(default_headers, headers)

    request = %Request{
      method: method,
      path_info: path_info,
      path_matches: path_matches,
      query_params: query_params,
      headers: headers_with_default,
      cookies: cookies,
      raw_body: raw_body,
      body: normalized_body,
      sender: {:gear, sender_name}
    }

    context = CoreContext.make(receiver_name, entry_point, context_id, epool_id)
    make_conn(request, context)
  end

  defp get_normalized_body_and_default_headers(body) when is_binary(body) do
    len = byte_size(body) |> Integer.to_string()
    {body, body, %{"content-type" => "text/plain", "content-length" => len}}
  end

  defp get_normalized_body_and_default_headers(body) when is_map(body) or is_list(body) do
    encoded = Poison.encode!(body)
    len = byte_size(encoded) |> Integer.to_string()
    normalized = Poison.decode!(encoded)
    {encoded, normalized, %{"content-type" => "application/json", "content-length" => len}}
  end

  defp make_conn(request, context) do
    # Explicitly fill default values here (current version of `Croma.Struct` provides no way to specify defaults of struct fields)
    %Conn{
      request: request,
      context: context,
      status: nil,
      resp_headers: %{},
      resp_cookies: %{},
      resp_body: "",
      before_send: [],
      assigns: %{}
    }
  end

  def reply_as_cowboy_res(
        %Conn{status: status, resp_headers: headers, resp_cookies: resp_cookies, resp_body: body} =
          conn,
        req
      ) do
    try do
      headers_with_defaults = add_default_resp_headers(headers)
      req2 = CoreCookies.merge_cookies_to_cowboy_req(resp_cookies, req)

      if body == nil or body == "" do
        :cowboy_req.reply(status, headers_with_defaults, req2)
      else
        :cowboy_req.reply(status, headers_with_defaults, body, req2)
      end
    rescue
      e ->
        # Field in `conn` is of unexpected type; we must fall-back to the gear's error handler (and then recur).
        st = __STACKTRACE__
        # Just to suppress validation error (due to invalid resp_body) during testing
        %Conn{conn | resp_body: ""}
        |> GearError.error({:error, e}, st)
        |> reply_as_cowboy_res(req)
    end
  end

  @default_secure_headers %{
    "x-frame-options" => "DENY",
    "x-xss-protection" => "1; mode=block",
    "x-content-type-options" => "nosniff",
    "strict-transport-security" => "max-age=31536000"
  }

  defp add_default_resp_headers(headers) do
    # Header names for :cowboy_req.reply must be lowercase; see also: http://ninenines.eu/docs/en/cowboy/HEAD/guide/resp/
    headers_downcased = Map.new(headers, fn {key, value} -> {String.downcase(key), value} end)

    # content-length header should be calculated based on the actual body and thus neglected (if any)
    headers_without_cl = Map.delete(headers_downcased, "content-length")
    Map.merge(@default_secure_headers, headers_without_cl)
  end

  def reply_as_g2g_res(
        %Conn{status: status, resp_headers: headers, resp_cookies: resp_cookies, resp_body: body} =
          conn
      ) do
    downcased_headers = Map.new(headers, fn {key, value} -> {String.downcase(key), value} end)

    try do
      if not is_binary(body) do
        raise "unexpected `resp_body` returned by controller action"
      end

      GRes.new!(status: status, headers: downcased_headers, cookies: resp_cookies, body: body)
    rescue
      e ->
        # Field in `conn` is of unexpected type; we must fall-back to the gear's error handler (and then recur).
        st = __STACKTRACE__
        # Just to suppress validation error (due to invalid resp_body) during testing
        %Conn{conn | resp_body: ""}
        |> GearError.error({:error, e}, st)
        |> reply_as_g2g_res()
    end
  end

  def run_before_send(conn, conn_before_action) do
    try do
      case conn do
        %Conn{before_send: before_send} -> Enum.reduce(before_send, conn, & &1.(&2))
        _ -> raise "unexpected value returned by controller action"
      end
    rescue
      e -> GearError.error(conn_before_action, {:error, e}, __STACKTRACE__)
    end
  end

  defun gear_name(%Conn{context: %Context{gear_name: gear_name}}) :: GearName.t(), do: gear_name

  defun request_info(%Conn{request: %Request{method: m, path_info: pi, query_params: q}}) ::
          String.t() do
    method_string = Method.to_string(m)
    path = "/" <> Enum.join(pi, "/")

    if Enum.empty?(q) do
      "#{method_string} #{path}"
    else
      params = Enum.map_join(q, "&", fn {k, v} -> "#{k}=#{v}" end)
      "#{method_string} #{path}?#{params}"
    end
  end

  # RFC9110 says:
  # > All 1xx (Informational), 204 (No Content), and 304 (Not Modified) responses do not include content.
  # RFC9110 has only 100 and 101 for 1xx.
  @empty_body_status_codes [100, 101, 204, 304]
  defun validate(%Conn{status: status, resp_body: body}) :: :ok do
    if Enum.member?(@empty_body_status_codes, status) do
      0 = byte_size(body)
    end

    :ok
  end
end