/
jsonpatch.ex
255 lines (208 loc) · 9.47 KB
/
jsonpatch.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
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}
alias Jsonpatch.Utils
@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.
## 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"}
]
"""
@spec diff(Types.json_container(), Types.json_container()) :: [Jsonpatch.t()]
def diff(source, destination)
def diff(%{} = source, %{} = destination) do
flat(destination)
|> do_diff(source, "")
end
def diff(source, destination) when is_list(source) and is_list(destination) do
flat(destination)
|> do_diff(source, "")
end
def diff(_, _) do
[]
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)
# Diff reduce loop
defp do_diff(destination, source, ancestor_path, acc \\ [], checked_keys \\ [])
defp do_diff([], source, ancestor_path, patches, checked_keys) do
# The complete desination was check. Every key that is not in the list of
# checked keys, must be removed.
source
|> flat()
|> Stream.map(fn {k, _} -> escape(k) end)
|> Stream.filter(fn k -> k not in checked_keys end)
|> Stream.map(fn k -> %{op: "remove", path: "#{ancestor_path}/#{k}"} end)
|> Enum.reduce(patches, fn remove_patch, patches -> [remove_patch | patches] end)
end
defp do_diff([{key, val} | tail], source, ancestor_path, patches, checked_keys) do
current_path = "#{ancestor_path}/#{escape(key)}"
patches =
case Utils.fetch(source, key) do
# Key is not present in source
{:error, _} ->
[%{op: "add", path: current_path, value: val} | patches]
# Source has a different value but both (destination and source) value are lists or a maps
{:ok, source_val} when are_unequal_lists(source_val, val) ->
val |> flat() |> Enum.reverse() |> do_diff(source_val, current_path, patches, [])
{:ok, source_val} when are_unequal_maps(source_val, val) ->
# Enter next level - set check_keys to empty list because it is a different level
val |> flat() |> do_diff(source_val, current_path, patches, [])
# Scalar source val that is not equal
{:ok, source_val} when source_val != val ->
[%{op: "replace", path: current_path, value: val} | patches]
_ ->
patches
end
# Diff next value of same level
do_diff(tail, source, ancestor_path, patches, [escape(key) | checked_keys])
end
# Transforms a map into a tuple list and a list also into a tuple list with indizes
defp flat(val) when is_list(val),
do: Stream.with_index(val) |> Enum.map(fn {v, k} -> {k, v} end)
defp flat(val) when is_map(val),
do: Map.to_list(val)
defp escape(fragment) when is_binary(fragment), do: Utils.escape(fragment)
defp escape(fragment) when is_integer(fragment), do: fragment
end