lib/web/router/router.ex

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

defmodule Antikythera.Router do
  @moduledoc """
  Defines the antikythera routing DSL.

  ## Routing macros

  This module defines macros to be used in each gear's Router module.
  The names of the macros are the same as the HTTP verbs: `get`, `post`, etc.

  The macros take the following 4 arguments (although you can omit the last and just pass 3 of them):

  - URL path pattern which consists of '/'-separated segments. The 1st character must be '/'.
    To match against incoming request path to a pattern you can use placeholders. See examples below for the usage.
  - Controller module.
    Antikythera expects that the module name given here does not contain `GearName.Controller.` as a prefix;
    it's automatically prepended by antikythera.
  - Name of the controller action as an atom.
  - Keyword list of options.
    Currently available options are `:from` and `:as`. See below for further explanations.

  ## Example

  If you define the following router module,

      defmodule MyGear.Router do
        use Antikythera.Router

        static_prefix "/static"

        websocket "/ws"

        get  "/foo"       , Hello, :exact_match
        post "/foo/:a/:b" , Hello, :placeholders
        put  "/foo/bar/*w", Hello, :wildcard
      end

  Then the following requests are routed as:

  - `GET  "/foo"`                 => `MyGear.Controller.Hello.exact_match/1`  is invoked with `path_matches`: `%{}`
  - `POST "/foo/bar/baz"`         => `MyGear.Controller.Hello.placeholders/1` is invoked with `path_matches`: `%{a: "bar", b: "baz"}`
  - `PUT  "/foo/bar/abc/def/ghi"` => `MyGear.Controller.Hello.wildcard/1`     is invoked with `path_matches`: `%{w: "abc/def/ghi"}`

  Note that

  - Each controller action is expected to receive a `Antikythera.Conn` struct and returns a `Antikythera.Conn` struct.
  - `Antikythera.Conn` struct has a field `request` which is a `Antikythera.Request` struct.
  - Matched segments are URL-decoded and stored in `path_matches` field in `Antikythera.Request`.
    If the result of URL-decoding is nonprintable binary, the request is rejected.

  ## Websocket endpoint

  To enable websocket interaction with clients, you must first define `MyGear.Websocket` module.
  See `Antikythera.Websocket` for more details about websocket handler module.
  Then invoke `websocket/1` macro in your router.

      websocket "/ws_path_pattern"

  The path pattern may have placeholders in the same way as normal routes.
  GET request with appropriate headers to this path will initialize a websocket connection using the HTTP 1.1 upgrade mechanism.

  If your gear does not interact with clients via websocket, simply don't invoke `websocket/1` macro in your router.

  ## Static file serving

  You can serve your static assets by placing them under `/priv/static` directory in your gear project.
  The endpoint to be used can be specified by `static_prefix/1` macro.
  For example, if you add

      static_prefix "/assets"

  to your router, you can download `/priv/static/html/index.html` file by sending GET request to the path `/assets/html/index.html`.

  If you don't need to serve static assets, just don't call `static_prefix/1` macro in your router.

  Currently, static assets served in this way are NOT automatically gzip compressed,
  even if `acceept-encoding: gzip` request header is set.
  It is recommended to use CDN to deliver large static assets in production.

  See also `Antikythera.Asset` for usage of CDN in delivery of static assets.

  ## Web requests and gear-to-gear (g2g) requests

  Antikythera treats both web requests and g2g requests in basically the same way.
  This means that if you define a route in your gear one can send request to the route using both HTTP and g2g communication.
  If you want to define a route that can be accessible only via g2g communication, specify `from: :gear` option.

      get  "/foo", Hello, :action1, from: :gear
      post "/bar", Hello, :action2, from: :gear

  Similarly passing `from: :web` makes the route accessible only from web request.

  When dealing with multiple routes, `only_from_web/1` and `only_from_gear/1` macros can be used.
  For example, the following routes definition is the same as above one.

      only_from_gear do
        get  "/foo", Hello, :action1
        post "/bar", Hello, :action2
      end

  ## Reverse routing

  To generate URL path of a route (e.g. a link in HTML), you will want to refer to the route's path.
  For this purpose you can specify `:as` option.
  For example, you have the following router module

      defmodule MyGear.Router do
        use Antikythera.Router

        get "/foo/:a/:b/*c", Hello, :placeholders, as: :myroute
      end

  By writing this the router automatically defines a function `myroute_path/4`,
  which receives segments that fill placeholders and an optional map for query parameters.

      MyGear.Router.myroute_path("segment_a", "segment_b", ["wildcard", "part"])
      => "/foo/segment_a/segment_b/wildcard/part
      MyGear.Router.myroute_path("segment_a", "segment_b", ["wildcard", "part"], %{"query" => "param"})
      => "/foo/segment_a/segment_b/wildcard/part?query=param

  Reverse routing helper functions automatically URI-encode all given arguments.

  If websocket endpoint is enabled, you can get its path with `MyGear.Router.websocket_path/0`.
  Also if static file serving is enabled, path prefix for static files can be obtained by `MyGear.Router.static_prefix/0`.

  ## Per-API timeout

  You can specify timeout for each API by `:timeout` option.
  The timeout is specified in milliseconds.
  For example, the following API times out after 60 seconds.

      get "/foo", Hello, :long_action, timeout: 60_000

  The maximum timeout is determined by `:gear_action_max_timeout` configuration in `config/config.exs`.
  The default value is 10 seconds, which can be configured by `GEAR_ACTION_TIMEOUT` environment variable.
  """

  alias Antikythera.Router.Impl

  defmacro __using__(_) do
    quote do
      import Antikythera.Router
      Module.register_attribute(__MODULE__, :antikythera_web_routes, accumulate: true)
      Module.register_attribute(__MODULE__, :antikythera_gear_routes, accumulate: true)
      Module.put_attribute(__MODULE__, :from_option, nil)
      @before_compile Antikythera.Router
    end
  end

  defmacro __before_compile__(%Macro.Env{module: module}) do
    web_routing_source = Module.get_attribute(module, :antikythera_web_routes) |> Enum.reverse()
    gear_routing_source = Module.get_attribute(module, :antikythera_gear_routes) |> Enum.reverse()

    routing_quotes(module, web_routing_source, gear_routing_source) ++
      reverse_routing_quotes(web_routing_source, gear_routing_source)
  end

  defp routing_quotes(module, web_source, gear_source) do
    Impl.generate_route_function_clauses(module, :web, web_source) ++
      Impl.generate_route_function_clauses(module, :gear, gear_source)
  end

  defp reverse_routing_quotes(web_source, gear_source) do
    alias Antikythera.Router.Reverse

    Enum.uniq(web_source ++ gear_source)
    |> Enum.reject(fn {_verb, _path, _controller, _action, opts} -> is_nil(opts[:as]) end)
    |> Enum.map(fn {_verb, path, _controller, _action, opts} ->
      Reverse.define_path_helper(opts[:as], path)
    end)
  end

  for from <- [:web, :gear] do
    # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
    defmacro unquote(:"only_from_#{from}")(do: block) do
      current_from = unquote(from)

      quote do
        if @from_option, do: raise("nested invocation of `only_from_*` is not allowed")
        @from_option unquote(current_from)
        unquote(block)
        @from_option nil
      end
    end
  end

  for verb <- Antikythera.Http.Method.all() do
    defmacro unquote(verb)(path, controller, action, opts \\ []) do
      %Macro.Env{module: router_module} = __CALLER__
      add_route(router_module, unquote(verb), path, controller, action, opts)
    end
  end

  defp add_route(router_module, verb, path, controller_given, action, opts) do
    quote bind_quoted: [
            r_m: router_module,
            verb: verb,
            path: path,
            c_g: controller_given,
            action: action,
            opts: opts
          ] do
      controller = Antikythera.Router.fully_qualified_controller_module(r_m, c_g, opts)
      from_grouped = Module.get_attribute(__MODULE__, :from_option)
      from_per_route = opts[:from]

      if from_grouped && from_per_route,
        do: raise("using :from option within `only_from_*` block is not allowed")

      opts_without_from_option = Keyword.delete(opts, :from)
      routing_info = {verb, path, controller, action, opts_without_from_option}

      case from_grouped || from_per_route do
        :web ->
          @antikythera_web_routes routing_info

        :gear ->
          @antikythera_gear_routes routing_info

        nil ->
          @antikythera_web_routes routing_info
          @antikythera_gear_routes routing_info
      end
    end
  end

  def fully_qualified_controller_module(router_module, controller, opts) do
    if opts[:websocket?] do
      controller
    else
      [
        Module.split(router_module) |> hd(),
        "Controller",
        # `{:__aliases__, meta, atoms}` must be expanded
        Macro.expand(controller, __ENV__)
      ]
      # Executed during compilation; `Module.concat/1` causes no problem
      |> Module.concat()
    end
  end

  defmacro websocket(path, opts \\ []) do
    %Macro.Env{module: router_module} = __CALLER__
    # during compilation, it's safe to call `Module.concat/2`
    # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
    ws_module = Module.split(router_module) |> hd() |> Module.concat("Websocket")

    quote do
      get(
        unquote(path),
        unquote(ws_module),
        :connect,
        [only_from: :web, websocket?: true] ++ unquote(opts)
      )
    end
  end

  defmacro static_prefix(prefix) do
    quote bind_quoted: [prefix: prefix] do
      if prefix =~ ~R|\A(/[0-9A-Za-z.~_-]+)+\z| do
        def static_prefix(), do: unquote(prefix)
      else
        raise "invalid path prefix given to `static_prefix/1`: #{prefix}"
      end
    end
  end
end