├── .credo.exs ├── .formatter.exs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── coveralls.json ├── lib ├── jsonpatch.ex ├── jsonpatch │ ├── error.ex │ ├── operation │ │ ├── add.ex │ │ ├── copy.ex │ │ ├── move.ex │ │ ├── remove.ex │ │ ├── replace.ex │ │ └── test.ex │ ├── types.ex │ └── utils.ex └── jsonpatch_exception.ex ├── mix.exs ├── mix.lock └── test ├── json-patch-tests.json ├── jsonpatch ├── operation │ ├── add_test.exs │ ├── copy_test.exs │ ├── move_test.exs │ ├── remove_test.exs │ ├── replace_test.exs │ └── test_test.exs └── utils_test.exs ├── jsonpatch_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib", "test"] 7 | }, 8 | checks: [ 9 | {Credo.Check.Refactor.RedundantWithClauseResult, false}, 10 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, 11 | {Credo.Check.Design.AliasUsage, false} 12 | ] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | # unit tests 13 | test: 14 | runs-on: ubuntu-latest 15 | name: Test - OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 16 | strategy: 17 | matrix: 18 | otp: ["24.3", "25.2"] 19 | elixir: ["1.13.4", "1.14.3"] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Setup elixir 23 | uses: erlef/setup-beam@v1 24 | with: 25 | otp-version: ${{matrix.otp}} 26 | elixir-version: ${{matrix.elixir}} 27 | - name: Install Dependencies 28 | run: mix deps.get 29 | - name: Run Tests 30 | run: mix test 31 | 32 | # mutation tests 33 | test-mutation: 34 | name: Test mutation 35 | runs-on: ubuntu-latest 36 | env: 37 | MIX_ENV: mutation 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Setup elixir 41 | uses: erlef/setup-beam@v1 42 | with: 43 | otp-version: "25.2" 44 | elixir-version: "1.14.3" 45 | - run: mix deps.get 46 | 47 | # Linit and type checking 48 | analyze: 49 | runs-on: ubuntu-latest 50 | name: Analyze - OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 51 | strategy: 52 | matrix: 53 | otp: ["24.3", "25.2"] 54 | elixir: ["1.13.4", "1.14.3"] 55 | steps: 56 | - uses: actions/checkout@v2 57 | - name: Setup elixir 58 | uses: erlef/setup-beam@v1 59 | with: 60 | otp-version: ${{matrix.otp}} 61 | elixir-version: ${{matrix.elixir}} 62 | - run: mix deps.get 63 | - run: mix dialyzer 64 | - run: mix credo --strict 65 | 66 | # Coverage 67 | coverage: 68 | name: Coverage 69 | runs-on: ubuntu-latest 70 | env: 71 | MIX_ENV: test 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | steps: 74 | - uses: actions/checkout@v2 75 | - name: Setup elixir 76 | uses: erlef/setup-beam@v1 77 | with: 78 | otp-version: "25.2" 79 | elixir-version: "1.14.3" 80 | - run: mix deps.get 81 | - run: mix coveralls.github 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | jsonpatch-*.tar 24 | 25 | .elixir_ls/ 26 | 27 | .vscode 28 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # 2.2.2 2 | - Bugfix - Resolves an issues preventing move working with keys: :atoms 3 | 4 | # 2.2.1 5 | - Bugfix - Fix diffing when map contain atoms as key 6 | 7 | # 2.2.0 8 | - Change - Allow to replace the entire root 9 | 10 | # 2.1.0 11 | - Create diffs as pure maps and do not use Jsonpatch structs 12 | 13 | # 2.0.0 14 | - Bugfix - ADD behaviour is now compliant with RFC (insert or update) 15 | - Bugfix - allow usage of nil values, previous implementation used `Map.get` with default `nil` to detect if a key was not present 16 | - Change - COPY operation to be based on ADD operation (as per RFC) 17 | - Change - MOVE operation to be based on COPY+REMOVE operation (as per RFC) 18 | - Change - REPLACE operation to be based on REMOVE+ADD operation (as per RFC) 19 | - Change - `Jsonpatch.apply_patch()` signature changes: 20 | - patches can be defined as `Jsonpatch.Operation.Add/Copy/Remove/...` structs or with plain map conforming to the jsonpatch schema 21 | - error reason is now defined with a `{:error, %Jsonpatch.Error{}}` tuple. 22 | %Jsonpatch.Error{patch_index: _, path: _, reason: _} reports the patch index, the path and the reason that caused the error. 23 | - Removed - `Jsonpatch.Mapper` module, in favour of new Jsonpatch.apply_patch signature 24 | - Removed - `Jsonpatch.Operation` protocol 25 | - Feature - introduced new `Jsonpatch.apply_patch()` option `keys: {:custom, convert_fn}` to convert path fragments with a user specific logic 26 | - Improvements - increased test coverage 27 | 28 | # 1.0.1 29 | - Escape remaining keys before comparing them to the (already escaped) keys from earlier in the diffing process when determining Remove operations 30 | 31 | # 1.0.0 32 | - Allow lists at top level of Jsonpatch.apply_patch 33 | - Fix error message when updating a non existing key in list 34 | 35 | # 0.13.1 36 | - Make Jsonpatch faster by (un)escaping conditional 37 | 38 | # 0.13.0 39 | - Allow usage of atoms for keys via `keys` option 40 | 41 | # 0.12.1 42 | - Generate diffs with correct order (thanks https://github.com/smartepsh) 43 | 44 | # 0.12.0 45 | - The functions apply_patch and apply_patch! do not sort anymore a list of patches before applying them 46 | 47 | # 0.11.0 48 | - Removed module Jsonpatch.FlatMap because it is not necessary anymore and not the focus of the lib 49 | - Reworked creating diff to create less unnecessary data and for more accurate patches 50 | - Fixed adding values to empty lists (thanks https://github.com/webdeb) 51 | 52 | # 0.10.0 53 | 54 | - Made jsonpatch more Elixir-API-like by adding Jsonpatch.apply_patch! (which raise an exception) and changed Jsonpatch.apply_patch to return a tuple. 55 | - Implemented escaping for '~' and '/' 56 | - Allow usage of '-' for Add and Copy operation 57 | - Fixed adding and copying values to array 58 | - Improved error feedback of test operation 59 | - Fixed: Replace operation adds error to target 60 | - Cleaned code: Replaced strange constructs of Enum.with_index with Enum.fetch 61 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at corka149@mailbox.org. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit 4 | helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/corka149/jsonpatch/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 23 | wanted" is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "enhancement" 28 | and "help wanted" is open to whoever wants to implement it. 29 | 30 | ### Write Documentation 31 | 32 | Jsonpatch could always use more documentation, whether as part of the 33 | official jsonpatch docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | ### Submit Feedback 37 | 38 | The best way to send feedback is to file an issue at https://github.com/corka149/jsonpatch/issues. 39 | 40 | If you are proposing a feature: 41 | 42 | * Explain in detail how it would work. 43 | * Keep the scope as narrow as possible, to make it easier to implement. 44 | * Remember that this is a volunteer-driven project, and that contributions 45 | are welcome :) 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sebastian Ziemann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check 2 | 3 | 4 | check: 5 | mix format --check-formatted 6 | mix test 7 | mix dialyzer 8 | mix credo --strict 9 | MIX_ENV=test mix coveralls 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jsonpatch 2 | 3 | ![Elixir CI](https://github.com/corka149/jsonpatch/workflows/Elixir%20CI/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/corka149/jsonpatch/badge.svg?branch=master)](https://coveralls.io/github/corka149/jsonpatch?branch=master) 5 | [![Generic badge](https://img.shields.io/badge/Mutation-Tested-success.svg)](https://shields.io/) 6 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/corka149/jsonpatch/graphs/commit-activity) 7 | [![Hex.pm Version](https://img.shields.io/hexpm/v/jsonpatch.svg?style=flat-square)](https://hex.pm/packages/jsonpatch) 8 | 9 | An implementation of [RFC 6902](https://tools.ietf.org/html/rfc6902) in pure Elixir. 10 | 11 | Features: 12 | 13 | - Creating a patch by comparing to maps and lists 14 | - Apply patches to maps and lists - supports operations: 15 | - add 16 | - replace 17 | - remove 18 | - copy 19 | - move 20 | - test 21 | - Escaping of "`/`" (by "`~1`") and "`~`" (by "`~0`") 22 | - Allow usage of `-` for appending things to list (Add and Copy operation) 23 | 24 | ## Getting started 25 | 26 | ### Installation 27 | 28 | The package can be installed by adding `jsonpatch` to your list of dependencies in `mix.exs`: 29 | 30 | ```elixir 31 | def deps do 32 | [ 33 | {:jsonpatch, "~> 2.2"} 34 | ] 35 | end 36 | ``` 37 | 38 | The docs can be found at [https://hexdocs.pm/jsonpatch](https://hexdocs.pm/jsonpatch). 39 | 40 | ### Create a diff 41 | 42 | ```elixir 43 | iex> source = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"]} 44 | iex> destination = %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33} 45 | iex> Jsonpatch.diff(source, destination) 46 | [ 47 | %{path: "/married", value: true, op: "replace"}, 48 | %{path: "/hobbies/2", op: "remove"}, 49 | %{path: "/hobbies/1", op: "remove"}, 50 | %{path: "/hobbies/0", value: "Elixir!", op: "replace"}, 51 | %{path: "/age", value: 33, op: "add"} 52 | ] 53 | ``` 54 | 55 | ### Apply patches 56 | 57 | ```elixir 58 | iex> patch = [ 59 | %{op: "add", path: "/age", value: 33}, 60 | %{op: "replace", path: "/hobbies/0", value: "Elixir!"}, 61 | %{op: "replace", path: "/married", value: true}, 62 | %{op: "remove", path: "/hobbies/1"}, 63 | %{op: "remove", path: "/hobbies/2"} 64 | ] 65 | iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"]} 66 | iex> Jsonpatch.apply_patch(patch, target) 67 | {:ok, %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33}} 68 | ``` 69 | 70 | ## Important sources 71 | - [Official RFC 6902](https://tools.ietf.org/html/rfc6902) 72 | - [Inspiration: python-json-patch](https://github.com/stefankoegl/python-json-patch) 73 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": ["test/support/"], 3 | "coverage_options": { 4 | "html_filter_full_covered": true, 5 | "treat_no_relevant_lines_as_covered": true, 6 | "minimum_coverage": 95 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/jsonpatch.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch do 2 | @moduledoc """ 3 | A implementation of [RFC 6902](https://tools.ietf.org/html/rfc6902) in pure Elixir. 4 | 5 | The patch can be a single change or a list of things that shall be changed. Therefore 6 | a list or a single JSON patch can be provided. Every patch belongs to a certain operation 7 | which influences the usage. 8 | 9 | According to [RFC 6901](https://tools.ietf.org/html/rfc6901) escaping of `/` and `~` is done 10 | by using `~1` for `/` and `~0` for `~`. 11 | """ 12 | 13 | alias Jsonpatch.Types 14 | alias Jsonpatch.Operation.{Add, Copy, Move, Remove, Replace, Test} 15 | alias Jsonpatch.Utils 16 | 17 | @typedoc """ 18 | A valid Jsonpatch operation by RFC 6902 19 | """ 20 | @type t :: map() | Add.t() | Remove.t() | Replace.t() | Copy.t() | Move.t() | Test.t() 21 | 22 | @doc """ 23 | Apply a Jsonpatch or a list of Jsonpatches to a map or struct. The whole patch will not be applied 24 | when any path is invalid or any other error occured. When a list is provided, the operations are 25 | applied in the order as they appear in the list. 26 | 27 | Atoms are never garbage collected. Therefore, `Jsonpatch` works by default only with maps 28 | which used binary strings as key. This behaviour can be controlled via the `:keys` option. 29 | 30 | ## Examples 31 | iex> patch = [ 32 | ...> %{op: "add", path: "/age", value: 33}, 33 | ...> %{op: "replace", path: "/hobbies/0", value: "Elixir!"}, 34 | ...> %{op: "replace", path: "/married", value: true}, 35 | ...> %{op: "remove", path: "/hobbies/2"}, 36 | ...> %{op: "remove", path: "/hobbies/1"}, 37 | ...> %{op: "copy", from: "/name", path: "/surname"}, 38 | ...> %{op: "move", from: "/home", path: "/work"}, 39 | ...> %{op: "test", path: "/name", value: "Bob"} 40 | ...> ] 41 | iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"], "home" => "Berlin"} 42 | iex> Jsonpatch.apply_patch(patch, target) 43 | {:ok, %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33, "surname" => "Bob", "work" => "Berlin"}} 44 | 45 | iex> # Patch will not be applied if test fails. The target will not be changed. 46 | iex> patch = [ 47 | ...> %{op: "add", path: "/age", value: 33}, 48 | ...> %{op: "test", path: "/name", value: "Alice"} 49 | ...> ] 50 | iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"], "home" => "Berlin"} 51 | iex> Jsonpatch.apply_patch(patch, target) 52 | {:error, %Jsonpatch.Error{patch: %{"op" => "test", "path" => "/name", "value" => "Alice"}, patch_index: 1, reason: {:test_failed, "Expected value '\\"Alice\\"' at '/name'"}}} 53 | 54 | iex> # Patch will succeed, not applying invalid path operations. 55 | iex> patch = [ 56 | ...> %{op: "replace", path: "/name", value: "Alice"}, 57 | ...> %{op: "replace", path: "/age", value: 42} 58 | ...> ] 59 | iex> target = %{"name" => "Bob"} # No age in target 60 | iex> Jsonpatch.apply_patch(patch, target, ignore_invalid_paths: true) 61 | {:ok, %{"name" => "Alice"}} 62 | """ 63 | @spec apply_patch(t() | [t()], target :: Types.json_container(), Types.opts()) :: 64 | {:ok, Types.json_container()} | {:error, Jsonpatch.Error.t()} 65 | def apply_patch(json_patch, target, opts \\ []) do 66 | # https://datatracker.ietf.org/doc/html/rfc6902#section-3 67 | # > Operations are applied sequentially in the order they appear in the array. 68 | {ignore_invalid_paths?, opts} = Keyword.pop(opts, :ignore_invalid_paths, false) 69 | 70 | json_patch 71 | |> List.wrap() 72 | |> Enum.with_index() 73 | |> Enum.reduce_while({:ok, target}, fn {patch, patch_index}, {:ok, acc} -> 74 | patch = cast_to_op_map(patch) 75 | 76 | do_apply_patch(patch, acc, opts) 77 | |> handle_patch_result(acc, patch, patch_index, ignore_invalid_paths?) 78 | end) 79 | end 80 | 81 | defp handle_patch_result(result, acc, patch, patch_index, ignore_invalid_paths?) do 82 | case result do 83 | {:error, {error, _} = reason} -> 84 | if ignore_invalid_paths? && error == :invalid_path do 85 | {:cont, {:ok, acc}} 86 | else 87 | error = %Jsonpatch.Error{patch: patch, patch_index: patch_index, reason: reason} 88 | {:halt, {:error, error}} 89 | end 90 | 91 | {:ok, res} -> 92 | {:cont, {:ok, res}} 93 | end 94 | end 95 | 96 | defp cast_to_op_map(%struct_mod{} = json_patch) do 97 | json_patch = 98 | json_patch 99 | |> Map.from_struct() 100 | 101 | op = 102 | case struct_mod do 103 | Add -> "add" 104 | Remove -> "remove" 105 | Replace -> "replace" 106 | Copy -> "copy" 107 | Move -> "move" 108 | Test -> "test" 109 | end 110 | 111 | json_patch = Map.put(json_patch, "op", op) 112 | 113 | cast_to_op_map(json_patch) 114 | end 115 | 116 | defp cast_to_op_map(json_patch) do 117 | Map.new(json_patch, fn {k, v} -> {to_string(k), v} end) 118 | end 119 | 120 | defp do_apply_patch(%{"op" => "add", "path" => path, "value" => value}, target, opts) do 121 | Add.apply(%Add{path: path, value: value}, target, opts) 122 | end 123 | 124 | defp do_apply_patch(%{"op" => "remove", "path" => path}, target, opts) do 125 | Remove.apply(%Remove{path: path}, target, opts) 126 | end 127 | 128 | defp do_apply_patch(%{"op" => "replace", "path" => path, "value" => value}, target, opts) do 129 | Replace.apply(%Replace{path: path, value: value}, target, opts) 130 | end 131 | 132 | defp do_apply_patch(%{"op" => "copy", "from" => from, "path" => path}, target, opts) do 133 | Copy.apply(%Copy{from: from, path: path}, target, opts) 134 | end 135 | 136 | defp do_apply_patch(%{"op" => "move", "from" => from, "path" => path}, target, opts) do 137 | Move.apply(%Move{from: from, path: path}, target, opts) 138 | end 139 | 140 | defp do_apply_patch(%{"op" => "test", "path" => path, "value" => value}, target, opts) do 141 | Test.apply(%Test{path: path, value: value}, target, opts) 142 | end 143 | 144 | defp do_apply_patch(json_patch, _target, _opts) do 145 | {:error, {:invalid_spec, json_patch}} 146 | end 147 | 148 | @doc """ 149 | Apply a Jsonpatch or a list of Jsonpatches to a map or struct. In case of an error 150 | it will raise an exception. When a list is provided, the operations are applied in 151 | the order as they appear in the list. 152 | 153 | (See Jsonpatch.apply_patch/2 for more details) 154 | """ 155 | @spec apply_patch!(t() | list(t()), target :: Types.json_container(), Types.opts()) :: 156 | Types.json_container() 157 | def apply_patch!(json_patch, target, opts \\ []) do 158 | case apply_patch(json_patch, target, opts) do 159 | {:ok, patched} -> patched 160 | {:error, _} = error -> raise JsonpatchException, error 161 | end 162 | end 163 | 164 | @doc """ 165 | Creates a patch from the difference of a source map to a destination map or list. 166 | 167 | ## Examples 168 | 169 | iex> source = %{"name" => "Bob", "married" => false, "hobbies" => ["Elixir", "Sport", "Football"]} 170 | iex> destination = %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33} 171 | iex> Jsonpatch.diff(source, destination) 172 | [ 173 | %{path: "/married", value: true, op: "replace"}, 174 | %{path: "/hobbies/2", op: "remove"}, 175 | %{path: "/hobbies/1", op: "remove"}, 176 | %{path: "/hobbies/0", value: "Elixir!", op: "replace"}, 177 | %{path: "/age", value: 33, op: "add"} 178 | ] 179 | """ 180 | @spec diff(Types.json_container(), Types.json_container()) :: [Jsonpatch.t()] 181 | def diff(source, destination) 182 | 183 | def diff(%{} = source, %{} = destination) do 184 | flat(destination) 185 | |> do_diff(source, "") 186 | end 187 | 188 | def diff(source, destination) when is_list(source) and is_list(destination) do 189 | flat(destination) 190 | |> do_diff(source, "") 191 | end 192 | 193 | def diff(_, _) do 194 | [] 195 | end 196 | 197 | defguardp are_unequal_maps(val1, val2) 198 | when val1 != val2 and is_map(val2) and is_map(val1) 199 | 200 | defguardp are_unequal_lists(val1, val2) 201 | when val1 != val2 and is_list(val2) and is_list(val1) 202 | 203 | # Diff reduce loop 204 | defp do_diff(destination, source, ancestor_path, acc \\ [], checked_keys \\ []) 205 | 206 | defp do_diff([], source, ancestor_path, patches, checked_keys) do 207 | # The complete desination was check. Every key that is not in the list of 208 | # checked keys, must be removed. 209 | source 210 | |> flat() 211 | |> Stream.map(fn {k, _} -> escape(k) end) 212 | |> Stream.filter(fn k -> k not in checked_keys end) 213 | |> Stream.map(fn k -> %{op: "remove", path: "#{ancestor_path}/#{k}"} end) 214 | |> Enum.reduce(patches, fn remove_patch, patches -> [remove_patch | patches] end) 215 | end 216 | 217 | defp do_diff([{key, val} | tail], source, ancestor_path, patches, checked_keys) do 218 | current_path = "#{ancestor_path}/#{escape(key)}" 219 | 220 | patches = 221 | case Utils.fetch(source, key) do 222 | # Key is not present in source 223 | {:error, _} -> 224 | [%{op: "add", path: current_path, value: val} | patches] 225 | 226 | # Source has a different value but both (destination and source) value are lists or a maps 227 | {:ok, source_val} when are_unequal_lists(source_val, val) -> 228 | val |> flat() |> Enum.reverse() |> do_diff(source_val, current_path, patches, []) 229 | 230 | {:ok, source_val} when are_unequal_maps(source_val, val) -> 231 | # Enter next level - set check_keys to empty list because it is a different level 232 | val |> flat() |> do_diff(source_val, current_path, patches, []) 233 | 234 | # Scalar source val that is not equal 235 | {:ok, source_val} when source_val != val -> 236 | [%{op: "replace", path: current_path, value: val} | patches] 237 | 238 | _ -> 239 | patches 240 | end 241 | 242 | # Diff next value of same level 243 | do_diff(tail, source, ancestor_path, patches, [escape(key) | checked_keys]) 244 | end 245 | 246 | # Transforms a map into a tuple list and a list also into a tuple list with indizes 247 | defp flat(val) when is_list(val), 248 | do: Stream.with_index(val) |> Enum.map(fn {v, k} -> {k, v} end) 249 | 250 | defp flat(val) when is_map(val), 251 | do: Map.to_list(val) 252 | 253 | defp escape(fragment) when is_binary(fragment), do: Utils.escape(fragment) 254 | defp escape(fragment), do: fragment 255 | end 256 | -------------------------------------------------------------------------------- /lib/jsonpatch/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Error do 2 | @moduledoc """ 3 | Describe an error that occured while patching. 4 | """ 5 | 6 | @enforce_keys [:patch, :patch_index, :reason] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | patch: Jsonpatch.t(), 11 | patch_index: non_neg_integer(), 12 | reason: Jsonpatch.Types.error_reason() 13 | } 14 | end 15 | -------------------------------------------------------------------------------- /lib/jsonpatch/operation/add.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.Add do 2 | @moduledoc """ 3 | The add operation is the operation for creating/updating values. 4 | Values can be inserted in a list using an index or appended using a `-`. 5 | 6 | ## Examples 7 | 8 | iex> add = %Add{path: "/a/b", value: 1} 9 | iex> target = %{"a" => %{"c" => false}} 10 | iex> Jsonpatch.Operation.Add.apply(add, target, []) 11 | {:ok, %{"a" => %{"b" => 1, "c" => false}}} 12 | 13 | iex> add = %Add{path: "/a/1", value: "b"} 14 | iex> target = %{"a" => ["a", "c"]} 15 | iex> Jsonpatch.Operation.Add.apply(add, target, []) 16 | {:ok, %{"a" => ["a", "b", "c"]}} 17 | 18 | iex> add = %Add{path: "/a/-", value: "z"} 19 | iex> target = %{"a" => ["x", "y"]} 20 | iex> Jsonpatch.Operation.Add.apply(add, target, []) 21 | {:ok, %{"a" => ["x", "y", "z"]}} 22 | """ 23 | 24 | alias Jsonpatch.Operation.Add 25 | alias Jsonpatch.Types 26 | alias Jsonpatch.Utils 27 | 28 | @enforce_keys [:path, :value] 29 | defstruct [:path, :value] 30 | @type t :: %__MODULE__{path: String.t(), value: any} 31 | 32 | @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: 33 | {:ok, Types.json_container()} | Types.error() 34 | def apply(%Add{path: path, value: value}, target, opts) do 35 | with {:ok, destination} <- Utils.get_destination(target, path, opts), 36 | {:ok, updated_destination} <- do_add(destination, value, opts) do 37 | Utils.update_destination(target, updated_destination, path, opts) 38 | end 39 | end 40 | 41 | defp do_add({_destination, :root}, value, _opts) do 42 | {:ok, value} 43 | end 44 | 45 | defp do_add({%{} = destination, last_fragment}, value, _opts) do 46 | {:ok, Map.put(destination, last_fragment, value)} 47 | end 48 | 49 | defp do_add({destination, last_fragment}, value, _opts) when is_list(destination) do 50 | index = to_index(last_fragment) 51 | {:ok, List.insert_at(destination, index, value)} 52 | end 53 | 54 | defp to_index(:-), do: -1 55 | defp to_index(index), do: index 56 | end 57 | -------------------------------------------------------------------------------- /lib/jsonpatch/operation/copy.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.Copy do 2 | @moduledoc """ 3 | Represents the handling of JSON patches with a copy operation. 4 | 5 | ## Examples 6 | 7 | iex> copy = %Jsonpatch.Operation.Copy{from: "/a/b", path: "/a/e"} 8 | iex> target = %{"a" => %{"b" => %{"c" => "Bob"}}, "d" => false} 9 | iex> Jsonpatch.Operation.Copy.apply(copy, target, []) 10 | {:ok, %{"a" => %{"b" => %{"c" => "Bob"}, "e" => %{"c" => "Bob"}}, "d" => false}} 11 | """ 12 | 13 | alias Jsonpatch.Types 14 | alias Jsonpatch.Operation.{Add, Copy} 15 | alias Jsonpatch.Utils 16 | 17 | @enforce_keys [:from, :path] 18 | defstruct [:from, :path] 19 | @type t :: %__MODULE__{from: String.t(), path: String.t()} 20 | 21 | @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: 22 | {:ok, Types.json_container()} | Types.error() 23 | def apply(%Copy{from: from, path: path}, target, opts) do 24 | with {:ok, destination} <- Utils.get_destination(target, from, opts), 25 | {:ok, from_fragments} = Utils.split_path(from), 26 | {:ok, copy_value} <- extract_copy_value(destination, from_fragments) do 27 | Add.apply(%Add{value: copy_value, path: path}, target, opts) 28 | end 29 | end 30 | 31 | defp extract_copy_value({%{} = destination, fragment}, from_path) do 32 | case destination do 33 | %{^fragment => val} -> {:ok, val} 34 | _ -> {:error, {:invalid_path, from_path}} 35 | end 36 | end 37 | 38 | defp extract_copy_value({destination, index}, from_path) when is_list(destination) do 39 | case Utils.fetch(destination, index) do 40 | {:ok, _} = ok -> ok 41 | {:error, :invalid_path} -> {:error, {:invalid_path, from_path}} 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/jsonpatch/operation/move.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.Move do 2 | @moduledoc """ 3 | Move operations change the position of values in map or struct. 4 | 5 | ## Examples 6 | 7 | iex> move = %Jsonpatch.Operation.Move{from: "/a/b", path: "/a/e"} 8 | iex> target = %{"a" => %{"b" => %{"c" => "Bob"}}, "d" => false} 9 | iex> Jsonpatch.Operation.Move.apply(move, target, []) 10 | {:ok, %{"a" => %{"e" => %{"c" => "Bob"}}, "d" => false}} 11 | """ 12 | 13 | alias Jsonpatch.Operation.{Copy, Move, Remove} 14 | alias Jsonpatch.Types 15 | 16 | @enforce_keys [:from, :path] 17 | defstruct [:from, :path] 18 | @type t :: %__MODULE__{from: String.t(), path: String.t()} 19 | 20 | @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: 21 | {:ok, Types.json_container()} | Types.error() 22 | def apply(%Move{from: from, path: path}, target, opts) do 23 | if from != path do 24 | do_move(from, path, target, opts) 25 | else 26 | {:ok, target} 27 | end 28 | end 29 | 30 | defp do_move(from, path, target, opts) do 31 | copy_patch = %Copy{from: from, path: path} 32 | remove_patch = %Remove{path: from} 33 | 34 | with {:ok, res} <- Copy.apply(copy_patch, target, opts), 35 | {:ok, res} <- Remove.apply(remove_patch, res, opts) do 36 | {:ok, res} 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/jsonpatch/operation/remove.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.Remove do 2 | @moduledoc """ 3 | A JSON patch remove operation is responsible for removing values. 4 | 5 | ## Examples 6 | 7 | iex> remove = %Jsonpatch.Operation.Remove{path: "/a/b"} 8 | iex> target = %{"a" => %{"b" => %{"c" => "Bob"}}, "d" => false} 9 | iex> Jsonpatch.Operation.Remove.apply(remove, target, []) 10 | {:ok, %{"a" => %{}, "d" => false}} 11 | 12 | iex> remove = %Jsonpatch.Operation.Remove{path: "/a/b"} 13 | iex> target = %{"a" => %{"b" => nil}, "d" => false} 14 | iex> Jsonpatch.Operation.Remove.apply(remove, target, []) 15 | {:ok, %{"a" => %{}, "d" => false}} 16 | """ 17 | 18 | alias Jsonpatch.Operation.Remove 19 | alias Jsonpatch.Types 20 | alias Jsonpatch.Utils 21 | 22 | @enforce_keys [:path] 23 | defstruct [:path] 24 | @type t :: %__MODULE__{path: String.t()} 25 | 26 | @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: 27 | {:ok, Types.json_container()} | Types.error() 28 | def apply(%Remove{path: path}, target, opts) do 29 | with {:ok, fragments} <- Utils.split_path(path) do 30 | do_remove(target, [], fragments, opts) 31 | end 32 | end 33 | 34 | defp do_remove(%{} = target, path, [fragment], opts) do 35 | with {:ok, fragment} <- Utils.cast_fragment(fragment, path, target, opts), 36 | %{^fragment => _} <- target do 37 | {:ok, Map.delete(target, fragment)} 38 | else 39 | %{} -> 40 | {:error, {:invalid_path, path ++ [fragment]}} 41 | 42 | # coveralls-ignore-start 43 | {:error, _} = error -> 44 | error 45 | # coveralls-ignore-stop 46 | end 47 | end 48 | 49 | defp do_remove(%{} = target, path, [fragment | tail], opts) do 50 | with {:ok, fragment} <- Utils.cast_fragment(fragment, path, target, opts), 51 | %{^fragment => val} <- target, 52 | {:ok, new_val} <- do_remove(val, path ++ [fragment], tail, opts) do 53 | {:ok, %{target | fragment => new_val}} 54 | else 55 | %{} -> {:error, {:invalid_path, path ++ [fragment]}} 56 | {:error, _} = error -> error 57 | end 58 | end 59 | 60 | defp do_remove(target, path, [fragment | tail], opts) when is_list(target) do 61 | case Utils.cast_fragment(fragment, path, target, opts) do 62 | {:ok, :-} -> 63 | {:error, {:invalid_path, path ++ [fragment]}} 64 | 65 | {:ok, index} -> 66 | if tail == [] do 67 | {:ok, List.delete_at(target, index)} 68 | else 69 | Utils.update_at(target, index, path, &do_remove(&1, path ++ [fragment], tail, opts)) 70 | end 71 | 72 | {:error, _} = error -> 73 | error 74 | end 75 | end 76 | 77 | defp do_remove(_target, path, [fragment | _], _opts) do 78 | {:error, {:invalid_path, path ++ [fragment]}} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/jsonpatch/operation/replace.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.Replace do 2 | @moduledoc """ 3 | The replace module helps replacing values in maps and structs by paths. 4 | 5 | ## Examples 6 | 7 | iex> add = %Jsonpatch.Operation.Replace{path: "/a/b", value: 1} 8 | iex> target = %{"a" => %{"b" => 2}} 9 | iex> Jsonpatch.Operation.Replace.apply(add, target, []) 10 | {:ok, %{"a" => %{"b" => 1}}} 11 | """ 12 | 13 | alias Jsonpatch.Types 14 | alias Jsonpatch.Operation.{Add, Remove, Replace} 15 | 16 | @enforce_keys [:path, :value] 17 | defstruct [:path, :value] 18 | @type t :: %__MODULE__{path: String.t(), value: any} 19 | 20 | @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: 21 | {:ok, Types.json_container()} | Types.error() 22 | def apply(%Replace{path: "", value: value}, _target, _opts) do 23 | {:ok, value} 24 | end 25 | 26 | def apply(%Replace{path: path, value: value}, target, opts) do 27 | remove_patch = %Remove{path: path} 28 | add_patch = %Add{value: value, path: path} 29 | 30 | with {:ok, res} <- Remove.apply(remove_patch, target, opts), 31 | {:ok, res} <- Add.apply(add_patch, res, opts) do 32 | {:ok, res} 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/jsonpatch/operation/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.Test do 2 | @moduledoc """ 3 | A test operation in a JSON patch prevents the patch application or allows it. 4 | 5 | ## Examples 6 | 7 | iex> test = %Jsonpatch.Operation.Test{path: "/x/y", value: "Bob"} 8 | iex> target = %{"x" => %{"y" => "Bob"}} 9 | iex> Jsonpatch.Operation.Test.apply(test, target, []) 10 | {:ok, %{"x" => %{"y" => "Bob"}}} 11 | """ 12 | 13 | alias Jsonpatch.Operation.Test 14 | alias Jsonpatch.Types 15 | alias Jsonpatch.Utils 16 | 17 | @enforce_keys [:path, :value] 18 | defstruct [:path, :value] 19 | @type t :: %__MODULE__{path: String.t(), value: any} 20 | 21 | @spec apply(Jsonpatch.t(), target :: Types.json_container(), Types.opts()) :: 22 | {:ok, Types.json_container()} | Types.error() 23 | def apply(%Test{path: path, value: value}, target, opts) do 24 | with {:ok, destination} <- Utils.get_destination(target, path, opts), 25 | {:ok, test_path} = Utils.split_path(path), 26 | {:ok, true} <- do_test(destination, value, test_path) do 27 | {:ok, target} 28 | else 29 | {:ok, false} -> 30 | {:error, {:test_failed, "Expected value '#{inspect(value)}' at '#{path}'"}} 31 | 32 | {:error, _} = error -> 33 | error 34 | end 35 | end 36 | 37 | defp do_test({%{} = target, last_fragment}, value, _path) do 38 | case target do 39 | %{^last_fragment => ^value} -> {:ok, true} 40 | %{} -> {:ok, false} 41 | end 42 | end 43 | 44 | defp do_test({target, index}, value, path) when is_list(target) do 45 | case Utils.fetch(target, index) do 46 | {:ok, fetched_value} -> 47 | {:ok, fetched_value == value} 48 | 49 | {:error, :invalid_path} -> 50 | {:error, {:invalid_path, path}} 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/jsonpatch/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Types do 2 | @moduledoc """ 3 | Types 4 | """ 5 | 6 | @type error :: {:error, error_reason()} 7 | @type error_reason :: 8 | {:invalid_spec, String.t()} 9 | | {:invalid_path, [casted_fragment()]} 10 | | {:test_failed, String.t()} 11 | 12 | @type json_container :: map() | list() 13 | 14 | @type convert_fn :: 15 | (fragment :: term(), target_path :: [term()], target :: json_container(), opts() -> 16 | {:ok, converted_fragment :: term()} | :error) 17 | 18 | @typedoc """ 19 | Keys options: 20 | 21 | - `:strings` (default) - decodes path fragments as binary strings 22 | - `:atoms` - path fragments are converted to atoms 23 | - `:atoms!` - path fragments are converted to existing atoms 24 | - `{:custom, convert_fn}` - path fragments are converted with `convert_fn` 25 | """ 26 | @type opt_keys :: 27 | :strings | :atoms | {:custom, convert_fn()} | {:ignore_invalid_paths, :boolean} 28 | 29 | @typedoc """ 30 | Types options: 31 | 32 | - `:keys` - controls how path fragments are decoded. 33 | """ 34 | @type opts :: [{:keys, opt_keys()}] 35 | 36 | @type casted_array_index :: :- | non_neg_integer() 37 | @type casted_object_key :: atom() | String.t() 38 | @type casted_fragment :: casted_array_index() | casted_object_key() 39 | end 40 | -------------------------------------------------------------------------------- /lib/jsonpatch/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Utils do 2 | @moduledoc false 3 | 4 | alias Jsonpatch.Types 5 | 6 | @default_opts_keys :strings 7 | 8 | @doc """ 9 | Split a path into its fragments 10 | 11 | ## Examples 12 | 13 | iex> path = "/a/b/c" 14 | iex> Jsonpatch.Utils.split_path(path) 15 | {:ok, ["a", "b", "c"]} 16 | """ 17 | @spec split_path(String.t()) :: {:ok, [String.t(), ...] | :root} | Types.error() 18 | def split_path("/" <> path) do 19 | fragments = 20 | path 21 | |> String.split("/") 22 | |> Enum.map(&unescape/1) 23 | 24 | {:ok, fragments} 25 | end 26 | 27 | def split_path(""), do: {:ok, :root} 28 | 29 | def split_path(path), do: {:error, {:invalid_path, path}} 30 | 31 | @doc """ 32 | Join path fragments 33 | 34 | ## Examples 35 | 36 | iex> fragments = ["a", "b", "c"] 37 | iex> Jsonpatch.Utils.join_path(fragments) 38 | "/a/b/c" 39 | """ 40 | @spec join_path(fragments :: [Types.casted_fragment(), ...]) :: String.t() 41 | def join_path([_ | _] = fragments) do 42 | fragments = 43 | fragments 44 | |> Enum.map(&to_string/1) 45 | |> Enum.map(&escape/1) 46 | 47 | "/" <> Enum.join(fragments, "/") 48 | end 49 | 50 | @doc """ 51 | Cast a path fragment according to the target type. 52 | 53 | ## Examples 54 | 55 | iex> Jsonpatch.Utils.cast_fragment("0", ["path"], ["x", "y"], []) 56 | {:ok, 0} 57 | 58 | iex> Jsonpatch.Utils.cast_fragment("-", ["path"], ["x", "y"], []) 59 | {:ok, :-} 60 | 61 | iex> Jsonpatch.Utils.cast_fragment("0", ["path"], %{"0" => "zero"}, []) 62 | {:ok, "0"} 63 | """ 64 | @spec cast_fragment( 65 | fragment :: String.t(), 66 | path :: [Types.casted_fragment()], 67 | target :: Types.json_container(), 68 | Types.opts() 69 | ) :: {:ok, Types.casted_fragment()} | Types.error() 70 | def cast_fragment(fragment, path, target, opts) when is_list(target) do 71 | keys = Keyword.get(opts, :keys, @default_opts_keys) 72 | 73 | case keys do 74 | {:custom, custom_fn} -> 75 | case custom_fn.(fragment, path, target, opts) do 76 | {:ok, _} = ok -> ok 77 | :error -> {:error, {:invalid_path, path ++ [fragment]}} 78 | end 79 | 80 | _ -> 81 | cast_index(fragment, path, target) 82 | end 83 | end 84 | 85 | def cast_fragment(fragment, path, target, opts) when is_map(target) do 86 | keys = Keyword.get(opts, :keys, @default_opts_keys) 87 | 88 | case keys do 89 | :strings -> 90 | {:ok, fragment} 91 | 92 | :atoms -> 93 | {:ok, String.to_atom(fragment)} 94 | 95 | :atoms! -> 96 | case string_to_existing_atom(fragment) do 97 | {:ok, _} = ok -> ok 98 | :error -> {:error, {:invalid_path, path ++ [fragment]}} 99 | end 100 | 101 | {:custom, custom_fn} -> 102 | case custom_fn.(fragment, path, target, opts) do 103 | {:ok, _} = ok -> ok 104 | :error -> {:error, {:invalid_path, path ++ [fragment]}} 105 | end 106 | end 107 | end 108 | 109 | @doc """ 110 | Uses a JSON patch path to get the last map/list that this path references. 111 | 112 | ## Examples 113 | 114 | iex> path = "/a/b/c/d" 115 | iex> target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} 116 | iex> Jsonpatch.Utils.get_destination(target, path) 117 | {:ok, {%{"d" => 1}, "d"}} 118 | 119 | iex> # Invalid path 120 | iex> path = "/a/e/c/d" 121 | iex> target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} 122 | iex> Jsonpatch.Utils.get_destination(target, path) 123 | {:error, {:invalid_path, ["a", "e"]}} 124 | 125 | iex> path = "/a/b/1/d" 126 | iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} 127 | iex> Jsonpatch.Utils.get_destination(target, path) 128 | {:ok, {%{"d" => 1}, "d"}} 129 | 130 | iex> path = "/a/b/1" 131 | iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} 132 | iex> Jsonpatch.Utils.get_destination(target, path) 133 | {:ok, {[true, %{"d" => 1}], 1}} 134 | 135 | iex> # Invalid path 136 | iex> path = "/a/b/42/d" 137 | iex> target = %{"a" => %{"b" => [true, %{"d" => 1}]}} 138 | iex> Jsonpatch.Utils.get_destination(target, path) 139 | {:error, {:invalid_path, ["a", "b", "42"]}} 140 | """ 141 | 142 | @spec get_destination( 143 | target :: Types.json_container(), 144 | path :: String.t(), 145 | Types.opts() 146 | ) :: 147 | {:ok, {Types.json_container(), last_fragment :: Types.casted_fragment()}} 148 | | Types.error() 149 | def get_destination(target, path, opts \\ []) 150 | 151 | def get_destination(target, "", _opts) do 152 | {:ok, {target, :root}} 153 | end 154 | 155 | def get_destination(target, path, opts) do 156 | with {:ok, fragments} <- split_path(path) do 157 | find_destination(target, [], fragments, opts) 158 | end 159 | end 160 | 161 | @doc """ 162 | Updatest a map/list reference by a given JSON patch path with the new destination. 163 | 164 | ## Examples 165 | 166 | iex> path = "/a/b/c/d" 167 | iex> target = %{"a" => %{"b" => %{"c" => %{"xx" => 0, "d" => 1}}}} 168 | iex> Jsonpatch.Utils.update_destination(target, %{"e" => 1}, path) 169 | {:ok, %{"a" => %{"b" => %{"c" => %{"e" => 1}}}}} 170 | 171 | iex> path = "/a/b/1" 172 | iex> target = %{"a" => %{"b" => [0, 1, 2]}} 173 | iex> Jsonpatch.Utils.update_destination(target, 9999, path) 174 | {:ok, %{"a" => %{"b" => 9999}}} 175 | """ 176 | @spec update_destination( 177 | target :: Types.json_container(), 178 | value :: term(), 179 | String.t(), 180 | Types.opts() 181 | ) :: 182 | {:ok, Types.json_container()} | Types.error() 183 | def update_destination(target, value, path, opts \\ []) do 184 | with {:ok, fragments} <- split_path(path) do 185 | do_update_destination(target, value, [], fragments, opts) 186 | end 187 | end 188 | 189 | @doc """ 190 | Unescape `~1` to `/` and `~0` to `~`. 191 | """ 192 | @spec unescape(fragment :: String.t() | integer()) :: String.t() 193 | def unescape(fragment) when is_binary(fragment) do 194 | fragment 195 | |> String.replace("~0", "~") 196 | |> String.replace("~1", "/") 197 | end 198 | 199 | @doc """ 200 | Escape `/` to `~1 and `~` to `~0`. 201 | """ 202 | @spec escape(fragment :: String.t() | integer()) :: String.t() 203 | def escape(fragment) when is_binary(fragment) do 204 | fragment 205 | |> String.replace("~", "~0") 206 | |> String.replace("/", "~1") 207 | end 208 | 209 | @doc """ 210 | Updates a list with the given update_fn while respecting Jsonpatch errors. 211 | In case uodate_fn returns an error then update_at will also return this error. 212 | When the update_fn succeeds it will return the list. 213 | """ 214 | @spec update_at( 215 | target :: list(), 216 | index :: non_neg_integer(), 217 | path :: [Types.casted_fragment()], 218 | update_fn :: (item :: term() -> updated_item :: term()) 219 | ) :: {:ok, list()} | Types.error() 220 | def update_at(target, index, path, update_fn) do 221 | case fetch(target, index) do 222 | {:ok, old_val} -> 223 | do_update_at(target, index, old_val, update_fn) 224 | 225 | {:error, :invalid_path} -> 226 | {:error, {:invalid_path, path ++ [to_string(index)]}} 227 | end 228 | end 229 | 230 | @spec fetch(Types.json_container(), Types.casted_fragment()) :: 231 | {:ok, term()} | {:error, :invalid_path} 232 | def fetch(_list, :-), do: {:error, :invalid_path} 233 | 234 | def fetch(container, key) do 235 | mod = 236 | cond do 237 | is_list(container) -> Enum 238 | is_map(container) -> Map 239 | end 240 | 241 | case mod.fetch(container, key) do 242 | # coveralls-ignore-start 243 | :error -> {:error, :invalid_path} 244 | # coveralls-ignore-stop 245 | {:ok, val} -> {:ok, val} 246 | end 247 | end 248 | 249 | @spec cast_index( 250 | fragment :: String.t(), 251 | path :: [Types.casted_fragment()], 252 | target :: Types.json_container() 253 | ) :: {:ok, Types.casted_fragment()} | Types.error() 254 | def cast_index(fragment, path, target) do 255 | case fragment do 256 | "-" -> 257 | {:ok, :-} 258 | 259 | _ -> 260 | case to_index(fragment, length(target)) do 261 | {:ok, index} -> {:ok, index} 262 | {:error, :invalid_path} -> {:error, {:invalid_path, path ++ [fragment]}} 263 | end 264 | end 265 | end 266 | 267 | defp to_index(unparsed_index, list_lenght) do 268 | case Integer.parse(unparsed_index) do 269 | {index, _} when 0 <= index and index <= list_lenght -> {:ok, index} 270 | {_index_out_of_range, _} -> {:error, :invalid_path} 271 | :error -> {:error, :invalid_path} 272 | end 273 | end 274 | 275 | defp find_destination(%{} = target, path, [fragment], opts) do 276 | with {:ok, fragment} <- cast_fragment(fragment, path, target, opts) do 277 | {:ok, {target, fragment}} 278 | end 279 | end 280 | 281 | defp find_destination(target, path, [fragment], opts) when is_list(target) do 282 | with {:ok, index} <- cast_fragment(fragment, path, target, opts) do 283 | {:ok, {target, index}} 284 | end 285 | end 286 | 287 | defp find_destination(%{} = target, path, [fragment | tail], opts) do 288 | with {:ok, fragment} <- cast_fragment(fragment, path, target, opts), 289 | %{^fragment => sub_target} <- target do 290 | find_destination(sub_target, path ++ [fragment], tail, opts) 291 | else 292 | %{} -> 293 | {:error, {:invalid_path, path ++ [fragment]}} 294 | 295 | # coveralls-ignore-start 296 | {:error, _} = error -> 297 | error 298 | # coveralls-ignore-stop 299 | end 300 | end 301 | 302 | defp find_destination(target, path, [fragment | tail], opts) when is_list(target) do 303 | with {:ok, index} <- cast_fragment(fragment, path, target, opts) do 304 | val = Enum.fetch!(target, index) 305 | find_destination(val, path ++ [fragment], tail, opts) 306 | end 307 | end 308 | 309 | defp do_update_destination(_target, value, _path, :root, _opts) do 310 | {:ok, value} 311 | end 312 | 313 | defp do_update_destination(_target, value, _path, [_fragment], _opts) do 314 | {:ok, value} 315 | end 316 | 317 | defp do_update_destination(%{} = target, value, path, [destination, _last_ele], opts) do 318 | with {:ok, destination} <- cast_fragment(destination, path, target, opts) do 319 | {:ok, Map.replace!(target, destination, value)} 320 | end 321 | end 322 | 323 | defp do_update_destination(target, value, path, [destination, _last_ele], opts) 324 | when is_list(target) do 325 | with {:ok, index} <- cast_fragment(destination, path, target, opts) do 326 | {:ok, List.replace_at(target, index, value)} 327 | end 328 | end 329 | 330 | defp do_update_destination(%{} = target, value, path, [fragment | tail], opts) do 331 | with {:ok, fragment} <- cast_fragment(fragment, path, target, opts), 332 | %{^fragment => sub_target} <- target, 333 | {:ok, updated_val} <- 334 | do_update_destination(sub_target, value, path ++ [fragment], tail, opts) do 335 | {:ok, %{target | fragment => updated_val}} 336 | else 337 | %{} -> {:error, {:invalid_path, path ++ [fragment]}} 338 | {:error, _} = error -> error 339 | end 340 | end 341 | 342 | defp do_update_destination(target, value, path, [fragment | tail], opts) when is_list(target) do 343 | with {:ok, index} <- cast_fragment(fragment, path, target, opts) do 344 | update_fn = &do_update_destination(&1, value, path ++ [fragment], tail, opts) 345 | update_at(target, index, path, update_fn) 346 | end 347 | end 348 | 349 | defp do_update_at(target, index, old_val, update_fn) do 350 | case update_fn.(old_val) do 351 | {:error, _} = error -> error 352 | {:ok, new_val} -> {:ok, List.replace_at(target, index, new_val)} 353 | end 354 | end 355 | 356 | defp string_to_existing_atom(data) when is_binary(data) do 357 | {:ok, String.to_existing_atom(data)} 358 | rescue 359 | ArgumentError -> :error 360 | end 361 | end 362 | -------------------------------------------------------------------------------- /lib/jsonpatch_exception.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonpatchException do 2 | @moduledoc """ 3 | JsonpatchException will be raised if a patch is applied with "!" 4 | and the patching fails. 5 | """ 6 | 7 | defexception [:message] 8 | 9 | @impl true 10 | def exception({:error, %Jsonpatch.Error{patch_index: patch_index, reason: reason}} = _error) do 11 | msg = "patch ##{patch_index} failed, '#{inspect(reason)}'" 12 | %JsonpatchException{message: msg} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :jsonpatch, 7 | name: "Jsonpatch", 8 | description: "Implementation of RFC 6902 in pure Elixir", 9 | version: "2.2.2", 10 | elixir: "~> 1.10", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | test_coverage: [tool: ExCoveralls], 14 | package: package(), 15 | source_url: "https://github.com/corka149/jsonpatch", 16 | preferred_cli_env: [ 17 | coveralls: :test, 18 | "coveralls.github": :test, 19 | "coveralls.detail": :test, 20 | "coveralls.post": :test, 21 | "coveralls.html": :test, 22 | docs: :dev 23 | ], 24 | docs: [ 25 | main: "readme", 26 | extras: ["README.md"] 27 | ] 28 | ] 29 | end 30 | 31 | def application do 32 | [ 33 | extra_applications: [:logger] 34 | ] 35 | end 36 | 37 | defp deps do 38 | [ 39 | {:excoveralls, "~> 0.18", only: [:test]}, 40 | {:credo, "~> 1.7.5", only: [:dev, :test], runtime: false}, 41 | {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, 42 | {:ex_doc, "~> 0.31", only: [:dev], runtime: false}, 43 | {:jason, "~> 1.4", only: [:dev, :test]} 44 | ] 45 | end 46 | 47 | defp package() do 48 | [ 49 | maintainers: ["Sebastian Ziemann"], 50 | licenses: ["MIT"], 51 | source_url: "https://github.com/corka149/jsonpatch", 52 | links: %{ 53 | "GitHub" => "https://github.com/corka149/jsonpatch" 54 | } 55 | ] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, 9 | "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, 10 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 11 | "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"}, 12 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 13 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 14 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 15 | "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"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 20 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 21 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 22 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 23 | } 24 | -------------------------------------------------------------------------------- /test/json-patch-tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "comment": "empty list, empty docs", 4 | "doc": {}, 5 | "patch": [], 6 | "expected": {} 7 | }, 8 | 9 | { 10 | "comment": "empty patch list", 11 | "doc": { "foo": 1 }, 12 | "patch": [], 13 | "expected": { "foo": 1 } 14 | }, 15 | 16 | { 17 | "comment": "rearrangements OK?", 18 | "doc": { "foo": 1, "bar": 2 }, 19 | "patch": [], 20 | "expected": { "bar": 2, "foo": 1 } 21 | }, 22 | 23 | { 24 | "comment": "rearrangements OK? How about one level down ... array", 25 | "doc": [{ "foo": 1, "bar": 2 }], 26 | "patch": [], 27 | "expected": [{ "bar": 2, "foo": 1 }] 28 | }, 29 | 30 | { 31 | "comment": "rearrangements OK? How about one level down...", 32 | "doc": { "foo": { "foo": 1, "bar": 2 } }, 33 | "patch": [], 34 | "expected": { "foo": { "bar": 2, "foo": 1 } } 35 | }, 36 | 37 | { 38 | "comment": "add replaces any existing field", 39 | "doc": { "foo": null }, 40 | "patch": [{ "op": "add", "path": "/foo", "value": 1 }], 41 | "expected": { "foo": 1 } 42 | }, 43 | 44 | { 45 | "comment": "toplevel array", 46 | "doc": [], 47 | "patch": [{ "op": "add", "path": "/0", "value": "foo" }], 48 | "expected": ["foo"] 49 | }, 50 | 51 | { 52 | "comment": "toplevel array, no change", 53 | "doc": ["foo"], 54 | "patch": [], 55 | "expected": ["foo"] 56 | }, 57 | 58 | { 59 | "comment": "toplevel object, numeric string", 60 | "doc": {}, 61 | "patch": [{ "op": "add", "path": "/foo", "value": "1" }], 62 | "expected": { "foo": "1" } 63 | }, 64 | 65 | { 66 | "comment": "toplevel object, integer", 67 | "doc": {}, 68 | "patch": [{ "op": "add", "path": "/foo", "value": 1 }], 69 | "expected": { "foo": 1 } 70 | }, 71 | 72 | { 73 | "comment": "Toplevel scalar values OK?", 74 | "doc": "foo", 75 | "patch": [{ "op": "replace", "path": "", "value": "bar" }], 76 | "expected": "bar", 77 | "disabled": true 78 | }, 79 | 80 | { 81 | "comment": "replace object document with array document?", 82 | "doc": {}, 83 | "patch": [{ "op": "add", "path": "", "value": [] }], 84 | "expected": [] 85 | }, 86 | 87 | { 88 | "comment": "replace array document with object document?", 89 | "doc": [], 90 | "patch": [{ "op": "add", "path": "", "value": {} }], 91 | "expected": {} 92 | }, 93 | 94 | { 95 | "comment": "append to root array document?", 96 | "doc": [], 97 | "patch": [{ "op": "add", "path": "/-", "value": "hi" }], 98 | "expected": ["hi"] 99 | }, 100 | 101 | { 102 | "comment": "Add, / target", 103 | "doc": {}, 104 | "patch": [{ "op": "add", "path": "/", "value": 1 }], 105 | "expected": { "": 1 } 106 | }, 107 | 108 | { 109 | "comment": "Add, /foo/ deep target (trailing slash)", 110 | "doc": { "foo": {} }, 111 | "patch": [{ "op": "add", "path": "/foo/", "value": 1 }], 112 | "expected": { "foo": { "": 1 } } 113 | }, 114 | 115 | { 116 | "comment": "Add composite value at top level", 117 | "doc": { "foo": 1 }, 118 | "patch": [{ "op": "add", "path": "/bar", "value": [1, 2] }], 119 | "expected": { "foo": 1, "bar": [1, 2] } 120 | }, 121 | 122 | { 123 | "comment": "Add into composite value", 124 | "doc": { "foo": 1, "baz": [{ "qux": "hello" }] }, 125 | "patch": [{ "op": "add", "path": "/baz/0/foo", "value": "world" }], 126 | "expected": { "foo": 1, "baz": [{ "qux": "hello", "foo": "world" }] } 127 | }, 128 | 129 | { 130 | "doc": { "bar": [1, 2] }, 131 | "patch": [{ "op": "add", "path": "/bar/8", "value": "5" }], 132 | "error": "Out of bounds (upper)" 133 | }, 134 | 135 | { 136 | "doc": { "bar": [1, 2] }, 137 | "patch": [{ "op": "add", "path": "/bar/-1", "value": "5" }], 138 | "error": "Out of bounds (lower)" 139 | }, 140 | 141 | { 142 | "doc": { "foo": 1 }, 143 | "patch": [{ "op": "add", "path": "/bar", "value": true }], 144 | "expected": { "foo": 1, "bar": true } 145 | }, 146 | 147 | { 148 | "doc": { "foo": 1 }, 149 | "patch": [{ "op": "add", "path": "/bar", "value": false }], 150 | "expected": { "foo": 1, "bar": false } 151 | }, 152 | 153 | { 154 | "doc": { "foo": 1 }, 155 | "patch": [{ "op": "add", "path": "/bar", "value": null }], 156 | "expected": { "foo": 1, "bar": null } 157 | }, 158 | 159 | { 160 | "comment": "0 can be an array index or object element name", 161 | "doc": { "foo": 1 }, 162 | "patch": [{ "op": "add", "path": "/0", "value": "bar" }], 163 | "expected": { "foo": 1, "0": "bar" } 164 | }, 165 | 166 | { 167 | "doc": ["foo"], 168 | "patch": [{ "op": "add", "path": "/1", "value": "bar" }], 169 | "expected": ["foo", "bar"] 170 | }, 171 | 172 | { 173 | "doc": ["foo", "sil"], 174 | "patch": [{ "op": "add", "path": "/1", "value": "bar" }], 175 | "expected": ["foo", "bar", "sil"] 176 | }, 177 | 178 | { 179 | "doc": ["foo", "sil"], 180 | "patch": [{ "op": "add", "path": "/0", "value": "bar" }], 181 | "expected": ["bar", "foo", "sil"] 182 | }, 183 | 184 | { 185 | "comment": "push item to array via last index + 1", 186 | "doc": ["foo", "sil"], 187 | "patch": [{ "op": "add", "path": "/2", "value": "bar" }], 188 | "expected": ["foo", "sil", "bar"] 189 | }, 190 | 191 | { 192 | "comment": "add item to array at index > length should fail", 193 | "doc": ["foo", "sil"], 194 | "patch": [{ "op": "add", "path": "/3", "value": "bar" }], 195 | "error": "index is greater than number of items in array" 196 | }, 197 | 198 | { 199 | "comment": "test against implementation-specific numeric parsing", 200 | "doc": { "1e0": "foo" }, 201 | "patch": [{ "op": "test", "path": "/1e0", "value": "foo" }], 202 | "expected": { "1e0": "foo" } 203 | }, 204 | 205 | { 206 | "comment": "test with bad number should fail", 207 | "doc": ["foo", "bar"], 208 | "patch": [{ "op": "test", "path": "/1e0", "value": "bar" }], 209 | "error": "test op shouldn't get array element 1" 210 | }, 211 | 212 | { 213 | "doc": ["foo", "sil"], 214 | "patch": [{ "op": "add", "path": "/bar", "value": 42 }], 215 | "error": "Object operation on array target" 216 | }, 217 | 218 | { 219 | "doc": ["foo", "sil"], 220 | "patch": [{ "op": "add", "path": "/1", "value": ["bar", "baz"] }], 221 | "expected": ["foo", ["bar", "baz"], "sil"], 222 | "comment": "value in array add not flattened" 223 | }, 224 | 225 | { 226 | "doc": { "foo": 1, "bar": [1, 2, 3, 4] }, 227 | "patch": [{ "op": "remove", "path": "/bar" }], 228 | "expected": { "foo": 1 } 229 | }, 230 | 231 | { 232 | "doc": { "foo": 1, "baz": [{ "qux": "hello" }] }, 233 | "patch": [{ "op": "remove", "path": "/baz/0/qux" }], 234 | "expected": { "foo": 1, "baz": [{}] } 235 | }, 236 | 237 | { 238 | "doc": { "foo": 1, "baz": [{ "qux": "hello" }] }, 239 | "patch": [{ "op": "replace", "path": "/foo", "value": [1, 2, 3, 4] }], 240 | "expected": { "foo": [1, 2, 3, 4], "baz": [{ "qux": "hello" }] } 241 | }, 242 | 243 | { 244 | "doc": { "foo": [1, 2, 3, 4], "baz": [{ "qux": "hello" }] }, 245 | "patch": [{ "op": "replace", "path": "/baz/0/qux", "value": "world" }], 246 | "expected": { "foo": [1, 2, 3, 4], "baz": [{ "qux": "world" }] } 247 | }, 248 | 249 | { 250 | "doc": ["foo"], 251 | "patch": [{ "op": "replace", "path": "/0", "value": "bar" }], 252 | "expected": ["bar"] 253 | }, 254 | 255 | { 256 | "doc": [""], 257 | "patch": [{ "op": "replace", "path": "/0", "value": 0 }], 258 | "expected": [0] 259 | }, 260 | 261 | { 262 | "doc": [""], 263 | "patch": [{ "op": "replace", "path": "/0", "value": true }], 264 | "expected": [true] 265 | }, 266 | 267 | { 268 | "doc": [""], 269 | "patch": [{ "op": "replace", "path": "/0", "value": false }], 270 | "expected": [false] 271 | }, 272 | 273 | { 274 | "doc": [""], 275 | "patch": [{ "op": "replace", "path": "/0", "value": null }], 276 | "expected": [null] 277 | }, 278 | 279 | { 280 | "doc": ["foo", "sil"], 281 | "patch": [{ "op": "replace", "path": "/1", "value": ["bar", "baz"] }], 282 | "expected": ["foo", ["bar", "baz"]], 283 | "comment": "value in array replace not flattened" 284 | }, 285 | 286 | { 287 | "comment": "replace whole document", 288 | "doc": { "foo": "bar" }, 289 | "patch": [{ "op": "replace", "path": "", "value": { "baz": "qux" } }], 290 | "expected": { "baz": "qux" } 291 | }, 292 | 293 | { 294 | "comment": "test replace with missing parent key should fail", 295 | "doc": { "bar": "baz" }, 296 | "patch": [{ "op": "replace", "path": "/foo/bar", "value": false }], 297 | "error": "replace op should fail with missing parent key" 298 | }, 299 | 300 | { 301 | "comment": "spurious patch properties", 302 | "doc": { "foo": 1 }, 303 | "patch": [{ "op": "test", "path": "/foo", "value": 1, "spurious": 1 }], 304 | "expected": { "foo": 1 } 305 | }, 306 | 307 | { 308 | "doc": { "foo": null }, 309 | "patch": [{ "op": "test", "path": "/foo", "value": null }], 310 | "expected": { "foo": null }, 311 | "comment": "null value should be valid obj property" 312 | }, 313 | 314 | { 315 | "doc": { "foo": null }, 316 | "patch": [{ "op": "replace", "path": "/foo", "value": "truthy" }], 317 | "expected": { "foo": "truthy" }, 318 | "comment": "null value should be valid obj property to be replaced with something truthy" 319 | }, 320 | 321 | { 322 | "doc": { "foo": null }, 323 | "patch": [{ "op": "move", "from": "/foo", "path": "/bar" }], 324 | "expected": { "bar": null }, 325 | "comment": "null value should be valid obj property to be moved" 326 | }, 327 | 328 | { 329 | "doc": { "foo": null }, 330 | "patch": [{ "op": "copy", "from": "/foo", "path": "/bar" }], 331 | "expected": { "foo": null, "bar": null }, 332 | "comment": "null value should be valid obj property to be copied" 333 | }, 334 | 335 | { 336 | "doc": { "foo": null }, 337 | "patch": [{ "op": "remove", "path": "/foo" }], 338 | "expected": {}, 339 | "comment": "null value should be valid obj property to be removed" 340 | }, 341 | 342 | { 343 | "doc": { "foo": "bar" }, 344 | "patch": [{ "op": "replace", "path": "/foo", "value": null }], 345 | "expected": { "foo": null }, 346 | "comment": "null value should still be valid obj property replace other value" 347 | }, 348 | 349 | { 350 | "doc": { "foo": { "foo": 1, "bar": 2 } }, 351 | "patch": [ 352 | { "op": "test", "path": "/foo", "value": { "bar": 2, "foo": 1 } } 353 | ], 354 | "expected": { "foo": { "foo": 1, "bar": 2 } }, 355 | "comment": "test should pass despite rearrangement" 356 | }, 357 | 358 | { 359 | "doc": { "foo": [{ "foo": 1, "bar": 2 }] }, 360 | "patch": [ 361 | { "op": "test", "path": "/foo", "value": [{ "bar": 2, "foo": 1 }] } 362 | ], 363 | "expected": { "foo": [{ "foo": 1, "bar": 2 }] }, 364 | "comment": "test should pass despite (nested) rearrangement" 365 | }, 366 | 367 | { 368 | "doc": { "foo": { "bar": [1, 2, 5, 4] } }, 369 | "patch": [ 370 | { "op": "test", "path": "/foo", "value": { "bar": [1, 2, 5, 4] } } 371 | ], 372 | "expected": { "foo": { "bar": [1, 2, 5, 4] } }, 373 | "comment": "test should pass - no error" 374 | }, 375 | 376 | { 377 | "doc": { "foo": { "bar": [1, 2, 5, 4] } }, 378 | "patch": [{ "op": "test", "path": "/foo", "value": [1, 2] }], 379 | "error": "test op should fail" 380 | }, 381 | 382 | { 383 | "comment": "Whole document", 384 | "doc": { "foo": 1 }, 385 | "patch": [{ "op": "test", "path": "", "value": { "foo": 1 } }], 386 | "disabled": true 387 | }, 388 | 389 | { 390 | "comment": "Empty-string element", 391 | "doc": { "": 1 }, 392 | "patch": [{ "op": "test", "path": "/", "value": 1 }], 393 | "expected": { "": 1 } 394 | }, 395 | 396 | { 397 | "doc": { 398 | "foo": ["bar", "baz"], 399 | "": 0, 400 | "a/b": 1, 401 | "c%d": 2, 402 | "e^f": 3, 403 | "g|h": 4, 404 | "i\\j": 5, 405 | "k\"l": 6, 406 | " ": 7, 407 | "m~n": 8 408 | }, 409 | "patch": [ 410 | { "op": "test", "path": "/foo", "value": ["bar", "baz"] }, 411 | { "op": "test", "path": "/foo/0", "value": "bar" }, 412 | { "op": "test", "path": "/", "value": 0 }, 413 | { "op": "test", "path": "/a~1b", "value": 1 }, 414 | { "op": "test", "path": "/c%d", "value": 2 }, 415 | { "op": "test", "path": "/e^f", "value": 3 }, 416 | { "op": "test", "path": "/g|h", "value": 4 }, 417 | { "op": "test", "path": "/i\\j", "value": 5 }, 418 | { "op": "test", "path": "/k\"l", "value": 6 }, 419 | { "op": "test", "path": "/ ", "value": 7 }, 420 | { "op": "test", "path": "/m~0n", "value": 8 } 421 | ], 422 | "expected": { 423 | "": 0, 424 | " ": 7, 425 | "a/b": 1, 426 | "c%d": 2, 427 | "e^f": 3, 428 | "foo": ["bar", "baz"], 429 | "g|h": 4, 430 | "i\\j": 5, 431 | "k\"l": 6, 432 | "m~n": 8 433 | } 434 | }, 435 | { 436 | "comment": "Move to same location has no effect", 437 | "doc": { "foo": 1 }, 438 | "patch": [{ "op": "move", "from": "/foo", "path": "/foo" }], 439 | "expected": { "foo": 1 } 440 | }, 441 | 442 | { 443 | "doc": { "foo": 1, "baz": [{ "qux": "hello" }] }, 444 | "patch": [{ "op": "move", "from": "/foo", "path": "/bar" }], 445 | "expected": { "baz": [{ "qux": "hello" }], "bar": 1 } 446 | }, 447 | 448 | { 449 | "doc": { "baz": [{ "qux": "hello" }], "bar": 1 }, 450 | "patch": [{ "op": "move", "from": "/baz/0/qux", "path": "/baz/1" }], 451 | "expected": { "baz": [{}, "hello"], "bar": 1 } 452 | }, 453 | 454 | { 455 | "doc": { "baz": [{ "qux": "hello" }], "bar": 1 }, 456 | "patch": [{ "op": "copy", "from": "/baz/0", "path": "/boo" }], 457 | "expected": { 458 | "baz": [{ "qux": "hello" }], 459 | "bar": 1, 460 | "boo": { "qux": "hello" } 461 | } 462 | }, 463 | 464 | { 465 | "comment": "replacing the root of the document is possible with add", 466 | "doc": { "foo": "bar" }, 467 | "patch": [{ "op": "add", "path": "", "value": { "baz": "qux" } }], 468 | "expected": { "baz": "qux" } 469 | }, 470 | 471 | { 472 | "comment": "Adding to \"/-\" adds to the end of the array", 473 | "doc": [1, 2], 474 | "patch": [ 475 | { "op": "add", "path": "/-", "value": { "foo": ["bar", "baz"] } } 476 | ], 477 | "expected": [1, 2, { "foo": ["bar", "baz"] }] 478 | }, 479 | 480 | { 481 | "comment": "Adding to \"/-\" adds to the end of the array, even n levels down", 482 | "doc": [1, 2, [3, [4, 5]]], 483 | "patch": [ 484 | { "op": "add", "path": "/2/1/-", "value": { "foo": ["bar", "baz"] } } 485 | ], 486 | "expected": [1, 2, [3, [4, 5, { "foo": ["bar", "baz"] }]]] 487 | }, 488 | 489 | { 490 | "comment": "test remove with bad number should fail", 491 | "doc": { "foo": 1, "baz": [{ "qux": "hello" }] }, 492 | "patch": [{ "op": "remove", "path": "/baz/1e0/qux" }], 493 | "error": "remove op shouldn't remove from array with bad number" 494 | }, 495 | 496 | { 497 | "comment": "test remove on array", 498 | "doc": [1, 2, 3, 4], 499 | "patch": [{ "op": "remove", "path": "/0" }], 500 | "expected": [2, 3, 4] 501 | }, 502 | 503 | { 504 | "comment": "test repeated removes", 505 | "doc": [1, 2, 3, 4], 506 | "patch": [ 507 | { "op": "remove", "path": "/1" }, 508 | { "op": "remove", "path": "/2" } 509 | ], 510 | "expected": [1, 3] 511 | }, 512 | 513 | { 514 | "comment": "test remove with bad index should fail", 515 | "doc": [1, 2, 3, 4], 516 | "patch": [{ "op": "remove", "path": "/1e0" }], 517 | "error": "remove op shouldn't remove from array with bad number" 518 | }, 519 | 520 | { 521 | "comment": "test replace with bad number should fail", 522 | "doc": [""], 523 | "patch": [{ "op": "replace", "path": "/1e0", "value": false }], 524 | "error": "replace op shouldn't replace in array with bad number" 525 | }, 526 | 527 | { 528 | "comment": "test copy with bad number should fail", 529 | "doc": { "baz": [1, 2, 3], "bar": 1 }, 530 | "patch": [{ "op": "copy", "from": "/baz/1e0", "path": "/boo" }], 531 | "error": "copy op shouldn't work with bad number" 532 | }, 533 | 534 | { 535 | "comment": "test move with bad number should fail", 536 | "doc": { "foo": 1, "baz": [1, 2, 3, 4] }, 537 | "patch": [{ "op": "move", "from": "/baz/1e0", "path": "/foo" }], 538 | "error": "move op shouldn't work with bad number" 539 | }, 540 | 541 | { 542 | "comment": "test add with bad number should fail", 543 | "doc": ["foo", "sil"], 544 | "patch": [{ "op": "add", "path": "/1e0", "value": "bar" }], 545 | "error": "add op shouldn't add to array with bad number" 546 | }, 547 | 548 | { 549 | "comment": "missing 'path' parameter", 550 | "doc": {}, 551 | "patch": [{ "op": "add", "value": "bar" }], 552 | "error": "missing 'path' parameter" 553 | }, 554 | 555 | { 556 | "comment": "'path' parameter with null value", 557 | "doc": {}, 558 | "patch": [{ "op": "add", "path": null, "value": "bar" }], 559 | "error": "null is not valid value for 'path'" 560 | }, 561 | 562 | { 563 | "comment": "invalid JSON Pointer token", 564 | "doc": {}, 565 | "patch": [{ "op": "add", "path": "foo", "value": "bar" }], 566 | "error": "JSON Pointer should start with a slash" 567 | }, 568 | 569 | { 570 | "comment": "missing 'value' parameter to add", 571 | "doc": [1], 572 | "patch": [{ "op": "add", "path": "/-" }], 573 | "error": "missing 'value' parameter" 574 | }, 575 | 576 | { 577 | "comment": "missing 'value' parameter to replace", 578 | "doc": [1], 579 | "patch": [{ "op": "replace", "path": "/0" }], 580 | "error": "missing 'value' parameter" 581 | }, 582 | 583 | { 584 | "comment": "missing 'value' parameter to test", 585 | "doc": [null], 586 | "patch": [{ "op": "test", "path": "/0" }], 587 | "error": "missing 'value' parameter" 588 | }, 589 | 590 | { 591 | "comment": "missing value parameter to test - where undef is falsy", 592 | "doc": [false], 593 | "patch": [{ "op": "test", "path": "/0" }], 594 | "error": "missing 'value' parameter" 595 | }, 596 | 597 | { 598 | "comment": "missing from parameter to copy", 599 | "doc": [1], 600 | "patch": [{ "op": "copy", "path": "/-" }], 601 | "error": "missing 'from' parameter" 602 | }, 603 | 604 | { 605 | "comment": "missing from location to copy", 606 | "doc": { "foo": 1 }, 607 | "patch": [{ "op": "copy", "from": "/bar", "path": "/foo" }], 608 | "error": "missing 'from' location" 609 | }, 610 | 611 | { 612 | "comment": "missing from parameter to move", 613 | "doc": { "foo": 1 }, 614 | "patch": [{ "op": "move", "path": "" }], 615 | "error": "missing 'from' parameter" 616 | }, 617 | 618 | { 619 | "comment": "missing from location to move", 620 | "doc": { "foo": 1 }, 621 | "patch": [{ "op": "move", "from": "/bar", "path": "/foo" }], 622 | "error": "missing 'from' location" 623 | }, 624 | 625 | { 626 | "comment": "duplicate ops", 627 | "doc": { "foo": "bar" }, 628 | "patch": [ 629 | { 630 | "op": "add", 631 | "path": "/baz", 632 | "value": "qux", 633 | "op": "move", 634 | "from": "/foo" 635 | } 636 | ], 637 | "error": "patch has two 'op' members", 638 | "disabled": true 639 | }, 640 | 641 | { 642 | "comment": "unrecognized op should fail", 643 | "doc": { "foo": 1 }, 644 | "patch": [{ "op": "spam", "path": "/foo", "value": 1 }], 645 | "error": "Unrecognized op 'spam'" 646 | }, 647 | 648 | { 649 | "comment": "test with bad array number that has leading zeros", 650 | "doc": ["foo", "bar"], 651 | "patch": [{ "op": "test", "path": "/00", "value": "foo" }], 652 | "error": "test op should reject the array value, it has leading zeros" 653 | }, 654 | 655 | { 656 | "comment": "test with bad array number that has leading zeros", 657 | "doc": ["foo", "bar"], 658 | "patch": [{ "op": "test", "path": "/01", "value": "bar" }], 659 | "error": "test op should reject the array value, it has leading zeros" 660 | }, 661 | 662 | { 663 | "comment": "Removing nonexistent field", 664 | "doc": { "foo": "bar" }, 665 | "patch": [{ "op": "remove", "path": "/baz" }], 666 | "error": "removing a nonexistent field should fail" 667 | }, 668 | 669 | { 670 | "comment": "Removing deep nonexistent path", 671 | "doc": { "foo": "bar" }, 672 | "patch": [{ "op": "remove", "path": "/missing1/missing2" }], 673 | "error": "removing a nonexistent field should fail" 674 | }, 675 | 676 | { 677 | "comment": "Removing nonexistent index", 678 | "doc": ["foo", "bar"], 679 | "patch": [{ "op": "remove", "path": "/2" }], 680 | "error": "removing a nonexistent index should fail" 681 | }, 682 | 683 | { 684 | "comment": "Patch with different capitalisation than doc", 685 | "doc": { "foo": "bar" }, 686 | "patch": [{ "op": "add", "path": "/FOO", "value": "BAR" }], 687 | "expected": { "foo": "bar", "FOO": "BAR" } 688 | } 689 | ] 690 | -------------------------------------------------------------------------------- /test/jsonpatch/operation/add_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.AddTest do 2 | use ExUnit.Case 3 | 4 | alias Jsonpatch.Operation.Add 5 | 6 | doctest Add 7 | 8 | test "Added element to path with multiple indices" do 9 | path = "/a/b/1/c/2/e" 10 | 11 | target = %{ 12 | "a" => %{ 13 | "b" => [ 14 | 1, 15 | %{ 16 | "c" => [ 17 | 1, 18 | 2, 19 | %{"f" => false} 20 | ] 21 | } 22 | ] 23 | } 24 | } 25 | 26 | add_op = %Add{path: path, value: true} 27 | 28 | expected_target = %{ 29 | "a" => %{ 30 | "b" => [ 31 | 1, 32 | %{ 33 | "c" => [ 34 | 1, 35 | 2, 36 | %{"f" => false, "e" => true} 37 | ] 38 | } 39 | ] 40 | } 41 | } 42 | 43 | assert {:ok, ^expected_target} = Jsonpatch.Operation.Add.apply(add_op, target, []) 44 | end 45 | 46 | test "Add a value on an existing path" do 47 | patch = %Add{path: "/a/b", value: 2} 48 | target = %{"a" => %{"b" => 1}} 49 | 50 | assert {:ok, %{"a" => %{"b" => 2}}} = Jsonpatch.Operation.Add.apply(patch, target, []) 51 | end 52 | 53 | test "Add a value to an array" do 54 | patch = %Add{path: "/a/2", value: 2} 55 | target = %{"a" => [0, 1, 3]} 56 | 57 | assert {:ok, %{"a" => [0, 1, 2, 3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) 58 | end 59 | 60 | test "Add a value to an empty array with binary key" do 61 | patch = %Add{path: "/a/0", value: 3} 62 | target = %{"a" => []} 63 | 64 | assert {:ok, %{"a" => [3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) 65 | end 66 | 67 | test "Add a value to an empty array with atom key" do 68 | patch = %Add{path: "/a/0", value: 3} 69 | target = %{"a" => []} 70 | 71 | assert {:ok, %{"a" => [3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) 72 | end 73 | 74 | test "Add a value to an array with invalid index" do 75 | patch = %Add{path: "/a/100", value: 3} 76 | target = %{"a" => [0, 1, 2]} 77 | 78 | assert {:error, {:invalid_path, ["a", "100"]}} = 79 | Jsonpatch.Operation.Add.apply(patch, target, []) 80 | 81 | patch = %Add{path: "/a/not_an_index", value: 3} 82 | 83 | assert {:error, {:invalid_path, ["a", "not_an_index"]}} = 84 | Jsonpatch.Operation.Add.apply(patch, target, []) 85 | end 86 | 87 | test "Add a value at the end of array" do 88 | patch = %Add{path: "/a/-", value: 3} 89 | target = %{"a" => [0, 1, 2]} 90 | 91 | assert {:ok, %{"a" => [0, 1, 2, 3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) 92 | 93 | patch = %Add{path: "/a/#{length(target["a"])}", value: 3} 94 | 95 | assert {:ok, %{"a" => [0, 1, 2, 3]}} = Jsonpatch.Operation.Add.apply(patch, target, []) 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/jsonpatch/operation/copy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.CopyTest do 2 | use ExUnit.Case 3 | 4 | alias Jsonpatch.Operation.Copy 5 | 6 | doctest Copy 7 | 8 | test "Copy element by path with multiple indices" do 9 | from = "/a/b/1/c/2" 10 | # Copy to end 11 | path = "/a/b/1/c/-" 12 | 13 | target = %{ 14 | "a" => %{ 15 | "b" => [ 16 | 1, 17 | %{ 18 | "c" => [ 19 | 1, 20 | 2, 21 | %{"f" => false} 22 | ] 23 | } 24 | ] 25 | } 26 | } 27 | 28 | copy_op = %Copy{path: path, from: from} 29 | 30 | expected_target = %{ 31 | "a" => %{ 32 | "b" => [ 33 | 1, 34 | %{ 35 | "c" => [ 36 | 1, 37 | 2, 38 | %{"f" => false}, 39 | %{"f" => false} 40 | ] 41 | } 42 | ] 43 | } 44 | } 45 | 46 | assert {:ok, ^expected_target} = Jsonpatch.Operation.Copy.apply(copy_op, target, []) 47 | end 48 | 49 | test "Copy element by path with invalid target index and expect error" do 50 | from = "/a/b/1/c/2" 51 | to = "/a/b/1/c/a" 52 | 53 | target = %{ 54 | "a" => %{ 55 | "b" => [ 56 | 1, 57 | %{ 58 | "c" => [ 59 | 1, 60 | 2, 61 | %{"f" => false} 62 | ] 63 | } 64 | ] 65 | } 66 | } 67 | 68 | copy_op = %Copy{path: to, from: from} 69 | 70 | assert {:error, {:invalid_path, ["a", "b", "1", "c", "a"]}} = 71 | Jsonpatch.Operation.Copy.apply(copy_op, target, []) 72 | end 73 | 74 | test "Copy element by path with invalid soure path and expect error" do 75 | from = "/b" 76 | to = "/c" 77 | 78 | target = %{ 79 | "a" => 1 80 | } 81 | 82 | copy_op = %Copy{path: to, from: from} 83 | 84 | assert {:error, {:invalid_path, ["b"]}} = Jsonpatch.Operation.Copy.apply(copy_op, target, []) 85 | end 86 | 87 | test "Copy element by path with invalid source index and expect error" do 88 | from = "/a/b/1/c/b" 89 | to = "/a/b/1/c/2" 90 | 91 | target = %{ 92 | "a" => %{ 93 | "b" => [ 94 | 1, 95 | %{ 96 | "c" => [ 97 | 1, 98 | 2, 99 | %{"f" => false} 100 | ] 101 | } 102 | ] 103 | } 104 | } 105 | 106 | copy_op = %Copy{path: to, from: from} 107 | 108 | assert {:error, {:invalid_path, ["a", "b", "1", "c", "b"]}} = 109 | Jsonpatch.Operation.Copy.apply(copy_op, target, []) 110 | end 111 | 112 | test "Copy list element" do 113 | patch = %Copy{from: "/a/0", path: "/a/1"} 114 | 115 | target = %{"a" => [999, 888]} 116 | 117 | assert {:ok, %{"a" => [999, 999, 888]}} = Jsonpatch.Operation.Copy.apply(patch, target, []) 118 | end 119 | 120 | test "Copy list element from invalid index" do 121 | patch = %Copy{from: "/a/6", path: "/a/0"} 122 | 123 | target = %{"a" => [999, 888]} 124 | 125 | assert {:error, {:invalid_path, ["a", "6"]}} = 126 | Jsonpatch.Operation.Copy.apply(patch, target, []) 127 | 128 | patch = %Copy{from: "/a/-", path: "/a/0"} 129 | 130 | assert {:error, {:invalid_path, ["a", "-"]}} = 131 | Jsonpatch.Operation.Copy.apply(patch, target, []) 132 | end 133 | 134 | test "Copy list element to invalid index" do 135 | patch = %Copy{from: "/a/0", path: "/a/5"} 136 | 137 | target = %{"a" => [999, 888]} 138 | 139 | assert {:error, {:invalid_path, ["a", "5"]}} = 140 | Jsonpatch.Operation.Copy.apply(patch, target, []) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/jsonpatch/operation/move_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.MoveTest do 2 | use ExUnit.Case 3 | doctest Jsonpatch.Operation.Move 4 | 5 | # Move is a combination of the copy and remove operation. 6 | test "move a value with atoms" do 7 | move = %Jsonpatch.Operation.Move{from: "/a/b", path: "/a/e"} 8 | target = %{a: %{b: %{c: "Bob"}}, d: false} 9 | 10 | assert Jsonpatch.Operation.Move.apply(move, target, keys: :atoms) == 11 | {:ok, %{a: %{e: %{c: "Bob"}}, d: false}} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/jsonpatch/operation/remove_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RemoveTest do 2 | use ExUnit.Case 3 | 4 | alias Jsonpatch.Operation.Remove 5 | 6 | doctest Remove 7 | 8 | test "Remove element by path with multiple indices" do 9 | path = "/a/b/1/c/2" 10 | 11 | target = %{ 12 | "a" => %{ 13 | "b" => [ 14 | 1, 15 | %{ 16 | "c" => [ 17 | 1, 18 | 2, 19 | %{"f" => false} 20 | ] 21 | } 22 | ] 23 | } 24 | } 25 | 26 | remove_op = %Remove{path: path} 27 | 28 | expected_target = %{ 29 | "a" => %{ 30 | "b" => [ 31 | 1, 32 | %{ 33 | "c" => [ 34 | 1, 35 | 2 36 | ] 37 | } 38 | ] 39 | } 40 | } 41 | 42 | assert {:ok, ^expected_target} = Jsonpatch.Operation.Remove.apply(remove_op, target, []) 43 | end 44 | 45 | test "Remove element by invalid path" do 46 | target = %{ 47 | "name" => "Bob", 48 | "married" => false, 49 | "hobbies" => ["Sport", "Elixir", "Football"], 50 | "home" => "Berlin" 51 | } 52 | 53 | remove_patch = %Remove{path: "/nameX"} 54 | 55 | assert {:error, {:invalid_path, ["nameX"]}} = 56 | Jsonpatch.Operation.Remove.apply(remove_patch, target, []) 57 | 58 | remove_patch = %Remove{path: "/home/nameX"} 59 | 60 | assert {:error, {:invalid_path, ["home", "nameX"]}} = 61 | Jsonpatch.Operation.Remove.apply(remove_patch, target, []) 62 | end 63 | 64 | test "Remove element in map with atom keys" do 65 | target = %{"name" => "Ceasar", "age" => 66} 66 | 67 | remove_patch = %Remove{path: "/age"} 68 | 69 | assert {:ok, %{"name" => "Ceasar"}} = 70 | Jsonpatch.Operation.Remove.apply(remove_patch, target, []) 71 | end 72 | 73 | test "Remove element by invalid index" do 74 | target = %{ 75 | "name" => "Bob", 76 | "married" => false, 77 | "hobbies" => ["Sport", "Elixir", "Football"], 78 | "home" => "Berlin" 79 | } 80 | 81 | remove_patch = %Remove{path: "/hobbies/a"} 82 | 83 | assert {:error, {:invalid_path, ["hobbies", "a"]}} = 84 | Jsonpatch.Operation.Remove.apply(remove_patch, target, []) 85 | 86 | # Longer path 87 | target = %{ 88 | "name" => "Bob", 89 | "married" => false, 90 | "hobbies" => [%{"description" => "Foo"}], 91 | "home" => "Berlin" 92 | } 93 | 94 | remove_patch = %Remove{path: "/hobbies/b/description"} 95 | 96 | assert {:error, {:invalid_path, ["hobbies", "b"]}} = 97 | Jsonpatch.Operation.Remove.apply(remove_patch, target, []) 98 | 99 | # Longer path, numeric - out of 100 | remove_patch = %Remove{path: "/hobbies/1/description"} 101 | 102 | assert {:error, {:invalid_path, ["hobbies", "1"]}} = 103 | Jsonpatch.Operation.Remove.apply(remove_patch, target, []) 104 | 105 | remove_patch = %Remove{path: "/hobbies/-"} 106 | 107 | assert {:error, {:invalid_path, ["hobbies", "-"]}} = 108 | Jsonpatch.Operation.Remove.apply(remove_patch, target, []) 109 | end 110 | 111 | test "Remove in list" do 112 | source = [1, 2, %{"three" => 3}, 5, 6] 113 | patch = %Remove{path: "/2/three"} 114 | 115 | assert {:ok, [1, 2, %{}, 5, 6]} = Jsonpatch.Operation.Remove.apply(patch, source, []) 116 | end 117 | 118 | test "Remove in list with wrong key" do 119 | source = [1, 2, %{"three" => 3}, 5, 6] 120 | patch = %Remove{path: "/2/four"} 121 | 122 | assert {:error, {:invalid_path, ["2", "four"]}} = 123 | Jsonpatch.Operation.Remove.apply(patch, source, []) 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /test/jsonpatch/operation/replace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.ReplaceTest do 2 | use ExUnit.Case 3 | 4 | alias Jsonpatch.Operation.Replace 5 | 6 | doctest Replace 7 | 8 | test "Replace element to path with multiple indices" do 9 | path = "/a/b/1/c/2/f" 10 | 11 | target = %{ 12 | "a" => %{ 13 | "b" => [ 14 | 1, 15 | %{ 16 | "c" => [ 17 | 1, 18 | 2, 19 | %{"f" => false} 20 | ] 21 | } 22 | ] 23 | } 24 | } 25 | 26 | replace_op = %Replace{path: path, value: true} 27 | 28 | expected_target = %{ 29 | "a" => %{ 30 | "b" => [ 31 | 1, 32 | %{ 33 | "c" => [ 34 | 1, 35 | 2, 36 | %{"f" => true} 37 | ] 38 | } 39 | ] 40 | } 41 | } 42 | 43 | assert {:ok, ^expected_target} = Replace.apply(replace_op, target, []) 44 | end 45 | 46 | test "Replace element to path with index out of range and expect error" do 47 | path = "/a/b/2" 48 | 49 | target = %{ 50 | "a" => %{ 51 | "b" => [ 52 | 1 53 | ] 54 | } 55 | } 56 | 57 | replace_op = %Replace{path: path, value: 2} 58 | 59 | assert {:error, {:invalid_path, ["a", "b", "2"]}} = Replace.apply(replace_op, target, []) 60 | end 61 | 62 | test "Replace element to path with invalid index and expect error" do 63 | path = "/a/b/c" 64 | 65 | target = %{ 66 | "a" => %{ 67 | "b" => [ 68 | 1 69 | ] 70 | } 71 | } 72 | 73 | replace_op = %Replace{path: path, value: 2} 74 | 75 | assert {:error, {:invalid_path, ["a", "b", "c"]}} = Replace.apply(replace_op, target, []) 76 | end 77 | 78 | test "Replace in not existing path" do 79 | path = "/a/b/c" 80 | 81 | target = %{ 82 | "a" => %{ 83 | "b" => 1 84 | } 85 | } 86 | 87 | replace_op = %Replace{path: path, value: 2} 88 | 89 | assert {:error, {:invalid_path, ["a", "b", "c"]}} = Replace.apply(replace_op, target, []) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/jsonpatch/operation/test_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.Operation.TestTest do 2 | use ExUnit.Case 3 | 4 | alias Jsonpatch.Operation.Test 5 | 6 | doctest Test 7 | 8 | test "Test successful element with path with multiple indices" do 9 | target = %{ 10 | "a" => %{ 11 | "b" => [ 12 | 1, 13 | %{ 14 | "c" => [ 15 | 1, 16 | 2, 17 | %{"f" => false} 18 | ] 19 | } 20 | ] 21 | } 22 | } 23 | 24 | test_op = %Test{path: "/a/b/1/c/2/f", value: false} 25 | assert {:ok, ^target} = Test.apply(test_op, target, []) 26 | 27 | test_op = %Test{path: "/a/b/1/c/0", value: 1} 28 | assert {:ok, ^target} = Test.apply(test_op, target, []) 29 | end 30 | 31 | test "Test with atom as key" do 32 | target = %{"role" => "Developer"} 33 | 34 | test_op = %Test{path: "/role", value: "Developer"} 35 | 36 | assert {:ok, ^target} = Test.apply(test_op, target, []) 37 | end 38 | 39 | test "Fail to test element with path with multiple indices" do 40 | target = %{ 41 | "a" => %{ 42 | "b" => [ 43 | 1, 44 | %{ 45 | "c" => [ 46 | 1, 47 | 2, 48 | %{"f" => false} 49 | ] 50 | } 51 | ] 52 | } 53 | } 54 | 55 | test_op = %Test{path: "/a/b/1/c/1", value: 42} 56 | 57 | assert {:error, {:test_failed, "Expected value '42' at '/a/b/1/c/1'"}} = 58 | Test.apply(test_op, target, []) 59 | end 60 | 61 | test "Test list with index out of range" do 62 | test = %Test{path: "/m/2", value: "foo"} 63 | target = %{"m" => [0, 1]} 64 | 65 | assert {:error, {:invalid_path, ["m", "2"]}} = Test.apply(test, target, []) 66 | end 67 | 68 | test "Test list with invalid index" do 69 | test = %Test{path: "/m/b", value: "foo"} 70 | target = %{"m" => [0, 1]} 71 | 72 | assert {:error, {:invalid_path, ["m", "b"]}} = Test.apply(test, target, []) 73 | end 74 | 75 | test "Test list at top level" do 76 | test = %Test{path: "/1", value: "bar"} 77 | target = ["foo", "bar", "ha"] 78 | 79 | assert {:ok, ^target} = Test.apply(test, target, []) 80 | end 81 | 82 | test "Test list at top level with error" do 83 | test = %Test{path: "/2", value: 3} 84 | target = [0, 1, 2] 85 | 86 | assert {:error, {:test_failed, "Expected value '3' at '/2'"}} = Test.apply(test, target, []) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/jsonpatch/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Jsonpatch.UtilsTest do 2 | use ExUnit.Case 3 | doctest Jsonpatch.Utils 4 | 5 | alias Jsonpatch.Utils 6 | 7 | test "Updated destination with invalid path and get an error" do 8 | path = "/a/x/y/z" 9 | target = %{"a" => %{"b" => %{"c" => %{"d" => 1}}}} 10 | 11 | assert {:error, {:invalid_path, ["a", "x"]}} = 12 | Utils.update_destination(target, %{"e" => 1}, path) 13 | end 14 | 15 | test "Unescape '~' and '/'" do 16 | assert "unescape~me" = Utils.unescape("unescape~0me") 17 | assert "unescape/me" = Utils.unescape("unescape~1me") 18 | assert "unescape~me/" = Utils.unescape("unescape~0me~1") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/jsonpatch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JsonpatchTest do 2 | use ExUnit.Case 3 | 4 | doctest Jsonpatch 5 | 6 | test "Create diff from list and apply it" do 7 | # Arrange 8 | source = [1, 2, %{"drei" => 3}, 5, 6] 9 | destination = [1, 2, %{"three" => 3}, 4, 5] 10 | 11 | # Act 12 | patch = Jsonpatch.diff(source, destination) 13 | 14 | patched_source = Jsonpatch.apply_patch!(patch, source) 15 | 16 | # Assert 17 | assert ^destination = patched_source 18 | end 19 | 20 | describe "Create diffs" do 21 | test "adding an Object Member" do 22 | source = %{"foo" => "bar"} 23 | destination = %{"foo" => "bar", "baz" => "qux"} 24 | 25 | assert_diff_apply(source, destination) 26 | end 27 | 28 | test "Adding an Array Element" do 29 | source = %{"foo" => ["bar", "baz"]} 30 | destination = %{"foo" => ["bar", "baz", "qux"]} 31 | 32 | assert_diff_apply(source, destination) 33 | end 34 | 35 | test "Removing an Object Member" do 36 | source = %{"baz" => "qux", "foo" => "bar"} 37 | destination = %{"foo" => "bar"} 38 | 39 | assert_diff_apply(source, destination) 40 | end 41 | 42 | test "Create no diff on unchanged nil object value" do 43 | source = %{"id" => nil} 44 | destination = %{"id" => nil} 45 | 46 | assert [] = Jsonpatch.diff(source, destination) 47 | end 48 | 49 | test "Create no diff on unchanged array value" do 50 | source = [nil] 51 | destination = [nil] 52 | 53 | assert [] = Jsonpatch.diff(source, destination) 54 | end 55 | 56 | test "Create no diff on unexpected input" do 57 | assert [] = Jsonpatch.diff("unexpected", 1) 58 | end 59 | 60 | test "A.4. Removing an Array Element" do 61 | source = %{"a" => %{"b" => ["c", "d"]}} 62 | destination = %{"a" => %{"b" => ["c"]}} 63 | 64 | assert_diff_apply(source, destination) 65 | end 66 | 67 | test "Replacing a Value" do 68 | source = %{"a" => %{"b" => %{"c" => "d"}}, "f" => "g"} 69 | destination = %{"a" => %{"b" => %{"c" => "h"}}, "f" => "g"} 70 | 71 | assert_diff_apply(source, destination) 72 | end 73 | 74 | test "Replacing an Array Element" do 75 | source = %{"a" => %{"b" => %{"c" => ["d1", "d2"]}}, "f" => "g"} 76 | destination = %{"a" => %{"b" => %{"c" => ["d1", "d3"]}}, "f" => "g"} 77 | 78 | assert_diff_apply(source, destination) 79 | end 80 | 81 | test "Create diff with escaped '~' and '/' in path when adding" do 82 | source = %{} 83 | destination = %{"escape/me~now" => "somnevalue"} 84 | 85 | assert_diff_apply(source, destination) 86 | end 87 | 88 | test "Create diff with escaped '~' and '/' in path when removing" do 89 | source = %{"escape/me~now" => "somnevalue"} 90 | destination = %{} 91 | 92 | assert_diff_apply(source, destination) 93 | end 94 | 95 | test "Create diff with escaped '~' and '/' in path when replacing" do 96 | source = %{"escape/me~now" => "somnevalue"} 97 | destination = %{"escape/me~now" => "othervalue"} 98 | 99 | assert_diff_apply(source, destination) 100 | end 101 | 102 | test "Create diff with nested map with correct Add/Remove order" do 103 | source = %{"a" => [%{"b" => []}]} 104 | destination = %{"a" => [%{"b" => [%{"c" => 1}, %{"d" => 2}]}]} 105 | 106 | assert_diff_apply(source, destination) 107 | 108 | source = %{"a" => [%{"b" => [%{"c" => 1}, %{"d" => 2}]}]} 109 | destination = %{"a" => [%{"b" => []}]} 110 | 111 | assert_diff_apply(source, destination) 112 | end 113 | 114 | test "Create diff that replace list with map" do 115 | source = %{"a" => [1, 2, 3]} 116 | destination = %{"a" => %{"foo" => :bar}} 117 | 118 | assert_diff_apply(source, destination) 119 | end 120 | 121 | test "Create diff when source has a scalar value where in the destination is a list" do 122 | source = %{"a" => 150} 123 | destination = %{"a" => [1, 5, 0]} 124 | 125 | assert_diff_apply(source, destination) 126 | end 127 | 128 | test "Create diff for lists" do 129 | source = [1, "pizza", %{"name" => "Alice"}, [4, 2]] 130 | destination = [1, "hamburger", %{"name" => "Alice", "age" => 55}] 131 | 132 | assert_diff_apply(source, destination) 133 | end 134 | 135 | test "Create diff for map with atoms as key" do 136 | source = %{atom: [1, 2]} 137 | destination = %{atom: [1, 2, 3]} 138 | 139 | patches = Jsonpatch.diff(source, destination) 140 | assert Jsonpatch.apply_patch(patches, source, keys: :atoms) == {:ok, destination} 141 | end 142 | 143 | defp assert_diff_apply(source, destination) do 144 | patches = Jsonpatch.diff(source, destination) 145 | assert Jsonpatch.apply_patch(patches, source) == {:ok, destination} 146 | end 147 | end 148 | 149 | describe "Apply patch/es" do 150 | test "invalid json patch specification" do 151 | patch = %{"invalid" => "invalid"} 152 | 153 | assert {:error, 154 | %Jsonpatch.Error{ 155 | patch: ^patch, 156 | patch_index: 0, 157 | reason: {:invalid_spec, %{"invalid" => "invalid"}} 158 | }} = Jsonpatch.apply_patch(patch, %{}) 159 | end 160 | 161 | test "Apply patch with invalid source path and expect error" do 162 | target = %{ 163 | "name" => "Bob", 164 | "married" => false, 165 | "hobbies" => ["Sport", "Elixir", "Football"], 166 | "home" => "Berlin" 167 | } 168 | 169 | patch = %{"op" => "add", "path" => "/child/0/age", "value" => 33} 170 | 171 | assert {:error, 172 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["child"]}}} = 173 | Jsonpatch.apply_patch(patch, target) 174 | 175 | patch = %{"op" => "replace", "path" => "/age", "value" => 42} 176 | 177 | assert {:error, 178 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["age"]}}} = 179 | Jsonpatch.apply_patch(patch, target) 180 | 181 | patch = %{"op" => "remove", "path" => "/hobby/4"} 182 | 183 | assert {:error, 184 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["hobby"]}}} = 185 | Jsonpatch.apply_patch(patch, target) 186 | 187 | patch = %{"from" => "/nameX", "op" => "copy", "path" => "/surname"} 188 | 189 | assert {:error, 190 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["nameX"]}}} = 191 | Jsonpatch.apply_patch(patch, target) 192 | 193 | patch = %{"from" => "/homeX", "op" => "move", "path" => "/work"} 194 | 195 | assert {:error, 196 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["homeX"]}}} = 197 | Jsonpatch.apply_patch(patch, target) 198 | end 199 | 200 | test "Apply patch with multiple operations with binary keys" do 201 | patch = [ 202 | %Jsonpatch.Operation.Remove{path: "/age"}, 203 | %Jsonpatch.Operation.Add{path: "/age", value: 34}, 204 | %Jsonpatch.Operation.Replace{path: "/age", value: 35} 205 | ] 206 | 207 | target = %{"age" => "33"} 208 | patched = Jsonpatch.apply_patch!(patch, target) 209 | 210 | assert %{"age" => 35} = patched 211 | end 212 | 213 | test "Apply patch with multiple operations with atom keys" do 214 | patch = [ 215 | %Jsonpatch.Operation.Remove{path: "/age"}, 216 | %Jsonpatch.Operation.Add{path: "/age", value: 34}, 217 | %Jsonpatch.Operation.Replace{path: "/age", value: 35} 218 | ] 219 | 220 | target = %{age: "33"} 221 | patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms!) 222 | 223 | assert %{age: 35} = patched 224 | end 225 | 226 | test "Apply patch with non existing atom" do 227 | target = %{} 228 | 229 | patch = %{"op" => "add", "path" => "/test_non_existing_atom", "value" => 34} 230 | 231 | assert {:error, 232 | %Jsonpatch.Error{ 233 | patch: ^patch, 234 | patch_index: 0, 235 | reason: {:invalid_path, ["test_non_existing_atom"]} 236 | }} = Jsonpatch.apply_patch(patch, target, keys: :atoms!) 237 | end 238 | 239 | test "Apply patch with custom keys option - example 1" do 240 | patch = [ 241 | %Jsonpatch.Operation.Replace{path: "/a1/b/c", value: 1}, 242 | %Jsonpatch.Operation.Replace{path: "/a2/b/d", value: 1} 243 | ] 244 | 245 | target = %{a1: %{b: %{"c" => 0}}, l: [], a2: %{b: %{"d" => 0}}} 246 | 247 | convert_fn = fn 248 | # All map keys are atoms except /*/b/* keys 249 | fragment, [_, :b], target, _opts when is_map(target) -> 250 | {:ok, fragment} 251 | 252 | fragment, _path, target, _opts when is_map(target) -> 253 | string_to_existing_atom(fragment) 254 | 255 | fragment, path, target, _opts when is_list(target) -> 256 | case Jsonpatch.Utils.cast_index(fragment, path, target) do 257 | {:ok, _} = ok -> ok 258 | {:error, _} -> :error 259 | end 260 | end 261 | 262 | patched = Jsonpatch.apply_patch!(patch, target, keys: {:custom, convert_fn}) 263 | assert %{a1: %{b: %{"c" => 1}}, a2: %{b: %{"d" => 1}}} = patched 264 | 265 | patch = %Jsonpatch.Operation.Add{path: "/l/0", value: 1} 266 | patched = Jsonpatch.apply_patch!(patch, target, keys: {:custom, convert_fn}) 267 | assert %{a1: %{b: %{"c" => 0}}, l: [1], a2: %{b: %{"d" => 0}}} = patched 268 | 269 | patch = %{"op" => "replace", "path" => "/not_existing_atom", "value" => 1} 270 | 271 | assert {:error, 272 | %Jsonpatch.Error{ 273 | patch: ^patch, 274 | patch_index: 0, 275 | reason: {:invalid_path, ["not_existing_atom"]} 276 | }} = Jsonpatch.apply_patch(patch, target, keys: {:custom, convert_fn}) 277 | 278 | patch = %{"op" => "replace", "path" => "/l/not_existing_atom", "value" => 20} 279 | 280 | assert {:error, 281 | %Jsonpatch.Error{ 282 | patch: ^patch, 283 | patch_index: 0, 284 | reason: {:invalid_path, [:l, "not_existing_atom"]} 285 | }} = Jsonpatch.apply_patch(patch, target, keys: {:custom, convert_fn}) 286 | end 287 | 288 | defmodule TestStruct do 289 | defstruct [:field] 290 | end 291 | 292 | test "struct are just maps" do 293 | patch = %Jsonpatch.Operation.Replace{path: "/a/field/c", value: 1} 294 | target = %{a: %TestStruct{field: %{c: 0}}} 295 | patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms) 296 | assert %{a: %TestStruct{field: %{c: 1}}} = patched 297 | 298 | patch = %Jsonpatch.Operation.Remove{path: "/a/field"} 299 | target = %{a: %TestStruct{field: %{c: 0}}} 300 | patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms) 301 | assert %{a: %{__struct__: TestStruct}} = patched 302 | end 303 | 304 | test "Apply patch with invalid target source path and expect error" do 305 | target = %{ 306 | "name" => "Bob", 307 | "married" => false, 308 | "hobbies" => ["Sport", "Elixir", "Football"], 309 | "home" => "Berlin" 310 | } 311 | 312 | patch = %{"op" => "copy", "from" => "/name", "path" => "/xyz/surname"} 313 | 314 | assert {:error, 315 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["xyz"]}}} = 316 | Jsonpatch.apply_patch(patch, target) 317 | 318 | patch = %{"from" => "/home", "op" => "move", "path" => "/xyz/work"} 319 | 320 | assert {:error, 321 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["xyz"]}}} = 322 | Jsonpatch.apply_patch(patch, target) 323 | 324 | patch = %{"op" => "remove", "path" => "/xyz/work"} 325 | 326 | assert {:error, 327 | %Jsonpatch.Error{patch: ^patch, patch_index: 0, reason: {:invalid_path, ["xyz"]}}} = 328 | Jsonpatch.apply_patch(patch, target) 329 | end 330 | 331 | test "Apply patch with one invalid path and expect error" do 332 | patch = [ 333 | %Jsonpatch.Operation.Add{path: "/age", value: 33}, 334 | %Jsonpatch.Operation.Replace{path: "/hobbies/0", value: "Elixir!"}, 335 | %Jsonpatch.Operation.Replace{path: "/married", value: true}, 336 | %Jsonpatch.Operation.Remove{path: "/hobbies/1"}, 337 | # Should fail 338 | %Jsonpatch.Operation.Remove{path: "/hobbies/4"}, 339 | %Jsonpatch.Operation.Copy{from: "/name", path: "/surname"}, 340 | %Jsonpatch.Operation.Move{from: "/home", path: "/work"}, 341 | %Jsonpatch.Operation.Test{path: "/name", value: "Bob"} 342 | ] 343 | 344 | target = %{ 345 | "name" => "Bob", 346 | "married" => false, 347 | "hobbies" => ["Sport", "Elixir", "Football"], 348 | "home" => "Berlin" 349 | } 350 | 351 | assert {:error, 352 | %Jsonpatch.Error{ 353 | patch: %{"op" => "remove", "path" => "/hobbies/4"}, 354 | patch_index: 4, 355 | reason: {:invalid_path, ["hobbies", "4"]} 356 | }} = Jsonpatch.apply_patch(patch, target) 357 | end 358 | 359 | test "Apply patch with failing test and expect error" do 360 | patch = [ 361 | %Jsonpatch.Operation.Add{path: "/age", value: 33}, 362 | %Jsonpatch.Operation.Replace{path: "/hobbies/0", value: "Elixir!"}, 363 | %Jsonpatch.Operation.Replace{path: "/married", value: true}, 364 | %Jsonpatch.Operation.Remove{path: "/hobbies/1"}, 365 | %Jsonpatch.Operation.Copy{from: "/name", path: "/surname"}, 366 | %Jsonpatch.Operation.Move{from: "/home", path: "/work"}, 367 | # Name is Bob therefore this should fail 368 | %Jsonpatch.Operation.Test{path: "/name", value: "Alice"}, 369 | # Should never be applied 370 | %Jsonpatch.Operation.Test{path: "/year", value: 1980} 371 | ] 372 | 373 | target = %{ 374 | "name" => "Bob", 375 | "married" => false, 376 | "hobbies" => ["Sport", "Elixir", "Football"], 377 | "home" => "Berlin" 378 | } 379 | 380 | assert {:error, 381 | %Jsonpatch.Error{ 382 | patch: %{"op" => "test", "path" => "/name", "value" => "Alice"}, 383 | patch_index: 6, 384 | reason: {:test_failed, "Expected value '\"Alice\"' at '/name'"} 385 | }} = Jsonpatch.apply_patch(patch, target) 386 | end 387 | 388 | test "Apply patch with escaped '~' and '/' in path" do 389 | patch = [ 390 | %Jsonpatch.Operation.Add{path: "/foo/escape~1me~0now", value: "somnevalue"}, 391 | %Jsonpatch.Operation.Remove{path: "/bar/escape~1me~0now"} 392 | ] 393 | 394 | target = %{"foo" => %{}, "bar" => %{"escape/me~now" => 5}} 395 | 396 | assert {:ok, %{"foo" => %{"escape/me~now" => "somnevalue"}, "bar" => %{}}} = 397 | Jsonpatch.apply_patch(patch, target) 398 | end 399 | 400 | test "Apply patch with '!' and expect valid result" do 401 | patch = %Jsonpatch.Operation.Remove{path: "/name"} 402 | target = %{"name" => "Alice", "age" => 44} 403 | 404 | patched = Jsonpatch.apply_patch!(patch, target) 405 | assert %{"age" => 44} = patched 406 | end 407 | 408 | test "Apply patch with '!' and expect exception" do 409 | patch = %Jsonpatch.Operation.Replace{path: "/surname", value: "Misty"} 410 | target = %{"name" => "Alice", "age" => 44} 411 | 412 | assert_raise JsonpatchException, fn -> Jsonpatch.apply_patch!(patch, target) end 413 | end 414 | 415 | test "Apply patch with a path containing an empty key" do 416 | patch = %Jsonpatch.Operation.Replace{path: "/a/", value: 35} 417 | target = %{"a" => %{"" => 33}} 418 | 419 | assert {:ok, %{"a" => %{"" => 35}}} = Jsonpatch.apply_patch(patch, target) 420 | end 421 | 422 | for %{ 423 | "comment" => comment, 424 | "doc" => target, 425 | "expected" => expected, 426 | "patch" => patch 427 | } = test_case <- 428 | File.read!("./test/json-patch-tests.json") |> Jason.decode!(), 429 | !test_case["disabled"] do 430 | @data %{target: target, expected: expected, patch: patch} 431 | 432 | test comment do 433 | expected = @data.expected 434 | patch = @data.patch 435 | target = @data.target 436 | 437 | assert {:ok, ^expected} = Jsonpatch.apply_patch(patch, target) 438 | end 439 | end 440 | 441 | test "ignore invalid paths when asked to" do 442 | patch = %{"op" => "replace", "path" => "/inexistent", "value" => 42} 443 | target = %{"foo" => "bar"} 444 | 445 | assert {:ok, %{"foo" => "bar"}} = 446 | Jsonpatch.apply_patch(patch, target, ignore_invalid_paths: true) 447 | end 448 | end 449 | 450 | defp string_to_existing_atom(data) when is_binary(data) do 451 | {:ok, String.to_existing_atom(data)} 452 | rescue 453 | ArgumentError -> :error 454 | end 455 | end 456 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------