lib/resourceful/jsonapi/fields.ex

defmodule Resourceful.JSONAPI.Fields do
  @moduledoc """
  Functions for validating fields, primarily for use with JSON:API
  [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets).
  Fields are provided by type type_name in requests and not inferred from root or
  relationship names. This means that if a type has multiple relationships
  pointing to a single type or a self-referential relationships, these will
  be applied to all instances of that type type_name regardless of its location
  in the graph.

  Since this is specific to JSON:API, field names are not converted to atoms in
  the generation options after successful validation. There's no need since
  these prevent any mapping from occurring and never make it to the data layer.

  It's also important to note that a "field" is
  [specifically defined](https://jsonapi.org/format/#document-type-object-fields)
  and is a collection of attribute names and relationship names. It specifically
  _excludes_ `id` and `type` despite all identifiers sharing a common namespace.

  NOTE: Relationships are currently not supported.
  """

  alias Resourceful.{Error, JSONAPI, Type}

  @doc """
  Takes a map of fields by type type_name (e.g.
  `%{"albums" => ["releaseDate", "title"]}`) and validates said fields against
  the provided type. If fields are included that are not part of a
  particular type, errors will be returned.
  """
  def validate(%Type{} = type, %{} = fields_by_type) do
    Map.new(
      fields_by_type,
      fn {type_name, fields} ->
        {
          type_name,
          List.wrap(
            with {:ok, related_type} <- validate_field_type(type, type_name),
                 do: validate_fields_with_type(related_type, fields)
          )
        }
      end
    )
  end

  defp invalid_field_error(field), do: Error.with_key(:invalid_field, field)

  defp validate_field_type(type, type_name) do
    case Type.fetch_related_type(type, type_name) do
      {:ok, _} = ok -> ok
      _ -> Error.with_key(:invalid_field_type, type_name)
    end
  end

  defp validate_fields_with_type(type, fields, context \\ %{})

  defp validate_fields_with_type(type, fields, _) when is_binary(fields) do
    validate_fields_with_type(
      type,
      JSONAPI.Params.split_string_list(fields),
      %{input: fields, source: ["fields", type.name]}
    )
  end

  defp validate_fields_with_type(type, fields, context) when is_list(fields) do
    fields
    |> Stream.with_index()
    |> Enum.map(fn {field, index} ->
      case Type.has_local_field?(type, field) do
        true ->
          {:ok, field}

        _ ->
          field
          |> invalid_field_error()
          |> Error.with_context(:resource_type, type.name)
          |> Error.with_source(Map.get(context, :source) || ["fields", type.name, index])
          |> Error.with_input(Map.get(context, :input) || field)
      end
    end)
  end

  defp validate_fields_with_type(_, field, _) do
    field
    |> invalid_field_error()
    |> Error.with_input(inspect(field))
  end
end