lib/jsonapi_plug/query_parser/ecto/include.ex

defmodule JSONAPIPlug.QueryParser.Ecto.Include do
  @moduledoc """
  JSON:API 'include' query parameter parser implementation for Ecto

  Expects `include` parameter to be in the [JSON:API include](https://jsonapi.org/format/#fetching-includes)
  format and converts them to Ecto `preload` option to `Ecto.Repo` functions.
  """

  alias JSONAPIPlug.{Exceptions.InvalidQuery, QueryParser, Resource}

  @behaviour QueryParser

  @impl QueryParser
  def parse(_jsonapi, nil), do: []

  def parse(%JSONAPIPlug{allowed_includes: allowed_includes, resource: resource}, include)
      when is_binary(include) do
    include
    |> String.split(",", trim: true)
    |> Enum.map(fn include ->
      include
      |> JSONAPIPlug.recase(:underscore)
      |> String.split(".", trim: true)
    end)
    |> valid_includes(resource, allowed_includes)
  end

  def parse(%JSONAPIPlug{resource: resource}, include) do
    raise InvalidQuery, type: resource.type(), param: "include", value: include
  end

  defp valid_includes(includes, resource, allowed_includes) do
    relationships = resource.relationships()
    valid_relationships_includes = Enum.map(relationships, &to_string(Resource.field_name(&1)))

    Enum.reduce(
      relationships,
      [],
      &process_relationship_include(
        resource,
        &1,
        includes,
        &2,
        valid_relationships_includes,
        allowed_includes
      )
    )
    |> Keyword.merge([], fn _k, a, b -> Keyword.merge(a, b) end)
  end

  defp process_relationship_include(
         resource,
         relationship,
         includes,
         valid_includes,
         valid_relationships_includes,
         allowed_includes
       ) do
    name = Resource.field_option(relationship, :name) || Resource.field_name(relationship)
    include_name = to_string(name)

    Enum.reduce(includes, [], fn
      [^include_name], relationship_includes ->
        update_in(
          relationship_includes,
          [name],
          &Keyword.merge(&1 || [], [], fn _k, a, b -> Keyword.merge(a, b) end)
        )

      [^include_name | rest], relationship_includes ->
        related_allowed_includes =
          is_list(allowed_includes) &&
            get_in(allowed_includes, [String.to_existing_atom(include_name)])

        case Resource.field_option(relationship, :resource) do
          nil ->
            relationship_includes

          related_resource ->
            update_in(
              relationship_includes,
              [name],
              &Keyword.merge(
                &1 || [],
                valid_includes([rest], related_resource, related_allowed_includes)
              )
            )
        end

      [include_name | _] = path, relationship_includes ->
        check_relationship_include(
          resource,
          valid_relationships_includes,
          allowed_includes,
          include_name,
          path,
          relationship_includes
        )
    end)
    |> Keyword.merge(valid_includes, fn _k, a, b -> Keyword.merge(a, b) end)
  end

  def check_relationship_include(
        resource,
        valid_relationships_includes,
        allowed_includes,
        include_name,
        path,
        relationship_includes
      ) do
    name = String.to_existing_atom(include_name)

    if include_name in valid_relationships_includes and
         (not is_list(allowed_includes) or get_in(allowed_includes, [name])) do
      relationship_includes
    else
      raise ArgumentError
    end
  rescue
    ArgumentError ->
      reraise InvalidQuery.exception(
                type: resource.type(),
                param: "include",
                value: Enum.join(path, ".")
              ),
              __STACKTRACE__
  end
end