lib/web/router/impl.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.Router.Impl do
  @moduledoc """
  Internal functions to implement request routing.
  """

  alias Antikythera.{Http.Method, PathSegment, PathInfo, Request.PathMatches, GearActionTimeout}

  @typep route_entry :: {Method.t(), String.t(), module, atom, Keyword.t(any)}
  @typep route_result_success :: {module, atom, PathMatches.t(), boolean}
  @typep route_result :: nil | route_result_success

  defun generate_route_function_clauses(
          router_module :: v[module],
          from :: v[:web | :gear],
          routing_source :: v[[route_entry]]
        ) :: Macro.t() do
    check_route_definitions(routing_source)
    routes_by_method = Enum.group_by(routing_source, fn {method, _, _, _, _} -> method end)

    Enum.flat_map(routes_by_method, fn {method, routes} ->
      Enum.map(routes, fn {_, path_pattern, controller, action, opts} ->
        route_to_clause(router_module, from, method, path_pattern, controller, action, opts)
      end)
    end) ++ [default_clause(from)]
  end

  defunp check_route_definitions(routing_source :: [route_entry]) :: :ok do
    if !path_names_uniq?(routing_source), do: raise("path names are not unique")

    Enum.each(routing_source, fn {_, path_pattern, _, _, _} ->
      check_path_pattern(path_pattern)
    end)
  end

  defunp path_names_uniq?(routing_source :: [route_entry]) :: boolean do
    Enum.map(routing_source, fn {_verb, _path, _controller, _action, opts} -> opts[:as] end)
    |> Enum.reject(&is_nil/1)
    |> unique_list?()
  end

  defunp unique_list?(l :: [String.t()]) :: boolean do
    length(l) == length(Enum.uniq(l))
  end

  defun check_path_pattern(path_pattern :: v[String.t()]) :: :ok do
    if !String.starts_with?(path_pattern, "/") do
      raise "path must start with '/': #{path_pattern}"
    end

    if byte_size(path_pattern) > 1 and String.ends_with?(path_pattern, "/") do
      raise "non-root path must not end with '/': #{path_pattern}"
    end

    if String.contains?(path_pattern, "//") do
      raise "path must not have '//': #{path_pattern}"
    end

    segments = AntikytheraCore.Handler.GearAction.split_path_to_segments(path_pattern)

    if !Enum.all?(segments, &correct_format?/1) do
      raise "invalid path format: #{path_pattern}"
    end

    if !placeholder_names_uniq?(segments) do
      raise "path format has duplicated placeholder names: #{path_pattern}"
    end

    if !wildcard_segment_comes_last?(segments) do
      raise "cannot have a wildcard '*' followed by other segments: #{path_pattern}"
    end

    :ok
  end

  defunp correct_format?(segment :: v[PathSegment.t()]) :: boolean do
    Regex.match?(~R/\A(([0-9A-Za-z.~_-]*)|([:*][a-z_][0-9a-z_]*))\z/, segment)
  end

  defunp placeholder_names_uniq?(segments :: v[PathInfo.t()]) :: boolean do
    Enum.map(segments, fn
      ":" <> name -> name
      "*" <> name -> name
      _ -> nil
    end)
    |> Enum.reject(&is_nil/1)
    |> unique_list?()
  end

  defunp wildcard_segment_comes_last?(segments :: v[PathInfo.t()]) :: boolean do
    case Enum.reverse(segments) do
      [] -> true
      [_last | others] -> Enum.all?(others, &(!String.starts_with?(&1, "*")))
    end
  end

  defunp route_to_clause(
           router_module :: v[module],
           from :: v[:web | :gear],
           method :: v[Method.t()],
           path_pattern :: v[String.t()],
           controller :: v[module],
           action :: v[atom],
           opts :: Keyword.t(any)
         ) :: Macro.t() do
    websocket? = Keyword.get(opts, :websocket?, false)

    unless is_boolean(websocket?) do
      raise "option `:websocket?` must be boolean but given: #{websocket?}"
    end

    timeout = Keyword.get(opts, :timeout, GearActionTimeout.default())

    unless GearActionTimeout.valid?(timeout) do
      raise "option `:timeout` must be a positive integer less than or equal to #{GearActionTimeout.max()} but given: #{timeout}"
    end

    if String.contains?(path_pattern, "/*") do
      # For route with wildcard we have to define a slightly modified clause (compared with nowildcard case):
      # - `path_info` must be matched with `[... | wildcard]` pattern
      # - value of `path_matches` must be `Enum.join`ed
      path_info_arg_expr_nowildcard =
        make_path_info_arg_expr_nowildcard(router_module, path_pattern)

      path_info_arg_expr = make_path_info_arg_expr_wildcard(path_info_arg_expr_nowildcard)
      path_matches_expr = make_path_matches_expr_wildcard(path_info_arg_expr_nowildcard)

      quote do
        # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
        def unquote(:"__#{from}_route__")(unquote(method), unquote(path_info_arg_expr)) do
          Antikythera.Router.Impl.route_clause_body(
            unquote(controller),
            unquote(action),
            unquote(path_matches_expr),
            unquote(websocket?),
            unquote(timeout)
          )
        end
      end
    else
      # For each route without wildcard, we define two clauses to match request paths with and without trailing '/'
      path_info_arg_expr = make_path_info_arg_expr_nowildcard(router_module, path_pattern)
      path_info_arg_expr2 = path_info_arg_expr ++ [""]
      path_matches_expr = make_path_matches_expr_nowildcard(path_info_arg_expr)

      quote do
        # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
        def unquote(:"__#{from}_route__")(unquote(method), unquote(path_info_arg_expr)) do
          Antikythera.Router.Impl.route_clause_body(
            unquote(controller),
            unquote(action),
            unquote(path_matches_expr),
            unquote(websocket?),
            unquote(timeout)
          )
        end

        # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
        def unquote(:"__#{from}_route__")(unquote(method), unquote(path_info_arg_expr2)) do
          Antikythera.Router.Impl.route_clause_body(
            unquote(controller),
            unquote(action),
            unquote(path_matches_expr),
            unquote(websocket?),
            unquote(timeout)
          )
        end
      end
    end
  end

  defunp default_clause(from :: v[:web | :gear]) :: Macro.t() do
    quote do
      # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
      def unquote(:"__#{from}_route__")(_, _) do
        nil
      end
    end
  end

  defunp make_path_info_arg_expr_nowildcard(
           router_module :: v[module],
           path_pattern :: v[String.t()]
         ) :: [String.t() | Macro.t()] do
    String.split(path_pattern, "/", trim: true)
    |> Enum.map(fn
      # `String.to_atom` during compilation; nothing to worry about
      # credo:disable-for-lines:2 Credo.Check.Warning.UnsafeToAtom
      ":" <> placeholder -> Macro.var(String.to_atom(placeholder), router_module)
      "*" <> placeholder -> Macro.var(String.to_atom(placeholder), router_module)
      fixed -> fixed
    end)
  end

  defunp make_path_info_arg_expr_wildcard(
           path_info_arg_expr_nowildcard :: v[[String.t() | Macro.t()]]
         ) :: Macro.t() do
    wildcard = List.last(path_info_arg_expr_nowildcard)

    case length(path_info_arg_expr_nowildcard) do
      1 ->
        quote do: unquote(wildcard)

      len ->
        segments = Enum.slice(path_info_arg_expr_nowildcard, 0, len - 2)
        before_wildcard = Enum.at(path_info_arg_expr_nowildcard, len - 2)

        quote do
          # wildcard part must not be an empty list
          [unquote_splicing(segments), unquote(before_wildcard) | [_ | _] = unquote(wildcard)]
        end
    end
  end

  defunp make_path_matches_expr_nowildcard(path_info_arg_expr :: v[[String.t() | Macro.t()]]) ::
           Keyword.t(Macro.t()) do
    Enum.map(path_info_arg_expr, fn
      {name, _, _} = v -> {name, v}
      _ -> nil
    end)
    |> Enum.reject(&is_nil/1)
  end

  defunp make_path_matches_expr_wildcard(
           path_info_arg_expr_nowildcard :: v[[String.t() | Macro.t()]]
         ) :: Keyword.t(Macro.t()) do
    {name, _, _} = var = List.last(path_info_arg_expr_nowildcard)
    wildcard_pair = {name, quote(do: Enum.join(unquote(var), "/"))}

    path_matches_expr_without_last =
      Enum.slice(path_info_arg_expr_nowildcard, 0, length(path_info_arg_expr_nowildcard) - 1)
      |> make_path_matches_expr_nowildcard

    path_matches_expr_without_last ++ [wildcard_pair]
  end

  defun route_clause_body(
          controller :: v[module],
          action :: v[atom],
          path_matches :: Keyword.t(String.t()),
          websocket? :: v[boolean],
          timeout :: v[pos_integer] \\ GearActionTimeout.default()
        ) :: route_result do
    if Enum.all?(path_matches, fn {_placeholder, match} -> String.printable?(match) end) do
      timeout_limited = min(timeout, GearActionTimeout.max())
      {controller, action, Map.new(path_matches), websocket?, timeout_limited}
    else
      nil
    end
  end
end