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