lib/ash_json_api/api/router.ex

defmodule AshJsonApi.Api.Router do
  @moduledoc """
  Use this module to create a router for your AshJsonApi.

  To use this, create a module and do the following:

  ```elixir
  defmodule YourRouter do
    use AshJsonApi.Api.Router,
      apis: [YourApi, YourOtherApi],
      # these next two are optional, only add them if you want those endpoints
      open_api: "/open_api",
      json_schema: "/json_schema"
  end
  ```

  Then in your Phoenix router or plug pipeline, forward to this plug.
  In phoenix, that looks like this:

  ```elixir
      forward "/api", YourRouter
  ```
  """
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      require Ash.Api.Info
      use Plug.Router
      require Ash
      apis = List.wrap(opts[:api] || opts[:apis])

      plug(:match)

      plug(Plug.Parsers,
        parsers: [:json],
        pass: ["application/vnd.api+json"],
        json_decoder: Jason
      )

      plug(:dispatch)

      if apis == [] do
        raise "At least one api option must be provided"
      end

      for api <- apis do
        prefix = AshJsonApi.Api.Info.prefix(api)
        resources = Ash.Api.Info.depend_on_resources(api)

        resources
        |> Enum.filter(&AshJsonApi.Resource.Info.type(&1))
        |> Enum.each(fn resource ->
          for %{
                route: route,
                action: action_name,
                controller: controller,
                method: method,
                action_type: action_type,
                relationship: relationship_name
              } = route_struct <-
                AshJsonApi.Api.Router.routes(resource) do
            opts =
              [
                relationship: Ash.Resource.Info.public_relationship(resource, relationship_name),
                action: Ash.Resource.Info.action(resource, action_name),
                resource: resource,
                api: api,
                prefix: prefix,
                route: route_struct
              ]
              |> Enum.reject(fn {_k, v} -> is_nil(v) end)

            match(route, via: method, to: controller, init_opts: opts)
          end
        end)
      end

      schema_apis = Enum.filter(apis, &AshJsonApi.Api.Info.serve_schema?(&1))

      unless Enum.empty?(schema_apis) do
        match("/schema", via: :get, to: AshJsonApi.Controllers.Schema, init_opts: [apis: apis])

        match("/schema.json",
          via: :get,
          to: AshJsonApi.Controllers.Schema,
          init_opts: [apis: apis]
        )
      end

      open_api_opts = AshJsonApi.Api.Router.open_api_opts(opts)

      case Code.ensure_loaded?(OpenApiSpex) && opts[:open_api] do
        falsy when falsy in [nil, false] ->
          :ok

        true ->
          match("/open_api",
            via: :get,
            to: AshJsonApi.Controllers.OpenApi,
            init_opts: open_api_opts
          )

        routes when is_list(routes) ->
          for route <- routes do
            match(route, via: :get, to: AshJsonApi.Controllers.OpenApi, init_opts: open_api_opts)
          end

        route ->
          match(route, via: :get, to: AshJsonApi.Controllers.OpenApi, init_opts: open_api_opts)
      end

      case opts[:json_schema] do
        nil ->
          :ok

        true ->
          match("/json_schema", via: :get, to: AshJsonApi.Controllers.Schema, init_opts: opts)

        routes when is_list(routes) ->
          for route <- routes do
            match(route, via: :get, to: AshJsonApi.Controllers.Schema, init_opts: opts)
          end

        route ->
          match(route, via: :get, to: AshJsonApi.Controllers.Schema, init_opts: opts)
      end

      match(_, to: AshJsonApi.Controllers.NoRouteFound)
    end
  end

  def open_api_opts(opts) do
    opts
    |> Keyword.put(:modify, Keyword.get(opts, :modify_open_api))
    |> Keyword.delete(:modify_open_api)
  end

  @doc false
  def routes(resource) do
    resource
    |> AshJsonApi.Resource.Info.routes()
    |> Enum.sort(fn left, right ->
      left_path = Path.split(left.route)
      right_path = Path.split(right.route)

      left_path
      |> Enum.zip(right_path)
      |> sorts_first?()
      |> case do
        :undecided ->
          Enum.count(left_path) > Enum.count(right_path)

        result ->
          result
      end
    end)
  end

  defp sorts_first?(zipped) do
    Enum.reduce_while(zipped, :undecided, fn {left_part, right_part}, :undecided ->
      left_param? = String.starts_with?(left_part, ":")
      right_param? = String.starts_with?(right_part, ":")

      cond do
        left_part == right_part ->
          {:cont, :undecided}

        left_param? and not right_param? ->
          {:halt, false}

        not left_param? and right_param? ->
          {:halt, true}

        true ->
          {:cont, :undecided}
      end
    end)
  end
end