lib/aino.ex

defmodule Aino do
  @moduledoc """
  Aino, an experimental HTTP framework

  To load Aino, add to your supervision tree.

  `callback`, `otp_app`, `host`, and `port` are required options.

  `environment` and `config` are optional and passed into your token
  when it's created on each request.

  ```elixir
    aino_config = [
      callback: Example.Web.Handler,
      otp_app: :example,
      host: config.host,
      port: config.port,
      environment: config.environment,
      config: %{}
    ]

    children = [
      {Aino, []}
    ]
  ```

  The `callback` should be an `Aino.Handler`, which has a single `handle/1` function that
  processes the request.

  `otp_app` should be the atom of your OTP application, e.g. `:example`.

  `host` and `port` are used for binding and booting the webserver, and
  as default assigns for the token when rendering views.

  `environment` is the environment the application is running under, similar to `Mix.env()`.

  `config` is a simple map that is passed into the token when created, example
  values to pass through this config map is your session salt.
  """

  @behaviour :elli_handler

  require Logger

  @doc false
  def child_spec(opts) do
    opts = [
      callback: Aino,
      callback_args: opts,
      port: opts[:port]
    ]

    %{
      id: __MODULE__,
      start: {:elli, :start_link, [opts]},
      type: :worker,
      restart: :permanent,
      shutdown: 500
    }
  end

  @impl true
  def init(_request, _args), do: :ignore

  @impl true
  def handle(request, options) do
    try do
      request
      |> handle_request(options)
      |> handle_response()
    rescue
      exception ->
        Logger.error(Exception.format(:error, exception, __STACKTRACE__))

        assigns = %{
          exception: Exception.format(:error, exception, __STACKTRACE__)
        }

        {500, [{"Content-Type", "text/html"}], Aino.Exception.render(assigns)}
    end
  end

  defp handle_request(request, options) do
    callback = options[:callback]

    token =
      request
      |> Aino.Request.from_record()
      |> Aino.Token.from_request()
      |> Map.put(:otp_app, options[:otp_app])
      |> Map.put(:scheme, scheme(options))
      |> Map.put(:host, options[:host])
      |> Map.put(:port, options[:port])
      |> Map.put(:default_assigns, %{})
      |> Map.put(:environment, options[:environment])
      |> Map.put(:config, options[:config])

    callback.handle(token)
  end

  defp handle_response(%{handover: true}) do
    {:close, <<>>}
  end

  defp handle_response(%{chunk: true} = token) do
    Aino.ChunkedHandler.Server.start_link(token)
    {:chunk, token.response_headers}
  end

  defp handle_response(token) do
    required_keys = [:response_status, :response_headers, :response_body]

    case Enum.all?(required_keys, fn key -> Map.has_key?(token, key) end) do
      true ->
        {token.response_status, token.response_headers, token.response_body}

      false ->
        missing_keys = required_keys -- Map.keys(token)

        raise "Token is missing required keys - #{inspect(missing_keys)}"
    end
  end

  defp scheme(options), do: options[:scheme] || :http

  @impl true
  def handle_event(:request_complete, data, _args) do
    {timings, _} = Enum.at(data, 4)
    diff = timings[:request_end] - timings[:request_start]
    microseconds = System.convert_time_unit(diff, :native, :microsecond)

    if microseconds > 1_000 do
      milliseconds = System.convert_time_unit(diff, :native, :millisecond)

      Logger.info("Request complete in #{milliseconds}ms")
    else
      Logger.info("Request complete in #{microseconds}μs")
    end

    :ok
  end

  def handle_event(:request_error, data, _args) do
    Logger.error("Internal server error, #{inspect(data)}")

    :ok
  end

  def handle_event(:elli_startup, _data, opts) do
    Logger.info("Aino started on #{scheme(opts)}://#{opts[:host]}:#{opts[:port]}")

    :ok
  end

  def handle_event(_event, _data, _args) do
    :ok
  end
end

defmodule Aino.Exception do
  @moduledoc false

  # Compiles the error page into a function for calling in `Aino`

  require EEx
  EEx.function_from_file(:def, :render, "lib/aino/exception.html.eex", [:assigns])
end