From 8ebef585422c4987a4688c1a87f9108cc0a28cf6 Mon Sep 17 00:00:00 2001 From: Giovanni Visciano Date: Thu, 18 May 2023 22:03:09 +0200 Subject: [PATCH 1/3] review --- .credo.exs | 182 +---------- .formatter.exs | 1 - .github/workflows/elixir.yml | 1 - CHANGELOG | 17 + Makefile | 1 - README.md | 52 +-- coveralls.json | 12 +- lib/jsonpatch.ex | 213 +++++++------ lib/jsonpatch/error.ex | 14 + lib/jsonpatch/mapper.ex | 124 -------- lib/jsonpatch/operation.ex | 21 -- lib/jsonpatch/operation/add.ex | 88 ++---- lib/jsonpatch/operation/copy.ex | 117 ++----- lib/jsonpatch/operation/move.ex | 29 +- lib/jsonpatch/operation/remove.ex | 110 ++++--- lib/jsonpatch/operation/replace.ex | 57 +--- lib/jsonpatch/operation/test.ex | 63 ++-- lib/jsonpatch/path_util.ex | 232 -------------- lib/jsonpatch/types.ex | 38 +++ lib/jsonpatch/utils.ex | 349 +++++++++++++++++++++ lib/jsonpatch_exception.ex | 14 - mix.exs | 31 +- mix.lock | 20 +- test/jsonpatch/mapper_test.exs | 113 ------- test/jsonpatch/operation/add_test.exs | 45 +-- test/jsonpatch/operation/copy_test.exs | 40 +-- test/jsonpatch/operation/remove_test.exs | 56 ++-- test/jsonpatch/operation/replace_test.exs | 30 +- test/jsonpatch/operation/test_test.exs | 22 +- test/jsonpatch/path_util_test.exs | 43 --- test/jsonpatch/res/deploy_destination.json | 116 ------- test/jsonpatch/res/deploy_source.json | 117 ------- test/jsonpatch/utils_test.exs | 20 ++ test/jsonpatch_test.exs | 328 +++++++++++-------- 34 files changed, 1028 insertions(+), 1688 deletions(-) create mode 100644 lib/jsonpatch/error.ex delete mode 100644 lib/jsonpatch/mapper.ex delete mode 100644 lib/jsonpatch/operation.ex delete mode 100644 lib/jsonpatch/path_util.ex create mode 100644 lib/jsonpatch/types.ex create mode 100644 lib/jsonpatch/utils.ex delete mode 100644 lib/jsonpatch_exception.ex delete mode 100644 test/jsonpatch/mapper_test.exs delete mode 100644 test/jsonpatch/path_util_test.exs delete mode 100644 test/jsonpatch/res/deploy_destination.json delete mode 100644 test/jsonpatch/res/deploy_source.json create mode 100644 test/jsonpatch/utils_test.exs diff --git a/.credo.exs b/.credo.exs index 7bc729a..3fcb8a9 100644 --- a/.credo.exs +++ b/.credo.exs @@ -1,188 +1,14 @@ -# This file contains the configuration for Credo and you are probably reading -# this after creating it with `mix credo.gen.config`. -# -# If you find anything wrong or unclear in this file, please report an -# issue on GitHub: https://github.com/rrrene/credo/issues -# %{ - # - # You can have as many configs as you like in the `configs:` field. configs: [ %{ - # - # Run any config using `mix credo -C `. If no config name is given - # "default" is used. - # name: "default", - # - # These are the files included in the analysis: files: %{ - # - # You can give explicit globs or simply directories. - # In the latter case `**/*.{ex,exs}` will be used. - # - included: [ - "lib/", - "src/", - "test/", - "web/", - "apps/*/lib/", - "apps/*/src/", - "apps/*/test/", - "apps/*/web/" - ], - excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + included: ["lib", "test"] }, - # - # Load and configure plugins here: - # - plugins: [], - # - # If you create your own checks, you must specify the source files for - # them here, so they can be loaded by Credo before running the analysis. - # - requires: [], - # - # If you want to enforce a style guide and need a more traditional linting - # experience, you can change `strict` to `true` below: - # - strict: false, - # - # To modify the timeout for parsing files, change this value: - # - parse_timeout: 5000, - # - # If you want to use uncolored output by default, you can change `color` - # to `false` below: - # - color: true, - # - # You can customize the parameters of any check by adding a second element - # to the tuple. - # - # To disable a check put `false` as second element: - # - # {Credo.Check.Design.DuplicatedCode, false} - # checks: [ - # - ## Consistency Checks - # - {Credo.Check.Consistency.ExceptionNames, []}, - {Credo.Check.Consistency.LineEndings, []}, - {Credo.Check.Consistency.ParameterPatternMatching, []}, - {Credo.Check.Consistency.SpaceAroundOperators, []}, - {Credo.Check.Consistency.SpaceInParentheses, []}, - {Credo.Check.Consistency.TabsOrSpaces, []}, - - # - ## Design Checks - # - # You can customize the priority of any check - # Priority values are: `low, normal, high, higher` - # - {Credo.Check.Design.AliasUsage, - [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, - # You can also customize the exit_status of each check. - # If you don't want TODO comments to cause `mix credo` to fail, just - # set this value to 0 (zero). - # - {Credo.Check.Design.TagTODO, [exit_status: 2]}, - {Credo.Check.Design.TagFIXME, []}, - - # - ## Readability Checks - # - {Credo.Check.Readability.AliasOrder, []}, - {Credo.Check.Readability.FunctionNames, []}, - {Credo.Check.Readability.LargeNumbers, []}, - {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, - {Credo.Check.Readability.ModuleAttributeNames, []}, - {Credo.Check.Readability.ModuleDoc, []}, - {Credo.Check.Readability.ModuleNames, []}, - {Credo.Check.Readability.ParenthesesInCondition, []}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, - {Credo.Check.Readability.PredicateFunctionNames, []}, - {Credo.Check.Readability.PreferImplicitTry, []}, - {Credo.Check.Readability.RedundantBlankLines, []}, - {Credo.Check.Readability.Semicolons, []}, - {Credo.Check.Readability.SpaceAfterCommas, []}, - {Credo.Check.Readability.StringSigils, []}, - {Credo.Check.Readability.TrailingBlankLine, []}, - {Credo.Check.Readability.TrailingWhiteSpace, []}, - {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, - {Credo.Check.Readability.VariableNames, []}, - - # - ## Refactoring Opportunities - # - {Credo.Check.Refactor.CondStatements, []}, - {Credo.Check.Refactor.CyclomaticComplexity, []}, - {Credo.Check.Refactor.FunctionArity, []}, - {Credo.Check.Refactor.LongQuoteBlocks, []}, - # {Credo.Check.Refactor.MapInto, []}, - {Credo.Check.Refactor.MatchInCondition, []}, - {Credo.Check.Refactor.NegatedConditionsInUnless, []}, - {Credo.Check.Refactor.NegatedConditionsWithElse, []}, - {Credo.Check.Refactor.Nesting, []}, - {Credo.Check.Refactor.UnlessWithElse, []}, - {Credo.Check.Refactor.WithClauses, []}, - - # - ## Warnings - # - {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, - {Credo.Check.Warning.BoolOperationOnSameValues, []}, - {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, - {Credo.Check.Warning.IExPry, []}, - {Credo.Check.Warning.IoInspect, []}, - # {Credo.Check.Warning.LazyLogging, []}, - {Credo.Check.Warning.MixEnv, false}, - {Credo.Check.Warning.OperationOnSameValues, []}, - {Credo.Check.Warning.OperationWithConstantResult, []}, - {Credo.Check.Warning.RaiseInsideRescue, []}, - {Credo.Check.Warning.UnusedEnumOperation, []}, - {Credo.Check.Warning.UnusedFileOperation, []}, - {Credo.Check.Warning.UnusedKeywordOperation, []}, - {Credo.Check.Warning.UnusedListOperation, []}, - {Credo.Check.Warning.UnusedPathOperation, []}, - {Credo.Check.Warning.UnusedRegexOperation, []}, - {Credo.Check.Warning.UnusedStringOperation, []}, - {Credo.Check.Warning.UnusedTupleOperation, []}, - {Credo.Check.Warning.UnsafeExec, []}, - - # - # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) - - # - # Controversial and experimental checks (opt-in, just replace `false` with `[]`) - # - {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, - {Credo.Check.Consistency.UnusedVariableNames, false}, - {Credo.Check.Design.DuplicatedCode, false}, - {Credo.Check.Readability.AliasAs, false}, - {Credo.Check.Readability.BlockPipe, false}, - {Credo.Check.Readability.ImplTrue, false}, - {Credo.Check.Readability.MultiAlias, false}, - {Credo.Check.Readability.SeparateAliasRequire, false}, - {Credo.Check.Readability.SinglePipe, false}, - {Credo.Check.Readability.Specs, false}, - {Credo.Check.Readability.StrictModuleLayout, false}, - {Credo.Check.Readability.WithCustomTaggedTuple, false}, - {Credo.Check.Refactor.ABCSize, false}, - {Credo.Check.Refactor.AppendSingleItem, false}, - {Credo.Check.Refactor.DoubleBooleanNegation, false}, - {Credo.Check.Refactor.ModuleDependencies, false}, - {Credo.Check.Refactor.NegatedIsNil, false}, - {Credo.Check.Refactor.PipeChainStart, false}, - {Credo.Check.Refactor.VariableRebinding, false}, - {Credo.Check.Warning.LeakyEnvironment, false}, - {Credo.Check.Warning.MapGetUnsafePass, false}, - {Credo.Check.Warning.UnsafeToAtom, false} - - # - # Custom checks can be created using `mix credo.gen.check`. - # + {Credo.Check.Refactor.RedundantWithClauseResult, false}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, + {Credo.Check.Design.AliasUsage, false} ] } ] diff --git a/.formatter.exs b/.formatter.exs index d2cda26..d304ff3 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,3 @@ -# Used by "mix format" [ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 4c4ffc9..b66ecc7 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -43,7 +43,6 @@ jobs: otp-version: "25.2" elixir-version: "1.14.3" - run: mix deps.get - - run: mix muzak --min-coverage 95.0 # Linit and type checking analyze: diff --git a/CHANGELOG b/CHANGELOG index be5da8a..6577de8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,20 @@ +# 2.0.0 +- precisare discorso riguardo structs +- rimosso modulo Jsonpatch.Mapper in favore fi apply_patch() piu' flessibile +- increased code coverage +- Jsonpatch.apply_patch signature + accetta map che rappresentano jsonpatch (key string or atom) e Jsonpath.Operation._ structs + restituisce {:error, %Jsonpatch.Error{}} +- %Jsonpatch.Error{} reports the patch index, the path and the reason that caused the error +- rimossa JsonpatchException +- review generale delle Map.get con relativo errore di test assenza chiave su base nil +- rimosso protocol Jsonpatch.Operation +- ADD behaviour aderente a specifica -> insert or update +- COPY operation based on ADD operation (as per RFC) +- MOVE operation based on COPY+REMOVE operation (as per RFC) +- REPLACE operation based on REMOVE+ADD operation (as per RFC) +- Introduced new appl_patch option `keys: {:custom, convert_fn}` to convert path fragments with a user specific logic + # 1.0.1 - Escape remaining keys before comparing them to the (already escaped) keys from earlier in the diffing process when determining Remove operations diff --git a/Makefile b/Makefile index efbb11c..d28a461 100644 --- a/Makefile +++ b/Makefile @@ -6,5 +6,4 @@ check: mix test mix dialyzer mix credo --strict - MIX_ENV=mutation mix muzak --min-coverage 95.0 MIX_ENV=test mix coveralls diff --git a/README.md b/README.md index 0ac3fae..7e56f9e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Jsonpatch + ![Elixir CI](https://github.com/corka149/jsonpatch/workflows/Elixir%20CI/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/corka149/jsonpatch/badge.svg?branch=master)](https://coveralls.io/github/corka149/jsonpatch?branch=master) [![Generic badge](https://img.shields.io/badge/Mutation-Tested-success.svg)](https://shields.io/) @@ -7,21 +8,18 @@ An implementation of [RFC 6902](https://tools.ietf.org/html/rfc6902) in pure Elixir. - Features: -1. Creating a patch by comparing to maps and structs -2. Apply patches to maps and structs - supports operations: +- Creating a patch by comparing to maps and lists +- Apply patches to maps and lists - supports operations: - add - replace - remove - copy - move - test -3. De/Encoding and mapping -4. Escaping of "`/`" (by "`~1`") and "`~`" (by "`~0`") -5. Allow usage of `-` for appending things to list (Add and Copy operation) - +- Escaping of "`/`" (by "`~1`") and "`~`" (by "`~0`") +- Allow usage of `-` for appending things to list (Add and Copy operation) ## Getting started @@ -54,24 +52,6 @@ iex> Jsonpatch.diff(source, destination) ]} ``` -### Mapping for de- and encoding - -Map a JSON patch struct to a regular map. - -```elixir -iex> add_patch_map = %Jsonpatch.Operation.Add{path: "/name", value: "Alice"} -iex> Jsonpatch.Mapper.to_map(add_patch_map) -%{op: "add", path: "/name", value: "Alice"} -``` - -Map a regular map to a JSON patch struct. - -```elixir -iex> add_patch_map = %{"op" => "add", "path" => "/name", "value" => "Alice"} -iex> Jsonpatch.Mapper.from_map(add_patch_map) -%Jsonpatch.Operation.Add{path: "/name", value: "Alice"} -``` - ### Apply patches ```elixir @@ -87,28 +67,6 @@ iex> Jsonpatch.apply_patch(patch, target) {:ok, %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33}} ``` -### In an `exs` script - -With `Mix.install` small scripts can be written to create JSON patches. -```elixir -Mix.install([:jsonpatch, :poison]) - -source = - File.read!("foo.json") - |> Poison.Parser.parse!(%{}) - -destination = - File.read!("bar.json") - |> Poison.Parser.parse!(%{}) - -patch = - source - |> Jsonpatch.diff(destination) - |> Jsonpatch.Mapper.to_map() - -IO.inspect(patch, label: :patch) -``` - ## Important sources - [Official RFC 6902](https://tools.ietf.org/html/rfc6902) - [Inspiration: python-json-patch](https://github.com/stefankoegl/python-json-patch) diff --git a/coveralls.json b/coveralls.json index b1b0a7f..a047af8 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,16 +1,8 @@ { "skip_files": ["test/support/"], - "default_stop_words": [ - "defdelegate", - "defmodule", - "defevent", - "defrecord", - "defstruct", - "defimpl", - "def.+(.+//.+).+do" - ], "coverage_options": { + "html_filter_full_covered": true, "treat_no_relevant_lines_as_covered": true, - "minimum_coverage": 85 + "minimum_coverage": 100 } } \ No newline at end of file diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 7c5cd87..d3e21e0 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -10,22 +10,14 @@ defmodule Jsonpatch do by using `~1` for `/` and `~0` for `~`. """ - alias Jsonpatch.Operation.Add - alias Jsonpatch.Operation.Copy - alias Jsonpatch.Operation.Move - alias Jsonpatch.Operation.Remove - alias Jsonpatch.Operation.Replace - alias Jsonpatch.Operation.Test + 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 :: Add.t() | Remove.t() | Replace.t() | Copy.t() | Move.t() | Test.t() - - @typedoc """ - Describe an error that occured while patching. - """ - @type error :: {:error, :invalid_path | :invalid_index | :test_failed, bitstring()} + @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 @@ -35,12 +27,6 @@ defmodule Jsonpatch do 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. - ## Options - * `:keys` - controls how parts of paths are decoded. Possible values: - * `:strings` (default) - decodes parts of paths as binary strings, - * `:atoms` - parts of paths are converted to atoms using `String.to_atom/1`, - * `:atoms!` - parts of paths are converted to atoms using `String.to_existing_atom/1` - ## Examples iex> patch = [ ...> %Jsonpatch.Operation.Add{path: "/age", value: 33}, @@ -63,31 +49,80 @@ defmodule Jsonpatch do ...> ] iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"], "home" => "Berlin"} iex> Jsonpatch.apply_patch(patch, target) - {:error, :test_failed, "Expected value 'Alice' at '/name'"} + {:error, %Jsonpatch.Error{patch: %{"op" => "test", "path" => "/name", "value" => "Alice"}, patch_index: 1, reason: {:test_failed, "Expected value '\\"Alice\\"' at '/name'"}}} """ - @spec apply_patch(Jsonpatch.t() | list(Jsonpatch.t()), map(), keyword()) :: - {:ok, map()} | Jsonpatch.error() - def apply_patch(json_patch, target, opts \\ []) - - def apply_patch(json_patch, target, opts) when is_list(json_patch) do + @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. - result = - Enum.reduce_while(json_patch, target, fn patch, acc -> - case Jsonpatch.Operation.apply_op(patch, acc, opts) do - {:error, _, _} = error -> {:halt, error} - result -> {:cont, result} - end - end) - - case result do - {:error, _, _} = error -> error - ok_result -> {:ok, ok_result} - end + json_patch + |> List.wrap() + |> Enum.with_index() + |> Enum.reduce_while({:ok, target}, fn {patch, patch_index}, {:ok, acc} -> + patch = cast_to_op_map(patch) + + case do_apply_patch(patch, acc, opts) do + {:error, reason} -> + error = %Jsonpatch.Error{patch: patch, patch_index: patch_index, reason: reason} + {:halt, {:error, error}} + + {:ok, res} -> + {:cont, {:ok, res}} + end + end) end - def apply_patch(json_patch, target, opts) do - apply_patch([json_patch], target, opts) + defp cast_to_op_map(%struct_mod{} = json_patch) do + json_patch = + json_patch + |> Map.from_struct() + + op = + case struct_mod do + Jsonpatch.Operation.Add -> "add" + Jsonpatch.Operation.Remove -> "remove" + Jsonpatch.Operation.Replace -> "replace" + Jsonpatch.Operation.Copy -> "copy" + Jsonpatch.Operation.Move -> "move" + Jsonpatch.Operation.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 + Jsonpatch.Operation.Add.apply(%Add{path: path, value: value}, target, opts) + end + + defp do_apply_patch(%{"op" => "remove", "path" => path}, target, opts) do + Jsonpatch.Operation.Remove.apply(%Remove{path: path}, target, opts) + end + + defp do_apply_patch(%{"op" => "replace", "path" => path, "value" => value}, target, opts) do + Jsonpatch.Operation.Replace.apply(%Replace{path: path, value: value}, target, opts) + end + + defp do_apply_patch(%{"op" => "copy", "from" => from, "path" => path}, target, opts) do + Jsonpatch.Operation.Copy.apply(%Copy{from: from, path: path}, target, opts) + end + + defp do_apply_patch(%{"op" => "move", "from" => from, "path" => path}, target, opts) do + Jsonpatch.Operation.Move.apply(%Move{from: from, path: path}, target, opts) + end + + defp do_apply_patch(%{"op" => "test", "path" => path, "value" => value}, target, opts) do + Jsonpatch.Operation.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 """ @@ -97,13 +132,12 @@ defmodule Jsonpatch do (See Jsonpatch.apply_patch/2 for more details) """ - @spec apply_patch!(Jsonpatch.t() | list(Jsonpatch.t()), map(), keyword()) :: map() - def apply_patch!(json_patch, target, opts \\ []) - - def apply_patch!(json_patch, target, opts) do + @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 + {:error, _} = error -> raise RuntimeError, inspect(error) end end @@ -123,17 +157,16 @@ defmodule Jsonpatch do %Jsonpatch.Operation.Add{path: "/age", value: 33} ] """ - @spec diff(maybe_improper_list | map, maybe_improper_list | map) :: list(Jsonpatch.t()) + @spec diff(Types.json_container(), Types.json_container()) :: [Jsonpatch.t()] def diff(source, destination) def diff(%{} = source, %{} = destination) do - Map.to_list(destination) + flat(destination) |> do_diff(source, "") end def diff(source, destination) when is_list(source) and is_list(destination) do - Enum.with_index(destination) - |> Enum.map(fn {v, k} -> {k, v} end) + flat(destination) |> do_diff(source, "") end @@ -141,96 +174,62 @@ defmodule Jsonpatch do [] end - # ===== ===== PRIVATE ===== ===== - - # Helper for better readability defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1) - # Helper for better readability 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, acc, checked_keys) do + 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. - acc = - source - |> flat() - |> Stream.map(fn {k, _} -> escape(k) end) - |> Stream.filter(fn k -> k not in checked_keys end) - |> Stream.map(fn k -> %Remove{path: "#{ancestor_path}/#{k}"} end) - |> Enum.reduce(acc, fn r, acc -> [r | acc] end) - - acc + source + |> flat() + |> Stream.map(fn {k, _} -> escape(k) end) + |> Stream.filter(fn k -> k not in checked_keys end) + |> Stream.map(fn k -> %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, acc, checked_keys) - when is_list(source) or is_map(source) do + defp do_diff([{key, val} | tail], source, ancestor_path, patches, checked_keys) do current_path = "#{ancestor_path}/#{escape(key)}" - acc = - case get(source, key) do + patches = + case Utils.fetch(source, key) do # Key is not present in source - :__jsonpatch_lib__missing_value__ -> - [%Add{path: current_path, value: val} | acc] + {:error, _} -> + [%Add{path: current_path, value: val} | patches] # Source has a different value but both (destination and source) value are lists or a maps - source_val when are_unequal_lists(source_val, val) -> - val |> flat() |> Enum.reverse() |> do_diff(source_val, current_path, acc, []) + {:ok, source_val} when are_unequal_lists(source_val, val) -> + val |> flat() |> Enum.reverse() |> do_diff(source_val, current_path, patches, []) - source_val when are_unequal_maps(source_val, val) -> + {: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, acc, []) + val |> flat() |> do_diff(source_val, current_path, patches, []) # Scalar source val that is not equal - source_val when source_val != val -> - [%Replace{path: current_path, value: val} | acc] + {:ok, source_val} when source_val != val -> + [%Replace{path: current_path, value: val} | patches] _ -> - acc + patches end # Diff next value of same level - do_diff(tail, source, ancestor_path, acc, [escape(key) | checked_keys]) + 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) - end - - defp flat(val) when is_map(val) do - Map.to_list(val) - end - - # Unified access to lists or maps - defp get(source, key) when is_list(source) do - Enum.at(source, key, :__jsonpatch_lib__missing_value__) - end - - defp get(source, key) when is_map(source) do - Map.get(source, key, :__jsonpatch_lib__missing_value__) - end + defp flat(val) when is_list(val), + do: Stream.with_index(val) |> Enum.map(fn {v, k} -> {k, v} end) - # Escape `/` to `~1 and `~` to `~0`. - defp escape(subpath) when is_bitstring(subpath) do - subpath - |> do_escape("~", "~0") - |> do_escape("/", "~1") - end - - defp escape(subpath) do - subpath - end + defp flat(val) when is_map(val), + do: Map.to_list(val) - defp do_escape(subpath, pattern, replacement) do - case String.contains?(subpath, pattern) do - true -> String.replace(subpath, pattern, replacement) - false -> subpath - end - end + defp escape(fragment) when is_binary(fragment), do: Utils.escape(fragment) + defp escape(fragment) when is_integer(fragment), do: fragment end diff --git a/lib/jsonpatch/error.ex b/lib/jsonpatch/error.ex new file mode 100644 index 0000000..9865f3b --- /dev/null +++ b/lib/jsonpatch/error.ex @@ -0,0 +1,14 @@ +defmodule Jsonpatch.Error do + @moduledoc """ + Describe an error that occured while patching. + """ + + @enforce_keys [:patch, :patch_index, :reason] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + patch: Jsonpatch.t(), + patch_index: non_neg_integer(), + reason: Jsonpatch.Types.error_reason() + } +end diff --git a/lib/jsonpatch/mapper.ex b/lib/jsonpatch/mapper.ex deleted file mode 100644 index f9ac918..0000000 --- a/lib/jsonpatch/mapper.ex +++ /dev/null @@ -1,124 +0,0 @@ -defmodule Jsonpatch.Mapper do - @moduledoc """ - Maps JSON patches between regular Maps and list or a single Jsonpatch.Operation. - """ - - @doc """ - Turns JSON patches into regular map/s. - - ## Examples - - iex> add_patch_map = %Jsonpatch.Operation.Add{path: "/name", value: "Alice"} - iex> Jsonpatch.Mapper.to_map(add_patch_map) - %{op: "add", path: "/name", value: "Alice"} - - iex> add_patch_map = %Jsonpatch.Operation.Add{path: "/name", value: "Alice"} - iex> remove_patch_map = %Jsonpatch.Operation.Remove{path: "/location"} - iex> Jsonpatch.Mapper.to_map([add_patch_map, remove_patch_map]) - [%{op: "add", path: "/name", value: "Alice"}, - %{op: "remove", path: "/location"}] - - """ - @spec to_map(Jsonpatch.t() | list(Jsonpatch.t())) :: - map() | list() | {:error, :invalid} - def to_map(patch) - - def to_map(patch_operations) when is_list(patch_operations) do - Enum.map(patch_operations, &prepare/1) - |> Enum.filter(&is_valid/1) - |> Enum.map(&Map.from_struct/1) - end - - def to_map(%{} = patch_operation) do - case prepare(patch_operation) do - {:error, _} = error -> error - patch -> patch |> Map.from_struct() - end - end - - @doc """ - Creates JSON patch struct/s from a single or list maps which represents JSON patches. - - ## Examples - - iex> add_patch_map = %{"op" => "add", "path" => "/name", "value" => "Alice"} - iex> Jsonpatch.Mapper.from_map(add_patch_map) - %Jsonpatch.Operation.Add{path: "/name", value: "Alice"} - - iex> unkown_patch_map = %{"op" => "foo", "path" => "/name", "value" => "Alice"} - iex> Jsonpatch.Mapper.from_map(unkown_patch_map) - {:error, :invalid} - """ - @spec from_map(map() | list(map())) :: - list(Jsonpatch.t()) | Jsonpatch.t() | {:error, :invalid} - def from_map(patch) - - def from_map(%{} = patch) do - convert_to(patch) - end - - def from_map(patch) when is_list(patch) do - Enum.map(patch, &from_map/1) - end - - # ===== ===== PRIVATE ===== ===== - - defp prepare(%Jsonpatch.Operation.Add{} = operation) do - Map.put(operation, :op, "add") - end - - defp prepare(%Jsonpatch.Operation.Remove{} = operation) do - Map.put(operation, :op, "remove") - end - - defp prepare(%Jsonpatch.Operation.Replace{} = operation) do - Map.put(operation, :op, "replace") - end - - defp prepare(%Jsonpatch.Operation.Copy{} = operation) do - Map.put(operation, :op, "copy") - end - - defp prepare(%Jsonpatch.Operation.Move{} = operation) do - Map.put(operation, :op, "move") - end - - defp prepare(%Jsonpatch.Operation.Test{} = operation) do - Map.put(operation, :op, "test") - end - - defp prepare(_) do - {:error, :invalid} - end - - defp convert_to(%{"op" => "add", "path" => path, "value" => value}) do - %Jsonpatch.Operation.Add{path: path, value: value} - end - - defp convert_to(%{"op" => "remove", "path" => path}) do - %Jsonpatch.Operation.Remove{path: path} - end - - defp convert_to(%{"op" => "replace", "path" => path, "value" => value}) do - %Jsonpatch.Operation.Replace{path: path, value: value} - end - - defp convert_to(%{"op" => "copy", "from" => from, "path" => path}) do - %Jsonpatch.Operation.Copy{from: from, path: path} - end - - defp convert_to(%{"op" => "move", "from" => from, "path" => path}) do - %Jsonpatch.Operation.Move{from: from, path: path} - end - - defp convert_to(%{"op" => "test", "path" => path, "value" => value}) do - %Jsonpatch.Operation.Test{path: path, value: value} - end - - defp convert_to(_) do - {:error, :invalid} - end - - defp is_valid({:error, _}), do: false - defp is_valid(_), do: true -end diff --git a/lib/jsonpatch/operation.ex b/lib/jsonpatch/operation.ex deleted file mode 100644 index 91b5496..0000000 --- a/lib/jsonpatch/operation.ex +++ /dev/null @@ -1,21 +0,0 @@ -defprotocol Jsonpatch.Operation do - @moduledoc """ - The Operation module is responsible for applying patches. For examples see in the - available implementation from this library for this protocol: - - - Jsonpatch.Operation.Add - - Jsonpatch.Operation.Copy - - Jsonpatch.Operation.Move - - Jsonpatch.Operation.Remove - - Jsonpatch.Operation.Replace - - Jsonpatch.Operation.Test - - """ - - @doc """ - Executes the given patch to map/struct. Possible options are defined in `Jsonpatch`. - """ - @spec apply_op(Jsonpatch.t(), list() | map() | Jsonpatch.error(), keyword()) :: - map() | Jsonpatch.error() - def apply_op(patch, target, opts \\ []) -end diff --git a/lib/jsonpatch/operation/add.ex b/lib/jsonpatch/operation/add.ex index 4f50d42..49cc6af 100644 --- a/lib/jsonpatch/operation/add.ex +++ b/lib/jsonpatch/operation/add.ex @@ -1,82 +1,52 @@ defmodule Jsonpatch.Operation.Add do @moduledoc """ - The add operation is the operation for adding values to a map or struct. - Values can be appended to lists by using `-` instead of an index. + The add operation is the operation for creating/updating values. + Values can be inserted in a list using an index or appended using a `-`. ## Examples iex> add = %Add{path: "/a/b", value: 1} iex> target = %{"a" => %{"c" => false}} - iex> Operation.apply_op(add, target) - %{"a" => %{"b" => 1, "c" => false}} + iex> Jsonpatch.Operation.Add.apply(add, target, []) + {:ok, %{"a" => %{"b" => 1, "c" => false}}} + + iex> add = %Add{path: "/a/1", value: "b"} + iex> target = %{"a" => ["a", "c"]} + iex> Jsonpatch.Operation.Add.apply(add, target, []) + {:ok, %{"a" => ["a", "b", "c"]}} iex> add = %Add{path: "/a/-", value: "z"} iex> target = %{"a" => ["x", "y"]} - iex> Operation.apply_op(add, target) - %{"a" => ["x", "y", "z"]} + iex> Jsonpatch.Operation.Add.apply(add, target, []) + {:ok, %{"a" => ["x", "y", "z"]}} """ - alias Jsonpatch.Operation alias Jsonpatch.Operation.Add - alias Jsonpatch.PathUtil + alias Jsonpatch.Types + alias Jsonpatch.Utils @enforce_keys [:path, :value] defstruct [:path, :value] @type t :: %__MODULE__{path: String.t(), value: any} - defimpl Operation do - @spec apply_op(Add.t(), list() | map() | Jsonpatch.error(), keyword()) :: map - def apply_op(_, {:error, _, _} = error, _opt), do: error - - def apply_op(%Add{path: path, value: value}, target, opts) do - PathUtil.get_final_destination(target, path, opts) - |> do_add(target, path, value, opts) + @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: + {:ok, Types.json_container()} | Types.error() + def apply(%Add{path: path, value: value}, target, opts) do + with {:ok, destination} <- Utils.get_destination(target, path, opts), + {:ok, updated_destination} <- do_add(destination, value, opts) do + Utils.update_destination(target, updated_destination, path, opts) end + end - # ===== ===== PRIVATE ===== ===== - - # Error - defp do_add({:error, _, _} = error, _target, _path, _value, _opts), do: error - - # Map - defp do_add({%{} = final_destination, last_fragment}, target, path, value, opts) do - updated_final_destination = Map.put_new(final_destination, last_fragment, value) - PathUtil.update_final_destination(target, updated_final_destination, path, opts) - end - - # List - defp do_add({final_destination, last_fragment}, target, path, value, opts) - when is_list(final_destination) do - case parse_index(final_destination, last_fragment) do - {:error, _, _} = error -> - error - - index -> - updated_final_destination = - if last_fragment == "-" or length(final_destination) == index do - Enum.concat(final_destination, [value]) - else - List.replace_at(final_destination, index, value) - end - - PathUtil.update_final_destination( - target, - updated_final_destination, - path, - opts - ) - end - end + defp do_add({%{} = destination, last_fragment}, value, _opts) do + {:ok, Map.put(destination, last_fragment, value)} + end - defp parse_index(list, unparsed) do - if unparsed == "-" do - length(list) - else - case Integer.parse(unparsed) do - :error -> {:error, :invalid_index, unparsed} - {index, _} -> index - end - end - end + defp do_add({destination, last_fragment}, value, _opts) when is_list(destination) do + index = to_index(last_fragment) + {:ok, List.insert_at(destination, index, value)} end + + defp to_index(:-), do: -1 + defp to_index(index), do: index end diff --git a/lib/jsonpatch/operation/copy.ex b/lib/jsonpatch/operation/copy.ex index a76eeb4..0b4c858 100644 --- a/lib/jsonpatch/operation/copy.ex +++ b/lib/jsonpatch/operation/copy.ex @@ -4,110 +4,41 @@ defmodule Jsonpatch.Operation.Copy do ## Examples - iex> copy = %Jsonpatch.Operation.Copy{from: "/a/b", path: "/a/e"} - iex> target = %{"a" => %{"b" => %{"c" => "Bob"}}, "d" => false} - iex> Jsonpatch.Operation.apply_op(copy, target) - %{"a" => %{"b" => %{"c" => "Bob"}, "e" => %{"c" => "Bob"}}, "d" => false} + iex> copy = %Jsonpatch.Operation.Copy{from: "/a/b", path: "/a/e"} + iex> target = %{"a" => %{"b" => %{"c" => "Bob"}}, "d" => false} + iex> Jsonpatch.Operation.Copy.apply(copy, target, []) + {:ok, %{"a" => %{"b" => %{"c" => "Bob"}, "e" => %{"c" => "Bob"}}, "d" => false}} """ - alias Jsonpatch.Operation - alias Jsonpatch.Operation.Copy - alias Jsonpatch.PathUtil + alias Jsonpatch.Types + alias Jsonpatch.Operation.{Add, Copy} + alias Jsonpatch.Utils @enforce_keys [:from, :path] defstruct [:from, :path] @type t :: %__MODULE__{from: String.t(), path: String.t()} - defimpl Operation do - @spec apply_op(Copy.t(), list() | map() | Jsonpatch.error(), keyword()) :: map() - def apply_op(_, {:error, _, _} = error, _opts), do: error - - def apply_op(%Copy{from: from, path: path}, target, opts) do - # %{"c" => "Bob"} - - updated_val = - target - |> PathUtil.get_final_destination(from, opts) - |> extract_copy_value() - |> do_copy(target, path, opts) - - case updated_val do - {:error, _, _} = error -> error - updated_val -> updated_val - end - end - - # ===== ===== PRIVATE ===== ===== - - defp do_copy({:error, _, _} = error, _target, _path, _opts) do - error - end - - defp do_copy(copied_value, target, path, opts) do - # copied_value = %{"c" => "Bob"} - - # "e" - copy_path_end = String.split(path, "/") |> List.last() - - # %{"b" => %{"c" => "Bob"}, "e" => %{"c" => "Bob"}} - updated_value = - target - # %{"b" => %{"c" => "Bob"}} is the "copy target" - |> PathUtil.get_final_destination(path, opts) - # Add copied_value to "copy target" - |> do_add(copied_value, copy_path_end) - - case updated_value do - {:error, _, _} = error -> - error - - updated_value -> - PathUtil.update_final_destination(target, updated_value, path, opts) - end - end - - defp extract_copy_value({%{} = final_destination, fragment}) do - Map.get(final_destination, fragment, {:error, :invalid_path, fragment}) - end - - defp extract_copy_value({final_destination, fragment}) when is_list(final_destination) do - case Integer.parse(fragment) do - :error -> - {:error, :invalid_index, fragment} - - {index, _} -> - case Enum.fetch(final_destination, index) do - :error -> {:error, :invalid_index, fragment} - {:ok, val} -> val - end - end + @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: + {:ok, Types.json_container()} | Types.error() + def apply(%Copy{from: from, path: path}, target, opts) do + with {:ok, destination} <- Utils.get_destination(target, from), + {:ok, from_fragments} = Utils.split_path(from), + {:ok, copy_value} <- extract_copy_value(destination, from_fragments) do + Add.apply(%Add{value: copy_value, path: path}, target, opts) end + end - defp do_add({%{} = copy_target, _last_fragment}, copied_value, copy_path_end) do - Map.put(copy_target, copy_path_end, copied_value) - end - - defp do_add({copy_target, _last_fragment}, copied_value, copy_path_end) - when is_list(copy_target) do - if copy_path_end == "-" do - List.insert_at(copy_target, length(copy_target), copied_value) - else - case Integer.parse(copy_path_end) do - :error -> - {:error, :invalid_index, copy_path_end} - - {index, _} -> - if index < length(copy_target) do - List.replace_at(copy_target, index, copied_value) - else - {:error, :invalid_index, copy_path_end} - end - end - end + defp extract_copy_value({%{} = destination, fragment}, from_path) do + case destination do + %{^fragment => val} -> {:ok, val} + _ -> {:error, {:invalid_path, from_path}} end + end - defp do_add({:error, _, _} = error, _, _) do - error + defp extract_copy_value({destination, index}, from_path) when is_list(destination) do + case Utils.fetch(destination, index) do + {:ok, _} = ok -> ok + {:error, :invalid_path} -> {:error, {:invalid_path, from_path}} end end end diff --git a/lib/jsonpatch/operation/move.ex b/lib/jsonpatch/operation/move.ex index 889ca93..9ba9552 100644 --- a/lib/jsonpatch/operation/move.ex +++ b/lib/jsonpatch/operation/move.ex @@ -6,31 +6,26 @@ defmodule Jsonpatch.Operation.Move do iex> move = %Jsonpatch.Operation.Move{from: "/a/b", path: "/a/e"} iex> target = %{"a" => %{"b" => %{"c" => "Bob"}}, "d" => false} - iex> Jsonpatch.Operation.apply_op(move, target) - %{"a" => %{"e" => %{"c" => "Bob"}}, "d" => false} + iex> Jsonpatch.Operation.Move.apply(move, target, []) + {:ok, %{"a" => %{"e" => %{"c" => "Bob"}}, "d" => false}} """ - alias Jsonpatch.Operation - alias Jsonpatch.Operation.Copy - alias Jsonpatch.Operation.Move - alias Jsonpatch.Operation.Remove + alias Jsonpatch.Operation.{Copy, Move, Remove} + alias Jsonpatch.Types @enforce_keys [:from, :path] defstruct [:from, :path] @type t :: %__MODULE__{from: String.t(), path: String.t()} - defimpl Operation do - @spec apply_op(Move.t(), list() | map() | Jsonpatch.error(), keyword()) :: - map() - def apply_op(_, {:error, _, _} = error, _opts), do: error + @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: + {:ok, Types.json_container()} | Types.error() + def apply(%Move{from: from, path: path}, target, opts) do + copy_patch = %Copy{from: from, path: path} + remove_patch = %Remove{path: from} - def apply_op(%Move{from: from, path: path}, target, opts) do - copy_patch = %Copy{from: from, path: path} - - case Operation.apply_op(copy_patch, target, opts) do - {:error, _, _} = error -> error - updated_target -> Operation.apply_op(%Remove{path: from}, updated_target, opts) - end + with {:ok, res} <- Copy.apply(copy_patch, target, opts), + {:ok, res} <- Remove.apply(remove_patch, res, opts) do + {:ok, res} end end end diff --git a/lib/jsonpatch/operation/remove.ex b/lib/jsonpatch/operation/remove.ex index 16bdd93..a6a85fa 100644 --- a/lib/jsonpatch/operation/remove.ex +++ b/lib/jsonpatch/operation/remove.ex @@ -6,79 +6,75 @@ defmodule Jsonpatch.Operation.Remove do iex> remove = %Jsonpatch.Operation.Remove{path: "/a/b"} iex> target = %{"a" => %{"b" => %{"c" => "Bob"}}, "d" => false} - iex> Jsonpatch.Operation.apply_op(remove, target) - %{"a" => %{}, "d" => false} + iex> Jsonpatch.Operation.Remove.apply(remove, target, []) + {:ok, %{"a" => %{}, "d" => false}} + + iex> remove = %Jsonpatch.Operation.Remove{path: "/a/b"} + iex> target = %{"a" => %{"b" => nil}, "d" => false} + iex> Jsonpatch.Operation.Remove.apply(remove, target, []) + {:ok, %{"a" => %{}, "d" => false}} """ - alias Jsonpatch.Operation alias Jsonpatch.Operation.Remove - alias Jsonpatch.PathUtil + alias Jsonpatch.Types + alias Jsonpatch.Utils @enforce_keys [:path] defstruct [:path] @type t :: %__MODULE__{path: String.t()} - defimpl Operation do - @spec apply_op(Remove.t(), list() | map() | Jsonpatch.error(), keyword()) :: - map() - def apply_op(_, {:error, _, _} = error, _opts), do: error - - def apply_op(%Remove{path: path}, target, opts) do - key_type = PathUtil.wanted_key_type(opts) - - # The first element is always "" which is useless. - [_ | fragments] = - path - |> String.split("/") - |> Enum.map(&PathUtil.unescape/1) - |> PathUtil.into_key_type(key_type) - - do_remove(target, fragments) + @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: + {:ok, Types.json_container()} | Types.error() + def apply(%Remove{path: path}, target, opts) do + with {:ok, fragments} <- Utils.split_path(path) do + do_remove(target, [], fragments, opts) end + end - # ===== ===== PRIVATE ===== ===== - - defp do_remove(%{} = target, [fragment | []]) do - case Map.pop(target, fragment) do - {nil, _} -> {:error, :invalid_path, fragment} - {_, purged_map} -> purged_map - end + defp do_remove(%{} = target, path, [fragment], opts) do + with {:ok, fragment} <- Utils.cast_fragment(fragment, path, target, opts), + %{^fragment => _} <- target do + {:ok, Map.delete(target, fragment)} + else + %{} -> + {:error, {:invalid_path, path ++ [fragment]}} + + # coveralls-ignore-start + {:error, _} = error -> + error + # coveralls-ignore-stop end + end - defp do_remove(target, [fragment | []]) when is_list(target) do - case Integer.parse(fragment) do - :error -> - {:error, :invalid_index, fragment} - - {index, _} -> - case List.pop_at(target, index) do - {nil, _} -> {:error, :invalid_index, fragment} - {_, purged_list} -> purged_list - end - end + defp do_remove(%{} = target, path, [fragment | tail], opts) do + with {:ok, fragment} <- Utils.cast_fragment(fragment, path, target, opts), + %{^fragment => val} <- target, + {:ok, new_val} <- do_remove(val, path ++ [fragment], tail, opts) do + {:ok, %{target | fragment => new_val}} + else + %{} -> {:error, {:invalid_path, path ++ [fragment]}} + {:error, _} = error -> error end + end - defp do_remove(%{} = target, [fragment | tail]) do - case Map.get(target, fragment) do - nil -> - {:error, :invalid_path, fragment} + defp do_remove(target, path, [fragment | tail], opts) when is_list(target) do + case Utils.cast_fragment(fragment, path, target, opts) do + {:ok, :-} -> + {:error, {:invalid_path, path ++ [fragment]}} - val -> - case do_remove(val, tail) do - {:error, _, _} = error -> error - new_val -> %{target | fragment => new_val} - end - end - end + {:ok, index} -> + if tail == [] do + {:ok, List.delete_at(target, index)} + else + Utils.update_at(target, index, path, &do_remove(&1, path ++ [fragment], tail, opts)) + end - defp do_remove(target, [fragment | tail]) when is_list(target) do - case Integer.parse(fragment) do - :error -> - {:error, :invalid_index, fragment} - - {index, _} -> - PathUtil.update_at(target, index, tail, &do_remove/2) - end + {:error, _} = error -> + error end end + + defp do_remove(_target, path, [fragment | _], _opts) do + {:error, {:invalid_path, path ++ [fragment]}} + end end diff --git a/lib/jsonpatch/operation/replace.ex b/lib/jsonpatch/operation/replace.ex index 7ace6c8..b149e75 100644 --- a/lib/jsonpatch/operation/replace.ex +++ b/lib/jsonpatch/operation/replace.ex @@ -6,59 +6,26 @@ defmodule Jsonpatch.Operation.Replace do iex> add = %Jsonpatch.Operation.Replace{path: "/a/b", value: 1} iex> target = %{"a" => %{"b" => 2}} - iex> Jsonpatch.Operation.apply_op(add, target) - %{"a" => %{"b" => 1}} + iex> Jsonpatch.Operation.Replace.apply(add, target, []) + {:ok, %{"a" => %{"b" => 1}}} """ - alias Jsonpatch.Operation - alias Jsonpatch.Operation.Replace - alias Jsonpatch.PathUtil + alias Jsonpatch.Types + alias Jsonpatch.Operation.{Add, Remove, Replace} @enforce_keys [:path, :value] defstruct [:path, :value] @type t :: %__MODULE__{path: String.t(), value: any} - defimpl Operation do - @spec apply_op(Replace.t(), list() | map() | Jsonpatch.error(), keyword()) :: map - def apply_op(_, {:error, _, _} = error, _opts), do: error + @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: + {:ok, Types.json_container()} | Types.error() + def apply(%Replace{path: path, value: value}, target, opts) do + remove_patch = %Remove{path: path} + add_patch = %Add{value: value, path: path} - def apply_op(%Replace{path: path, value: value}, target, opts) do - {final_destination, last_fragment} = PathUtil.get_final_destination(target, path, opts) - - case do_update(final_destination, last_fragment, value) do - {:error, _, _} = error -> - error - - updated_final_destination -> - PathUtil.update_final_destination( - target, - updated_final_destination, - path, - opts - ) - end - end - - # ===== ===== PRIVATE ===== ===== - - defp do_update(%{} = final_destination, last_fragment, value) do - case final_destination do - %{^last_fragment => _} -> %{final_destination | last_fragment => value} - _ -> {:error, :invalid_path, last_fragment} - end - end - - defp do_update(final_destination, last_fragment, value) when is_list(final_destination) do - case Integer.parse(last_fragment) do - :error -> - {:error, :invalid_index, last_fragment} - - {index, _} -> - case List.pop_at(final_destination, index) do - {nil, _} -> {:error, :invalid_index, last_fragment} - _ -> List.replace_at(final_destination, index, value) - end - end + with {:ok, res} <- Remove.apply(remove_patch, target, opts), + {:ok, res} <- Add.apply(add_patch, res, opts) do + {:ok, res} end end end diff --git a/lib/jsonpatch/operation/test.ex b/lib/jsonpatch/operation/test.ex index fa450b2..fc48e88 100644 --- a/lib/jsonpatch/operation/test.ex +++ b/lib/jsonpatch/operation/test.ex @@ -4,49 +4,50 @@ defmodule Jsonpatch.Operation.Test do ## Examples - iex> test = %Jsonpatch.Operation.Test{path: "/x/y", value: "Bob"} - iex> target = %{"x" => %{"y" => "Bob"}} - iex> Jsonpatch.Operation.apply_op(test, target) - %{"x" => %{"y" => "Bob"}} + iex> test = %Jsonpatch.Operation.Test{path: "/x/y", value: "Bob"} + iex> target = %{"x" => %{"y" => "Bob"}} + iex> Jsonpatch.Operation.Test.apply(test, target, []) + {:ok, %{"x" => %{"y" => "Bob"}}} """ - alias Jsonpatch.Operation alias Jsonpatch.Operation.Test - alias Jsonpatch.PathUtil + alias Jsonpatch.Types + alias Jsonpatch.Utils @enforce_keys [:path, :value] defstruct [:path, :value] @type t :: %__MODULE__{path: String.t(), value: any} - defimpl Operation do - @spec apply_op(Test.t(), list() | map() | Jsonpatch.error(), keyword()) :: map() - def apply_op(_, {:error, _, _} = error, _opts), do: error - - def apply_op(%Test{path: path, value: value}, target, opts) do - case PathUtil.get_final_destination(target, path, opts) |> do_test(value) do - true -> target - false -> {:error, :test_failed, "Expected value '#{value}' at '#{path}'"} - {:error, _, _} = error -> error - end + @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: + {:ok, Types.json_container()} | Types.error() + def apply(%Test{path: path, value: value}, target, opts) do + with {:ok, destination} <- Utils.get_destination(target, path, opts), + {:ok, test_path} = Utils.split_path(path), + {:ok, true} <- do_test(destination, value, test_path) do + {:ok, target} + else + {:ok, false} -> + {:error, {:test_failed, "Expected value '#{inspect(value)}' at '#{path}'"}} + + {:error, _} = error -> + error end + end - # ===== ===== PRIVATE ===== ===== - - defp do_test({%{} = target, last_fragment}, value) do - Map.get(target, last_fragment) == value + defp do_test({%{} = target, last_fragment}, value, _path) do + case target do + %{^last_fragment => ^value} -> {:ok, true} + %{} -> {:ok, false} end + end + + defp do_test({target, index}, value, path) when is_list(target) do + case Utils.fetch(target, index) do + {:ok, fetched_value} -> + {:ok, fetched_value == value} - defp do_test({target, last_fragment}, value) when is_list(target) do - case Integer.parse(last_fragment) do - {index, _} -> - case Enum.fetch(target, index) do - {:ok, target_val} -> target_val == value - :error -> {:error, :invalid_index, last_fragment} - end - - :error -> - {:error, :invalid_index, last_fragment} - end + {:error, :invalid_path} -> + {:error, {:invalid_path, path}} end end end diff --git a/lib/jsonpatch/path_util.ex b/lib/jsonpatch/path_util.ex deleted file mode 100644 index c4a1fec..0000000 --- a/lib/jsonpatch/path_util.ex +++ /dev/null @@ -1,232 +0,0 @@ -defmodule Jsonpatch.PathUtil do - @moduledoc false - - # ===== Internal documentation ===== - # Helper module for handling JSON paths. - - @doc """ - Uses a JSON patch path to get the last map that this path references. - - ## Examples - - iex> path = "/a/b/c/d" - iex> target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} - iex> Jsonpatch.PathUtil.get_final_destination(target, path) - {%{"d" => 1}, "d"} - - iex> # Invalid path - iex> path = "/a/e/c/d" - iex> target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} - iex> Jsonpatch.PathUtil.get_final_destination(target, path) - {:error, :invalid_path, "e"} - - iex> path = "/a/b/1/d" - iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} - iex> Jsonpatch.PathUtil.get_final_destination(target, path) - {%{"d" => 1}, "d"} - - iex> # Invalid path - iex> path = "/a/b/42/d" - iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} - iex> Jsonpatch.PathUtil.get_final_destination(target, path) - {:error, :invalid_index, "42"} - """ - @spec get_final_destination(map, binary, keyword) :: - {map, binary} | {list, binary} | Jsonpatch.error() - def get_final_destination(target, path, opts \\ []) when is_bitstring(path) do - key_type = wanted_key_type(opts) - - # The first element is always "" which is useless. - [_ | fragments] = - path - |> String.split("/") - |> Enum.map(&unescape/1) - |> into_key_type(key_type) - - find_final_destination(target, fragments) - end - - @doc """ - Updatest a map reference by a given JSON patch path with the new final destination. - - ## Examples - - iex> path = "/a/b/c/d" - iex> target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} - iex> Jsonpatch.PathUtil.update_final_destination(target, %{"e" => 1}, path) - %{"a" => %{"b" => %{"c" => %{"e" => 1}}}} - """ - @spec update_final_destination(map, map | list, binary, keyword) :: map | Jsonpatch.error() - def update_final_destination(target, new_destination, path, opts \\ []) do - key_type = wanted_key_type(opts) - - # The first element is always "" which is useless. - [_ | fragments] = - path - |> String.split("/") - |> Enum.map(&unescape/1) - |> into_key_type(key_type) - - do_update_final_destination(target, new_destination, fragments) - end - - @doc """ - Unescape `~1` to `/` and `~0` to `~`. - """ - def unescape(fragment) when is_bitstring(fragment) do - fragment - |> do_unescape("~1", "/") - |> do_unescape("~0", "~") - end - - def unescape(fragment) do - fragment - end - - @spec wanted_key_type(keyword) :: atom() - def wanted_key_type(opts) do - Keyword.get(opts, :keys, :strings) - end - - @doc """ - Converts the given path fragements into the wanted key types. - """ - @spec into_key_type(list(binary()), :atoms | :atoms! | :strings) :: list - def into_key_type(fragements, key_type) do - converter = - case key_type do - :strings -> - fn fragement -> fragement end - - :atoms -> - &to_atom/1 - - :atoms! -> - &to_existing_atom/1 - - unknown -> - raise JsonpatchException, {:error, :bad_api_usage, "Unknown key type '#{unknown}'"} - end - - Enum.map(fragements, converter) - end - - @doc """ - Updates a list with the given update_fn while respecting Jsonpatch errors. - In case uodate_fn returns an error then update_at will also return this error. - When the update_fn succeeds it will return the list. - """ - @spec update_at(list(), integer(), list(binary()), (any, list(binary()) -> any)) :: - list | {:error, any, any} - def update_at(target, index, subpath, update_fn) do - case get_at(target, index) do - {:ok, old_val} -> - do_update_at(target, index, old_val, subpath, update_fn) - - {:error, _, _} = error -> - error - end - end - - # ===== ===== PRIVATE ===== ===== - - defp find_final_destination(%{} = target, [fragment | []]) do - {target, fragment} - end - - defp find_final_destination(target, [fragment | []]) when is_list(target) do - {target, fragment} - end - - defp find_final_destination(%{} = target, [fragment | tail]) do - case Map.get(target, fragment) do - nil -> {:error, :invalid_path, fragment} - val -> find_final_destination(val, tail) - end - end - - defp find_final_destination(target, [fragment | tail]) when is_list(target) do - {index, _} = Integer.parse(fragment) - - case Enum.fetch(target, index) do - :error -> {:error, :invalid_index, fragment} - {:ok, val} -> find_final_destination(val, tail) - end - end - - # " [final_dest | [_last_ele |[]]] " means: We want to stop, when there are only two elements left. - defp do_update_final_destination(%{} = target, new_final_dest, [final_dest | [_last_ele | []]]) do - Map.replace!(target, final_dest, new_final_dest) - end - - defp do_update_final_destination(target, new_final_dest, [final_dest | [_last_ele | []]]) - when is_list(target) do - {index, _} = Integer.parse(final_dest) - - List.replace_at(target, index, new_final_dest) - end - - defp do_update_final_destination(_target, new_final_dest, [_fragment | []]) do - new_final_dest - end - - defp do_update_final_destination(%{} = target, new_final_dest, [fragment | tail]) do - case Map.get(target, fragment) do - nil -> - {:error, :invalid_path, fragment} - - val -> - case do_update_final_destination(val, new_final_dest, tail) do - {:error, _, _} = error -> error - updated_val -> %{target | fragment => updated_val} - end - end - end - - defp do_update_final_destination(target, new_final_dest, [fragment | tail]) - when is_list(target) do - {index, _} = Integer.parse(fragment) - - update_fn = &do_update_final_destination(&1, new_final_dest, &2) - update_at(target, index, tail, update_fn) - end - - defp to_atom(fragment) do - case is_number?(fragment) do - true -> fragment - false -> String.to_atom(fragment) - end - end - - defp to_existing_atom(fragment) do - case is_number?(fragment) do - true -> fragment - false -> String.to_existing_atom(fragment) - end - end - - defp is_number?(term) do - is_binary(term) and Regex.match?(~r/^\d+$/, term) - end - - defp do_unescape(fragment, pattern, replacement) when is_binary(fragment) do - case String.contains?(fragment, pattern) do - true -> String.replace(fragment, pattern, replacement) - false -> fragment - end - end - - def get_at(list, index) do - case Enum.at(list, index) do - nil -> {:error, :invalid_index, index} - ele -> {:ok, ele} - end - end - - def do_update_at(list, index, target, subpath, update_fn) do - case update_fn.(target, subpath) do - {:error, _, _} = error -> error - new_val -> List.replace_at(list, index, new_val) - end - end -end diff --git a/lib/jsonpatch/types.ex b/lib/jsonpatch/types.ex new file mode 100644 index 0000000..b17d6d3 --- /dev/null +++ b/lib/jsonpatch/types.ex @@ -0,0 +1,38 @@ +defmodule Jsonpatch.Types do + @moduledoc """ + Types + """ + + @type error :: {:error, error_reason()} + @type error_reason :: + {:invalid_spec, String.t()} + | {:invalid_path, [casted_fragment()]} + | {:test_failed, String.t()} + + @type json_container :: map() | list() + + @type convert_fn :: + (fragment :: term(), target_path :: [term()], target :: json_container(), opts() -> + {:ok, converted_fragment :: term()} | :error) + + @typedoc """ + Keys options: + + - `:strings` (default) - decodes path fragments as binary strings + - `:atoms` - path fragments are converted to atoms + - `:atoms!` - path fragments are converted to existing atoms + - `{:custom, convert_fn}` - path fragments are converted with `convert_fn` + """ + @type opt_keys :: :strings | :atoms | {:custom, convert_fn()} + + @typedoc """ + Types options: + + - `:keys` - controls how path fragments are decoded. + """ + @type opts :: [{:keys, opt_keys()}] + + @type casted_array_index :: :- | non_neg_integer() + @type casted_object_key :: atom() | String.t() + @type casted_fragment :: casted_array_index() | casted_object_key() +end diff --git a/lib/jsonpatch/utils.ex b/lib/jsonpatch/utils.ex new file mode 100644 index 0000000..9f23db8 --- /dev/null +++ b/lib/jsonpatch/utils.ex @@ -0,0 +1,349 @@ +defmodule Jsonpatch.Utils do + @moduledoc false + + alias Jsonpatch.Types + + @default_opts_keys :strings + + @doc """ + Split a path into its fragments + + ## Examples + + iex> path = "/a/b/c" + iex> Jsonpatch.Utils.split_path(path) + {:ok, ["a", "b", "c"]} + """ + @spec split_path(String.t()) :: {:ok, [String.t(), ...]} | Types.error() + def split_path("/" <> path) do + fragments = + path + |> String.split("/") + |> Enum.map(&unescape/1) + + {:ok, fragments} + end + + def split_path(path), do: {:error, {:invalid_path, path}} + + @doc """ + Join path fragments + + ## Examples + + iex> fragments = ["a", "b", "c"] + iex> Jsonpatch.Utils.join_path(fragments) + "/a/b/c" + """ + @spec join_path(fragments :: [Types.casted_fragment(), ...]) :: String.t() + def join_path([_ | _] = fragments) do + fragments = + fragments + |> Enum.map(&to_string/1) + |> Enum.map(&escape/1) + + "/" <> Enum.join(fragments, "/") + end + + @doc """ + Cast a path fragment according to the target type. + + ## Examples + + iex> Jsonpatch.Utils.cast_fragment("0", ["path"], ["x", "y"], []) + {:ok, 0} + + iex> Jsonpatch.Utils.cast_fragment("-", ["path"], ["x", "y"], []) + {:ok, :-} + + iex> Jsonpatch.Utils.cast_fragment("0", ["path"], %{"0" => "zero"}, []) + {:ok, "0"} + """ + @spec cast_fragment( + fragment :: String.t(), + path :: [Types.casted_fragment()], + target :: Types.json_container(), + Types.opts() + ) :: {:ok, Types.casted_fragment()} | Types.error() + def cast_fragment(fragment, path, target, opts) when is_list(target) do + keys = Keyword.get(opts, :keys, @default_opts_keys) + + case keys do + {:custom, custom_fn} -> + case custom_fn.(fragment, path, target, opts) do + {:ok, _} = ok -> ok + :error -> {:error, {:invalid_path, path ++ [fragment]}} + end + + _ -> + cast_index(fragment, path, target) + end + end + + def cast_fragment(fragment, path, target, opts) when is_map(target) do + keys = Keyword.get(opts, :keys, @default_opts_keys) + + case keys do + :strings -> + {:ok, fragment} + + :atoms -> + {:ok, String.to_atom(fragment)} + + :atoms! -> + case string_to_existing_atom(fragment) do + {:ok, _} = ok -> ok + :error -> {:error, {:invalid_path, path ++ [fragment]}} + end + + {:custom, custom_fn} -> + case custom_fn.(fragment, path, target, opts) do + {:ok, _} = ok -> ok + :error -> {:error, {:invalid_path, path ++ [fragment]}} + end + end + end + + @doc """ + Uses a JSON patch path to get the last map/list that this path references. + + ## Examples + + iex> path = "/a/b/c/d" + iex> target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} + iex> Jsonpatch.Utils.get_destination(target, path) + {:ok, {%{"d" => 1}, "d"}} + + iex> # Invalid path + iex> path = "/a/e/c/d" + iex> target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} + iex> Jsonpatch.Utils.get_destination(target, path) + {:error, {:invalid_path, ["a", "e"]}} + + iex> path = "/a/b/1/d" + iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} + iex> Jsonpatch.Utils.get_destination(target, path) + {:ok, {%{"d" => 1}, "d"}} + + iex> path = "/a/b/1" + iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} + iex> Jsonpatch.Utils.get_destination(target, path) + {:ok, {[true, %{"d" => 1}], 1}} + + iex> # Invalid path + iex> path = "/a/b/42/d" + iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} + iex> Jsonpatch.Utils.get_destination(target, path) + {:error, {:invalid_path, ["a", "b", "42"]}} + """ + + @spec get_destination( + target :: Types.json_container(), + path :: String.t(), + Types.opts() + ) :: + {:ok, {Types.json_container(), last_fragment :: Types.casted_fragment()}} + | Types.error() + def get_destination(target, path, opts \\ []) do + with {:ok, fragments} <- split_path(path) do + find_destination(target, [], fragments, opts) + end + end + + @doc """ + Updatest a map/list reference by a given JSON patch path with the new destination. + + ## Examples + + iex> path = "/a/b/c/d" + iex> target = %{"a" => %{"b" => %{"c" => %{"xx" => 0, "d" => 1}}}} + iex> Jsonpatch.Utils.update_destination(target, %{"e" => 1}, path) + {:ok, %{"a" => %{"b" => %{"c" => %{"e" => 1}}}}} + + iex> path = "/a/b/1" + iex> target = %{"a" => %{"b" => [0, 1, 2]}} + iex> Jsonpatch.Utils.update_destination(target, 9999, path) + {:ok, %{"a" => %{"b" => 9999}}} + """ + @spec update_destination( + target :: Types.json_container(), + value :: term(), + String.t(), + Types.opts() + ) :: + {:ok, Types.json_container()} | Types.error() + def update_destination(target, value, path, opts \\ []) do + with {:ok, fragments} <- split_path(path) do + do_update_destination(target, value, [], fragments, opts) + end + end + + @doc """ + Unescape `~1` to `/` and `~0` to `~`. + """ + @spec unescape(fragment :: String.t() | integer()) :: String.t() + def unescape(fragment) when is_binary(fragment) do + fragment + |> String.replace("~0", "~") + |> String.replace("~1", "/") + end + + @doc """ + Escape `/` to `~1 and `~` to `~0`. + """ + @spec escape(fragment :: String.t() | integer()) :: String.t() + def escape(fragment) when is_binary(fragment) do + fragment + |> String.replace("~", "~0") + |> String.replace("/", "~1") + end + + @doc """ + Updates a list with the given update_fn while respecting Jsonpatch errors. + In case uodate_fn returns an error then update_at will also return this error. + When the update_fn succeeds it will return the list. + """ + @spec update_at( + target :: list(), + index :: non_neg_integer(), + path :: [Types.casted_fragment()], + update_fn :: (item :: term() -> updated_item :: term()) + ) :: {:ok, list()} | Types.error() + def update_at(target, index, path, update_fn) do + case fetch(target, index) do + {:ok, old_val} -> + do_update_at(target, index, old_val, update_fn) + + {:error, :invalid_path} -> + {:error, {:invalid_path, path ++ [to_string(index)]}} + end + end + + @spec fetch(Types.json_container(), Types.casted_fragment()) :: + {:ok, term()} | {:error, :invalid_path} + def fetch(_list, :-), do: {:error, :invalid_path} + + def fetch(container, key) do + mod = + cond do + is_list(container) -> Enum + is_map(container) -> Map + end + + case mod.fetch(container, key) do + # coveralls-ignore-start + :error -> {:error, :invalid_path} + # coveralls-ignore-stop + {:ok, val} -> {:ok, val} + end + end + + @spec cast_index( + fragment :: String.t(), + path :: [Types.casted_fragment()], + target :: Types.json_container() + ) :: {:ok, Types.casted_fragment()} | Types.error() + def cast_index(fragment, path, target) do + case fragment do + "-" -> + {:ok, :-} + + _ -> + case to_index(fragment, length(target)) do + {:ok, index} -> {:ok, index} + {:error, :invalid_path} -> {:error, {:invalid_path, path ++ [fragment]}} + end + end + end + + defp to_index(unparsed_index, list_lenght) do + case Integer.parse(unparsed_index) do + {index, _} when 0 <= index and index <= list_lenght -> {:ok, index} + {_index_out_of_range, _} -> {:error, :invalid_path} + :error -> {:error, :invalid_path} + end + end + + defp find_destination(%{} = target, path, [fragment], opts) do + with {:ok, fragment} <- cast_fragment(fragment, path, target, opts) do + {:ok, {target, fragment}} + end + end + + defp find_destination(target, path, [fragment], opts) when is_list(target) do + with {:ok, index} <- cast_fragment(fragment, path, target, opts) do + {:ok, {target, index}} + end + end + + defp find_destination(%{} = target, path, [fragment | tail], opts) do + with {:ok, fragment} <- cast_fragment(fragment, path, target, opts), + %{^fragment => sub_target} <- target do + find_destination(sub_target, path ++ [fragment], tail, opts) + else + %{} -> + {:error, {:invalid_path, path ++ [fragment]}} + + # coveralls-ignore-start + {:error, _} = error -> + error + # coveralls-ignore-stop + end + end + + defp find_destination(target, path, [fragment | tail], opts) when is_list(target) do + with {:ok, index} <- cast_fragment(fragment, path, target, opts) do + val = Enum.fetch!(target, index) + find_destination(val, path ++ [fragment], tail, opts) + end + end + + defp do_update_destination(_target, value, _path, [_fragment], _opts) do + {:ok, value} + end + + defp do_update_destination(%{} = target, value, path, [destination, _last_ele], opts) do + with {:ok, destination} <- cast_fragment(destination, path, target, opts) do + {:ok, Map.replace!(target, destination, value)} + end + end + + defp do_update_destination(target, value, path, [destination, _last_ele], opts) + when is_list(target) do + with {:ok, index} <- cast_fragment(destination, path, target, opts) do + {:ok, List.replace_at(target, index, value)} + end + end + + defp do_update_destination(%{} = target, value, path, [fragment | tail], opts) do + with {:ok, fragment} <- cast_fragment(fragment, path, target, opts), + %{^fragment => sub_target} <- target, + {:ok, updated_val} <- + do_update_destination(sub_target, value, path ++ [fragment], tail, opts) do + {:ok, %{target | fragment => updated_val}} + else + %{} -> {:error, {:invalid_path, path ++ [fragment]}} + {:error, _} = error -> error + end + end + + defp do_update_destination(target, value, path, [fragment | tail], opts) when is_list(target) do + with {:ok, index} <- cast_fragment(fragment, path, target, opts) do + update_fn = &do_update_destination(&1, value, path ++ [fragment], tail, opts) + update_at(target, index, path, update_fn) + end + end + + defp do_update_at(target, index, old_val, update_fn) do + case update_fn.(old_val) do + {:error, _} = error -> error + {:ok, new_val} -> {:ok, List.replace_at(target, index, new_val)} + end + end + + defp string_to_existing_atom(data) when is_binary(data) do + {:ok, String.to_existing_atom(data)} + rescue + ArgumentError -> :error + end +end diff --git a/lib/jsonpatch_exception.ex b/lib/jsonpatch_exception.ex deleted file mode 100644 index 4beaf6e..0000000 --- a/lib/jsonpatch_exception.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule JsonpatchException do - @moduledoc """ - JsonpatchException will be raised if a patch is applied with "!" - and the patching fails. - """ - - defexception [:message] - - @impl true - def exception({:error, err_type, err_msg} = _error) do - msg = "#{err_type}: '#{err_msg}'" - %JsonpatchException{message: msg} - end -end diff --git a/mix.exs b/mix.exs index b4567b7..eefcc9e 100644 --- a/mix.exs +++ b/mix.exs @@ -6,14 +6,21 @@ defmodule Jsonpatch.MixProject do app: :jsonpatch, name: "Jsonpatch", description: "Implementation of RFC 6902 in pure Elixir", - version: "1.0.1", + version: "2.0.0", elixir: "~> 1.10", start_permanent: Mix.env() == :prod, deps: deps(), test_coverage: [tool: ExCoveralls], package: package(), source_url: "https://github.com/corka149/jsonpatch", - preferred_cli_env: [muzak: :test], + preferred_cli_env: [ + coveralls: :test, + "coveralls.github": :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + docs: :dev + ], docs: [ main: "readme", extras: ["README.md"] @@ -21,31 +28,19 @@ defmodule Jsonpatch.MixProject do ] end - # Run "mix help compile.app" to learn about applications. def application do [ extra_applications: [:logger] ] end - # Run "mix help deps" to learn about dependencies. defp deps do [ - # REQUIRED - - # DEV - ## testing with real json files - {:poison, "~> 4.0", only: [:test]}, - ## code test coverage - {:excoveralls, "~> 0.15.3", only: [:test]}, - ## linting - {:credo, "~> 1.6.0", only: [:dev, :test], runtime: false}, - ## type checking + {:jason, "~> 1.0", only: [:dev, :test]}, + {:excoveralls, "~> 0.15", only: [:test]}, + {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.2", only: [:dev], runtime: false}, - ## Docs - {:ex_doc, "~> 0.29", only: [:dev], runtime: false}, - ## Mutation testing - {:muzak, "~> 1.1.1", only: :mutation} + {:ex_doc, "~> 0.29", only: [:dev], runtime: false} ] end diff --git a/mix.lock b/mix.lock index d8ec699..f0e4b5e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,25 +1,23 @@ %{ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, - "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, - "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.30", "0b938aa5b9bafd455056440cdaa2a79197ca5e693830b4a982beada840513c5f", [:mix], [], "hexpm", "3b5385c2d36b0473d0b206927b841343d25adb14f95f0110062506b300cd5a1b"}, + "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, + "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, - "excoveralls": {:hex, :excoveralls, "0.15.3", "54bb54043e1cf5fe431eb3db36b25e8fd62cf3976666bafe491e3fa5e29eba47", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8eb5d8134d84c327685f7bb8f1db4147f1363c3c9533928234e496e3070114e"}, + "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, + "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "muzak": {:hex, :muzak, "1.1.1", "188362ff10515e815a4649b99ebf7bc8911c4028974453b94020118168aa17bf", [:mix], [], "hexpm", "5784462f600741ae7190cfb284809b92d9f06f92ec45d6ab5ae0d79eea61547e"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/jsonpatch/mapper_test.exs b/test/jsonpatch/mapper_test.exs deleted file mode 100644 index 0512040..0000000 --- a/test/jsonpatch/mapper_test.exs +++ /dev/null @@ -1,113 +0,0 @@ -defmodule Jsonpatch.MapperTest do - use ExUnit.Case - doctest Jsonpatch.Mapper - - describe "Jsonpatch.Mapper.from_map" do - test "Create add struct from map" do - patch_map = %{"op" => "add", "path" => "/name", "value" => "Alice"} - - assert %Jsonpatch.Operation.Add{path: "/name", value: "Alice"} = - Jsonpatch.Mapper.from_map(patch_map) - end - - test "Create replace struct from map" do - patch_map = %{"op" => "replace", "path" => "/name", "value" => "Alice"} - - assert %Jsonpatch.Operation.Replace{path: "/name", value: "Alice"} = - Jsonpatch.Mapper.from_map(patch_map) - end - - test "Create remove struct from map" do - patch_map = %{"op" => "remove", "path" => "/name"} - assert %Jsonpatch.Operation.Remove{path: "/name"} = Jsonpatch.Mapper.from_map(patch_map) - end - - test "Create copy struct from map" do - patch_map = %{"op" => "copy", "path" => "/name", "from" => "/surname"} - - assert %Jsonpatch.Operation.Copy{from: "/surname", path: "/name"} = - Jsonpatch.Mapper.from_map(patch_map) - end - - test "Create move struct from map" do - patch_map = %{"op" => "move", "path" => "/name", "from" => "/surname"} - - assert %Jsonpatch.Operation.Move{from: "/surname", path: "/name"} = - Jsonpatch.Mapper.from_map(patch_map) - end - - test "Create test struct from map" do - patch_map = %{"op" => "test", "path" => "/name", "value" => 42} - - assert %Jsonpatch.Operation.Test{value: 42, path: "/name"} = - Jsonpatch.Mapper.from_map(patch_map) - end - - test "Create Jsonpatch struct from list" do - patch_map = %{"op" => "test", "path" => "/name", "value" => 42} - - assert [%Jsonpatch.Operation.Test{value: 42, path: "/name"}] = - Jsonpatch.Mapper.from_map([patch_map]) - end - - test "Create Jsonpatch struct from invalid map" do - assert {:error, :invalid} = - Jsonpatch.Mapper.from_map(%{ - "op" => "tessstt", - "path" => "/name", - "value" => "Alice" - }) - end - end - - describe "Jsonpatch.Mapper.to_map" do - test "Create map from add struct" do - patch_map = %Jsonpatch.Operation.Add{path: "/name", value: "Alice"} - - assert %{op: "add", path: "/name", value: "Alice"} = Jsonpatch.Mapper.to_map(patch_map) - end - - test "Create map from replace struct" do - patch_map = %Jsonpatch.Operation.Replace{path: "/name", value: "Alice"} - - assert %{op: "replace", path: "/name", value: "Alice"} = Jsonpatch.Mapper.to_map(patch_map) - end - - test "Create map from remove struct" do - patch_map = %Jsonpatch.Operation.Remove{path: "/name"} - assert %{op: "remove", path: "/name"} = Jsonpatch.Mapper.to_map(patch_map) - end - - test "Create map from copy struct" do - patch_map = %Jsonpatch.Operation.Copy{from: "/surname", path: "/name"} - - assert %{from: "/surname", op: "copy", path: "/name"} = Jsonpatch.Mapper.to_map(patch_map) - end - - test "Create map from move struct" do - patch_map = %Jsonpatch.Operation.Move{from: "/surname", path: "/name"} - - assert %{from: "/surname", op: "move", path: "/name"} = Jsonpatch.Mapper.to_map(patch_map) - end - - test "Create map from test struct" do - patch_map = %Jsonpatch.Operation.Test{value: 42, path: "/name"} - - assert %{op: "test", path: "/name", value: 42} = Jsonpatch.Mapper.to_map(patch_map) - end - - test "Create list from a list of Jsonpatches" do - patch_map = %Jsonpatch.Operation.Test{value: 42, path: "/name"} - - assert [%{op: "test", path: "/name", value: 42}] = Jsonpatch.Mapper.to_map([patch_map]) - end - - test "Map invalid paramter list to map and expect no mapping" do - assert [] = Jsonpatch.Mapper.to_map([%{foo: "bar"}]) - end - - test "Map invalid paramter to map and expect error" do - assert {:error, :invalid} = Jsonpatch.Mapper.to_map(%{foo: "bar"}) - end - end -end diff --git a/test/jsonpatch/operation/add_test.exs b/test/jsonpatch/operation/add_test.exs index b656326..f297447 100644 --- a/test/jsonpatch/operation/add_test.exs +++ b/test/jsonpatch/operation/add_test.exs @@ -1,7 +1,6 @@ defmodule Jsonpatch.Operation.AddTest do use ExUnit.Case - alias Jsonpatch.Operation alias Jsonpatch.Operation.Add doctest Add @@ -26,9 +25,7 @@ defmodule Jsonpatch.Operation.AddTest do add_op = %Add{path: path, value: true} - patched_target = Operation.apply_op(add_op, target) - - excpected_target = %{ + expected_target = %{ "a" => %{ "b" => [ 1, @@ -43,48 +40,58 @@ defmodule Jsonpatch.Operation.AddTest do } } - assert ^excpected_target = patched_target + assert {:ok, ^expected_target} = Jsonpatch.Operation.Add.apply(add_op, target, []) + end + + test "Add a value on an existing path" do + patch = %Add{path: "/a/b", value: 2} + target = %{"a" => %{"b" => 1}} + + assert {:ok, %{"a" => %{"b" => 2}}} = Jsonpatch.Operation.Add.apply(patch, target, []) end test "Add a value to an array" do - patch = %Add{path: "/a/2", value: 3} - target = %{"a" => [0, 1, 2]} + patch = %Add{path: "/a/2", value: 2} + target = %{"a" => [0, 1, 3]} - assert %{"a" => [0, 1, 3]} = Operation.apply_op(patch, target) + assert {:ok, %{"a" => [0, 1, 2, 3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) end test "Add a value to an empty array with binary key" do patch = %Add{path: "/a/0", value: 3} target = %{"a" => []} - assert %{"a" => [3]} = Operation.apply_op(patch, target) + assert {:ok, %{"a" => [3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) end test "Add a value to an empty array with atom key" do patch = %Add{path: "/a/0", value: 3} - target = %{a: []} + target = %{"a" => []} - assert %{a: [3]} = Operation.apply_op(patch, target, keys: :atoms) + assert {:ok, %{"a" => [3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) end test "Add a value to an array with invalid index" do - patch = %Add{path: "/a/b", value: 3} + patch = %Add{path: "/a/100", value: 3} target = %{"a" => [0, 1, 2]} - assert {:error, :invalid_index, "b"} = Operation.apply_op(patch, target) + assert {:error, {:invalid_path, ["a", "100"]}} = + Jsonpatch.Operation.Add.apply(patch, target, []) + + patch = %Add{path: "/a/not_an_index", value: 3} + + assert {:error, {:invalid_path, ["a", "not_an_index"]}} = + Jsonpatch.Operation.Add.apply(patch, target, []) end test "Add a value at the end of array" do patch = %Add{path: "/a/-", value: 3} target = %{"a" => [0, 1, 2]} - assert %{"a" => [0, 1, 2, 3]} = Operation.apply_op(patch, target) - end + assert {:ok, %{"a" => [0, 1, 2, 3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) - test "Return error when patch error was provided to add operation" do - patch = %Add{path: "/a", value: false} - error = {:error, :invalid_index, "4"} + patch = %Add{path: "/a/#{length(target["a"])}", value: 3} - assert ^error = Operation.apply_op(patch, error) + assert {:ok, %{"a" => [0, 1, 2, 3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) end end diff --git a/test/jsonpatch/operation/copy_test.exs b/test/jsonpatch/operation/copy_test.exs index a5cd663..39b1e26 100644 --- a/test/jsonpatch/operation/copy_test.exs +++ b/test/jsonpatch/operation/copy_test.exs @@ -1,14 +1,13 @@ defmodule Jsonpatch.Operation.CopyTest do use ExUnit.Case - alias Jsonpatch.Operation alias Jsonpatch.Operation.Copy doctest Copy test "Copy element by path with multiple indices" do from = "/a/b/1/c/2" - # Copy to end + # Copy to end path = "/a/b/1/c/-" target = %{ @@ -28,9 +27,7 @@ defmodule Jsonpatch.Operation.CopyTest do copy_op = %Copy{path: path, from: from} - patched_target = Operation.apply_op(copy_op, target) - - excpected_target = %{ + expected_target = %{ "a" => %{ "b" => [ 1, @@ -46,7 +43,7 @@ defmodule Jsonpatch.Operation.CopyTest do } } - assert ^excpected_target = patched_target + assert {:ok, ^expected_target} = Jsonpatch.Operation.Copy.apply(copy_op, target, []) end test "Copy element by path with invalid target index and expect error" do @@ -70,9 +67,8 @@ defmodule Jsonpatch.Operation.CopyTest do copy_op = %Copy{path: to, from: from} - patched_target = Operation.apply_op(copy_op, target) - - assert {:error, :invalid_index, "a"} = patched_target + assert {:error, {:invalid_path, ["a", "b", "1", "c", "a"]}} = + Jsonpatch.Operation.Copy.apply(copy_op, target, []) end test "Copy element by path with invalid soure path and expect error" do @@ -85,9 +81,7 @@ defmodule Jsonpatch.Operation.CopyTest do copy_op = %Copy{path: to, from: from} - patched_target = Operation.apply_op(copy_op, target) - - assert {:error, :invalid_path, "b"} = patched_target + assert {:error, {:invalid_path, ["b"]}} = Jsonpatch.Operation.Copy.apply(copy_op, target, []) end test "Copy element by path with invalid source index and expect error" do @@ -111,9 +105,8 @@ defmodule Jsonpatch.Operation.CopyTest do copy_op = %Copy{path: to, from: from} - patched_target = Operation.apply_op(copy_op, target) - - assert {:error, :invalid_index, "b"} = patched_target + assert {:error, {:invalid_path, ["a", "b", "1", "c", "b"]}} = + Jsonpatch.Operation.Copy.apply(copy_op, target, []) end test "Copy list element" do @@ -121,9 +114,7 @@ defmodule Jsonpatch.Operation.CopyTest do target = %{"a" => [999, 888]} - patched = Operation.apply_op(patch, target) - - assert %{"a" => [999, 999]} = patched + assert {:ok, %{"a" => [999, 999, 888]}} = Jsonpatch.Operation.Copy.apply(patch, target, []) end test "Copy list element from invalid index" do @@ -131,9 +122,13 @@ defmodule Jsonpatch.Operation.CopyTest do target = %{"a" => [999, 888]} - patched_error = Operation.apply_op(patch, target) + assert {:error, {:invalid_path, ["a", "6"]}} = + Jsonpatch.Operation.Copy.apply(patch, target, []) + + patch = %Copy{from: "/a/-", path: "/a/0"} - assert {:error, :invalid_index, "6"} = patched_error + assert {:error, {:invalid_path, ["a", "-"]}} = + Jsonpatch.Operation.Copy.apply(patch, target, []) end test "Copy list element to invalid index" do @@ -141,8 +136,7 @@ defmodule Jsonpatch.Operation.CopyTest do target = %{"a" => [999, 888]} - patched_error = Operation.apply_op(patch, target) - - assert {:error, :invalid_index, "5"} = patched_error + assert {:error, {:invalid_path, ["a", "5"]}} = + Jsonpatch.Operation.Copy.apply(patch, target, []) end end diff --git a/test/jsonpatch/operation/remove_test.exs b/test/jsonpatch/operation/remove_test.exs index 765a4e1..5f08af9 100644 --- a/test/jsonpatch/operation/remove_test.exs +++ b/test/jsonpatch/operation/remove_test.exs @@ -1,7 +1,6 @@ defmodule RemoveTest do use ExUnit.Case - alias Jsonpatch.Operation alias Jsonpatch.Operation.Remove doctest Remove @@ -26,9 +25,7 @@ defmodule RemoveTest do remove_op = %Remove{path: path} - patched_target = Operation.apply_op(remove_op, target) - - excpected_target = %{ + expected_target = %{ "a" => %{ "b" => [ 1, @@ -42,7 +39,7 @@ defmodule RemoveTest do } } - assert ^excpected_target = patched_target + assert {:ok, ^expected_target} = Jsonpatch.Operation.Remove.apply(remove_op, target, []) end test "Remove element by invalid path" do @@ -54,15 +51,23 @@ defmodule RemoveTest do } remove_patch = %Remove{path: "/nameX"} - assert {:error, :invalid_path, "nameX"} = Operation.apply_op(remove_patch, target) + + assert {:error, {:invalid_path, ["nameX"]}} = + Jsonpatch.Operation.Remove.apply(remove_patch, target, []) + + remove_patch = %Remove{path: "/home/nameX"} + + assert {:error, {:invalid_path, ["home", "nameX"]}} = + Jsonpatch.Operation.Remove.apply(remove_patch, target, []) end test "Remove element in map with atom keys" do - target = %{name: "Ceasar", age: 66} + target = %{"name" => "Ceasar", "age" => 66} remove_patch = %Remove{path: "/age"} - assert %{name: "Ceasar"} = Operation.apply_op(remove_patch, target, keys: :atoms) + assert {:ok, %{"name" => "Ceasar"}} = + Jsonpatch.Operation.Remove.apply(remove_patch, target, []) end test "Remove element by invalid index" do @@ -74,7 +79,9 @@ defmodule RemoveTest do } remove_patch = %Remove{path: "/hobbies/a"} - assert {:error, :invalid_index, "a"} = Operation.apply_op(remove_patch, target) + + assert {:error, {:invalid_path, ["hobbies", "a"]}} = + Jsonpatch.Operation.Remove.apply(remove_patch, target, []) # Longer path target = %{ @@ -85,41 +92,34 @@ defmodule RemoveTest do } remove_patch = %Remove{path: "/hobbies/b/description"} - assert {:error, :invalid_index, "b"} = Operation.apply_op(remove_patch, target) + + assert {:error, {:invalid_path, ["hobbies", "b"]}} = + Jsonpatch.Operation.Remove.apply(remove_patch, target, []) # Longer path, numeric - out of remove_patch = %Remove{path: "/hobbies/1/description"} - assert {:error, :invalid_index, 1} = Operation.apply_op(remove_patch, target) - end - test "Return error when patch error was provided to remove operation" do - patch = %Remove{path: "/a"} - error = {:error, :invalid_index, "4"} + assert {:error, {:invalid_path, ["hobbies", "1"]}} = + Jsonpatch.Operation.Remove.apply(remove_patch, target, []) + + remove_patch = %Remove{path: "/hobbies/-"} - assert ^error = Operation.apply_op(patch, error) + assert {:error, {:invalid_path, ["hobbies", "-"]}} = + Jsonpatch.Operation.Remove.apply(remove_patch, target, []) end test "Remove in list" do - # Arrange source = [1, 2, %{"three" => 3}, 5, 6] patch = %Remove{path: "/2/three"} - # Act - patched_source = Operation.apply_op(patch, source) - - # Assert - assert [1, 2, %{}, 5, 6] = patched_source + assert {:ok, [1, 2, %{}, 5, 6]} = Jsonpatch.Operation.Remove.apply(patch, source, []) end test "Remove in list with wrong key" do - # Arrange source = [1, 2, %{"three" => 3}, 5, 6] patch = %Remove{path: "/2/four"} - # Act - patched_source = Operation.apply_op(patch, source) - - # Assert - assert {:error, :invalid_path, "four"} = patched_source + assert {:error, {:invalid_path, ["2", "four"]}} = + Jsonpatch.Operation.Remove.apply(patch, source, []) end end diff --git a/test/jsonpatch/operation/replace_test.exs b/test/jsonpatch/operation/replace_test.exs index 27124e9..83ac811 100644 --- a/test/jsonpatch/operation/replace_test.exs +++ b/test/jsonpatch/operation/replace_test.exs @@ -1,7 +1,6 @@ defmodule Jsonpatch.Operation.ReplaceTest do use ExUnit.Case - alias Jsonpatch.Operation alias Jsonpatch.Operation.Replace doctest Replace @@ -26,9 +25,7 @@ defmodule Jsonpatch.Operation.ReplaceTest do replace_op = %Replace{path: path, value: true} - patched_target = Operation.apply_op(replace_op, target) - - excpected_target = %{ + expected_target = %{ "a" => %{ "b" => [ 1, @@ -43,7 +40,7 @@ defmodule Jsonpatch.Operation.ReplaceTest do } } - assert ^excpected_target = patched_target + assert {:ok, ^expected_target} = Replace.apply(replace_op, target, []) end test "Replace element to path with index out of range and expect error" do @@ -59,9 +56,7 @@ defmodule Jsonpatch.Operation.ReplaceTest do replace_op = %Replace{path: path, value: 2} - patched_target = Operation.apply_op(replace_op, target) - - assert {:error, :invalid_index, "2"} = patched_target + assert {:error, {:invalid_path, ["a", "b", "2"]}} = Replace.apply(replace_op, target, []) end test "Replace element to path with invalid index and expect error" do @@ -77,15 +72,20 @@ defmodule Jsonpatch.Operation.ReplaceTest do replace_op = %Replace{path: path, value: 2} - patched_target = Operation.apply_op(replace_op, target) - - assert {:error, :invalid_index, "c"} = patched_target + assert {:error, {:invalid_path, ["a", "b", "c"]}} = Replace.apply(replace_op, target, []) end - test "Return error when patch error was provided to replace operation" do - patch = %Replace{path: "/a", value: true} - error = {:error, :invalid_index, "4"} + test "Replace in not existing path" do + path = "/a/b/c" + + target = %{ + "a" => %{ + "b" => 1 + } + } + + replace_op = %Replace{path: path, value: 2} - assert ^error = Operation.apply_op(patch, error) + assert {:error, {:invalid_path, ["a", "b", "c"]}} = Replace.apply(replace_op, target, []) end end diff --git a/test/jsonpatch/operation/test_test.exs b/test/jsonpatch/operation/test_test.exs index 9ba1e0b..a7c53c2 100644 --- a/test/jsonpatch/operation/test_test.exs +++ b/test/jsonpatch/operation/test_test.exs @@ -1,7 +1,6 @@ defmodule Jsonpatch.Operation.TestTest do use ExUnit.Case - alias Jsonpatch.Operation alias Jsonpatch.Operation.Test doctest Test @@ -23,18 +22,18 @@ defmodule Jsonpatch.Operation.TestTest do } test_op = %Test{path: "/a/b/1/c/2/f", value: false} - assert ^target = Operation.apply_op(test_op, target) + assert {:ok, ^target} = Test.apply(test_op, target, []) test_op = %Test{path: "/a/b/1/c/0", value: 1} - assert ^target = Operation.apply_op(test_op, target) + assert {:ok, ^target} = Test.apply(test_op, target, []) end test "Test with atom as key" do - target = %{role: "Developer"} + target = %{"role" => "Developer"} test_op = %Test{path: "/role", value: "Developer"} - assert ^target = Operation.apply_op(test_op, target, keys: :atoms) + assert {:ok, ^target} = Test.apply(test_op, target, []) end test "Fail to test element with path with multiple indices" do @@ -55,36 +54,35 @@ defmodule Jsonpatch.Operation.TestTest do test_op = %Test{path: "/a/b/1/c/1", value: 42} - patched_target = Operation.apply_op(test_op, target) - - assert {:error, :test_failed, "Expected value '42' at '/a/b/1/c/1'"} = patched_target + assert {:error, {:test_failed, "Expected value '42' at '/a/b/1/c/1'"}} = + Test.apply(test_op, target, []) end test "Test list with index out of range" do test = %Test{path: "/m/2", value: "foo"} target = %{"m" => [0, 1]} - assert {:error, :invalid_index, "2"} = Operation.apply_op(test, target) + assert {:error, {:invalid_path, ["m", "2"]}} = Test.apply(test, target, []) end test "Test list with invalid index" do test = %Test{path: "/m/b", value: "foo"} target = %{"m" => [0, 1]} - assert {:error, :invalid_index, "b"} = Operation.apply_op(test, target) + assert {:error, {:invalid_path, ["m", "b"]}} = Test.apply(test, target, []) end test "Test list at top level" do test = %Test{path: "/1", value: "bar"} target = ["foo", "bar", "ha"] - assert ^target = Operation.apply_op(test, target) + assert {:ok, ^target} = Test.apply(test, target, []) end test "Test list at top level with error" do test = %Test{path: "/2", value: 3} target = [0, 1, 2] - assert {:error, :test_failed, "Expected value '3' at '/2'"} = Operation.apply_op(test, target) + assert {:error, {:test_failed, "Expected value '3' at '/2'"}} = Test.apply(test, target, []) end end diff --git a/test/jsonpatch/path_util_test.exs b/test/jsonpatch/path_util_test.exs deleted file mode 100644 index 4c1303f..0000000 --- a/test/jsonpatch/path_util_test.exs +++ /dev/null @@ -1,43 +0,0 @@ -defmodule Jsonpatch.PathUtilTest do - use ExUnit.Case - doctest Jsonpatch.PathUtil - - alias Jsonpatch.PathUtil - - test "Updated final destination with invalid path and get an error" do - path = "/a/x/y/z" - target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} - - assert {:error, :invalid_path, "x"} = - PathUtil.update_final_destination(target, %{"e" => 1}, path) - end - - test "Unescape '~' and '/'" do - assert "unescape~me" = PathUtil.unescape("unescape~0me") - assert "unescape/me" = PathUtil.unescape("unescape~1me") - assert "unescape~me/" = PathUtil.unescape("unescape~0me~1") - assert 1 = PathUtil.unescape(1) - end - - describe "Convert key types" do - test "With success" do - assert ["foo", "bar"] = PathUtil.into_key_type(["foo", "bar"], :strings) - assert [:foo, :bar] = PathUtil.into_key_type(["foo", "bar"], :atoms) - assert [:foo, "1"] = PathUtil.into_key_type(["foo", "1"], :atoms) - assert [:foo, :bar] = PathUtil.into_key_type(["foo", "bar"], :atoms!) - assert [:foo, "1"] = PathUtil.into_key_type(["foo", "1"], :atoms!) - end - - test "Expect exception when :atoms! was provided but atom does not exist" do - assert_raise ArgumentError, fn -> - PathUtil.into_key_type(["does_not_", "exists_as_atom"], :atoms!) - end - end - - test "Do not accept unknown key type" do - assert_raise JsonpatchException, fn -> - PathUtil.into_key_type(["does not matter"], :not_valid_type) - end - end - end -end diff --git a/test/jsonpatch/res/deploy_destination.json b/test/jsonpatch/res/deploy_destination.json deleted file mode 100644 index 136b4a4..0000000 --- a/test/jsonpatch/res/deploy_destination.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "apiVersion": "v1", - "items": [ - { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "annotations": { - "deployment.kubernetes.io/revision": "44", - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"whoami\"},\"name\":\"whoami-deployment\",\"namespace\":\"default\"},\"spec\":{\"replicas\":1,\"selector\":{\"matchLabels\":{\"app\":\"whoami\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"whoami\"}},\"spec\":{\"containers\":[{\"env\":[{\"name\":\"ENVIRONMENT_MESSAGE\",\"value\":\"Greetings stranger!\"}],\"image\":\"whoami:1.1.1\",\"livenessProbe\":{\"httpGet\":{\"path\":\"/v1/health\",\"port\":4000}},\"name\":\"whoami\",\"ports\":[{\"containerPort\":4000}],\"readinessProbe\":{\"httpGet\":{\"path\":\"/v1/ready\",\"port\":4000}},\"resources\":{\"limits\":{\"cpu\":\"0.5\",\"memory\":\"400Mi\"},\"requests\":{\"cpu\":\"0.2\",\"memory\":\"100Mi\"}}}]}}}}\n" - }, - "creationTimestamp": "2020-01-31T19:56:31Z", - "generation": 97, - "labels": { - "app": "whoami" - }, - "name": "whoami-deployment", - "namespace": "default", - "resourceVersion": "246245", - "selfLink": "/apis/apps/v1/namespaces/default/deployments/whoami-deployment", - "uid": "acbdb7a7-cd6a-4d8b-a07c-c716afbcadfe" - }, - "spec": { - "progressDeadlineSeconds": 600, - "replicas": 1, - "revisionHistoryLimit": 10, - "selector": { - "matchLabels": { - "app": "whoami" - } - }, - "strategy": { - "rollingUpdate": { - "maxSurge": "25%", - "maxUnavailable": "25%" - }, - "type": "RollingUpdate" - }, - "template": { - "metadata": { - "creationTimestamp": null, - "labels": { - "app": "whoami", - "pod-template-hash": "844cc7674c", - "timestamp": "200217T1818" - } - }, - "spec": { - "containers": [ - { - "env": [ - { - "name": "ENVIRONMENT_MESSAGE", - "value": "Greetings stranger" - }, - { - "name": "ANOTHER_MESSAGE", - "value": "Hey there!" - } - ], - "image": "whoami:1.1.2", - "imagePullPolicy": "IfNotPresent", - "livenessProbe": { - "failureThreshold": 3, - "httpGet": { - "path": "/v1/health", - "port": 4000, - "scheme": "HTTP" - }, - "periodSeconds": 10, - "successThreshold": 1, - "timeoutSeconds": 1 - }, - "name": "whoami", - "ports": [], - "readinessProbe": { - "failureThreshold": 3, - "httpGet": { - "path": "/v1/ready", - "port": 4000, - "scheme": "HTTP" - }, - "periodSeconds": 10, - "successThreshold": 1, - "timeoutSeconds": 1 - }, - "resources": { - "limits": { - "cpu": "500m", - "memory": "400Mi" - }, - "requests": { - "cpu": "200m", - "memory": "100Mi" - } - }, - "terminationMessagePath": "/dev/termination-log", - "terminationMessagePolicy": "File" - } - ], - "dnsPolicy": "ClusterFirst", - "restartPolicy": "Always", - "schedulerName": "default-scheduler", - "securityContext": {}, - "terminationGracePeriodSeconds": 30 - } - } - } - } - ], - "kind": "List", - "metadata": { - "resourceVersion": "", - "selfLink": "" - } -} diff --git a/test/jsonpatch/res/deploy_source.json b/test/jsonpatch/res/deploy_source.json deleted file mode 100644 index 23b0ba3..0000000 --- a/test/jsonpatch/res/deploy_source.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "apiVersion": "v1", - "items": [ - { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "annotations": { - "deployment.kubernetes.io/revision": "44", - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"whoami\"},\"name\":\"whoami-deployment\",\"namespace\":\"default\"},\"spec\":{\"replicas\":1,\"selector\":{\"matchLabels\":{\"app\":\"whoami\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"whoami\"}},\"spec\":{\"containers\":[{\"env\":[{\"name\":\"ENVIRONMENT_MESSAGE\",\"value\":\"Greetings stranger!\"}],\"image\":\"whoami:1.1.1\",\"livenessProbe\":{\"httpGet\":{\"path\":\"/v1/health\",\"port\":4000}},\"name\":\"whoami\",\"ports\":[{\"containerPort\":4000}],\"readinessProbe\":{\"httpGet\":{\"path\":\"/v1/ready\",\"port\":4000}},\"resources\":{\"limits\":{\"cpu\":\"0.5\",\"memory\":\"400Mi\"},\"requests\":{\"cpu\":\"0.2\",\"memory\":\"100Mi\"}}}]}}}}\n" - }, - "creationTimestamp": "2020-01-31T19:56:31Z", - "generation": 97, - "labels": { - "app": "whoami" - }, - "name": "whoami-deployment", - "namespace": "default", - "resourceVersion": "246245", - "selfLink": "/apis/apps/v1/namespaces/default/deployments/whoami-deployment", - "uid": "acbdb7a7-cd6a-4d8b-a07c-c716afbcadfe" - }, - "spec": { - "progressDeadlineSeconds": 600, - "replicas": 1, - "revisionHistoryLimit": 10, - "selector": { - "matchLabels": { - "app": "whoami" - } - }, - "strategy": { - "rollingUpdate": { - "maxSurge": "25%", - "maxUnavailable": "25%" - }, - "type": "RollingUpdate" - }, - "template": { - "metadata": { - "creationTimestamp": null, - "labels": { - "app": "whoami", - "pod-template-hash": "844cc7674c", - "timestamp": "200217T1818" - } - }, - "spec": { - "containers": [ - { - "env": [ - { - "name": "ENVIRONMENT_MESSAGE1", - "value": "Greetings stranger" - } - ], - "image": "whoami:1.1.1", - "imagePullPolicy": "IfNotPresent", - "livenessProbe": { - "failureThreshold": 3, - "httpGet": { - "path": "/v1/health", - "port": 4000, - "scheme": "HTTP" - }, - "periodSeconds": 10, - "successThreshold": 1, - "timeoutSeconds": 1 - }, - "name": "whoami", - "ports": [ - { - "containerPort": 4000, - "protocol": "TCP" - } - ], - "readinessProbe": { - "failureThreshold": 3, - "httpGet": { - "path": "/v1/ready", - "port": 4000, - "scheme": "HTTP" - }, - "periodSeconds": 10, - "successThreshold": 1, - "timeoutSeconds": 1 - }, - "resources": { - "limits": { - "cpu": "500m", - "memory": "400Mi" - }, - "requests": { - "cpu": "200m", - "memory": "100Mi" - } - }, - "terminationMessagePath": "/dev/termination-log", - "terminationMessagePolicy": "File" - } - ], - "dnsPolicy": "ClusterFirst", - "restartPolicy": "Always", - "schedulerName": "default-scheduler", - "securityContext": {}, - "terminationGracePeriodSeconds": 30 - } - } - } - } - ], - "kind": "List", - "metadata": { - "resourceVersion": "", - "selfLink": "" - } -} diff --git a/test/jsonpatch/utils_test.exs b/test/jsonpatch/utils_test.exs new file mode 100644 index 0000000..893eebc --- /dev/null +++ b/test/jsonpatch/utils_test.exs @@ -0,0 +1,20 @@ +defmodule Jsonpatch.UtilsTest do + use ExUnit.Case + doctest Jsonpatch.Utils + + alias Jsonpatch.Utils + + test "Updated destination with invalid path and get an error" do + path = "/a/x/y/z" + target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} + + assert {:error, {:invalid_path, ["a", "x"]}} = + Utils.update_destination(target, %{"e" => 1}, path) + end + + test "Unescape '~' and '/'" do + assert "unescape~me" = Utils.unescape("unescape~0me") + assert "unescape/me" = Utils.unescape("unescape~1me") + assert "unescape~me/" = Utils.unescape("unescape~0me~1") + end +end diff --git a/test/jsonpatch_test.exs b/test/jsonpatch_test.exs index 5f6d32d..fe517fb 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -1,10 +1,6 @@ defmodule JsonpatchTest do use ExUnit.Case - alias Jsonpatch.Operation.Add - alias Jsonpatch.Operation.Remove - alias Jsonpatch.Operation.Replace - doctest Jsonpatch test "Create diff from list and apply it" do @@ -21,38 +17,32 @@ defmodule JsonpatchTest do assert ^destination = patched_source end - # ===== DIFF ===== describe "Create diffs" do test "adding an Object Member" do source = %{"foo" => "bar"} destination = %{"foo" => "bar", "baz" => "qux"} - patch = Jsonpatch.diff(source, destination) - - assert [%Add{path: "/baz", value: "qux"}] = patch + assert_diff_apply(source, destination) end test "Adding an Array Element" do source = %{"foo" => ["bar", "baz"]} destination = %{"foo" => ["bar", "baz", "qux"]} - patch = Jsonpatch.diff(source, destination) - - assert [%Add{path: "/foo/2", value: "qux"}] = patch + assert_diff_apply(source, destination) end test "Removing an Object Member" do source = %{"baz" => "qux", "foo" => "bar"} destination = %{"foo" => "bar"} - patch = Jsonpatch.diff(source, destination) - - assert [%Remove{path: "/baz"}] = patch + assert_diff_apply(source, destination) end test "Create no diff on unchanged nil object value" do source = %{"id" => nil} destination = %{"id" => nil} + assert [] = Jsonpatch.diff(source, destination) end @@ -71,142 +61,95 @@ defmodule JsonpatchTest do source = %{"a" => %{"b" => ["c", "d"]}} destination = %{"a" => %{"b" => ["c"]}} - patch = Jsonpatch.diff(source, destination) - - assert [%Remove{path: "/a/b/1"}] = patch + assert_diff_apply(source, destination) end test "Replacing a Value" do source = %{"a" => %{"b" => %{"c" => "d"}}, "f" => "g"} destination = %{"a" => %{"b" => %{"c" => "h"}}, "f" => "g"} - patch = Jsonpatch.diff(source, destination) - - assert [%Replace{path: "/a/b/c", value: "h"}] = patch + assert_diff_apply(source, destination) end test "Replacing an Array Element" do source = %{"a" => %{"b" => %{"c" => ["d1", "d2"]}}, "f" => "g"} destination = %{"a" => %{"b" => %{"c" => ["d1", "d3"]}}, "f" => "g"} - patch = Jsonpatch.diff(source, destination) - - assert [%Replace{path: "/a/b/c/1", value: "d3"}] = patch - end - - test "Create diff for a Kubernetes deployment" do - source = - File.read!("test/jsonpatch/res/deploy_source.json") - |> Poison.Parser.parse!(%{}) - - destination = - File.read!("test/jsonpatch/res/deploy_destination.json") - |> Poison.Parser.parse!(%{}) - - patch = Jsonpatch.diff(source, destination) - - assert [ - %Jsonpatch.Operation.Remove{ - path: "/items/0/spec/template/spec/containers/0/ports/0" - }, - %Jsonpatch.Operation.Replace{ - path: "/items/0/spec/template/spec/containers/0/image", - value: "whoami:1.1.2" - }, - %Jsonpatch.Operation.Replace{ - path: "/items/0/spec/template/spec/containers/0/env/0/name", - value: "ENVIRONMENT_MESSAGE" - }, - %Jsonpatch.Operation.Add{ - path: "/items/0/spec/template/spec/containers/0/env/1", - value: %{"name" => "ANOTHER_MESSAGE", "value" => "Hey there!"} - } - ] = patch + assert_diff_apply(source, destination) end test "Create diff with escaped '~' and '/' in path when adding" do source = %{} destination = %{"escape/me~now" => "somnevalue"} - actual_patch = Jsonpatch.diff(source, destination) - - assert [%Jsonpatch.Operation.Add{path: "/escape~1me~0now", value: "somnevalue"}] = - actual_patch + assert_diff_apply(source, destination) end test "Create diff with escaped '~' and '/' in path when removing" do source = %{"escape/me~now" => "somnevalue"} destination = %{} - actual_patch = Jsonpatch.diff(source, destination) - - assert [%Jsonpatch.Operation.Remove{path: "/escape~1me~0now"}] = actual_patch + assert_diff_apply(source, destination) end test "Create diff with escaped '~' and '/' in path when replacing" do source = %{"escape/me~now" => "somnevalue"} destination = %{"escape/me~now" => "othervalue"} - actual_patch = Jsonpatch.diff(source, destination) - - assert [%Jsonpatch.Operation.Replace{path: "/escape~1me~0now", value: "othervalue"}] = - actual_patch + assert_diff_apply(source, destination) end test "Create diff with nested map with correct Add/Remove order" do source = %{"a" => [%{"b" => []}]} - target = %{"a" => [%{"b" => [%{"c" => 1}, %{"d" => 2}]}]} + destination = %{"a" => [%{"b" => [%{"c" => 1}, %{"d" => 2}]}]} - patches = Jsonpatch.diff(source, target) - - assert [ - %Jsonpatch.Operation.Add{path: "/a/0/b/0", value: %{"c" => 1}}, - %Jsonpatch.Operation.Add{path: "/a/0/b/1", value: %{"d" => 2}} - ] = patches + assert_diff_apply(source, destination) source = %{"a" => [%{"b" => [%{"c" => 1}, %{"d" => 2}]}]} - target = %{"a" => [%{"b" => []}]} - - patches = Jsonpatch.diff(source, target) + destination = %{"a" => [%{"b" => []}]} - assert [ - %Jsonpatch.Operation.Remove{path: "/a/0/b/1"}, - %Jsonpatch.Operation.Remove{path: "/a/0/b/0"} - ] = patches + assert_diff_apply(source, destination) end test "Create diff that replace list with map" do source = %{"a" => [1, 2, 3]} - target = %{"a" => %{"foo" => :bar}} + destination = %{"a" => %{"foo" => :bar}} - patch = Jsonpatch.diff(source, target) - assert [%Replace{path: "/a", value: %{"foo" => :bar}}] = patch + assert_diff_apply(source, destination) end test "Create diff when source has a scalar value where in the destination is a list" do source = %{"a" => 150} destination = %{"a" => [1, 5, 0]} - patch = Jsonpatch.diff(source, destination) - assert [%Replace{path: "/a", value: [1, 5, 0]}] = patch + assert_diff_apply(source, destination) end test "Create diff for lists" do source = [1, "pizza", %{"name" => "Alice"}, [4, 2]] - target = [1, "hamburger", %{"name" => "Alice", "age" => 55}] + destination = [1, "hamburger", %{"name" => "Alice", "age" => 55}] - patch = Jsonpatch.diff(source, target) + assert_diff_apply(source, destination) + end - assert [ - %Jsonpatch.Operation.Remove{path: "/3"}, - %Jsonpatch.Operation.Add{path: "/2/age", value: 55}, - %Jsonpatch.Operation.Replace{path: "/1", value: "hamburger"} - ] = patch + defp assert_diff_apply(source, destination) do + patches = Jsonpatch.diff(source, destination) + assert Jsonpatch.apply_patch(patches, source) == {:ok, destination} end end - # ===== APPLY ===== describe "Apply patch/es" do + test "invalid json patch specification" do + patch = %{"invalid" => "invalid"} + + assert {:error, + %Jsonpatch.Error{ + patch: ^patch, + patch_index: 0, + reason: {:invalid_spec, %{"invalid" => "invalid"}} + }} = Jsonpatch.apply_patch(patch, %{}) + end + test "Apply patch with invalid source path and expect error" do target = %{ "name" => "Bob", @@ -215,35 +158,38 @@ defmodule JsonpatchTest do "home" => "Berlin" } - assert {:error, :invalid_path, "child"} = - Jsonpatch.apply_patch( - %Jsonpatch.Operation.Add{path: "/child/0/age", value: 33}, - target - ) + patch = %{"op" => "add", "path" => "/child/0/age", "value" => 33} + + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["child"]}}} = + Jsonpatch.apply_patch(patch, target) + + patch = %{"op" => "replace", "path" => "/age", "value" => 42} + + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["age"]}}} = + Jsonpatch.apply_patch(patch, target) + + patch = %{"op" => "remove", "path" => "/hobby/4"} - assert {:error, :invalid_path, "age"} = - Jsonpatch.apply_patch( - %Jsonpatch.Operation.Replace{path: "/age", value: 42}, - target - ) + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["hobby"]}}} = + Jsonpatch.apply_patch(patch, target) + + patch = %{"from" => "/nameX", "op" => "copy", "path" => "/surname"} - assert {:error, :invalid_path, "hobby"} = - Jsonpatch.apply_patch(%Jsonpatch.Operation.Remove{path: "/hobby/4"}, target) + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["nameX"]}}} = + Jsonpatch.apply_patch(patch, target) - assert {:error, :invalid_path, "nameX"} = - Jsonpatch.apply_patch( - %Jsonpatch.Operation.Copy{from: "/nameX", path: "/surname"}, - target - ) + patch = %{"from" => "/homeX", "op" => "move", "path" => "/work"} - assert {:error, :invalid_path, "homeX"} = - Jsonpatch.apply_patch( - %Jsonpatch.Operation.Move{from: "/homeX", path: "/work"}, - target - ) + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["homeX"]}}} = + Jsonpatch.apply_patch(patch, target) end - test "Apply patch with multilple operations with binary keys" do + test "Apply patch with multiple operations with binary keys" do patch = [ %Jsonpatch.Operation.Remove{path: "/age"}, %Jsonpatch.Operation.Add{path: "/age", value: 34}, @@ -256,7 +202,7 @@ defmodule JsonpatchTest do assert %{"age" => 35} = patched end - test "Apply patch with multilple operations with atom keys" do + test "Apply patch with multiple operations with atom keys" do patch = [ %Jsonpatch.Operation.Remove{path: "/age"}, %Jsonpatch.Operation.Add{path: "/age", value: 34}, @@ -264,11 +210,89 @@ defmodule JsonpatchTest do ] target = %{age: "33"} - patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms) + patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms!) assert %{age: 35} = patched end + test "Apply patch with non existing atom" do + target = %{} + + patch = %{"op" => "add", "path" => "/test_non_existing_atom", "value" => 34} + + assert {:error, + %Jsonpatch.Error{ + patch: ^patch, + patch_index: 0, + reason: {:invalid_path, ["test_non_existing_atom"]} + }} = Jsonpatch.apply_patch(patch, target, keys: :atoms!) + end + + test "Apply patch with custom keys option - example 1" do + patch = [ + %Jsonpatch.Operation.Replace{path: "/a1/b/c", value: 1}, + %Jsonpatch.Operation.Replace{path: "/a2/b/d", value: 1} + ] + + target = %{a1: %{b: %{"c" => 0}}, l: [], a2: %{b: %{"d" => 0}}} + + convert_fn = fn + # All map keys are atoms except /*/b/* keys + fragment, [_, :b], target, _opts when is_map(target) -> + {:ok, fragment} + + fragment, _path, target, _opts when is_map(target) -> + string_to_existing_atom(fragment) + + fragment, path, target, _opts when is_list(target) -> + case Jsonpatch.Utils.cast_index(fragment, path, target) do + {:ok, _} = ok -> ok + {:error, _} -> :error + end + end + + patched = Jsonpatch.apply_patch!(patch, target, keys: {:custom, convert_fn}) + assert %{a1: %{b: %{"c" => 1}}, a2: %{b: %{"d" => 1}}} = patched + + patch = %Jsonpatch.Operation.Add{path: "/l/0", value: 1} + patched = Jsonpatch.apply_patch!(patch, target, keys: {:custom, convert_fn}) + assert %{a1: %{b: %{"c" => 0}}, l: [1], a2: %{b: %{"d" => 0}}} = patched + + patch = %{"op" => "replace", "path" => "/not_existing_atom", "value" => 1} + + assert {:error, + %Jsonpatch.Error{ + patch: ^patch, + patch_index: 0, + reason: {:invalid_path, ["not_existing_atom"]} + }} = Jsonpatch.apply_patch(patch, target, keys: {:custom, convert_fn}) + + patch = %{"op" => "replace", "path" => "/l/not_existing_atom", "value" => 20} + + assert {:error, + %Jsonpatch.Error{ + patch: ^patch, + patch_index: 0, + reason: {:invalid_path, [:l, "not_existing_atom"]} + }} = Jsonpatch.apply_patch(patch, target, keys: {:custom, convert_fn}) + end + + defmodule TestStruct do + defstruct [:field] + end + + test "struct are just maps" do + patch = %Jsonpatch.Operation.Replace{path: "/a/field/c", value: 1} + target = %{a: %TestStruct{field: %{c: 0}}} + patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms) + assert %{a: %TestStruct{field: %{c: 1}}} = patched + + patch = %Jsonpatch.Operation.Remove{path: "/a/field"} + target = %{a: %TestStruct{field: %{c: 0}}} + patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms) + assert %{a: %{__struct__: TestStruct}} = patched + end + test "Apply patch with invalid target source path and expect error" do target = %{ "name" => "Bob", @@ -277,23 +301,23 @@ defmodule JsonpatchTest do "home" => "Berlin" } - assert {:error, :invalid_path, "xyz"} = - Jsonpatch.apply_patch( - %Jsonpatch.Operation.Copy{from: "/name", path: "/xyz/surname"}, - target - ) + patch = %{"op" => "copy", "from" => "/name", "path" => "/xyz/surname"} + + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["xyz"]}}} = + Jsonpatch.apply_patch(patch, target) - assert {:error, :invalid_path, "xyz"} = - Jsonpatch.apply_patch( - %Jsonpatch.Operation.Move{from: "/home", path: "/xyz/work"}, - target - ) + patch = %{"from" => "/home", "op" => "move", "path" => "/xyz/work"} - assert {:error, :invalid_path, "xyz"} = - Jsonpatch.apply_patch( - %Jsonpatch.Operation.Remove{path: "/xyz/work"}, - target - ) + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["xyz"]}}} = + Jsonpatch.apply_patch(patch, target) + + patch = %{"op" => "remove", "path" => "/xyz/work"} + + assert {:error, + %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["xyz"]}}} = + Jsonpatch.apply_patch(patch, target) end test "Apply patch with one invalid path and expect error" do @@ -316,7 +340,12 @@ defmodule JsonpatchTest do "home" => "Berlin" } - assert {:error, :invalid_index, "4"} = Jsonpatch.apply_patch(patch, target) + assert {:error, + %Jsonpatch.Error{ + patch: %{"op" => "remove", "path" => "/hobbies/4"}, + patch_index: 4, + reason: {:invalid_path, ["hobbies", "4"]} + }} = Jsonpatch.apply_patch(patch, target) end test "Apply patch with failing test and expect error" do @@ -340,8 +369,12 @@ defmodule JsonpatchTest do "home" => "Berlin" } - assert {:error, :test_failed, "Expected value 'Alice' at '/name'"} = - Jsonpatch.apply_patch(patch, target) + assert {:error, + %Jsonpatch.Error{ + patch: %{"op" => "test", "path" => "/name", "value" => "Alice"}, + patch_index: 6, + reason: {:test_failed, "Expected value '\"Alice\"' at '/name'"} + }} = Jsonpatch.apply_patch(patch, target) end test "Apply patch with escaped '~' and '/' in path" do @@ -368,7 +401,32 @@ defmodule JsonpatchTest do patch = %Jsonpatch.Operation.Replace{path: "/surname", value: "Misty"} target = %{"name" => "Alice", "age" => 44} - assert_raise JsonpatchException, fn -> Jsonpatch.apply_patch!(patch, target) end + assert_raise RuntimeError, fn -> Jsonpatch.apply_patch!(patch, target) end end + + test "Apply patch with a path containing an empty key" do + patch = %Jsonpatch.Operation.Replace{path: "/a/", value: 35} + target = %{"a" => %{"" => 33}} + + assert {:ok, %{"a" => %{"" => 35}}} = Jsonpatch.apply_patch(patch, target) + end + + test "error on empty path" do + patch = %{"op" => "replace", "path" => "", "value" => 35} + target = %{} + + assert {:error, + %Jsonpatch.Error{ + patch: ^patch, + patch_index: 0, + reason: {:invalid_path, ""} + }} = Jsonpatch.apply_patch(patch, target) + end + end + + defp string_to_existing_atom(data) when is_binary(data) do + {:ok, String.to_existing_atom(data)} + rescue + ArgumentError -> :error end end From 035ab798f9d4a9de369ac2fe6128a24fe7312a89 Mon Sep 17 00:00:00 2001 From: Giovanni Visciano Date: Tue, 23 May 2023 23:16:35 +0200 Subject: [PATCH 2/3] review --- CHANGELOG | 3 +-- lib/jsonpatch.ex | 2 +- lib/jsonpatch_exception.ex | 14 ++++++++++++++ mix.exs | 1 - test/jsonpatch_test.exs | 2 +- 5 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 lib/jsonpatch_exception.ex diff --git a/CHANGELOG b/CHANGELOG index 24c11df..f870509 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -9,9 +9,8 @@ - error reason is now defined with a `{:error, %Jsonpatch.Error{}}` tuple. %Jsonpatch.Error{patch_index: _, path: _, reason: _} reports the patch index, the path and the reason that caused the error. - Removed - `Jsonpatch.Mapper` module, in favour of new Jsonpatch.apply_patch signature -- Removed - `JsonpatchException` exception - Removed - `Jsonpatch.Operation` protocol -- Feature - introduced new `Jasonpatch.apply_patch()` option `keys: {:custom, convert_fn}` to convert path fragments with a user specific logic +- Feature - introduced new `Jsonpatch.apply_patch()` option `keys: {:custom, convert_fn}` to convert path fragments with a user specific logic - Improvements - increased test coverage # 1.0.1 diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index d3e21e0..4cc7846 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -137,7 +137,7 @@ defmodule Jsonpatch do def apply_patch!(json_patch, target, opts \\ []) do case apply_patch(json_patch, target, opts) do {:ok, patched} -> patched - {:error, _} = error -> raise RuntimeError, inspect(error) + {:error, _} = error -> raise JsonpatchException, error end end diff --git a/lib/jsonpatch_exception.ex b/lib/jsonpatch_exception.ex new file mode 100644 index 0000000..ce8eccd --- /dev/null +++ b/lib/jsonpatch_exception.ex @@ -0,0 +1,14 @@ +defmodule JsonpatchException do + @moduledoc """ + JsonpatchException will be raised if a patch is applied with "!" + and the patching fails. + """ + + defexception [:message] + + @impl true + def exception({:error, %Jsonpatch.Error{patch_index: patch_index, reason: reason}} = _error) do + msg = "patch ##{patch_index} failed, '#{inspect(reason)}'" + %JsonpatchException{message: msg} + end +end diff --git a/mix.exs b/mix.exs index eefcc9e..5793d3f 100644 --- a/mix.exs +++ b/mix.exs @@ -36,7 +36,6 @@ defmodule Jsonpatch.MixProject do defp deps do [ - {:jason, "~> 1.0", only: [:dev, :test]}, {:excoveralls, "~> 0.15", only: [:test]}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.2", only: [:dev], runtime: false}, diff --git a/test/jsonpatch_test.exs b/test/jsonpatch_test.exs index fe517fb..1b7fea1 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -401,7 +401,7 @@ defmodule JsonpatchTest do patch = %Jsonpatch.Operation.Replace{path: "/surname", value: "Misty"} target = %{"name" => "Alice", "age" => 44} - assert_raise RuntimeError, fn -> Jsonpatch.apply_patch!(patch, target) end + assert_raise JsonpatchException, fn -> Jsonpatch.apply_patch!(patch, target) end end test "Apply patch with a path containing an empty key" do From 06cc7966a7ea51407ecdfb3a822e9b73d5fa4e50 Mon Sep 17 00:00:00 2001 From: visciang Date: Fri, 26 May 2023 07:34:24 +0200 Subject: [PATCH 3/3] Update CHANGELOG --- CHANGELOG | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f870509..6d1dcfe 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,9 +1,9 @@ # 2.0.0 -- Bugfix - ADD behaviour to be compliant with RFC (insert or update) -- Bugfix - allow usage of nil values, previous implementation used `Map.get` with default `nil` to if a key was not present +- Bugfix - ADD behaviour is now compliant with RFC (insert or update) +- Bugfix - allow usage of nil values, previous implementation used `Map.get` with default `nil` to detect if a key was not present - Change - COPY operation to be based on ADD operation (as per RFC) -- Change - MOVE operation to be based on on COPY+REMOVE operation (as per RFC) -- Change - REPLACE operation to be based on on REMOVE+ADD operation (as per RFC) +- Change - MOVE operation to be based on COPY+REMOVE operation (as per RFC) +- Change - REPLACE operation to be based on REMOVE+ADD operation (as per RFC) - Change - `Jsonpatch.apply_patch()` signature changes: - patches can be defined as `Jsonpatch.Operation.Add/Copy/Remove/...` structs or with plain map conforming to the jsonpatch schema - error reason is now defined with a `{:error, %Jsonpatch.Error{}}` tuple.