lib/middleware/arg_loader.ex

defmodule AbsintheUtils.Middleware.ArgLoader do
  @moduledoc """
  Absinthe middleware for loading entities in `field` arguments.

  This middleware should be defined before `resolve`. It will manipulate the arguments
  before they are passed to the resolver function.

  As configuration it accepts a map of original argument names to a keyword list, containing:

  - `load_function`: the function used to load the argument into an entity.
    As an input accepts one single argument: the input received in the resolution.
    The function should return the entity, or `nil` when not found.
  - `new_name`: the new name to push the loaded entity into.
    (optional, defaults to the original argument name).

  ## Examples

  ```
  query do
    field :user, :user do
      arg(:id, :id)

      # Add the middleware before your resolver
      middleware(
        ArgLoader,
        %{
          id: [
            new_name: :user,
            load_function: &get_user_by_id/1
          ]
        }
      )

      resolve(fn _, arguments, _ ->
        {:ok, Map.get(arguments, :user)}
      end)
    end
  ```

  This will define a `user` query that accepts an `id` input. Before calling the resolver,
  it will load the user via `get_user_by_id(id)` into the `user` argument, removing `id`.

  `ArgLoader` can also be used to load a `list_of` arguments:

  ```
  query do
    field :users, non_null(list_of(:user)) do
      arg(:ids, non_null(list_of(:id)))

      middleware(
        ArgLoader,
        %{
          ids: [
            new_name: :users,
            load_function: fn ids ->
              ids
              |> get_users_by_id()
              |> AbsintheUtils.Helpers.Sorting.sort_alike(ids, & &1.id)
            end
          ]
        }
      )

      resolve(fn _, params, _ ->
        {:ok, Map.get(params, :users)}
      end)
    end
  end
  ```

  Note the use of ` AbsintheUtils.Helpers.Sorting.sort_alike/2` to ensure the returned list of
  entities from the repository is sorted according to the user's input.
  """

  @behaviour Absinthe.Middleware

  alias AbsintheUtils.Helpers.Errors

  @impl true
  def call(
        resolution = %{arguments: arguments},
        opts
      ) do
    {arguments, not_found_arguments} =
      opts
      |> Enum.reduce(
        {arguments, []},
        fn {argument_name, opts}, {arguments, not_found_arguments} ->
          case load_entities(
                 arguments,
                 argument_name,
                 opts
               ) do
            :not_found ->
              {
                arguments,
                [argument_name | not_found_arguments]
              }

            arguments ->
              {
                arguments,
                not_found_arguments
              }
          end
        end
      )

    if Enum.empty?(not_found_arguments) do
      %{
        resolution
        | arguments: arguments
      }
    else
      Errors.put_error(
        resolution,
        "The entity(ies) provided in the following arg(s), could not be found: " <>
          Enum.join(not_found_arguments, ", "),
        "NOT_FOUND"
      )
    end
  end

  def load_entities(arguments, argument_name, opts)
      when is_map_key(arguments, argument_name) do
    load_function = Keyword.fetch!(opts, :load_function)
    push_to_key = Keyword.get(opts, :new_name, argument_name)

    {input_value, arguments} = Map.pop!(arguments, argument_name)

    case load_function.(input_value) do
      nil ->
        :not_found

      entities when is_list(entities) ->
        # TODO: Can we assume the result is a list of entities
        #  based on the output of the load function?
        # We could also check the input type,
        #  but the saver solution might be to to add a `is_list` option.

        entities = Enum.reject(entities, &is_nil/1)

        if is_list(input_value) and length(entities) != length(input_value) do
          :not_found
        else
          Map.put(arguments, push_to_key, entities)
        end

      value ->
        Map.put(arguments, push_to_key, value)
    end
  end

  def load_entities(arguments, _argument_identifier, _opts) do
    arguments
  end
end