defmodule Jsonpatch do @moduledoc """ A implementation of [RFC 6902](https://tools.ietf.org/html/rfc6902) in pure Elixir. The patch can be a single change or a list of things that shall be changed. Therefore a list or a single JSON patch can be provided. Every patch belongs to a certain operation which influences the usage. According to [RFC 6901](https://tools.ietf.org/html/rfc6901) escaping of `/` and `~` is done by using `~1` for `/` and `~0` for `~`. """ alias Jsonpatch.Types alias Jsonpatch.Operation.{Add, Copy, Move, Remove, Replace, Test} @typedoc """ A valid Jsonpatch operation by RFC 6902 """ @type t :: map() | Add.t() | Remove.t() | Replace.t() | Copy.t() | Move.t() | Test.t() @doc """ Apply a Jsonpatch or a list of Jsonpatches to a map or struct. The whole patch will not be applied when any path is invalid or any other error occured. When a list is provided, the operations are applied in the order as they appear in the list. Atoms are never garbage collected. Therefore, `Jsonpatch` works by default only with maps which used binary strings as key. This behaviour can be controlled via the `:keys` option. ## Examples iex> patch = [ ...> %{op: "add", path: "/age", value: 33}, ...> %{op: "replace", path: "/hobbies/0", value: "Elixir!"}, ...> %{op: "replace", path: "/married", value: true}, ...> %{op: "remove", path: "/hobbies/2"}, ...> %{op: "remove", path: "/hobbies/1"}, ...> %{op: "copy", from: "/name", path: "/surname"}, ...> %{op: "move", from: "/home", path: "/work"}, ...> %{op: "test", path: "/name", value: "Bob"} ...> ] iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"], "home" => "Berlin"} iex> Jsonpatch.apply_patch(patch, target) {:ok, %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33, "surname" => "Bob", "work" => "Berlin"}} iex> # Patch will not be applied if test fails. The target will not be changed. iex> patch = [ ...> %{op: "add", path: "/age", value: 33}, ...> %{op: "test", path: "/name", value: "Alice"} ...> ] iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"], "home" => "Berlin"} iex> Jsonpatch.apply_patch(patch, target) {:error, %Jsonpatch.Error{patch: %{"op" => "test", "path" => "/name", "value" => "Alice"}, patch_index: 1, reason: {:test_failed, "Expected value '\\"Alice\\"' at '/name'"}}} iex> # Patch will succeed, not applying invalid path operations. iex> patch = [ ...> %{op: "replace", path: "/name", value: "Alice"}, ...> %{op: "replace", path: "/age", value: 42} ...> ] iex> target = %{"name" => "Bob"} # No age in target iex> Jsonpatch.apply_patch(patch, target, ignore_invalid_paths: true) {:ok, %{"name" => "Alice"}} """ @spec apply_patch(t() | [t()], target :: Types.json_container(), Types.opts()) :: {:ok, Types.json_container()} | {:error, Jsonpatch.Error.t()} def apply_patch(json_patch, target, opts \\ []) do # https://datatracker.ietf.org/doc/html/rfc6902#section-3 # > Operations are applied sequentially in the order they appear in the array. {ignore_invalid_paths?, opts} = Keyword.pop(opts, :ignore_invalid_paths, false) json_patch |> List.wrap() |> Enum.with_index() |> Enum.reduce_while({:ok, target}, fn {patch, patch_index}, {:ok, acc} -> patch = cast_to_op_map(patch) do_apply_patch(patch, acc, opts) |> handle_patch_result(acc, patch, patch_index, ignore_invalid_paths?) end) end defp handle_patch_result(result, acc, patch, patch_index, ignore_invalid_paths?) do case result do {:error, {error, _} = reason} -> if ignore_invalid_paths? && error == :invalid_path do {:cont, {:ok, acc}} else error = %Jsonpatch.Error{patch: patch, patch_index: patch_index, reason: reason} {:halt, {:error, error}} end {:ok, res} -> {:cont, {:ok, res}} end end defp cast_to_op_map(%struct_mod{} = json_patch) do json_patch = json_patch |> Map.from_struct() op = case struct_mod do Add -> "add" Remove -> "remove" Replace -> "replace" Copy -> "copy" Move -> "move" Test -> "test" end json_patch = Map.put(json_patch, "op", op) cast_to_op_map(json_patch) end defp cast_to_op_map(json_patch) do Map.new(json_patch, fn {k, v} -> {to_string(k), v} end) end defp do_apply_patch(%{"op" => "add", "path" => path, "value" => value}, target, opts) do Add.apply(%Add{path: path, value: value}, target, opts) end defp do_apply_patch(%{"op" => "remove", "path" => path}, target, opts) do Remove.apply(%Remove{path: path}, target, opts) end defp do_apply_patch(%{"op" => "replace", "path" => path, "value" => value}, target, opts) do Replace.apply(%Replace{path: path, value: value}, target, opts) end defp do_apply_patch(%{"op" => "copy", "from" => from, "path" => path}, target, opts) do Copy.apply(%Copy{from: from, path: path}, target, opts) end defp do_apply_patch(%{"op" => "move", "from" => from, "path" => path}, target, opts) do Move.apply(%Move{from: from, path: path}, target, opts) end defp do_apply_patch(%{"op" => "test", "path" => path, "value" => value}, target, opts) do Test.apply(%Test{path: path, value: value}, target, opts) end defp do_apply_patch(json_patch, _target, _opts) do {:error, {:invalid_spec, json_patch}} end @doc """ Apply a Jsonpatch or a list of Jsonpatches to a map or struct. In case of an error it will raise an exception. When a list is provided, the operations are applied in the order as they appear in the list. (See Jsonpatch.apply_patch/2 for more details) """ @spec apply_patch!(t() | list(t()), target :: Types.json_container(), Types.opts()) :: Types.json_container() def apply_patch!(json_patch, target, opts \\ []) do case apply_patch(json_patch, target, opts) do {:ok, patched} -> patched {:error, _} = error -> raise JsonpatchException, error end end @doc """ Creates a patch from the difference of a source map to a destination map or list. ## Options * `:ancestor_path` - Sets the initial ancestor path for the diff operation. Defaults to `""` (root). Useful when you need to diff starting from a nested path. * `:prepare_map` - A function that lets to customize maps and structs before diffing. Defaults to `fn map -> map end` (no-op). Useful when you need to customize how maps and structs are handled during the diff process. Example: ```elixir fn %Struct{field1: value1, field2: value2} -> %{field1: "\#{value1} - \#{value2}"} %OtherStruct{} = struct -> Map.take(struct, [:field1]) map -> map end ``` ## Examples iex> source = %{"name" => "Bob", "married" => false, "hobbies" => ["Elixir", "Sport", "Football"]} iex> destination = %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33} iex> Jsonpatch.diff(source, destination) [ %{path: "/married", value: true, op: "replace"}, %{path: "/hobbies/2", op: "remove"}, %{path: "/hobbies/1", op: "remove"}, %{path: "/hobbies/0", value: "Elixir!", op: "replace"}, %{path: "/age", value: 33, op: "add"} ] iex> source = %{"a" => 1, "b" => 2} iex> destination = %{"a" => 3, "c" => 4} iex> Jsonpatch.diff(source, destination, ancestor_path: "/nested") [ %{path: "/nested/b", op: "remove"}, %{path: "/nested/c", value: 4, op: "add"}, %{path: "/nested/a", value: 3, op: "replace"} ] """ @spec diff(Types.json_container(), Types.json_container(), Types.opts_diff()) :: [Jsonpatch.t()] def diff(source, destination, opts \\ []) do opts = opts |> Keyword.update(:object_hash, nil, &make_safe_hash_fn/1) |> Keyword.validate!( ancestor_path: "", prepare_map: &Function.identity/1, object_hash: nil ) do_diff(destination, source, opts[:ancestor_path], nil, [], opts) end defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1) defguardp are_unequal_lists(val1, val2) when val1 != val2 and is_list(val2) and is_list(val1) defp do_diff(dest, source, path, key, patches, opts) when are_unequal_lists(dest, source) do # uneqal lists, let's use a specialized function for that do_list_diff(dest, source, join_key(path, key), patches, opts) end defp do_diff(dest, source, path, key, patches, opts) when are_unequal_maps(dest, source) do # Convert structs to maps if prepare_map function is provided dest = maybe_prepare_map(dest, opts) source = maybe_prepare_map(source, opts) if not is_map(dest) or not is_map(source) do # type changed, let's process it again do_diff(dest, source, path, key, patches, opts) else # uneqal maps, let's use a specialized function for that do_map_diff(dest, source, join_key(path, key), patches, opts) end end defp do_diff(dest, source, path, key, patches, opts) when dest != source do # scalar values or change of type (map -> list etc), let's just make a replace patch [%{op: "replace", path: join_key(path, key), value: maybe_prepare_map(dest, opts)} | patches] end defp do_diff(_dest, _source, _path, _key, patches, _opts) do # no changes, return patches as is patches end defp do_map_diff(%{} = destination, %{} = source, ancestor_path, patches, opts) do # entrypoint for map diff, let's convert the map to a list of {k, v} tuples destination |> Map.to_list() |> do_map_diff(source, ancestor_path, patches, [], opts) end defp do_map_diff([], source, ancestor_path, patches, checked_keys, _opts) do # The complete desination was check. Every key that is not in the list of # checked keys, must be removed. Enum.reduce(source, patches, fn {k, _}, patches -> if k in checked_keys do patches else [%{op: "remove", path: join_key(ancestor_path, k)} | patches] end end) end defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys, opts) do # normal iteration through list of map {k, v} tuples. We track seen keys to later remove not seen keys. patches = case Map.fetch(source, key) do {:ok, source_val} -> do_diff(val, source_val, ancestor_path, key, patches, opts) :error -> value = maybe_prepare_map(val, opts) [%{op: "add", path: join_key(ancestor_path, key), value: value} | patches] end # Diff next value of same level do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys], opts) end defp do_list_diff(destination, source, ancestor_path, patches, opts) do if opts[:object_hash] do do_hash_list_diff(destination, source, ancestor_path, patches, opts) else do_pairwise_list_diff(destination, source, ancestor_path, patches, 0, opts) end catch # happens if we've got a nil hash or we tried to hash a non-map :hash_not_implemented -> do_pairwise_list_diff(destination, source, ancestor_path, patches, 0, opts) end defp do_pairwise_list_diff(destination, source, ancestor_path, patches, idx, opts) defp do_pairwise_list_diff([], [], _path, patches, _idx, _opts), do: patches defp do_pairwise_list_diff([], [_item | source_rest], ancestor_path, patches, idx, opts) do # if we find any leftover items in source, we have to remove them patches = [%{op: "remove", path: join_key(ancestor_path, idx)} | patches] do_pairwise_list_diff([], source_rest, ancestor_path, patches, idx + 1, opts) end defp do_pairwise_list_diff(items, [], ancestor_path, patches, idx, opts) do # we have to do it without recursion, because we have to keep the order of the items items |> Enum.map_reduce(idx, fn val, idx -> {%{op: "add", path: join_key(ancestor_path, idx), value: maybe_prepare_map(val, opts)}, idx + 1} end) |> elem(0) |> Kernel.++(patches) end defp do_pairwise_list_diff( [val | rest], [source_val | source_rest], ancestor_path, patches, idx, opts ) do # case when there's an item in both desitation and source. Let's just compare them patches = do_diff(val, source_val, ancestor_path, idx, patches, opts) do_pairwise_list_diff(rest, source_rest, ancestor_path, patches, idx + 1, opts) end defp do_hash_list_diff(destination, source, ancestor_path, patches, opts) do hash_fn = Keyword.fetch!(opts, :object_hash) {additions, modifications, removals} = greedy_find_additions_modifications_removals( List.to_tuple(destination), List.to_tuple(source), index_by(destination, hash_fn), index_by(source, hash_fn), hash_fn, ancestor_path, opts ) List.flatten([removals, additions, modifications, patches]) end # credo:disable-for-next-line defp greedy_find_additions_modifications_removals( dest, source, dest_map, source_map, hash_fn, path, opts, dest_idx \\ 0, source_idx \\ 0, additions \\ [], modifications \\ [], removals \\ [] ) do cond do tuple_size(dest) == dest_idx -> # we're at the end of the destination tuple, let's remove all remaining source items removals = add_removals(source_idx, tuple_size(source) - 1, path, removals) {Enum.reverse(additions), modifications, removals} tuple_size(source) == source_idx -> # we're at the end of the source tuple, let's add all remaining destination items additions = add_additions(dest_idx, tuple_size(dest) - 1, path, dest, additions, opts) {Enum.reverse(additions), modifications, removals} true -> # we're in the middle of the tuples, let's find the next matching items dest_item = elem(dest, dest_idx) source_item = elem(source, source_idx) source_hash = hash_fn.(source_item) dest_hash = hash_fn.(dest_item) if source_hash == dest_hash do # same items, let's diff recursively and bump both indexes modifications = do_diff(dest_item, source_item, path, dest_idx, modifications, opts) greedy_find_additions_modifications_removals( dest, source, dest_map, source_map, hash_fn, path, opts, dest_idx + 1, source_idx + 1, additions, modifications, removals ) else # different items, let's find index of destination item in source and vice versa {next_dest_idx, next_source_idx} = determine_next_idx( dest_idx, source_idx, Map.get(dest_map, source_hash), Map.get(source_map, dest_hash) ) removals = add_removals(source_idx, next_source_idx - 1, path, removals) additions = add_additions(dest_idx, next_dest_idx - 1, path, dest, additions, opts) greedy_find_additions_modifications_removals( dest, source, dest_map, source_map, hash_fn, path, opts, next_dest_idx, next_source_idx, additions, modifications, removals ) end end end # credo:disable-for-next-line defp determine_next_idx(d_idx, s_idx, next_d_idx, next_s_idx) do dest_found = next_d_idx != nil and next_d_idx > d_idx source_found = next_s_idx != nil and next_s_idx > s_idx source_closer = dest_found and source_found and next_s_idx - s_idx < next_d_idx - d_idx cond do # in case when we can jump to either of them, we want to jump to the closer one source_closer -> {d_idx, next_s_idx} # only source is found ahead, we have to do source jump next_d_idx == nil and source_found -> {d_idx, next_s_idx} # only dest is found ahead, we have to do dest jump next_s_idx == nil and dest_found -> {next_d_idx, s_idx} # neither is found ahead, we have to advance both indexes true -> {d_idx + 1, s_idx + 1} end end @compile {:inline, index_by: 2} defp index_by(list, hash_fn) do list |> Enum.reduce({%{}, 0}, fn item, {map, idx} -> # if we have a hash collision, we throw an error and handle as if the hash is not implemented {Map.update(map, hash_fn.(item), idx, fn _ -> throw(:hash_not_implemented) end), idx + 1} end) |> elem(0) end @compile {:inline, add_removals: 4} defp add_removals(from_idx, to_idx, path, removals) do Enum.reduce(from_idx..to_idx//1, removals, fn idx, removals -> [%{op: "remove", path: join_key(path, idx)} | removals] end) end @compile {:inline, add_additions: 6} defp add_additions(from_idx, to_idx, path, dest_tuple, additions, opts) do Enum.reduce(from_idx..to_idx//1, additions, fn idx, additions -> value = dest_tuple |> elem(idx) |> maybe_prepare_map(opts) [%{op: "add", path: join_key(path, idx), value: value} | additions] end) end @compile {:inline, maybe_prepare_map: 2} defp maybe_prepare_map(value, opts) when is_map(value) do prepare_fn = Keyword.fetch!(opts, :prepare_map) prepare_fn.(value) end defp maybe_prepare_map(value, _opts), do: value @compile {:inline, escape: 1} defp escape(fragment) when is_binary(fragment) do fragment = if :binary.match(fragment, "~") != :nomatch, do: String.replace(fragment, "~", "~0"), else: fragment if :binary.match(fragment, "/") != :nomatch, do: String.replace(fragment, "/", "~1"), else: fragment end defp escape(fragment), do: fragment @compile {:inline, join_key: 2} defp join_key(path, nil), do: path defp join_key(path, key), do: "#{path}/#{escape(key)}" defp make_safe_hash_fn(hash_fn) do # we want to compare only maps, and returning nil should mean # we should compare lists pairwise instead fn %{} = item -> case hash_fn.(item) do nil -> throw(:hash_not_implemented) hash -> hash end _item -> throw(:hash_not_implemented) end end end