lib/ankh/http/request.ex

defmodule Ankh.HTTP.Request do
  @moduledoc """
  Ankh HTTP Request
  """
  alias Ankh.HTTP
  alias Plug.Conn.Query

  @typedoc "HTTP request options"
  @type options :: keyword()

  @typedoc "Request method"
  @type method :: :CONNECT | :DELETE | :GET | :HEAD | :OPTIONS | :PATCH | :POST | :PUT | :TRACE

  @typedoc "Request path"
  @type path :: String.t()

  @typedoc "Request query"
  @type query() :: Enum.t()

  @typedoc "HTTP Request"
  @type t() :: %__MODULE__{
          method: method(),
          path: path(),
          headers: HTTP.headers(),
          trailers: HTTP.headers(),
          body: HTTP.body(),
          options: options()
        }

  defstruct method: :GET,
            path: "/",
            headers: [],
            trailers: [],
            body: [],
            options: []

  @spec new(Enum.t()) :: t()
  def new(attrs \\ []), do: struct(__MODULE__, attrs)

  @spec to_uri(t()) :: URI.t()
  def to_uri(%{path: path}), do: URI.parse(path)

  @spec from_uri(URI.t()) :: t()
  def from_uri(uri), do: put_uri(new(), uri)

  @spec put_uri(t(), URI.t()) :: t()
  def put_uri(request, %URI{path: nil, query: nil}), do: request
  def put_uri(request, %URI{path: path, query: nil}), do: set_path(request, path)
  def put_uri(request, %URI{path: nil, query: query}), do: set_query(request, query)

  def put_uri(request, %URI{path: path, query: query}) do
    request
    |> set_path(path)
    |> set_query(Query.decode(query))
  end

  @spec put_options(t(), options()) :: t()
  def put_options(%{options: options} = request, new_options),
    do: %{request | options: Keyword.merge(options, new_options)}

  @spec set_body(t(), iodata()) :: t()
  def set_body(request, body), do: %{request | body: body}

  @spec set_method(t(), method()) :: t()
  def set_method(request, method), do: %{request | method: method}

  @spec set_path(t(), path()) :: t()
  def set_path(request, path), do: %{request | path: path}

  @spec put_path(t(), path()) :: t()
  def put_path(request, path) do
    new_path =
      case to_uri(request) do
        %URI{query: nil} -> path
        %URI{query: query} -> path <> "?" <> query
      end

    %{request | path: new_path}
  end

  @spec set_query(t(), query()) :: t()
  def set_query(request, query) do
    %URI{path: path} = to_uri(request)

    new_path =
      case query do
        nil ->
          path

        query ->
          query = Query.encode(query)
          path <> "?" <> query
      end

    %{request | path: new_path}
  end

  @spec put_query(t(), query()) :: t()
  def put_query(request, query) do
    query =
      case to_uri(request) do
        %URI{query: nil} ->
          query

        %URI{query: old_query} ->
          old_query
          |> Query.decode()
          |> Map.merge(query)
      end

    set_query(request, query)
  end

  @spec fetch_header_values(t(), HTTP.header_name()) :: [HTTP.header_value()]
  defdelegate fetch_header_values(request, header), to: HTTP

  @spec fetch_trailer_values(t(), HTTP.header_name()) :: [HTTP.header_value()]
  defdelegate fetch_trailer_values(request, trailer), to: HTTP

  @spec put_header(t(), HTTP.header_name(), HTTP.header_value()) :: t()
  defdelegate put_header(request, name, value), to: HTTP

  @spec put_headers(t(), HTTP.headers()) :: t()
  defdelegate put_headers(request, headers), to: HTTP

  @spec put_trailer(t(), HTTP.header_name(), HTTP.header_value()) :: t()
  defdelegate put_trailer(request, name, value), to: HTTP

  @spec put_trailers(t(), HTTP.headers()) :: t()
  defdelegate put_trailers(request, trailers), to: HTTP

  @spec validate_body(t()) :: {:ok, t()} | :error
  defdelegate validate_body(request), to: HTTP

  @spec validate_headers(HTTP.headers(), boolean(), [HTTP.header_name()]) ::
          :ok | {:error, :protocol_error}
  def validate_headers(headers, strict, forbidden \\ []),
    do: do_validate_headers(headers, strict, forbidden, %{}, false)

  defp do_validate_headers(
         [],
         _strict,
         _forbidden,
         %{method: true, scheme: true, authority: true, path: true},
         _end_pseudo
       ),
       do: :ok

  defp do_validate_headers([], _strict, _forbidden, _stats, _end_pseudo),
    do: {:error, :protocol_error}

  defp do_validate_headers(
         [{":" <> pseudo_header, value} | rest],
         strict,
         forbidden,
         stats,
         false
       )
       when pseudo_header in ["authority", "method", "path", "scheme"] do
    pseudo = String.to_existing_atom(pseudo_header)

    if value == "" or Map.get(stats, pseudo, false) do
      {:error, :protocol_error}
    else
      stats = Map.put(stats, pseudo, true)
      do_validate_headers(rest, strict, forbidden, stats, false)
    end
  end

  defp do_validate_headers(
         [{":" <> _pseaudo_header, _value} | _rest],
         _strict,
         _forbidden,
         _stats,
         _end_pseudo
       ),
       do: {:error, :protocol_error}

  defp do_validate_headers([{header, value} | rest], strict, forbidden, stats, _end_pseudo) do
    case {String.downcase(header), value} do
      {"te", value} when value != "trailers" ->
        {:error, :protocol_error}

      {name, _value} ->
        if name not in forbidden and HTTP.header_name_valid?(header, strict) do
          do_validate_headers(rest, strict, forbidden, stats, true)
        else
          {:error, :protocol_error}
        end
    end
  end
end