lib/json_ld.ex

defmodule JSON.LD do
  @moduledoc """
  An implementation of JSON-LD 1.1.

  It includes an implementation of the `RDF.Serialization.Format` behaviour of RDF.ex.

  See <https://json-ld.org/>
  """

  use RDF.Serialization.Format

  import RDF.Sigils

  alias JSON.LD.{
    Compaction,
    Context,
    Expansion,
    Flattening,
    DocumentLoader,
    Encoder,
    Decoder,
    Options
  }

  alias JSON.LD.DocumentLoader.RemoteDocument
  alias RDF.{IRI, PropertyMap}

  @id ~I<http://www.w3.org/ns/formats/JSON-LD>
  @name :jsonld
  @extension "jsonld"
  @media_type "application/ld+json"

  @keywords ~w[
               @type
               @base
               @container
               @context
               @default
               @direction
               @graph
               @id
               @import
               @included
               @index
               @json
               @language
               @list
               @nest
               @none
               @prefix
               @propagate
               @protected
               @reverse
               @set
               @value
               @version
               @vocab
               :
              ]

  @type input :: map | [map] | String.t() | IRI.t() | RemoteDocument.t()
  @type context_convertible ::
          map | String.t() | nil | RDF.PropertyMap.t() | list(context_convertible)

  @spec options :: Options.t()
  def options, do: Options.new()

  @doc """
  The set of all JSON-LD keywords.

  see <https://www.w3.org/TR/json-ld/#syntax-tokens-and-keywords>
  """
  @spec keywords :: [String.t()]
  def keywords, do: @keywords

  @doc """
  Returns if the given value is a JSON-LD keyword.
  """
  @spec keyword?(String.t() | any) :: boolean
  def keyword?(value) when is_binary(value) and value in @keywords, do: true
  def keyword?(_value), do: false

  @doc """
  Expands the given input according to the steps in the JSON-LD Expansion Algorithm.

  > Expansion is the process of taking a JSON-LD document and applying a `@context`
  > such that all IRIs, types, and values are expanded so that the `@context` is
  > no longer necessary.

  -- <https://www.w3.org/TR/json-ld/#expanded-document-form>

  Details at <http://json-ld.org/spec/latest/json-ld-api/#expansion-algorithm>

  This is the `expand()` API function of the `JsonLdProcessor` interface as specified in
  <https://www.w3.org/TR/json-ld11-api/#the-application-programming-interface>
  """
  @spec expand(input(), Options.convertible()) :: map | [map] | nil
  def expand(input, options \\ []) do
    {processor_options, options} = Options.extract(options)
    expand(input, options, processor_options)
  end

  defp expand(%IRI{} = iri, options, processor_options),
    do: iri |> IRI.to_string() |> expand(options, processor_options)

  defp expand(url, options, processor_options) when is_binary(url) do
    case DocumentLoader.load(url, processor_options) do
      {:ok, document} -> expand(document, options, processor_options)
      {:error, error} -> raise error
    end
  end

  defp expand(%RemoteDocument{} = document, options, processor_options) do
    %{
      Context.new()
      | base_iri: processor_options.base || document.document_url,
        original_base_url: document.document_url || processor_options.base
    }
    |> expand(
      document.document,
      Keyword.put(options, :context_url, document.context_url),
      processor_options
    )
  end

  defp expand(input, options, processor_options) do
    processor_options
    |> Context.new()
    |> expand(input, options, processor_options)
  end

  defp expand(active_context, input, options, processor_options) do
    active_context =
      if processor_options.expand_context do
        context =
          case processor_options.expand_context do
            %{"@context" => context} -> context
            %{} = context -> context
            context when is_binary(context) -> context
            invalid -> raise ArgumentError, "Invalid expand context value: #{inspect(invalid)}"
          end

        Context.update(
          active_context,
          context,
          Options.set_base(processor_options, active_context.original_base_url)
        )
      else
        active_context
      end

    {active_context, options} =
      case Keyword.pop(options, :context_url) do
        {nil, options} ->
          {active_context, options}

        {context_url, options} ->
          {Context.update(active_context, context_url,
             base: context_url,
             processor_options: processor_options
           ), options}
      end

    case Expansion.expand(active_context, nil, input, options, processor_options) do
      result = %{"@graph" => graph} when map_size(result) == 1 -> graph
      nil -> []
      result when not is_list(result) -> [result]
      result -> result
    end
  end

  @doc """
  Compacts the given input according to the steps in the JSON-LD Compaction Algorithm.

  > Compaction is the process of applying a developer-supplied context to shorten
  > IRIs to terms or compact IRIs and JSON-LD values expressed in expanded form
  > to simple values such as strings or numbers. Often this makes it simpler to
  > work with document as the data is expressed in application-specific terms.
  > Compacted documents are also typically easier to read for humans.

  -- <https://www.w3.org/TR/json-ld/#compacted-document-form>

  Details at <https://www.w3.org/TR/json-ld-api/#compaction-algorithms>

  This is the `compact()` API function of the `JsonLdProcessor` interface as specified in
  <https://www.w3.org/TR/json-ld11-api/#the-application-programming-interface>
  """
  @spec compact(input(), context_convertible(), Options.convertible()) :: map
  def compact(input, context, options \\ []) do
    {processor_options, options} = Options.extract(options)
    compact(input, context, options, processor_options)
  end

  defp compact(%IRI{} = iri, context, options, processor_options),
    do: iri |> IRI.to_string() |> compact(context, options, processor_options)

  # 3)
  defp compact(url, context, options, processor_options) when is_binary(url) do
    case DocumentLoader.load(url, processor_options) do
      {:ok, document} -> compact(document, context, options, processor_options)
      {:error, error} -> raise error
    end
  end

  defp compact(input, context, _opts, popts) do
    # 4)
    expanded = JSON.LD.expand(input, %{popts | ordered: false})

    # 5)
    context_base = if match?(%RemoteDocument{}, input), do: input.document_url, else: popts.base

    # 6)
    context =
      case context do
        %{"@context" => context} -> context
        context -> context
      end

    # 7)
    active_context =
      %{
        context(context, %{popts | base: context_base})
        | # 8)
          api_base_iri: popts.base || if(popts.compact_to_relative, do: context_base)
      }
      |> Context.set_inverse()

    # 9)
    result =
      case Compaction.compact(expanded, active_context, nil, popts, popts.compact_arrays) do
        [] ->
          %{}

        result when is_list(result) ->
          %{Compaction.compact_iri("@graph", active_context, popts) => result}

        result ->
          result
      end

    cond do
      is_binary(context) -> Map.put(result, "@context", context)
      is_nil(context) || Enum.empty?(context) -> result
      true -> Map.put(result, "@context", context)
    end
  end

  @doc """
  Flattens the given input according to the steps in the JSON-LD Flattening Algorithm.

  > Flattening collects all properties of a node in a single JSON object and labels
  > all blank nodes with blank node identifiers. This ensures a shape of the data
  > and consequently may drastically simplify the code required to process JSON-LD
  > in certain applications.

  -- <https://www.w3.org/TR/json-ld/#flattened-document-form>

  Details at <https://www.w3.org/TR/json-ld-api/#flattening-algorithms>

  This is the `flatten()` API function of the `JsonLdProcessor` interface as specified in
  <https://www.w3.org/TR/json-ld11-api/#the-application-programming-interface>
  """
  @spec flatten(input(), context_convertible(), Options.convertible()) :: [map]
  def flatten(input, context \\ nil, options \\ %Options{}) do
    {processor_options, options} = Options.extract(options)
    flatten(input, context, options, processor_options)
  end

  defp flatten(%IRI{} = iri, context, options, processor_options),
    do: iri |> IRI.to_string() |> flatten(context, options, processor_options)

  # 3)
  defp flatten(url, context, options, processor_options) when is_binary(url) do
    case DocumentLoader.load(url, processor_options) do
      {:ok, document} -> flatten(document, context, options, processor_options)
      {:error, error} -> raise error
    end
  end

  defp flatten(input, context, _options, processor_options) do
    flattened =
      input
      |> expand(%{processor_options | ordered: false})
      |> Flattening.flatten(processor_options)

    if context && !Enum.empty?(flattened) do
      compact(
        flattened,
        context,
        if(
          is_nil(processor_options.base) and processor_options.compact_to_relative and
            match?(%RemoteDocument{}, input),
          do: %{processor_options | base: input.document_url},
          else: processor_options
        )
      )
    else
      flattened
    end
  end

  @doc """
  Transforms the given `RDF.Dataset` into a JSON-LD document in expanded form.

  Details at <https://www.w3.org/TR/json-ld-api/#serialize-rdf-as-json-ld-algorithm>

  This is the `toRdf()` API function of the `JsonLdProcessor` interface as specified in
  <https://www.w3.org/TR/json-ld11-api/#the-application-programming-interface>
  """
  defdelegate from_rdf(data, options \\ %Options{}), to: Encoder

  @doc """
  Transforms the given JSON-LD document into an `RDF.Dataset`.

  Details at <https://www.w3.org/TR/json-ld-api/#deserialize-json-ld-to-rdf-algorithm>

  This is the `fromRdf()` API function of the `JsonLdProcessor` interface as specified in
  <https://www.w3.org/TR/json-ld11-api/#the-application-programming-interface>
  """
  defdelegate to_rdf(input, options \\ %Options{}), to: Decoder

  @doc """
  Generator function for `JSON.LD.Context`s.

  You can either pass a map with a `"@context"` key having the JSON-LD context
  object its value, or the JSON-LD context object directly.

  This function can be used also to create `JSON.LD.Context` from a `RDF.PropertyMap`.
  """
  @spec context(context_convertible(), Options.t()) :: Context.t()
  def context(context, options \\ %Options{}) do
    context
    |> normalize_context()
    |> do_context(options)
  end

  defp do_context(%{"@context" => _} = object, options), do: Context.create(object, options)
  defp do_context(context, options), do: Context.create(%{"@context" => context}, options)

  defp normalize_context(%PropertyMap{} = property_map) do
    Map.new(property_map, fn {property, iri} ->
      {to_string(property), to_string(iri)}
    end)
  end

  defp normalize_context(map) when is_map(map) do
    Map.new(map, fn
      {key, value} when is_map(value) -> {to_string(key), normalize_context(value)}
      {key, value} -> {to_string(key), value}
    end)
  end

  defp normalize_context(list) when is_list(list), do: Enum.map(list, &normalize_context/1)
  defp normalize_context(value), do: value

  @doc """
  Generator function for JSON-LD node maps.
  """
  defdelegate node_map(input, node_id_map \\ nil), to: Flattening
end