lib/aino/middleware.ex

defmodule Aino.Middleware do
  @moduledoc """
  Middleware functions for processing a request into a response

  Included in Aino are common functions that deal with requests, such
  as parsing the POST body for form data or parsing query/path params.
  """

  require Logger

  alias Aino.Middleware.Parsers.FormURLEncoded
  alias Aino.Token

  @doc """
  Common middleware that process low level request data

  Processes the request:
  - method
  - path
  - headers
  - query parameters
  - parses response body
  - parses cookies
  """
  def common() do
    [
      &method/1,
      &path/1,
      &headers/1,
      &query_params/1,
      &request_body/1,
      &adjust_method/1,
      &cookies/1
    ]
  end

  @doc """
  Processes request headers

  Downcases all of the headers and stores in the key `:headers`
  """
  def headers(%{request: request} = token) do
    headers =
      Enum.map(request.headers, fn {header, value} ->
        {String.downcase(header), value}
      end)

    Map.put(token, :headers, headers)
  end

  @doc """
  Processes the `Cookie` request header

  Defaults to an empty map if no `Cookie` header is present.

  Stores cookies as a map in the key `:cookies`
  """
  def cookies(token) do
    case Token.request_header(token, "cookie") do
      [cookies] ->
        cookies =
          cookies
          |> String.split(";")
          |> Enum.map(fn cookie ->
            [variable | cookie] =
              cookie
              |> String.split("=")
              |> Enum.map(&String.trim/1)

            {variable, Enum.join(cookie, "=")}
          end)
          |> Enum.into(%{})

        Map.put(token, :cookies, cookies)

      [] ->
        Map.put(token, :cookies, %{})
    end
  end

  @doc """
  Stores the request method on the token

  Downcases and converts to an atom on the key `:method`

      iex> request = %Aino.Request{method: :GET}
      iex> token = %{request: request}
      iex> token = Middleware.method(token)
      iex> token.method
      :get
  """
  def method(%{request: request} = token) do
    method =
      request.method
      |> to_string()
      |> String.downcase()
      |> String.to_atom()

    Map.put(token, :method, method)
  end

  @doc """
  Stores the request path on the token on the key `:path`

      iex> request = %Aino.Request{path: "/orders/10"}
      iex> token = %{request: request}
      iex> token = Middleware.path(token)
      iex> token.path
      "/orders/10"
  """
  def path(%{request: request} = token) do
    Map.put(token, :path, request.path)
  end

  @doc """
  Stores query parameters on the token

  Converts map and stores on the key `:query_params`

      iex> request = %Aino.Request{query_params: %{"key[]" => "value"}}
      iex> token = %{request: request}
      iex> token = Middleware.query_params(token)
      iex> token.query_params
      %{"key" => ["value"]}
  """
  def query_params(%{request: request} = token) do
    query_params = Aino.Middleware.Parsers.ParamKeys.parse(request.query_params)
    Map.put(token, :query_params, query_params)
  end

  @doc """
  Processes the request body

  Only if the request should have a body (e.g. POST requests)

  Handles the following content types:
  - `application/x-www-form-urlencoded`
  - `application/json`
  """
  def request_body(token) do
    case token.method do
      :post ->
        [content_type | _] = Token.request_header(token, "content-type")

        case content_type do
          "application/x-www-form-urlencoded" ->
            FormURLEncoded.parse(token)

          "application/json" ->
            parse_json(token)
        end

      _ ->
        token
    end
  end

  defp parse_json(token) do
    case Jason.decode(token.request.body) do
      {:ok, json} ->
        Map.put(token, :parsed_body, json)

      :error ->
        token
    end
  end

  @doc """
  Adjust the request's method based on a special post body parameter

  Since browsers cannot perform DELETE/PUT/PATCH requests, allow overriding the method
  based on the `_method` parameter.

  POST body data *must* be parsed before being able to adjust the method.

      iex> token = %{method: :post, parsed_body: %{"_method" => "delete"}}
      iex> token = Middleware.adjust_method(token)
      iex> token.method
      :delete

      iex> token = %{method: :post, parsed_body: %{"_method" => "patch"}}
      iex> token = Middleware.adjust_method(token)
      iex> token.method
      :patch

      iex> token = %{method: :post, parsed_body: %{"_method" => "put"}}
      iex> token = Middleware.adjust_method(token)
      iex> token.method
      :put

  Ignored adjustments

      iex> token = %{method: :post, parsed_body: %{"_method" => "new"}}
      iex> token = Middleware.adjust_method(token)
      iex> token.method
      :post

      iex> token = %{method: :get}
      iex> token = Middleware.adjust_method(token)
      iex> token.method
      :get
  """
  def adjust_method(%{method: :post} = token) do
    case token.parsed_body["_method"] do
      "delete" ->
        Map.put(token, :method, :delete)

      "patch" ->
        Map.put(token, :method, :patch)

      "put" ->
        Map.put(token, :method, :put)

      _ ->
        token
    end
  end

  def adjust_method(token), do: token

  @doc """
  Merge params into a single map

  Merges in the following order:
  - Path params
  - Query params
  - POST body
  """
  def params(token) do
    param_providers = [
      token[:path_params],
      token[:query_params],
      token[:parsed_body]
    ]

    params =
      Enum.reduce(param_providers, %{}, fn provider, params ->
        case is_map(provider) do
          true ->
            provider = stringify_keys(provider)
            Map.merge(params, provider)

          false ->
            params
        end
      end)

    Map.put(token, :params, params)
  end

  defp stringify_keys(map) do
    Enum.into(map, %{}, fn {key, value} ->
      {to_string(key), value}
    end)
  end

  @doc """
  Serve static assets

  Loads static files out the `priv/static` folder for your OTP app. Looks for
  the path to begin with `/assets` and everything afterwards is used as a file.
  If the file exists, it is returned with a `200` status code.

  The content type will be guessed at using the `MIME` hex package.

  Example: `/assets/js/app.js` will look for a file in `priv/static/js/app.js`
  """
  def assets(token) do
    case token.path do
      "/assets/" <> path ->
        path = Aino.Assets.file_from_asset(path)
        path = Path.join(:code.priv_dir(token.otp_app), "/static/" <> path)

        case File.exists?(path) do
          true ->
            data = File.read!(path)

            token
            |> Map.put(:halt, true)
            |> Token.response_status(200)
            |> Token.response_header("Cache-Control", asset_cache_control(token))
            |> Token.response_header("Content-Type", asset_content_type(path))
            |> Token.response_body(data)

          false ->
            token
            |> Map.put(:halt, true)
            |> Token.response_status(404)
            |> Token.response_header("Content-Type", "text/plain")
            |> Token.response_body("Not found")
        end

      _ ->
        token
    end
  end

  defp asset_cache_control(token) do
    case token.environment do
      "production" ->
        "public, max-age=604800"

      "staging" ->
        "public, max-age=604800"

      "development" ->
        "no-cache"
    end
  end

  defp asset_content_type(path) do
    MIME.from_path(path)
  end

  def logging(%{params: params} = token) do
    method = String.upcase(to_string(token.method))
    path = token.path

    case Map.keys(params) == [] do
      true ->
        Logger.info("#{method} #{path}")

      false ->
        Logger.info("#{method} #{path}\nParameters: #{inspect(token.params)}")
    end

    token
  end

  def logging(token) do
    method = String.upcase(to_string(token.method))
    path = token.path

    Logger.info("#{method} #{path}")

    token
  end
end

defmodule Aino.Middleware.Development do
  @moduledoc """
  Development only middleware

  These should *not* be used in production.
  """

  require Logger

  @doc """
  Recompiles the application
  """
  def recompile(token) do
    IEx.Helpers.recompile()

    token
  end

  @doc """
  Debug log a key on the token
  """
  def inspect(token, key) do
    Logger.debug(inspect(token[key]))
    token
  end
end

defmodule Aino.Middleware.Parsers do
  @moduledoc """
  Behaviour for parsing request bodys
  """

  @doc """
  Parse the request body for a token
  """
  @callback parse(token :: map()) :: map()
end

defmodule Aino.Middleware.Parsers.FormURLEncoded do
  @moduledoc """
  Parse `application/x-www-form-urlencoded` request bodies
  """

  @behaviour Aino.Middleware.Parsers

  @impl true
  def parse(token) do
    parsed_body =
      token.request.body
      |> String.split("&")
      |> Enum.map(fn param ->
        [key, value] =
          param
          |> URI.decode_www_form()
          |> String.split("=")

        {key, value}
      end)
      |> Aino.Middleware.Parsers.ParamKeys.parse()

    Map.put(token, :parsed_body, parsed_body)
  end
end

defmodule Aino.Middleware.Parsers.ParamKeys do
  @moduledoc """
  Common code for parsing param maps
  """

  @doc """
  Converts a flat map with special keys into a deep map
  """
  def parse(params) do
    params
    |> Enum.map(fn {key, value} ->
      {parse_key(key), value}
    end)
    |> Enum.reduce(%{}, fn {key, value}, map ->
      merge_keys(map, key, value)
    end)
  end

  defp parse_key(key, current \\ "", keys \\ [])

  defp parse_key(<<>>, "", keys) do
    keys
  end

  defp parse_key(<<>>, current, keys) do
    keys ++ [current]
  end

  defp parse_key(<<"[]", rest::binary>>, current, keys) do
    parse_key(rest, "", keys ++ [current, :array])
  end

  defp parse_key(<<"[", rest::binary>>, current, keys) do
    parse_key(rest, "", keys ++ [current])
  end

  defp parse_key(<<"]", rest::binary>>, current, keys) do
    parse_key(rest, "", keys ++ [current])
  end

  defp parse_key(<<character::binary-size(1), rest::binary>>, current, keys) do
    parse_key(rest, current <> character, keys)
  end

  # Deep merge maps and arrays
  defp merge_keys(_map, [], value), do: value

  defp merge_keys(map, [key], value) do
    Map.put(map, key, value)
  end

  defp merge_keys(map, [key, :array | keys], value) do
    current = Map.get(map, key, [])
    Map.put(map, key, current ++ [merge_keys(current, keys, value)])
  end

  defp merge_keys(map, [key | keys], value) do
    current = Map.get(map, key, %{})
    Map.put(map, key, merge_keys(current, keys, value))
  end
end