lib/aino/routes.ex

defmodule Aino.Middleware.Routes do
  @moduledoc """
  An Aino set of middleware for dealing with routes and routing

  ## Examples

  To use the routes middleware together, see the example below.

  ```elixir
  def routes() do
    [
      get("/orders", &Orders.index/1, as: :orders),
      get("/orders/:id", [&Orders.authorize/1, &Order.show/1], as: :order),
      post("/orders", &Orders.create/1),
      post("/orders/:id", [&Orders.authorize/1, &Order.update/1])
    ]
  end

  def handle(token) do
    middleware = [
      Aino.Middleware.common(),
      &Aino.Middleware.Routes.routes(&1, routes()),
      &Aino.Middleware.Routes.match_route/1,
      &Aino.Middleware.params/1,
      &Aino.Middleware.Routes.handle_route/1
    ]

    Aino.Token.reduce(token, middleware)
  end
  ```

  In the example above you can see why `match_route/1` and `handle_route/1` are
  separate functions, you can perform other middleware in between the two. In this
  example, params are merged together via `Aino.Middleware.params/1` before
  handling the route.
  """

  alias Aino.Token

  @doc """
  Configure routes for the handler

  For use with a `__MODULE__.Routes` module to hold for route helper functions

  When defining routes, provide the `:as` option to have `_path` and `_url` functions
  generated for you. E.g. `as: :sign_in` will generate `Routes.sign_in_path/2` and
  `Routes.sign_in_url/2`.

  Note that when defining routes, you must only define one `:as` a particular atom. For
  instance, if you have multiple routes pointing at the url `/orders/:id`, you should only
  add `as: :order` to the first route.

  ```elixir
  def routes() do
    [
      get("/", &MyApp.Web.Page.root/1, as: :root),
      get("/sign-in", &MyApp.Web.Session.show/1, as: :sign_in),
      post("/sign-in", &MyApp.Web.Session.create/1),
      delete("/sign-out", &MyApp.Web.Session.delete/1, as: :sign_out),
      get("/orders", &MyApp.Web.Orders.index/1, as: :orders),
      get("/orders/:id", &MyApp.Web.Orders.show/1, as: :order)
    ]
  end

  defmodule MyApp.Web.Handler.Routes do
    require Aino.Middleware.Routes

    Aino.Middleware.Routes.compile(routes())
  end
  ```
  """
  defmacro compile(routes) do
    quote bind_quoted: [routes: routes] do
      routes
      |> List.flatten()
      |> Enum.reject(fn route -> is_nil(route[:as]) end)
      |> Enum.map(&Aino.Middleware.Routes.compile_route/1)
    end
  end

  @doc false
  defmacro compile_route(route) do
    quote bind_quoted: [route: route] do
      path = :"#{route[:as]}_path"
      url = :"#{route[:as]}_url"

      def unquote(path)(_token, params \\ %{}) do
        Aino.Middleware.Routes.compile_path(unquote(route.path), params)
      end

      def unquote(url)(token, params \\ %{}) do
        Aino.Middleware.Routes.compile_url(token, unquote(route.path), params)
      end
    end
  end

  @doc false
  def compile_url(token, path, params) do
    path = compile_path(path, params)
    "#{token.scheme}://#{token.host}:#{token.port}#{path}"
  end

  @doc false
  def compile_path(path, params) do
    path_keys = Enum.filter(path, &is_atom/1)

    {path_params, query_params} =
      Enum.split_with(params, fn {key, _value} ->
        key in path_keys
      end)

    path =
      Enum.map_join(path, "/", fn part ->
        case is_atom(part) do
          true ->
            path_params[part]

          false ->
            part
        end
      end)

    uri = URI.parse("/" <> path)
    query = URI.encode_query(query_params)
    URI.to_string(%{uri | query: query})
  end

  @doc """
  Create a DELETE route

  ## Examples

  ```elixir
  routes = [
    delete("/orders/:id", [&Orders.authorize/1, &Order.delete/1], as: :order)
  ]
  ```
  """
  def delete(path, middleware, opts \\ []) do
    middleware = List.wrap(middleware)

    path =
      path
      |> String.split("/")
      |> Enum.reject(fn part -> part == "" end)
      |> Enum.map(fn
        ":" <> variable ->
          String.to_atom(variable)

        part ->
          part
      end)

    %{
      method: :delete,
      path: path,
      middleware: middleware,
      as: opts[:as]
    }
  end

  @doc """
  Create a GET route

  ## Examples

  ```elixir
  routes = [
    get("/orders", &Orders.index/1, as: :orders),
    get("/orders/:id", [&Orders.authorize/1, &Order.show/1], as: :order)
  ]
  ```
  """
  def get(path, middleware, opts \\ []) do
    middleware = List.wrap(middleware)

    path =
      path
      |> String.split("/")
      |> Enum.reject(fn part -> part == "" end)
      |> Enum.map(fn
        ":" <> variable ->
          String.to_atom(variable)

        part ->
          part
      end)

    %{
      method: :get,
      path: path,
      middleware: middleware,
      as: opts[:as]
    }
  end

  @doc """
  Create a POST route

  ## Examples

  ```elixir
  routes = [
    post("/orders", &Orders.create/1, as: :orders),
    post("/orders/:id", [&Orders.authorize/1, &Order.update/1], as: :order)
  ]
  ```
  """
  def post(path, middleware, opts \\ []) do
    middleware = List.wrap(middleware)

    path =
      path
      |> String.split("/")
      |> Enum.reject(fn part -> part == "" end)
      |> Enum.map(fn
        ":" <> variable ->
          String.to_atom(variable)

        part ->
          part
      end)

    %{
      method: :post,
      path: path,
      middleware: middleware,
      as: opts[:as]
    }
  end

  @doc """
  Create a PUT route

  ## Examples

  ```elixir
  routes = [
    put("/orders", &Orders.create/1, as: :orders),
    post("/orders/:id", [&Orders.authorize/1, &Order.update/1], as: :order)
  ]
  ```
  """
  def put(path, middleware, opts \\ []) do
    middleware = List.wrap(middleware)

    path =
      path
      |> String.split("/")
      |> Enum.reject(fn part -> part == "" end)
      |> Enum.map(fn
        ":" <> variable ->
          String.to_atom(variable)

        part ->
          part
      end)

    %{
      method: :put,
      path: path,
      middleware: middleware,
      as: opts[:as]
    }
  end

  @doc """
  Set routes for the token

  Adds the following keys to the token `[:routes]`
  """
  def routes(token, routes) do
    Map.put(token, :routes, routes)
  end

  @doc """
  Matches the request against routes on the token

  _Must_ have routes set via `routes/2` before running this middleware.

  You _should_ run `handle_route/1` after matching the route, otherwise
  the route is not run.

  Adds the following keys to the token `[:path_params, :route_middleware]`
  """
  def match_route(token) do
    path =
      token.path
      |> String.split("/")
      |> Enum.reject(&match?("", &1))

    case find_route(token.routes, token.method, path) do
      {:ok, %{middleware: middleware}, path_params} ->
        token
        |> Map.put(:path_params, path_params)
        |> Map.put(:route_middleware, middleware)

      :error ->
        token
        |> Token.response_status(404)
        |> Token.response_header("Content-Type", "text/html")
        |> Token.response_body("Not found")
    end
  end

  @doc """
  Run the matched route from `match_route/1`

  If no route is present, nothing happens. If a route is present, the
  middleware stored on the token from the matched request is reduced over.
  """
  def handle_route(%{route_middleware: middleware} = token) do
    Aino.Token.reduce(token, middleware)
  end

  def handle_route(token), do: token

  @doc false
  def find_route([route | routes], method, path) when is_list(route) do
    case find_route(route, method, path) do
      {:ok, route, path_params} ->
        {:ok, route, path_params}

      :error ->
        find_route(routes, method, path)
    end
  end

  def find_route([route = %{method: method} | routes], method, path) do
    case check_path(path, route.path) do
      {:ok, path_params} ->
        {:ok, route, path_params}

      :error ->
        find_route(routes, method, path)
    end
  end

  def find_route([_route | routes], method, path) do
    find_route(routes, method, path)
  end

  def find_route([], _method, _path), do: :error

  @doc false
  def check_path(path, route_path, params \\ %{})

  def check_path([], [], params), do: {:ok, params}

  def check_path([value | path], [variable | route_path], params) when is_atom(variable) do
    params = Map.put(params, variable, value)
    check_path(path, route_path, params)
  end

  def check_path([part | path], [part | route_path], params) do
    check_path(path, route_path, params)
  end

  def check_path(_path, _route_path, _params), do: :error
end