Skip to main content

lib/json_path.ex

defmodule JSONPath do
  @moduledoc """
  [RFC-9535](https://www.rfc-editor.org/rfc/rfc9535) compliant JSON Path evaluator.
  """

  alias JSONPath.{AST, Eval, Tokenizer}

  @type json() :: nil | number() | String.t() | boolean() | [json()] | %{String.t() => json()}
  @type returning() :: :values | :paths | :values_and_paths

  @doc """
  Builds a JSON Path query `t:JSONPath.AST.t/0`. Returns `{:ok, ast}` or
  `{:error, JSONPath.Error.t()}`.

  Prefer this function when running the same query multiple times, since building the query
  each time performs potentially expensive semantic checks.
  """
  @spec build(String.t()) :: {:ok, JSONPath.AST.t()} | {:error, JSONPath.Error.t()}
  def build(query) when is_binary(query) do
    case Tokenizer.tokenize(query) do
      {:ok, tokens} -> AST.parse(tokens)
      {:error, _} = e -> e
    end
  end

  @doc """
  Same as `build/1` but raises in case of error
  """
  @spec build!(String.t()) :: JSONPath.AST.t()
  def build!(query) when is_binary(query) do
    case build(query) do
      {:ok, ast} -> ast
      {:error, %JSONPath.Error{} = e} -> raise e
    end
  end

  @doc """
  Evaluates a JSON value against the given query string or parsed AST. Returns an
  `{:ok, results}` or `{:error, JSONPath.Error.t()}` tuple.

  The `results` type is controlled by the `returning` argument:
  - `:values` - List of node values. This is the default behavior
  - `:paths` - List of [normalized paths](https://www.rfc-editor.org/info/rfc9535/#name-normalized-paths)
  - `:values_and_paths` - List of tuples `{value, normalized_path}`
  """
  @spec evaluate(json(), String.t() | AST.t(), returning()) ::
          {:ok, list(json())} | {:error, JSONPath.Error.t()}
  @deprecated "Use one of JSONPath.values/2, JSONPath.paths/2 or JSONPath.value_paths/2"
  def evaluate(document, query, returning \\ :values), do: run(document, query, returning)

  defp run(document, query, returning) when is_binary(query) do
    case build(query) do
      {:ok, ast} -> {:ok, Eval.evaluate(document, ast) |> keep(returning)}
      error -> error
    end
  end

  defp run(document, query, returning) do
    {:ok, Eval.evaluate(document, query) |> keep(returning)}
  end

  @doc """
  Evaluates a JSON value against the given query string or parsed AST. Returns a result
  tuple containing the list of matching node values.

  ## Examples
      iex> JSONPath.values(["aba", "bbab", "bab"], "$[?match(@, 'b.b')]")
      {:ok, ["bab"]}

      iex> JSONPath.values(%{"foo" => ["a", "b", "c", "d"]}, "$.foo[::-1]")
      {:ok, ["d", "c", "b", "a"]}

      iex> JSONPath.values(%{"foo" => [1,2,3,4]}, "$.foo[?@ > 2]")
      {:ok, [3, 4]}

      iex> JSONPath.values(%{"foo" => %{"bar" => "baz"}}, "$[?length(@)]")
      {:error, %JSONPath.Error{
        type: :invalid_expression,
        expression: "length(@)",
        message: "comparison operator expected"
        }
      }
  """
  @doc since: "0.4.0"
  @spec values(json(), String.t() | AST.t()) :: {:ok, [json()]} | {:error, JSONPath.Error.t()}
  def values(document, query), do: run(document, query, :values)

  @doc """
  Same as `values/2` but returns the list of nodes values or raises an error

  ## Examples

      iex> JSONPath.values!(%{"foo" => [1,2,3,4]}, "$.foo[?@ > 2]")
      [3, 4]
  """
  @doc since: "0.4.0"
  @spec values!(json(), String.t() | AST.t()) :: [json()]
  def values!(document, query) do
    case values(document, query) do
      {:ok, results} -> results
      {:error, exc} -> raise exc
    end
  end

  @doc """
  Evaluates a JSON value against the given query string or parsed AST. Returns a result
  tuple containing the list of [normalized paths](https://www.rfc-editor.org/info/rfc9535/#name-normalized-paths)

  ## Examples
      iex> JSONPath.paths(["aba", "bbab", "bab"], "$[?match(@, 'b.b')]")
      {:ok, ["$[2]"]}

      iex> JSONPath.paths(%{"foo" => ["a", "b", "c", "d"]}, "$.foo[::-1]")
      {:ok, ["$['foo'][3]", "$['foo'][2]", "$['foo'][1]", "$['foo'][0]"]}
  """
  @spec paths(json(), String.t() | AST.t()) :: {:ok, [String.t()]} | {:error, JSONPath.Error.t()}
  @doc since: "0.4.0"
  def paths(document, query), do: run(document, query, :paths)

  @doc """
  Same as `paths/2` but returns the list of normalized paths or raises an error

  ## Examples
      iex> JSONPath.paths!(["aba", "bbab", "bab"], "$[?match(@, 'b.b')]")
      ["$[2]"]
  """
  @doc since: "0.4.0"
  @spec paths!(json(), String.t() | AST.t()) :: [String.t()]
  def paths!(document, query) do
    case paths(document, query) do
      {:ok, results} -> results
      {:error, exc} -> raise exc
    end
  end

  @doc """
  Evaluates a JSON value against the given query string or parsed AST. Returns a
  result tuple containing two-element tuples of `{node_value, normalized_path}`

  ## Examples
      iex> JSONPath.value_paths(["aba", "bbab", "bab"], "$[?match(@, 'b.b')]")
      {:ok, [{"bab", "$[2]"}]}
  """
  @spec value_paths(json(), String.t() | AST.t()) ::
          {:ok, [{json(), String.t()}]} | {:error, JSONPath.Error.t()}
  @doc since: "0.4.0"
  def value_paths(document, query), do: run(document, query, :values_and_paths)

  @doc """
  Same as `value_paths/2` but returns the list of two-element tuple `{node_value, normalized_path}`
  or raises an error

  ## Examples
      iex> JSONPath.value_paths!(["aba", "bbab", "bab"], "$[?match(@, 'b.b')]")
      [{"bab", "$[2]"}]
  """
  @doc since: "0.4.0"
  @spec value_paths!(json(), String.t() | AST.t()) :: [{json(), String.t()}]
  def value_paths!(document, query) do
    case value_paths(document, query) do
      {:ok, results} -> results
      {:error, exc} -> raise exc
    end
  end

  @doc """
  Same as `evaluate/2` but raises in case of error
  """
  @deprecated "Use one of JSONPath.values!/2, JSONPath.paths!/2 or JSONPath.value_paths!/2"
  @spec evaluate!(json(), String.t() | AST.t(), returning()) :: list(json())
  def evaluate!(document, query, returning \\ :values)

  def evaluate!(document, query, returning) do
    case evaluate(document, query, returning) do
      {:ok, result} -> result
      {:error, %JSONPath.Error{} = e} -> raise e
    end
  end

  defp keep(results, :values), do: Enum.map(results, &elem(&1, 0))

  defp keep(results, :paths), do: Enum.map(results, fn {_value, path} -> to_result_path(path) end)

  defp keep(results, :values_and_paths) do
    Enum.map(results, fn {value, path} -> {value, to_result_path(path)} end)
  end

  defp to_result_path([]), do: "$"

  defp to_result_path(path) when is_list(path) do
    "$" <>
      Enum.map_join(Enum.reverse(path), fn
        val when is_integer(val) -> "[#{val}]"
        val when is_binary(val) -> "['#{escape_codepoints(val)}']"
      end)
  end

  defp escape_codepoints(string) do
    string
    |> String.replace("\\", "\\\\")
    |> String.replace("\b", "\\b")
    |> String.replace("\t", "\\t")
    |> String.replace("\n", "\\n")
    |> String.replace("\f", "\\f")
    |> String.replace("\r", "\\r")
    |> String.replace("'", "\\'")
  end
end