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 = %Aino.Config{
      callback: Example.Web.Handler,
      otp_app: :example,
      scheme: config.scheme,
      host: config.host,
      port: config.port,
      url_port: config.url_port,
      url_scheme: config.url_scheme,
      environment: config.environment,
      config: %{}
    }

    children = [
      {Aino.Supervisor, aino_config}
    ]
  ```

  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(options) do
    opts = [
      callback: Aino,
      callback_args: options,
      port: options.port
    ]

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

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

  @impl true
  def handle(request, options) do
    try do
      request
      |> handle_request(options)
      |> handle_response()
    rescue
      exception in Aino.View.MissingTemplateException ->
        assigns = %{
          exception: exception,
          stacktrace: Exception.format_stacktrace(__STACKTRACE__)
        }

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

      exception ->
        message = Exception.format(:error, exception, __STACKTRACE__)
        Logger.error(message)
        assigns = %{exception: Aino.View.Engine.html_escape(message)}

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

  defp handle_request(request, options) do
    callback = options.callback

    token =
      request
      |> Aino.Elli.Request.from_record()
      |> create_token(options)

    callback.handle(token)
  end

  # Create a token from an `Aino.Request`
  defp create_token(request, options) do
    request
    |> Aino.Token.from_request()
    |> Map.put(:otp_app, options.otp_app)
    |> Map.put(:scheme, options.url_scheme)
    |> Map.put(:host, options.host)
    |> Map.put(:port, options.url_port)
    |> Map.put(:environment, options.environment)
    |> Map.put(:config, options.config)
    |> Map.put(:assigns, %{})
  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

  @impl true
  def handle_event(:request_complete, data, _options) 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, _options) do
    Logger.error("Internal server error, #{inspect(data)}")

    :ok
  end

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

    :ok
  end

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

defmodule Aino.Config do
  @moduledoc """
  Config for `Aino` when launching in a supervision tree

  ```elixir
    aino_config = %Aino.Config{
      callback: Example.Web.Handler,
      otp_app: :example,
      scheme: config.scheme,
      host: config.host,
      port: config.port,
      url_port: config.url_port,
      url_scheme: config.url_scheme,
      environment: config.environment,
      config: %{}
    }

    children = [
      {Aino.Supervisor, aino_config}
    ]
  ```
  """

  @enforce_keys [:callback, :otp_app, :host, :port, :url_port]
  defstruct [
    :callback,
    :otp_app,
    :host,
    :port,
    :config,
    :url_port,
    environment: "development",
    scheme: :http,
    url_scheme: :http
  ]
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_generic, "lib/aino/exceptions/generic.html.eex", [:assigns])

  EEx.function_from_file(
    :def,
    :render_view_missing,
    "lib/aino/exceptions/view-missing.html.eex",
    [:assigns]
  )
end