lib/routex/extension/alternatives.ex

defmodule Routex.Extension.Alternatives do
  @moduledoc """
  Creates alternative routes based on `scopes` configured in a Routex backend
  module. Scopes can be nested and each scope can provide `Routex.Attrs` to be shared
  with other extensions.

  ## Configuration
  ```diff
  # file /lib/example_web/routex_backend.ex
  # This example uses a `Struct` for custom attributes, so there is no attribute inheritance;
  # only struct defaults. When using maps, nested scopes will inherit attributes from their parent.

  + defmodule ExampleWeb.RoutexBackend.AltAttrs do
  +  @moduledoc false
  +  defstruct [:contact, locale: "en"]
  + end

  defmodule ExampleWeb.RoutexBackend do
  + alias ExampleWeb.RoutexBackend.AltAttrs

  use Routex,
  extensions: [
  + Routex.Extension.Alternatives
  ],
  + scopes: %{
  +    "/" => %{
  +      attrs: %AltAttrs{contact: "root@example.com"},
  +      scopes: %{
  +        "/europe" => %{
  +          attrs: %AltAttrs{contact: "europe@example.com"},
  +          scopes: %{
  +            "/nl" => %{attrs: %AltAttrs{locale: "nl", contact: "verkoop@example.nl"}},
  +            "/be" => %{attrs: %AltAttrs{locale: "nl", contact: "handel@example.be"}}
  +          }
  +        },
  +      "/gb" => %{attrs: %AltAttrs{contact: "sales@example.com"}
  +    }
  +  }
  ```

  ## Pseudo result
                          ⇒ /products/:id/edit              locale: "en", contact: "rootexample.com"
      /products/:id/edit  ⇒ /europe/nl/products/:id/edit    locale: "nl", contact: "verkoop@example.nl"
                          ⇒ /europe/be/products/:id/edit    locale: "nl", contact: "handel@example.be"
                          ⇒ /gb/products/:id/edit           locale: "en", contact: "sales@example.com"

  ## `Routex.Attrs`
  **Requires**
  - none

  **Sets**
  - **any key/value in `:attrs`**
  - scope_helper
  - scope_alias
  - scope_prefix
  - scope_opts
  - alternatives (list of `Phoenix.Route.Route`)
  """
  @behaviour Routex.Extension

  alias Routex.Attrs
  alias Routex.Extension.Alternatives.Config
  alias Routex.Extension.Alternatives.Scopes
  alias Routex.Path
  alias Routex.Route

  @expandable_route_methods [
    :get,
    :post,
    :put,
    :patch,
    :delete,
    :options,
    :connect,
    :trace,
    :head,
    :live
  ]

  @impl Routex.Extension
  def configure(config, _backend) do
    scopes_nested = Scopes.add_precomputed_values!(config[:alternatives])
    expansion_config = Config.new!(scopes_nested: scopes_nested)

    [{:scopes, expansion_config.scopes} | config]
  end

  @impl Routex.Extension
  def transform(routes, backend, _env) do
    config = backend.config()

    routes =
      for route <- routes do
        if route.verb in @expandable_route_methods do
          route
          |> expand_route(config)
        else
          route
        end
      end

    List.flatten(routes)
  end

  @impl Routex.Extension
  def post_transform(routes, _cm, _env) do
    grouped = Route.group_by_path(routes)

    routes =
      for {_path, groutes} <- grouped, route <- groutes do
        Attrs.put(route, :alternatives, groutes)
      end

    List.flatten(routes)
  end

  defp expand_route(route, config) do
    for {{_scope, scope_opts}, suborder} <- Enum.with_index(config.scopes) do
      path = Path.add_prefix(route.path, scope_opts.scope_prefix)
      helper = helper_name(route.helper, scope_opts.scope_alias)

      %{route | path: path, helper: helper}
      |> Attrs.merge(scope_opts.attrs)
      |> Attrs.merge(scope_opts |> Map.from_struct() |> Map.delete(:attrs))
      |> Attrs.update(:__order__, &List.insert_at(&1, -1, suborder))
      |> Attrs.put(:scope_helper, scope_opts.attrs.scope_helper)
    end
  end

  defp helper_name(nil, _nil), do: nil
  defp helper_name(helper, nil), do: helper
  defp helper_name(helper, suffix), do: Enum.join([helper, "_", suffix])
end