core/handler/cowboy_routing.ex

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

use Croma

defmodule AntikytheraCore.Handler.CowboyRouting do
  alias Antikythera.{Env, Domain, CowboyWildcardSubdomain, GearName, GearNameStr}
  alias AntikytheraCore.GearModule
  alias AntikytheraCore.Config.Gear, as: GearConfig
  alias AntikytheraCore.Ets.ConfigCache
  alias AntikytheraCore.Handler.{GearAction, Healthcheck, SystemInfoExporter}
  require AntikytheraCore.Logger, as: L

  @healthcheck_route_initialized {"/healthcheck", Healthcheck.Initialized, nil}
  @healthcheck_route_uninitialized {"/healthcheck", Healthcheck.Uninitialized, nil}
  @version_report_route {"/versions", SystemInfoExporter.Versions, nil}
  @upgradability_check_route {"/upgradability", SystemInfoExporter.Upgradability, nil}
  @total_error_count_route {"/error_count/_total", SystemInfoExporter.ErrorCount, :total}
  @per_app_error_count_route {"/error_count/:otp_app_name", SystemInfoExporter.ErrorCount,
                              :per_otp_app}

  defun compiled_routes(gear_names :: [GearName.t()], initialized? :: v[boolean]) ::
          :cowboy_router.dispatch_rules() do
    gear_routes = Enum.flat_map(gear_names, &per_gear_domain_pathroutes_pairs/1)
    :cowboy_router.compile(gear_routes ++ wildcard_domain_routes(initialized?))
  end

  defunp wildcard_domain_routes(initialized? :: v[boolean]) :: :cowboy_router.routes() do
    path_rules = [
      if(initialized?, do: @healthcheck_route_initialized, else: @healthcheck_route_uninitialized),
      @version_report_route,
      @upgradability_check_route,
      @total_error_count_route,
      @per_app_error_count_route
    ]

    [{:_, path_rules}]
  end

  defunp per_gear_domain_pathroutes_pairs(gear_name :: v[GearName.t()]) :: :cowboy_router.routes() do
    routes =
      [
        static_file_serving_route(gear_name),
        normal_routes(gear_name)
      ]
      |> Enum.reject(&is_nil/1)

    domains_of(gear_name) |> Enum.map(fn domain -> {domain, routes} end)
  end

  @typep route_path :: {String.t(), module, any}

  defunp static_file_serving_route(gear_name :: v[GearName.t()]) :: nil | route_path do
    router_module = GearModule.router(gear_name)

    try do
      router_module.static_prefix()
    rescue
      UndefinedFunctionError -> nil
    end
    |> case do
      nil ->
        nil

      prefix ->
        {"#{prefix}/[...]", :cowboy_static,
         {:priv_dir, gear_name, "static", [{:mimetypes, :cow_mimetypes, :all}]}}
    end
  end

  defunp normal_routes(gear_name :: v[GearName.t()]) :: route_path do
    {"/[...]", GearAction.Web, gear_name}
  end

  defunp domains_of(gear_name :: v[GearName.t()]) :: [Domain.t() | CowboyWildcardSubdomain.t()] do
    custom_domains =
      case ConfigCache.Gear.read(gear_name) do
        nil -> []
        %GearConfig{domains: domains} -> domains
      end

    [default_domain(gear_name) | custom_domains]
  end

  defun update_routing(gear_names :: [GearName.t()], initialized? :: v[boolean]) :: :ok do
    if Env.no_listen?() do
      :ok
    else
      L.info("updating cowboy routing (initialized?=#{initialized?})")

      :cowboy.set_env(
        :antikythera_http_listener,
        :dispatch,
        compiled_routes(gear_names, initialized?)
      )
    end
  end

  #
  # Handling domains
  #
  @deployments Application.compile_env!(:antikythera, :deployments)
  @current_compile_env Env.compile_env()

  defunp base_domain(env :: v[Env.t()]) :: Domain.t() do
    case Keyword.get(@deployments, env) do
      nil -> System.get_env("BASE_DOMAIN") || "localhost"
      domain -> domain
    end
  end

  # This can also be used by administrative gears, and should be used when validating custom domains
  defun conflicts_with_default_domain?(
          custom_domain :: v[Domain.t() | CowboyWildcardSubdomain.t()],
          env :: v[Env.t()] \\ @current_compile_env
        ) :: v[boolean] do
    custom_domain |> String.split(".", parts: 2) |> Enum.at(1) == base_domain(env)
  end

  # This can also be used by administrative gears
  defun default_domain(
          gear_name :: v[GearName.t() | GearNameStr.t()],
          env :: v[Env.t()] \\ @current_compile_env
        ) :: Domain.t() do
    gear_name_replaced = to_string(gear_name) |> String.replace("_", "-")
    base_domain = base_domain(env)

    "#{gear_name_replaced}.#{base_domain}"
  end
end