lib/routex/extension/cloak.ex

defmodule Routex.Extension.Cloak do
  @moduledoc """
  Transforms routes to be unrecognizable.


  > #### Warning {: .warning}
  >
  > This extension is intended for testing and demonstration. It may change at
  > any given moment to generate other routes without prior notice.


  The Cloak extension demonstrates how Routex enables extensions to transform
  routes beyond recognition without breaking Phoenix' native and Routex' routing
  features.

  Currently it numbers all routes. Starting at 1 and incremening the counter for
  each route. It also shifts the parameter to the left; causing a chaotic route
  structure.

  Do note: this still works with the Verified Routes extension. You can use the
  original, non transformed, routes in templates (e.g. `~p"/products/%{product}"`)
  and still be sure the transformed routes rendered at runtime (e.g. `/88/2` when product.id = 88)
  are valid routes.

  ## Do (not) try this at home
  - Try this extension with a route generating extension like
  `Routex.Extension.Alternatives` for even more chaos.

  - Adapt this extension to use character repetition instead of numbers. Can you
  guess where `/90/!!` brings to?


  ## Options
  - `cloak`: Binary to duplicate or tuple with {module, function, arguments} which will receive a
  index counter as first argument.


  ## Configuration
  ```diff
  # file /lib/example_web/routex_backend.ex
  defmodule ExampleWeb.RoutexBackend do
    use Routex.Backend,
    extensions: [
     Routex.Extension.AttrGetters, # required
  +  Routex.Extension.Cloak
  ],
  cloak: "!"
  ```

  ## Pseudo result
      Original                 Rewritten    Result (product_id: 88, 89, 90)
      /products                ⇒     /1     ⇒    /1
      /products/:id/edit       ⇒ /:id/2     ⇒ /88/2, /89/2, /90/2 etc...
      /products/:id/show/edit  ⇒ /:id/3     ⇒ /88/3, /89/3, /90/3 etc...


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

  **Sets**
  - none
  """

  @behaviour Routex.Extension

  alias Routex.Types, as: T

  @interpolate ":"
  @catch_all "*"

  def default_transform("/", _idx, _opt), do: []
  def default_transform(_path, idx, _opt), do: [to_string(idx)]

  def binary_transform("/", _idx, _binary), do: []
  def binary_transform(_path, idx, binary), do: [String.duplicate(binary, idx + 1)]

  def function_transform("/", _idx, _mfa), do: []
  def function_transform(path, idx, {m, f, a}), do: apply(m, f, [path, idx | a])

  @impl Routex.Extension
  @spec configure(T.opts(), T.backend()) :: T.opts()
  def configure(config, _backend) do
    opt = Keyword.get(config, :cloak)

    transform_mfa =
      cond do
        is_binary(opt) -> {__MODULE__, :binary_transform, [opt]}
        is_tuple(opt) -> {__MODULE__, :function_transform, [opt]}
        is_nil(opt) -> {__MODULE__, :default_transform, [opt]}
      end

    Keyword.put(config, :cloak_transform, transform_mfa)
  end

  @impl Routex.Extension
  @spec transform(T.routes(), T.backend(), T.env()) :: T.routes()
  def transform(routes, backend, _env) do
    {cm, cf, ca} = backend.config().cloak_transform

    routes
    |> Enum.with_index()
    |> Enum.reduce({[], %{}}, fn {route, idx}, {routes, cloak_map} ->
      if path = cloak_map[route.path] do
        route = %{route | path: path}
        {[route | routes], cloak_map}
      else
        dynamics =
          route.path
          |> Path.split()
          |> Enum.filter(&(&1 === @catch_all || String.starts_with?(&1, @interpolate)))

        static = apply(cm, cf, [route.path, idx | ca])

        path = ["/", dynamics, static] |> Path.join() |> Path.absname()
        cloak_map = Map.put_new(cloak_map, route.path, path)
        route = %{route | path: path}

        {[route | routes], cloak_map}
      end
    end)
    |> elem(0)
    |> Enum.reverse()
  end
end