├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md ├── config └── config.exs ├── jsonapi-assert.png ├── lib ├── json_api_assert.ex └── json_api_assert │ ├── error.ex │ ├── inflector.ex │ └── serializer.ex ├── mix.exs ├── mix.lock └── test ├── assert_data_test.exs ├── assert_included_test.exs ├── assert_relationship_test.exs ├── inflector_test.exs ├── jsonapi_test.exs ├── refute_data_test.exs ├── refute_included_test.exs ├── refute_relationship_test.exs ├── serializer_test.exs ├── support └── test_data.ex └── test_helper.exs /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | ## Version 23 | 24 | 25 | ## Test Case 26 | 27 | 28 | ## Steps to reproduce 29 | 30 | 31 | ## Expected Behavior 32 | 33 | 34 | ## Actual Behavior 35 | 36 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Closes # . 12 | 13 | ## Changes proposed in this pull request 14 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | erl_crash.dump 6 | *.ez 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.3.0 4 | - 1.4.0 5 | otp_release: 6 | - 18.0 7 | sudo: false # to use faster container based build environment 8 | cache: 9 | directories: 10 | - _build 11 | - deps 12 | -------------------------------------------------------------------------------- /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, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | 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 brian@dockyard.com. 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 [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Improve documentation 4 | 5 | We are always looking to improve our documentation. If at some moment you are 6 | reading the documentation and something is not clear, or you can't find what you 7 | are looking for, then please open an issue with the repository. This gives us a 8 | chance to answer your question and to improve the documentation if needed. 9 | 10 | Pull requests correcting spelling or grammar mistakes are always welcome. 11 | 12 | ## Found a bug? 13 | 14 | Please try to answer at least the following questions when reporting a bug: 15 | 16 | - Which version of the project did you use when you noticed the bug? 17 | - How do you reproduce the error condition? 18 | - What happened that you think is a bug? 19 | - What should it do instead? 20 | 21 | It would really help the maintainers if you could provide a reduced test case 22 | that reproduces the error condition. 23 | 24 | ## Have a feature request? 25 | 26 | Please provide some thoughful commentary and code samples on what this feature 27 | should do and why it should be added (your use case). The minimal questions you 28 | should answer when submitting a feature request should be: 29 | 30 | - What will it allow you to do that you can't do today? 31 | - Why do you need this feature and how will it benefit other users? 32 | - Are there any drawbacks to this feature? 33 | 34 | ## Submitting a pull-request? 35 | 36 | Here are some things that will increase the chance that your pull-request will 37 | get accepted: 38 | - Did you confirm this fix/feature is something that is needed? 39 | - Did you write tests, preferably in a test driven style? 40 | - Did you add documentation for the changes you made? 41 | - Did you follow our [styleguide](https://github.com/dockyard/styleguides)? 42 | 43 | If your pull-request addresses an issue then please add the corresponding 44 | issue's number to the description of your pull-request. 45 | 46 | # How to work with this project locally 47 | 48 | ## Installation 49 | 50 | First clone this repository: 51 | 52 | ```sh 53 | git clone https://github.com/DockYard/json_api_assert.git 54 | ``` 55 | 56 | 57 | 58 | ## Running tests 59 | 60 | 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JsonApiAssert [![Build Status](https://secure.travis-ci.org/DockYard/json_api_assert.svg?branch=master)](http://travis-ci.org/DockYard/json_api_assert) 2 | 3 | Easily build composable queries for Elixir. 4 | 5 | **[JsonApiAssert is built and maintained by DockYard, contact us for expert Elixir and Phoenix consulting](https://dockyard.com/phoenix-consulting)**. 6 | 7 | ## Usage 8 | 9 | JsonApiAssert is a collection of composable test helpers to ease 10 | the pain of testing [JSON API](http://jsonapi.org) payloads. 11 | 12 | You can use the functions individually but they are optimally used in a composable 13 | fashion with the pipe operator: 14 | 15 | ```elixir 16 | import JsonApiAssert 17 | 18 | payload 19 | |> assert_data(user1) 20 | |> assert_data(user2) 21 | |> refute_data(user3) 22 | |> assert_relationship(pet1, as: "pets", for: user1) 23 | |> assert_relationship(pet2, as: "pets", for: user2) 24 | |> assert_included(pet1) 25 | |> assert_included(pet2) 26 | ``` 27 | 28 | The records passed *must* already be serialized. Read more about 29 | [serializers](#record-serialization) below to see how to easily manage this with structs or Ecto 30 | models. 31 | 32 | If you've tested JSON API payloads before the benefits of this pattern should 33 | be obvious. Hundreds of lines of codes can be reduced to just a handful. Brittle tests are 34 | now flexible and don't care about inseration / render order. 35 | 36 | ## Record Serialization 37 | 38 | The assert/refute functions expect json-api serialized maps. You can 39 | write these yourself but that can be verbose. Instead it is easier to 40 | manage your data as structs or better from Ecto models. Just use our 41 | built-in serializers. 42 | 43 | ```elixir 44 | import JsonApiAssert.Serializer, only: [serialize: 1] 45 | 46 | user = %User{email: "brian.dockyard@example.com"} 47 | 48 | user_json = serialize(user) 49 | 50 | payload 51 | |> assert_data(user_json) 52 | ``` 53 | 54 | As a convenience you can import the short-hand `s` function 55 | 56 | ```elixir 57 | import JsonApiAssert.Serializer, only: [s: 1] 58 | 59 | payload 60 | |> assert_data(s(user)) 61 | ``` 62 | 63 | If you want to serialize JSON-API payloads from maps instead of structs, the only extra work you must do is provide a `type` argument 64 | 65 | ```elixir 66 | import JsonApiAssert.Serializer, only: [serialize: 2] 67 | 68 | user_params = %{email: "brian.dockyard@example.com"} 69 | 70 | user_json = serialize(user_params, type: "user") 71 | 72 | payload 73 | |> assert_data(user_json) 74 | ``` 75 | 76 | The built-in serializers should be good enough for most cases. However, 77 | they are not a requirement. Feel free to use any serializer you'd like. 78 | The final schema just needs to match the [json-api resource 79 | schema](http://jsonapi.org/format/#document-resource-objects): 80 | 81 | ```elixir 82 | %{ 83 | "id" => 1, 84 | "type" => "author", 85 | "attributes" => %{ 86 | "first-name" => "Brian", 87 | "last-name" => "Cardarella" 88 | } 89 | } 90 | ``` 91 | 92 | ## Value matching via regex 93 | 94 | Use a Regex as the value if that value is non-deterministic. 95 | 96 | ```elixir 97 | record = %{ 98 | "id" => ~r/\d+/, 99 | "type" => "author", 100 | "attributes" => %{ 101 | "first-name" => "Brian", 102 | "last-name" => "Cardarella" 103 | } 104 | } 105 | 106 | assert_data(payload, record) 107 | ``` 108 | 109 | A common use-case for this is when you are creating new records. The 110 | resulting value for the `id` is likely unknown and assigned at the 111 | run-time of the test suite. Using a Regex will allow you to assert that 112 | the id value is present and conforms to a value range and pattern that 113 | you expect. 114 | 115 | Be careful however, you may get false-positives as the Regex matching 116 | can be done upon data as well as datum sets. 117 | 118 | ## Authors 119 | 120 | * [Brian Cardarella](http://twitter.com/bcardarella) 121 | 122 | [We are very thankful for the many contributors](https://github.com/dockyard/json_api_assert/graphs/contributors) 123 | 124 | ## Versioning 125 | 126 | This library follows [Semantic Versioning](http://semver.org) 127 | 128 | ## Want to help? 129 | 130 | Please do! We are always looking to improve this library. Please see our 131 | [Contribution Guidelines](https://github.com/dockyard/json_api_assert/blob/master/CONTRIBUTING.md) 132 | on how to properly submit issues and pull requests. 133 | 134 | ## Legal 135 | 136 | [DockYard](http://dockyard.com/), Inc. © 2016 137 | 138 | [@dockyard](http://twitter.com/dockyard) 139 | 140 | [Licensed under the MIT license](http://www.opensource.org/licenses/mit-license.php) 141 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :json_api_assert, :plural_rules, [ 6 | {"person", "people"} 7 | ] 8 | -------------------------------------------------------------------------------- /jsonapi-assert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DockYard/json_api_assert/8e15ef5ecdb520c6d93fce692f7e578f60ab0716/jsonapi-assert.png -------------------------------------------------------------------------------- /lib/json_api_assert.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiAssert do 2 | import ExUnit.Assertions, only: [assert: 2, refute: 2] 3 | 4 | @moduledoc """ 5 | JsonApiAssert is a collection of composable test helpers to ease 6 | the pain of testing [JSON API](http://jsonapi.org) payloads. 7 | 8 | You can use the functions individually but they are optimally used in a composable 9 | fashion with the pipe operator: 10 | 11 | ## Examples 12 | 13 | payload 14 | |> assert_data(user1) 15 | |> assert_data(user2) 16 | |> refute_data(user3) 17 | |> assert_relationship(pet1, as: "pets", for: user1) 18 | |> assert_relationship(pet2, as: "pets", for: user2) 19 | |> assert_included(pet1) 20 | |> assert_included(pet2) 21 | 22 | If you've tested JSON API payloads before the benefits of this pattern should 23 | be obvious. Hundreds of lines of code can be reduced to just a handful. Brittle tests are 24 | now flexible and don't care about insertion / render order. 25 | """ 26 | 27 | @doc """ 28 | Asserts that the "jsonapi" object exists in the payload 29 | 30 | ## Examples 31 | 32 | payload 33 | |> assert_jsonapi(version: "1.0") 34 | 35 | The members argument should be a key/value pair of members you expect to be be in 36 | the "jsonapi" object of the payload. 37 | """ 38 | @spec assert_jsonapi(map, list) :: map 39 | def assert_jsonapi(payload, members \\ []) 40 | 41 | def assert_jsonapi(%{"jsonapi" => jsonapi} = payload, members) do 42 | enforce_top_level_constraints(payload) 43 | 44 | Enum.reduce(members, [], fn({key, value}, unmatched) -> 45 | actual_value = jsonapi[Atom.to_string(key)] 46 | 47 | if actual_value != value do 48 | unmatched ++ ["Expected:\n `#{key}` \"#{value}\"\nGot:\n `#{key}` \"#{actual_value}\""] 49 | else 50 | unmatched 51 | end 52 | end) 53 | |> case do 54 | [] -> payload 55 | unmatched -> 56 | raise ExUnit.AssertionError, "jsonapi object mismatch\n#{Enum.join(unmatched, ", ")}" 57 | end 58 | end 59 | 60 | def assert_jsonapi(_payload, _members), 61 | do: raise ExUnit.AssertionError, "jsonapi object not found" 62 | 63 | @doc """ 64 | Asserts that a given record is included in the `data` object of the payload. 65 | 66 | ## Examples 67 | 68 | payload 69 | |> assert_data(user1) 70 | 71 | Can also take a list of records. The list will be iterated over asserting each record individually. 72 | """ 73 | @spec assert_data(map, map | struct | list) :: map 74 | def assert_data(payload, []), do: payload 75 | def assert_data(payload, [record | records]) do 76 | assert_data(payload, record) 77 | |> assert_data(records) 78 | end 79 | def assert_data(payload, record) do 80 | assert_record(payload["data"], record) 81 | 82 | payload 83 | end 84 | 85 | @doc """ 86 | Refutes that a given record is included in the `data` object of the payload. 87 | 88 | ## Examples 89 | 90 | payload 91 | |> refute_data(user1) 92 | 93 | Can also take a list of records. The list will be iterated over refuting each record individually. 94 | """ 95 | def refute_data(payload, []), do: payload 96 | def refute_data(payload, [record | records]) do 97 | refute_data(payload, record) 98 | |> refute_data(records) 99 | end 100 | @spec refute_data(map, map | struct | list) :: map 101 | def refute_data(payload, record) do 102 | refute_record(payload["data"], record) 103 | 104 | payload 105 | end 106 | 107 | @doc """ 108 | Asserts that a given record is included in the `included` object of the payload. 109 | 110 | ## Examples 111 | 112 | payload 113 | |> assert_included(pet1) 114 | 115 | Can also take a list of records. The list will be iterated over asserting each record individually. 116 | """ 117 | @spec assert_included(map, map | struct | list) :: map 118 | def assert_included(payload, []), do: payload 119 | def assert_included(payload, [record | records]) do 120 | assert_included(payload, record) 121 | |> assert_included(records) 122 | end 123 | def assert_included(payload, record) do 124 | assert_record(payload["included"], record) 125 | 126 | payload 127 | end 128 | 129 | 130 | @doc """ 131 | Refutes that a given record is included in the `included` object of the payload. 132 | 133 | ## Examples 134 | 135 | payload 136 | |> refute_included(pet1) 137 | 138 | Can also take a list of records. The list will be iterated over refuting each record individually. 139 | """ 140 | @spec refute_included(map, map | struct | list) :: map 141 | def refute_included(payload, []), do: payload 142 | def refute_included(payload, [record | records]) do 143 | refute_included(payload, record) 144 | |> refute_included(records) 145 | end 146 | def refute_included(payload, record) do 147 | refute_record(payload["included"], record) 148 | 149 | payload 150 | end 151 | 152 | @doc """ 153 | Asserts that the proper relationship meta data exists between a parent and child record 154 | 155 | ## Examples 156 | 157 | payload 158 | |> assert_relationship(pet1, as: "pets", for: [:data, owner1]) 159 | 160 | The `as:` atom must be passed the name of the relationship. It will not be derived from the child. 161 | 162 | An optional `included` boolean can be passed if you'd like to assert if the record is in the `included` section of 163 | the payload: 164 | 165 | payload 166 | |> assert_relationship(pet1, as: "pets", for: [:data, owner1], included: true) 167 | 168 | This is functionally equivalent to: 169 | 170 | payload 171 | |> assert_relationship(pet1, as: "pets", for: [:data, owner1]) 172 | |> assert_included(pet1) 173 | 174 | If you pass `false` instead `refute_included/3` is used. 175 | 176 | This function can also take a list of child records. The list will be iterated over asserting each record individually. 177 | """ 178 | @spec assert_relationship(map, map | list, [as: binary, for: list, included: boolean]) :: map 179 | def assert_relationship(payload, [], _opts), do: payload 180 | def assert_relationship(payload, [child_record | child_records], opts) do 181 | assert_relationship(payload, child_record, opts) 182 | |> assert_relationship(child_records, opts) 183 | end 184 | def assert_relationship(payload, child_record, [as: as, for: path]), 185 | do: assert_relationship(payload, child_record, as: as, for: path, included: nil) 186 | def assert_relationship(payload, child_record, [as: as, for: path, included: included?]) do 187 | parent_record = record_from_path(payload, path) 188 | 189 | relationships = parent_record["relationships"] 190 | 191 | if !relationships do 192 | {id, type} = extract_identifiers(parent_record) 193 | raise ExUnit.AssertionError, "could not find any relationships for record matching `id` #{id} and `type` \"#{type}\"" 194 | end 195 | 196 | relationship = relationships[as] 197 | 198 | if !relationship do 199 | {id, type} = extract_identifiers(parent_record) 200 | raise ExUnit.AssertionError, "could not find the relationship `#{as}` for record matching `id` #{id} and `type` \"#{type}\"" 201 | end 202 | 203 | relationship = 204 | get_in(parent_record, ["relationships", as, "data"]) 205 | |> List.wrap() 206 | |> Enum.find(&(meta_data_match?(&1, child_record))) 207 | 208 | {child_id, child_type} = extract_identifiers(child_record) 209 | {parent_id, parent_type} = extract_identifiers(parent_record) 210 | assert relationship, "could not find relationship `#{as}` with `id` #{child_id} and `type` \"#{child_type}\" for record matching `id` #{parent_id} and `type` \"#{parent_type}\"" 211 | 212 | case included? do 213 | nil -> payload 214 | true -> assert_included(payload, child_record) 215 | false -> refute_included(payload, child_record) 216 | end 217 | end 218 | def assert_relationship(_, _, [for: _]), 219 | do: raise ExUnit.AssertionError, "you must pass `as:` with the name of the relationship" 220 | def assert_relationship(_, _, [as: _]), 221 | do: raise ExUnit.AssertionError, "you must pass `for:` with the parent record" 222 | 223 | @doc """ 224 | Refutes a relationship between two records 225 | 226 | ## Examples 227 | 228 | payload 229 | |> refute_relationship(pet1, as: "pets", for: [:data, owner1]) 230 | 231 | The `as:` atom must be passed the name of the relationship. It will not be derived from the child. 232 | 233 | This function can also take a list of child records. The list will be iterated over refuting each record individually. 234 | """ 235 | @spec refute_relationship(map, map | list, [as: binary, for: list]) :: map 236 | def refute_relationship(payload, [], _opts), do: payload 237 | def refute_relationship(payload, [child_record | child_records], opts) do 238 | refute_relationship(payload, child_record, opts) 239 | |> refute_relationship(child_records, opts) 240 | end 241 | def refute_relationship(payload, child_record, [as: as, for: path]) do 242 | parent_record = record_from_path(payload, path) 243 | 244 | {child_id, child_type} = extract_identifiers(child_record) 245 | {parent_id, parent_type} = extract_identifiers(parent_record) 246 | 247 | parent_record 248 | |> get_in(["relationships", as, "data"]) 249 | |> List.wrap() 250 | |> Enum.find(&(meta_data_match?(&1, child_record))) 251 | |> refute("was not expecting to find the relationship `#{as}` with `id` #{child_id} and `type` \"#{child_type}\" for record matching `id` #{parent_id} and `type` \"#{parent_type}\"") 252 | 253 | payload 254 | end 255 | def refute_relationship(_, _, [for: _]), 256 | do: raise ExUnit.AssertionError, "you must pass `as:` with the name of the relationship" 257 | def refute_relationship(_, _, [as: _]), 258 | do: raise ExUnit.AssertionError, "you must pass `for:` with the parent record" 259 | 260 | defp assert_record(data, record) do 261 | {id, type} = extract_identifiers(record) 262 | 263 | data 264 | |> find_record(record) 265 | |> case do 266 | nil -> 267 | assert nil, "could not find a record with matching `id` #{id} and `type` \"#{type}\"" 268 | %{"attributes" => attributes} = found_record -> 269 | Enum.reduce(attributes, [], fn({key, value}, attrs) -> 270 | if value_match?(value, record["attributes"][key]) do 271 | attrs 272 | else 273 | attrs ++ [key] 274 | end 275 | end) 276 | |> case do 277 | [] -> found_record 278 | keys -> 279 | opts = [ 280 | left: Enum.into(keys, %{}, fn(key) -> {key, attributes[key]} end), 281 | right: Enum.into(keys, %{}, fn(key) -> {key, record["attributes"][key]} end), 282 | message: "record with `id` #{id} and `type` \"#{type}\" was found but had mis-matching attributes" 283 | ] 284 | raise(ExUnit.AssertionError, opts) 285 | end 286 | end 287 | end 288 | 289 | defp refute_record(data, record) do 290 | data 291 | |> find_record(record) 292 | |> case do 293 | %{"attributes" => attributes} -> 294 | matching = 295 | attributes 296 | |> Enum.reduce([], fn({key, value}, attrs) -> 297 | if value_match?(value, record["attributes"][key]) do 298 | attrs ++ [{key, value}] 299 | else 300 | attrs 301 | end 302 | end) 303 | 304 | refute Map.keys(attributes) |> length() == length(matching), "did not expect #{inspect Map.delete(record, "attributes")} to be found." 305 | 306 | nil -> nil 307 | end 308 | end 309 | 310 | defp find_record(data, record) when is_list(data), 311 | do: Enum.find(data, &(meta_data_match?(&1, record))) 312 | defp find_record(data, record), 313 | do: find_record([data], record) 314 | 315 | defp meta_data_match?(record_1, record_2), 316 | do: value_match?(record_1["id"], record_2["id"]) && value_match?(record_1["type"], record_2["type"]) 317 | 318 | defp value_match?(value, %Regex{} = matcher), 319 | do: value_match?(matcher, value) 320 | defp value_match?(%Regex{} = matcher, value), 321 | do: Regex.match?(matcher, value) 322 | defp value_match?(value, value), do: true 323 | defp value_match?(_, _), do: false 324 | 325 | defp enforce_top_level_constraints(payload) do 326 | if Map.has_key?(payload, "data") && Map.has_key?(payload, "errors"), 327 | do: raise ExUnit.AssertionError, "the members `data` and `errors` MUST NOT coexist in the same document" 328 | 329 | if !Map.has_key?(payload, "data") && Map.has_key?(payload, "included"), 330 | do: raise ExUnit.AssertionError, "If a document does not contain a top-level data key, the included member MUST NOT be present either." 331 | 332 | unless Map.has_key?(payload, "data") || Map.has_key?(payload, "errors") || Map.has_key?(payload, "meta"), 333 | do: raise ExUnit.AssertionError, "A document MUST contain at least one of the following top-level members: 'data', 'errors', 'meta'" 334 | end 335 | 336 | defp record_from_path(payload, path) do 337 | data_path = 338 | List.delete_at(path, -1) 339 | |> Enum.map(&(Atom.to_string(&1))) 340 | 341 | get_in(payload, data_path) 342 | |> assert_record(Enum.at(path, -1)) 343 | end 344 | 345 | defp extract_identifiers(record), 346 | do: {extract_value(record["id"]), extract_value(record["type"])} 347 | 348 | defp extract_value(%Regex{} = value), do: inspect(value) 349 | defp extract_value(value), do: value 350 | end 351 | -------------------------------------------------------------------------------- /lib/json_api_assert/error.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiAssert.Error do 2 | defexception [:message] 3 | end 4 | -------------------------------------------------------------------------------- /lib/json_api_assert/inflector.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiAssert.Inflector do 2 | @moduledoc """ 3 | Inflection functions 4 | 5 | `use` this module in any module you wish to add inflection functions. It is 6 | necessary so the inflection rules are imported at compile-time to ensure fast lookup. 7 | 8 | To add a new rule add the following to your config: 9 | 10 | config :json_api_assert, :plural_rules, 11 | [{"dog", "dogs"}, 12 | {"person", "people"}] 13 | 14 | Each member of the list *must* be a tuple with the first element being the singular form 15 | and the second element being the pluralized form. 16 | """ 17 | 18 | defmacro __using__([]) do 19 | quote do 20 | @rules Application.get_env(:json_api_assert, :plural_rules) 21 | |> Enum.reduce(%{}, fn({singular, plural}, rules) -> 22 | rules 23 | |> Map.put(singular, plural) 24 | |> Map.put(plural, singular) 25 | end) 26 | 27 | @doc """ 28 | Pluralize the given word 29 | 30 | Will try to use a pre-defined rule or default to appending "s" to the end of the word. 31 | """ 32 | def pluralize(word) when is_binary(word) do 33 | @rules 34 | |> Map.get(word, "#{word}s") 35 | end 36 | @doc """ 37 | Singuliarze the given word 38 | 39 | Will try to use a pre-defined rule or default to removing the "s" at the end of the word. 40 | """ 41 | def singularize(word) when is_binary(word) do 42 | @rules 43 | |> Map.get(word, String.replace(word, ~r/s$/, "")) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/json_api_assert/serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiAssert.Serializer do 2 | @moduledoc """ 3 | Basic record serializer for use with JsonApiAssert 4 | """ 5 | 6 | @doc """ 7 | Will serialize a record into the JSON API format. 8 | 9 | %User{first_name: "Brian", last_name: "Cardarella", id: 1} 10 | |> serialize() 11 | 12 | # Result: 13 | 14 | %{ 15 | "id" => "1", 16 | "type" => "users", 17 | "attributes" => %{ 18 | "first-name" => "Brian", 19 | "last-name" => "Cardarella" 20 | } 21 | } 22 | 23 | Options: 24 | 25 | * `type` - override the type being derived from the record. Or provide one if a type cannot be derived 26 | * `primary_key` - override the key from which the primary_key value will be obtained 27 | * `only` - a list of attributes to limit serialization to 28 | * `except` - a list of attributes to exclude in serialization 29 | """ 30 | @spec serialize(struct, type: binary, primary_key: atom) :: map 31 | def serialize(record, opts \\ []) do 32 | %{} 33 | |> put_id(record, opts[:primary_key]) 34 | |> put_type(record, opts[:type]) 35 | |> put_attributes(record, opts) 36 | end 37 | 38 | @doc """ 39 | Shortened alias of `serialize` 40 | """ 41 | def s(record, opts \\ []), 42 | do: serialize(record, opts) 43 | 44 | defp put_id(serialized_record, record, primary_key) do 45 | id = Map.get(record, get_primary_key(record, primary_key)) 46 | |> to_string 47 | 48 | Map.put(serialized_record, "id", id) 49 | end 50 | 51 | defp get_primary_key(record, primary_key) when is_binary(primary_key), 52 | do: get_primary_key(record, String.to_atom(primary_key)) 53 | defp get_primary_key(_record, primary_key), 54 | do: primary_key || :id 55 | 56 | defp put_type(map, %{__struct__: struct}, nil) do 57 | type = 58 | struct 59 | |> Module.split() 60 | |> List.last() 61 | |> Macro.underscore() 62 | 63 | Map.put(map, "type", type) 64 | end 65 | defp put_type(_map, _record, nil), 66 | do: raise ArgumentError, "No type can be derived from record. Please pass a type to `serialize`." 67 | defp put_type(map, _record, type), 68 | do: Map.put(map, "type", type) 69 | 70 | defp put_attributes(map, %{__struct__: _struct} = record, opts), 71 | do: put_attributes(map, Map.from_struct(record), opts) 72 | defp put_attributes(map, record, opts) do 73 | primary_key = get_primary_key(record, opts[:primary_key]) 74 | 75 | except = 76 | (opts[:except] || []) 77 | |> Enum.into(%{}, fn(key) -> {key, true} end) 78 | |> Map.put(primary_key, true) 79 | 80 | only = 81 | ((opts[:only] || []) -- Map.keys(except)) 82 | |> Enum.into(%{}, fn(key) -> {key, true} end) 83 | 84 | attributes = 85 | Enum.reduce(record, %{}, fn({key, value}, attributes) -> 86 | cond do 87 | Map.has_key?(except, key) and Map.has_key?(only, key) -> 88 | attributes 89 | Map.has_key?(except, key) -> 90 | attributes 91 | only == %{} -> 92 | put_serialized_kv(attributes, key, value) 93 | !Map.has_key?(only, key) -> 94 | attributes 95 | true -> 96 | put_serialized_kv(attributes, key, value) 97 | end 98 | end) 99 | 100 | Map.put(map, "attributes", attributes) 101 | end 102 | 103 | defp put_serialized_kv(attributes, key, value) do 104 | Map.put(attributes, serialize_key(key), serialize_value(value)) 105 | end 106 | 107 | defp serialize_key(key) when is_atom(key), 108 | do: key 109 | |> Atom.to_string() 110 | |> serialize_key() 111 | defp serialize_key(key) when is_binary(key), 112 | do: key 113 | |> String.downcase() 114 | |> String.replace("_", "-") 115 | 116 | defp serialize_value(%{__struct__: Ecto.DateTime} = value), 117 | do: apply(Ecto.DateTime, :to_iso8601, [value]) 118 | defp serialize_value(%{__struct__: Ecto.Time} = value), 119 | do: apply(Ecto.Time, :to_iso8601, [value]) 120 | defp serialize_value(%{__struct__: Ecto.Date} = value), 121 | do: apply(Ecto.Date, :to_iso8601, [value]) 122 | defp serialize_value(%{__struct__: NaiveDateTime} = value), 123 | do: apply(NaiveDateTime, :to_iso8601, [value]) 124 | defp serialize_value(value), do: value 125 | end 126 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule JsonApiAssert.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :json_api_assert, 6 | version: "0.0.2", 7 | elixir: "~> 1.2", 8 | elixirc_paths: elixirc_paths(Mix.env), 9 | build_embedded: Mix.env == :prod, 10 | start_permanent: Mix.env == :prod, 11 | package: package(), 12 | description: "assertions for JSON API payload", 13 | deps: deps(), 14 | docs: [ 15 | main: "JsonApiAssert", 16 | logo: "jsonapi-assert.png" 17 | ]] 18 | end 19 | 20 | # Configuration for the OTP application 21 | # 22 | # Type "mix help compile.app" for more information 23 | def application do 24 | [applications: [:logger]] 25 | end 26 | 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_env), do: ["lib"] 29 | 30 | def package do 31 | [maintainers: ["Brian Cardarella"], 32 | licenses: ["MIT"], 33 | links: %{"GitHub" => "https://github.com/DockYard/json_api_assert"}] 34 | end 35 | 36 | # Dependencies can be Hex packages: 37 | # 38 | # {:mydep, "~> 0.3.0"} 39 | # 40 | # Or git/path repositories: 41 | # 42 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 43 | # 44 | # Type "mix help deps" for more examples and options 45 | defp deps do 46 | [{:ecto, "~> 1.1.8", only: :test}, 47 | {:earmark, "~> 0.2.1", only: :dev}, 48 | {:ex_doc, "~> 0.11.5", only: :dev}] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"decimal": {:hex, :decimal, "1.1.2", "79a769d4657b2d537b51ef3c02d29ab7141d2b486b516c109642d453ee08e00c", [:mix], []}, 2 | "earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], []}, 3 | "ecto": {:hex, :ecto, "1.1.8", "0f0348e678fa5a450c266d69816808f97fbd82ade32cf88d4b09bbe8f8c27545", [:mix], [{:sbroker, "~> 0.7", [hex: :sbroker, optional: true]}, {:postgrex, "~> 0.11.0", [hex: :postgrex, optional: true]}, {:poolboy, "~> 1.4", [hex: :poolboy, optional: false]}, {:poison, "~> 1.0 or ~> 2.0", [hex: :poison, optional: true]}, {:mariaex, "~> 0.5.0 or ~> 0.6.0", [hex: :mariaex, optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}, 4 | "ex_doc": {:hex, :ex_doc, "0.11.5", "0dc51cb84f8312162a2313d6c71573a9afa332333d8a332bb12540861b9834db", [:mix], [{:earmark, "~> 0.1.17 or ~> 0.2", [hex: :earmark, optional: true]}]}, 5 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}} 6 | -------------------------------------------------------------------------------- /test/assert_data_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AssertDataTest do 2 | use ExUnit.Case 3 | import JsonApiAssert, only: [assert_data: 2] 4 | import JsonApiAssert.TestData, only: [data: 1] 5 | 6 | @article %{ 7 | "id" => "1", 8 | "type" => "article", 9 | "attributes" => %{ 10 | "title" => "Mother of all demos" 11 | } 12 | } 13 | 14 | test "will not raise when record is found" do 15 | assert_data(data(:payload), data(:post)) 16 | end 17 | 18 | test "will not raise when record is found using regex" do 19 | post = 20 | data(:post) 21 | |> put_in(["id"], ~r/\d+/) 22 | 23 | assert_data(data(:payload), post) 24 | end 25 | 26 | test "will not raise when matching attribute with regex" do 27 | post = 28 | data(:post) 29 | |> put_in(["attributes", "title"], ~r/^Mother.+$/) 30 | 31 | assert_data(data(:payload), post) 32 | end 33 | 34 | test "will raise when record with different attribute values is not found" do 35 | post = 36 | data(:post) 37 | |> put_in(["attributes", "title"], "Father of all demos") 38 | 39 | try do 40 | assert_data(data(:payload), post) 41 | rescue 42 | error in [ExUnit.AssertionError] -> 43 | assert %{"title" => "Mother of all demos"} == error.left 44 | assert %{"title" => "Father of all demos"} == error.right 45 | assert "record with `id` 1 and `type` \"post\" was found but had mis-matching attributes" == error.message 46 | end 47 | end 48 | 49 | test "will raise when there is an id mismatch" do 50 | msg = "could not find a record with matching `id` 2 and `type` \"post\"" 51 | 52 | post = 53 | data(:post) 54 | |> put_in(["id"], "2") 55 | 56 | try do 57 | assert_data(data(:payload), post) 58 | rescue 59 | error in [ExUnit.AssertionError] -> 60 | assert msg == error.message 61 | end 62 | end 63 | 64 | test "will raise when there is an id mismatch via regex" do 65 | msg = "could not find a record with matching `id` ~r/^$/ and `type` \"post\"" 66 | 67 | post = 68 | data(:post) 69 | |> put_in(["id"], ~r/^$/) 70 | 71 | try do 72 | assert_data(data(:payload), post) 73 | rescue 74 | error in [ExUnit.AssertionError] -> 75 | assert msg == error.message 76 | end 77 | end 78 | 79 | test "will raise when there is a type mismatch" do 80 | msg = "could not find a record with matching `id` 1 and `type` \"article\"" 81 | 82 | try do 83 | assert_data(data(:payload), @article) 84 | rescue 85 | error in [ExUnit.AssertionError] -> 86 | assert msg == error.message 87 | end 88 | end 89 | 90 | test "will return the original payload" do 91 | payload = assert_data(data(:payload), data(:post)) 92 | 93 | assert payload == data(:payload) 94 | end 95 | 96 | test "can assert many records at once" do 97 | payload = assert_data(data(:payload_2), [data(:post), data(:post_2)]) 98 | 99 | assert payload == data(:payload_2) 100 | end 101 | 102 | test "will fail if one of the records is not present" do 103 | msg = "could not find a record with matching `id` 2 and `type` \"post\"" 104 | 105 | try do 106 | assert_data(data(:payload), [data(:post), data(:post_2)]) 107 | rescue 108 | error in [ExUnit.AssertionError] -> 109 | assert msg == error.message 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/assert_included_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AssertIncludedTest do 2 | use ExUnit.Case 3 | import JsonApiAssert, only: [assert_included: 2] 4 | import JsonApiAssert.TestData, only: [data: 1] 5 | 6 | @writer %{ 7 | "id" => "1", 8 | "type" => "writer", 9 | "attributes" => %{ 10 | "first-name" => "Douglas", 11 | "last-name" => "Engelbart" 12 | } 13 | } 14 | 15 | test "will not raise when record is found" do 16 | assert_included(data(:payload), data(:author)) 17 | end 18 | 19 | test "will raise when record with different attribute values is not found" do 20 | author = 21 | data(:author) 22 | |> put_in(["attributes", "first-name"], "Yosemite") 23 | |> put_in(["attributes", "last-name"], "Sam") 24 | 25 | try do 26 | assert_included(data(:payload), author) 27 | rescue 28 | error in [ExUnit.AssertionError] -> 29 | assert %{"first-name" => "Douglas", "last-name" => "Engelbart"} == error.left 30 | assert %{"first-name" => "Yosemite", "last-name" => "Sam"} == error.right 31 | assert "record with `id` 1 and `type` \"author\" was found but had mis-matching attributes" == error.message 32 | end 33 | end 34 | 35 | test "will raise when there is an id mismatch" do 36 | msg = "could not find a record with matching `id` 2 and `type` \"author\"" 37 | author = 38 | data(:author) 39 | |> put_in(["id"], "2") 40 | 41 | try do 42 | assert_included(data(:payload), author) 43 | rescue 44 | error in [ExUnit.AssertionError] -> 45 | assert msg == error.message 46 | end 47 | end 48 | 49 | test "will raise when there is a type mismatch" do 50 | msg = "could not find a record with matching `id` 1 and `type` \"writer\"" 51 | 52 | try do 53 | assert_included(data(:payload), @writer) 54 | rescue 55 | error in [ExUnit.AssertionError] -> 56 | assert msg == error.message 57 | end 58 | end 59 | 60 | test "will return the original payload" do 61 | payload = assert_included(data(:payload), data(:author)) 62 | 63 | assert payload == data(:payload) 64 | end 65 | 66 | test "can assert many records at once" do 67 | payload = assert_included(data(:payload_2), [data(:comment_1), data(:comment_2)]) 68 | 69 | assert payload == data(:payload_2) 70 | end 71 | 72 | test "will fail if one of the records is not present" do 73 | msg = "could not find a record with matching `id` 3 and `type` \"comment\"" 74 | 75 | try do 76 | assert_included(data(:payload), [data(:comment_1), data(:comment_3)]) 77 | rescue 78 | error in [ExUnit.AssertionError] -> 79 | assert msg == error.message 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/assert_relationship_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AssertRelationshipTest do 2 | use ExUnit.Case 3 | import JsonApiAssert, only: [assert_relationship: 3] 4 | import JsonApiAssert.TestData, only: [data: 1] 5 | 6 | test "will not raise when relationship is found in a data record" do 7 | assert_relationship(data(:payload), data(:author), as: "author", for: [:data, data(:post)]) 8 | end 9 | 10 | test "will not raise when relationship is found in a included record" do 11 | assert_relationship(data(:payload), data(:post), as: "posts", for: [:included, data(:author)]) 12 | end 13 | 14 | test "will raise if `as:` is not passed" do 15 | try do 16 | assert_relationship(data(:payload), data(:author), for: [:data, data(:post)]) 17 | rescue 18 | error in [ExUnit.AssertionError] -> 19 | assert "you must pass `as:` with the name of the relationship" == error.message 20 | end 21 | end 22 | 23 | test "will raise if `for:` is not passed" do 24 | try do 25 | assert_relationship(data(:payload), data(:author), as: "author") 26 | rescue 27 | error in [ExUnit.AssertionError] -> 28 | assert "you must pass `for:` with the parent record" == error.message 29 | end 30 | end 31 | 32 | test "will raise when child record's id not found as a relationship for parent" do 33 | msg = "could not find relationship `author` with `id` 2 and `type` \"author\" for record matching `id` 1 and `type` \"post\"" 34 | author = 35 | data(:author) 36 | |> put_in(["id"], "2") 37 | 38 | try do 39 | assert_relationship(data(:payload), author, as: "author", for: [:data, data(:post)]) 40 | rescue 41 | error in [ExUnit.AssertionError] -> 42 | assert msg == error.message 43 | end 44 | end 45 | 46 | test "will raise when child record's type not found as a relationship for parent" do 47 | msg = "could not find relationship `author` with `id` 1 and `type` \"writer\" for record matching `id` 1 and `type` \"post\"" 48 | author = 49 | data(:author) 50 | |> put_in(["type"], "writer") 51 | 52 | try do 53 | assert_relationship(data(:payload), author, as: "author", for: [:data, data(:post)]) 54 | rescue 55 | error in [ExUnit.AssertionError] -> 56 | assert msg == error.message 57 | end 58 | end 59 | 60 | test "will raise when relationship name not found" do 61 | msg = "could not find the relationship `writer` for record matching `id` 1 and `type` \"post\"" 62 | 63 | try do 64 | assert_relationship(data(:payload), data(:author), as: "writer", for: [:data, data(:post)]) 65 | rescue 66 | error in [ExUnit.AssertionError] -> 67 | assert msg == error.message 68 | end 69 | end 70 | 71 | test "will raise when no relationship data in parent record" do 72 | msg = "could not find any relationships for record matching `id` 1 and `type` \"post\"" 73 | payload = %{ 74 | "jsonapi" => %{ "version" => "1.0" }, 75 | "data" => %{ 76 | "id" => "1", 77 | "type" => "post", 78 | "attributes" => %{ 79 | "title" => "Mother of all demos" 80 | } 81 | } 82 | } 83 | 84 | try do 85 | assert_relationship(payload, data(:author), as: "writer", for: [:data, data(:post)]) 86 | rescue 87 | error in [ExUnit.AssertionError] -> 88 | assert msg == error.message 89 | end 90 | end 91 | 92 | test "will raise when parent record is not found" do 93 | post = 94 | data(:post) 95 | |> put_in(["attributes", "title"], "Father of all demos") 96 | 97 | try do 98 | assert_relationship(data(:payload), data(:author), as: "writer", for: [:data, post]) 99 | rescue 100 | error in [ExUnit.AssertionError] -> 101 | assert %{"title" => "Mother of all demos"} == error.left 102 | assert %{"title" => "Father of all demos"} == error.right 103 | assert "record with `id` 1 and `type` \"post\" was found but had mis-matching attributes" == error.message 104 | end 105 | end 106 | 107 | test "will return the original payload" do 108 | payload = assert_relationship(data(:payload), data(:author), as: "author", for: [:data, data(:post)]) 109 | assert payload == data(:payload) 110 | end 111 | 112 | test "will assert the record is included when `included: true` is used" do 113 | assert_relationship(data(:payload), data(:comment_1), as: "comments", for: [:data, data(:post)], included: true) 114 | end 115 | 116 | test "will raise when the record is not included and `included: true` is used" do 117 | msg = "could not find a record with matching `id` 5 and `type` \"comment\"" 118 | 119 | try do 120 | assert_relationship(data(:payload), data(:comment_5), as: "comments", for: [:data, data(:post)], included: true) 121 | rescue 122 | error in [ExUnit.AssertionError] -> 123 | assert msg == error.message 124 | end 125 | end 126 | 127 | test "will raise when the record is included and `included: false` is used" do 128 | record = %{ 129 | "id" => "1", 130 | "type" => "comment" 131 | } 132 | msg = "did not expect #{inspect record} to be found." 133 | 134 | try do 135 | assert_relationship(data(:payload), data(:comment_1), as: "comments", for: [:data, data(:post)], included: false) 136 | rescue 137 | error in [ExUnit.AssertionError] -> 138 | assert msg == error.message 139 | end 140 | end 141 | 142 | test "can assert many records at once" do 143 | payload = assert_relationship(data(:payload_2), [data(:comment_1), data(:comment_2)], as: "comments", for: [:data, data(:post)]) 144 | 145 | assert payload == data(:payload_2) 146 | end 147 | 148 | test "will fail if one of the records is not present" do 149 | msg = "could not find relationship `comments` with `id` 3 and `type` \"comment\" for record matching `id` 1 and `type` \"post\"" 150 | 151 | try do 152 | assert_relationship(data(:payload_2), [data(:comment_1), data(:comment_3)], as: "comments", for: [:data, data(:post)]) 153 | rescue 154 | error in [ExUnit.AssertionError] -> 155 | assert msg == error.message 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /test/inflector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule InflectorTest do 2 | use ExUnit.Case 3 | use JsonApiAssert.Inflector 4 | 5 | test "will pluralize words with no rules by adding `s` to the end" do 6 | assert pluralize("post") == "posts" 7 | end 8 | 9 | test "will pluralize words according to rule if it exists" do 10 | assert pluralize("person") == "people" 11 | end 12 | 13 | test "will singularize words with no rules by removing trailing `s` on the end" do 14 | assert singularize("posts") == "post" 15 | end 16 | 17 | test "will singularize words according to rule if it exists" do 18 | assert singularize("people") == "person" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/jsonapi_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AssertJsonApiTest do 2 | use ExUnit.Case 3 | import JsonApiAssert, only: [assert_jsonapi: 2] 4 | import JsonApiAssert.TestData, only: [data: 1] 5 | 6 | test "assert - will not raise when matching jsonapi object exists" do 7 | payload = %{ 8 | "jsonapi" => %{ 9 | "version" => "1.0" 10 | }, 11 | "meta" => %{ 12 | "authors" => [ 13 | "Brian Cardarella" 14 | ] 15 | } 16 | } 17 | 18 | assert_jsonapi(payload, version: "1.0") 19 | end 20 | 21 | test "assert - will raise if no jsonapi object exists" do 22 | try do 23 | assert_jsonapi(%{}, version: "1.1") 24 | rescue 25 | error in [ExUnit.AssertionError] -> 26 | assert "jsonapi object not found" == error.message 27 | end 28 | end 29 | 30 | test "assert will raise when matching jsonapi object does not exist" do 31 | payload = %{ 32 | "jsonapi" => %{ 33 | "version" => "1.0" 34 | }, 35 | "meta" => %{ 36 | "authors" => [ 37 | "Brian Cardarella" 38 | ] 39 | } 40 | } 41 | 42 | try do 43 | assert_jsonapi(payload, version: "1.1") 44 | rescue 45 | error in [ExUnit.AssertionError] -> 46 | assert "jsonapi object mismatch\nExpected:\n `version` \"1.1\"\nGot:\n `version` \"1.0\"" == error.message 47 | end 48 | end 49 | 50 | test "assert will return original payload" do 51 | payload = %{ 52 | "jsonapi" => %{ 53 | "version" => "1.0" 54 | }, 55 | "meta" => %{ 56 | "authors" => [ 57 | "Brian Cardarella" 58 | ] 59 | } 60 | } 61 | 62 | assert payload == assert_jsonapi(payload, version: "1.0") 63 | end 64 | 65 | test "will raise when both errors and data objects exist in the response" do 66 | msg = "the members `data` and `errors` MUST NOT coexist in the same document" 67 | 68 | try do 69 | Map.merge(data(:payload), %{"errors" => []}) 70 | |> assert_jsonapi(version: "1.0") 71 | rescue 72 | error in [ExUnit.AssertionError] -> 73 | assert msg == error.message 74 | else 75 | _ -> assert false, message: "Expected Exception #{msg}" 76 | end 77 | end 78 | 79 | test "will raise when an included object exists in the response without the presence of the data object" do 80 | msg = "If a document does not contain a top-level data key, the included member MUST NOT be present either." 81 | 82 | try do 83 | Map.delete(data(:payload), "data") 84 | |> assert_jsonapi(version: "1.0") 85 | rescue 86 | error in [ExUnit.AssertionError] -> 87 | assert msg == error.message 88 | else 89 | _ -> assert false, message: "Expected Exception #{msg}" 90 | end 91 | end 92 | 93 | test "will raise if document does not contain at least one of the following objects: 'data', 'errors', 'meta'" do 94 | msg = "A document MUST contain at least one of the following top-level members: 'data', 'errors', 'meta'" 95 | 96 | try do 97 | Map.delete(data(:payload), "data") 98 | |> Map.delete("included") 99 | |> assert_jsonapi(version: "1.0") 100 | rescue 101 | error in [ExUnit.AssertionError] -> 102 | assert msg == error.message 103 | else 104 | _ -> assert false, message: "Expected Exception #{msg}" 105 | end 106 | 107 | Map.delete(data(:payload), "data") 108 | |> Map.delete("included") 109 | |> Map.merge(%{"errors" => []}) 110 | |> assert_jsonapi(version: "1.0") 111 | 112 | Map.delete(data(:payload), "data") 113 | |> Map.delete("included") 114 | |> Map.merge(%{"meta" => []}) 115 | |> assert_jsonapi(version: "1.0") 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/refute_data_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RefuteDataTest do 2 | use ExUnit.Case 3 | import JsonApiAssert, only: [refute_data: 2] 4 | import JsonApiAssert.TestData, only: [data: 1] 5 | 6 | test "will not raise when record is not found" do 7 | post = 8 | data(:post) 9 | |> put_in(["attributes", "title"], "Father of all demos") 10 | 11 | refute_data(data(:payload), post) 12 | end 13 | 14 | test "will raise when record is found" do 15 | record = %{ 16 | "id" => "1", 17 | "type" => "post" 18 | } 19 | 20 | msg = "did not expect #{inspect record} to be found." 21 | 22 | try do 23 | refute_data(data(:payload), data(:post)) 24 | rescue 25 | error in [ExUnit.AssertionError] -> 26 | assert msg == error.message 27 | end 28 | end 29 | 30 | test "will raise when record is found with regex" do 31 | post = 32 | data(:post) 33 | |> put_in(["id"], ~r/\d+/) 34 | 35 | record = %{ 36 | "id" => ~r/\d+/, 37 | "type" => "post" 38 | } 39 | 40 | msg = "did not expect #{inspect record} to be found." 41 | 42 | try do 43 | refute_data(data(:payload), post) 44 | rescue 45 | error in [ExUnit.AssertionError] -> 46 | assert msg == error.message 47 | end 48 | end 49 | 50 | test "will raise when matching attribute with regex" do 51 | post = 52 | data(:post) 53 | |> put_in(["attributes", "title"], ~r/^Mother.+$/) 54 | 55 | record = %{ 56 | "id" => "1", 57 | "type" => "post" 58 | } 59 | 60 | msg = "did not expect #{inspect record} to be found." 61 | 62 | try do 63 | refute_data(data(:payload), post) 64 | rescue 65 | error in [ExUnit.AssertionError] -> 66 | assert msg == error.message 67 | end 68 | end 69 | 70 | test "will return the original payload" do 71 | post = 72 | data(:post) 73 | |> put_in(["attributes", "title"], "Father of all demos") 74 | 75 | payload = refute_data(data(:payload), post) 76 | 77 | assert payload == data(:payload) 78 | end 79 | 80 | test "will not raise if we force an id value mis-match and everything else matches" do 81 | post = 82 | data(:post) 83 | |> put_in(["id"], "2") 84 | 85 | refute_data(data(:payload), post) 86 | end 87 | 88 | test "will not raise if we force an id value mis-match with regex and everything else matches" do 89 | post = 90 | data(:post) 91 | |> put_in(["id"], ~r/^$/) 92 | 93 | refute_data(data(:payload), post) 94 | end 95 | 96 | test "can refute many records at once" do 97 | post = 98 | data(:post) 99 | |> put_in(["attributes", "title"], "Father of all demos") 100 | 101 | payload = refute_data(data(:payload), [post, data(:post_2)]) 102 | 103 | assert payload == data(:payload) 104 | end 105 | 106 | test "will fail if one of the records is present" do 107 | record = %{ 108 | "id" => "1", 109 | "type" => "post" 110 | } 111 | msg = "did not expect #{inspect record} to be found." 112 | 113 | try do 114 | refute_data(data(:payload), [data(:post_2), data(:post)]) 115 | rescue 116 | error in [ExUnit.AssertionError] -> 117 | assert msg == error.message 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/refute_included_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RefuteIncludedTest do 2 | use ExUnit.Case 3 | import JsonApiAssert, only: [refute_included: 2] 4 | import JsonApiAssert.TestData, only: [data: 1] 5 | 6 | test "will not raise when record is not found" do 7 | author = 8 | data(:author) 9 | |> put_in(["attributes", "first-name"], "Yosemite") 10 | |> put_in(["attributes", "last-name"], "Sam") 11 | 12 | refute_included(data(:payload), author) 13 | end 14 | 15 | test "will raise when record is found" do 16 | record = %{ 17 | "id" => "1", 18 | "type" => "author" 19 | } 20 | 21 | msg = "did not expect #{inspect record} to be found." 22 | 23 | try do 24 | refute_included(data(:payload), data(:author)) 25 | rescue 26 | error in [ExUnit.AssertionError] -> 27 | assert msg == error.message 28 | end 29 | end 30 | 31 | test "will return the original payload" do 32 | author = 33 | data(:author) 34 | |> put_in(["attributes", "first-name"], "Yosemite") 35 | |> put_in(["attributes", "last-name"], "Sam") 36 | 37 | payload = refute_included(data(:payload), author) 38 | 39 | assert payload == data(:payload) 40 | end 41 | 42 | test "will not raise if we force an value mis-match and everything else matches" do 43 | author = 44 | data(:author) 45 | |> put_in(["id"], "2") 46 | 47 | refute_included(data(:payload), author) 48 | end 49 | 50 | test "can refute many records at once" do 51 | payload = refute_included(data(:payload), [data(:comment_3), data(:comment_4)]) 52 | 53 | assert payload == data(:payload) 54 | end 55 | 56 | test "will fail if one of the records is present" do 57 | record = %{ 58 | "id" => "1", 59 | "type" => "comment" 60 | } 61 | msg = "did not expect #{inspect record} to be found." 62 | 63 | try do 64 | refute_included(data(:payload), [data(:comment_3), data(:comment_1)]) 65 | rescue 66 | error in [ExUnit.AssertionError] -> 67 | assert msg == error.message 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/refute_relationship_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RefuteRelationshipTest do 2 | use ExUnit.Case 3 | import JsonApiAssert, only: [refute_relationship: 3] 4 | import JsonApiAssert.TestData, only: [data: 1] 5 | 6 | test "will raise when relationship is found in a data record" do 7 | msg = "was not expecting to find the relationship `author` with `id` 1 and `type` \"author\" for record matching `id` 1 and `type` \"post\"" 8 | 9 | try do 10 | refute_relationship(data(:payload), data(:author), as: "author", for: [:data, data(:post)]) 11 | rescue 12 | error in [ExUnit.AssertionError] -> 13 | assert msg == error.message 14 | end 15 | end 16 | 17 | test "will raise if `as:` is not passed" do 18 | try do 19 | refute_relationship(data(:payload), data(:author), for: [:data, data(:post)]) 20 | rescue 21 | error in [ExUnit.AssertionError] -> 22 | assert "you must pass `as:` with the name of the relationship" == error.message 23 | end 24 | end 25 | 26 | test "will raise if `for:` is not passed" do 27 | try do 28 | refute_relationship(data(:payload), data(:author), as: "author") 29 | rescue 30 | error in [ExUnit.AssertionError] -> 31 | assert "you must pass `for:` with the parent record" == error.message 32 | end 33 | end 34 | 35 | test "will not raise when child record's id not found as a relationship for parent" do 36 | author = 37 | data(:author) 38 | |> put_in(["id"], "2") 39 | 40 | refute_relationship(data(:payload), author, as: "author", for: [:data, data(:post)]) 41 | end 42 | 43 | test "will not raise when child record's type not found as a relationship for parent" do 44 | author = 45 | data(:author) 46 | |> put_in(["type"], "writer") 47 | 48 | refute_relationship(data(:payload), author, as: "author", for: [:data, data(:post)]) 49 | end 50 | 51 | test "will not raise when relationship name not found in data" do 52 | refute_relationship(data(:payload), data(:author), as: "writer", for: [:data, data(:post)]) 53 | end 54 | 55 | test "will not raise when relationship name not found in included" do 56 | refute_relationship(data(:payload), data(:post), as: "posting", for: [:included, data(:author)]) 57 | end 58 | 59 | test "will not raise when no relationship data in parent record" do 60 | payload = %{ 61 | "jsonapi" => %{ "version" => "1.0" }, 62 | "data" => %{ 63 | "id" => "1", 64 | "type" => "post", 65 | "attributes" => %{ 66 | "title" => "Mother of all demos" 67 | } 68 | } 69 | } 70 | 71 | refute_relationship(payload, data(:author), as: "writer", for: [:data, data(:post)]) 72 | end 73 | 74 | test "will raise when parent record is not found" do 75 | post = 76 | data(:post) 77 | |> put_in(["attributes", "title"], "Father of all demos") 78 | 79 | try do 80 | refute_relationship(data(:payload), data(:author), as: "writer", for: [:data, post]) 81 | rescue 82 | error in [ExUnit.AssertionError] -> 83 | assert %{"title" => "Mother of all demos"} == error.left 84 | assert %{"title" => "Father of all demos"} == error.right 85 | assert "record with `id` 1 and `type` \"post\" was found but had mis-matching attributes" == error.message 86 | end 87 | end 88 | 89 | test "will return the original payload" do 90 | payload = refute_relationship(data(:payload), data(:author), as: "writer", for: [:data, data(:post)]) 91 | assert payload == data(:payload) 92 | end 93 | 94 | test "can refute many records at once" do 95 | payload = refute_relationship(data(:payload_2), [data(:comment_3), data(:comment_4)], as: "comments", for: [:data, data(:post)]) 96 | 97 | assert payload == data(:payload_2) 98 | end 99 | 100 | test "will fail if one of the records is present" do 101 | msg = "was not expecting to find the relationship `comments` with `id` 1 and `type` \"comment\" for record matching `id` 1 and `type` \"post\"" 102 | 103 | try do 104 | refute_relationship(data(:payload_2), [data(:comment_3), data(:comment_1)], as: "comments", for: [:data, data(:post)]) 105 | rescue 106 | error in [ExUnit.AssertionError] -> 107 | assert msg == error.message 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/serializer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JsonApiAssert.SerializerTest do 2 | use ExUnit.Case 3 | import JsonApiAssert.Serializer, only: [serialize: 1, serialize: 2, s: 1, s: 2] 4 | 5 | defmodule Author do 6 | defstruct id: nil, 7 | first_name: nil, 8 | last_name: nil 9 | end 10 | 11 | defmodule Writer do 12 | defstruct other_id: nil, 13 | first_name: nil, 14 | last_name: nil 15 | end 16 | 17 | defmodule Post do 18 | defstruct id: nil, 19 | created_at: nil 20 | end 21 | 22 | test "serializes data from a record" do 23 | actual = 24 | %Author{id: 1, first_name: "Douglas", last_name: "Engelbart"} 25 | |> serialize() 26 | 27 | expected = %{ 28 | "id" => "1", 29 | "type" => "author", 30 | "attributes" => %{ 31 | "first-name" => "Douglas", 32 | "last-name" => "Engelbart" 33 | } 34 | } 35 | 36 | assert actual == expected 37 | end 38 | 39 | test "serializes data from a map" do 40 | actual = %{ 41 | id: 1, 42 | first_name: "Douglas", 43 | last_name: "Engelbart" 44 | } 45 | 46 | actual = serialize(actual, type: "author") 47 | 48 | expected = %{ 49 | "id" => "1", 50 | "type" => "author", 51 | "attributes" => %{ 52 | "first-name" => "Douglas", 53 | "last-name" => "Engelbart" 54 | } 55 | } 56 | 57 | assert actual == expected 58 | end 59 | 60 | test "raises when serialize can not determine type" do 61 | assert_raise ArgumentError, "No type can be derived from record. Please pass a type to `serialize`.", fn -> 62 | %{ 63 | id: 1, 64 | first_name: "Douglas", 65 | last_name: "Engelbart" 66 | } 67 | |> serialize() 68 | end 69 | end 70 | 71 | test "serializer can override type" do 72 | actual = 73 | %Author{id: 1, first_name: "Douglas", last_name: "Engelbart"} 74 | |> serialize(type: "writers") 75 | 76 | expected = %{ 77 | "id" => "1", 78 | "type" => "writers", 79 | "attributes" => %{ 80 | "first-name" => "Douglas", 81 | "last-name" => "Engelbart" 82 | } 83 | } 84 | 85 | assert actual == expected 86 | end 87 | 88 | test "serializer can override primary_key" do 89 | 90 | actual = 91 | %Writer{other_id: 1, first_name: "Douglas", last_name: "Engelbart"} 92 | |> serialize(primary_key: :other_id) 93 | 94 | expected = %{ 95 | "id" => "1", 96 | "type" => "writer", 97 | "attributes" => %{ 98 | "first-name" => "Douglas", 99 | "last-name" => "Engelbart" 100 | } 101 | } 102 | 103 | assert actual == expected 104 | end 105 | 106 | test "serializer can override with string primary_key" do 107 | actual = 108 | %Writer{other_id: 1, first_name: "Douglas", last_name: "Engelbart"} 109 | |> serialize(primary_key: "other_id") 110 | 111 | expected = %{ 112 | "id" => "1", 113 | "type" => "writer", 114 | "attributes" => %{ 115 | "first-name" => "Douglas", 116 | "last-name" => "Engelbart" 117 | } 118 | } 119 | 120 | assert actual == expected 121 | end 122 | 123 | test "can exclude certain attributes with `except`" do 124 | actual = 125 | %Author{id: 1, first_name: "Douglas", last_name: "Engelbart"} 126 | |> serialize(except: [:first_name]) 127 | 128 | expected = %{ 129 | "id" => "1", 130 | "type" => "author", 131 | "attributes" => %{ 132 | "last-name" => "Engelbart" 133 | } 134 | } 135 | 136 | assert actual == expected 137 | end 138 | 139 | test "can limit attributes with `only`" do 140 | actual = 141 | %Author{id: 1, first_name: "Douglas", last_name: "Engelbart"} 142 | |> serialize(only: [:first_name]) 143 | 144 | expected = %{ 145 | "id" => "1", 146 | "type" => "author", 147 | "attributes" => %{ 148 | "first-name" => "Douglas" 149 | } 150 | } 151 | 152 | assert actual == expected 153 | end 154 | 155 | test "`except` overrides `only`" do 156 | actual = 157 | %Author{id: 1, first_name: "Douglas", last_name: "Engelbart"} 158 | |> serialize(except: [:first_name], only: [:first_name]) 159 | 160 | expected = %{ 161 | "id" => "1", 162 | "type" => "author", 163 | "attributes" => %{ 164 | "last-name" => "Engelbart" 165 | } 166 | } 167 | 168 | assert actual == expected 169 | end 170 | 171 | test "shortened `s` function" do 172 | actual = 173 | %Author{id: 1, first_name: "Douglas", last_name: "Engelbart"} 174 | |> s() 175 | 176 | expected = %{ 177 | "id" => "1", 178 | "type" => "author", 179 | "attributes" => %{ 180 | "first-name" => "Douglas", 181 | "last-name" => "Engelbart" 182 | } 183 | } 184 | 185 | assert actual == expected 186 | end 187 | 188 | test "shortened `s` take options" do 189 | actual = 190 | %Author{id: 1, first_name: "Douglas", last_name: "Engelbart"} 191 | |> s(type: "writers") 192 | 193 | expected = %{ 194 | "id" => "1", 195 | "type" => "writers", 196 | "attributes" => %{ 197 | "first-name" => "Douglas", 198 | "last-name" => "Engelbart" 199 | } 200 | } 201 | 202 | assert actual == expected 203 | end 204 | 205 | test "serializaing Ecto.DateTime values" do 206 | actual = 207 | %Post{id: 1, created_at: Ecto.DateTime.from_erl({{2016,1,1},{0,0,0}})} 208 | |> serialize() 209 | 210 | expected = %{ 211 | "id" => "1", 212 | "type" => "post", 213 | "attributes" => %{ 214 | "created-at" => "2016-01-01T00:00:00Z" 215 | } 216 | } 217 | 218 | assert actual == expected 219 | end 220 | 221 | test "serializaing Ecto.Time values" do 222 | actual = 223 | %Post{id: 1, created_at: Ecto.Time.from_erl({0,0,0})} 224 | |> serialize() 225 | 226 | expected = %{ 227 | "id" => "1", 228 | "type" => "post", 229 | "attributes" => %{ 230 | "created-at" => "00:00:00" 231 | } 232 | } 233 | 234 | assert actual == expected 235 | end 236 | 237 | test "serializaing Ecto.Date values" do 238 | actual = 239 | %Post{id: 1, created_at: Ecto.Date.from_erl({2016,1,1})} 240 | |> serialize() 241 | 242 | expected = %{ 243 | "id" => "1", 244 | "type" => "post", 245 | "attributes" => %{ 246 | "created-at" => "2016-01-01" 247 | } 248 | } 249 | 250 | assert actual == expected 251 | end 252 | 253 | test "serializing NaiveDateTime values" do 254 | {:ok, created_at} = NaiveDateTime.from_erl({{2016,1,1},{0,0,0}}) 255 | 256 | actual = 257 | %Post{id: 1, created_at: created_at} 258 | |> serialize() 259 | 260 | expected = %{ 261 | "id" => "1", 262 | "type" => "post", 263 | "attributes" => %{ 264 | "created-at" => "2016-01-01T00:00:00" 265 | } 266 | } 267 | 268 | assert actual == expected 269 | end 270 | end 271 | -------------------------------------------------------------------------------- /test/support/test_data.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiAssert.TestData do 2 | def data(name) do 3 | apply(__MODULE__, name, []) 4 | end 5 | 6 | def post do 7 | %{ 8 | "id" => "1", 9 | "type" => "post", 10 | "attributes" => %{ 11 | "title" => "Mother of all demos" 12 | } 13 | } 14 | end 15 | 16 | def post_2 do 17 | %{ 18 | "id" => "2", 19 | "type" => "post", 20 | "attributes" => %{ 21 | "title" => "Father of all demos" 22 | } 23 | } 24 | end 25 | 26 | def author do 27 | %{ 28 | "id" => "1", 29 | "type" => "author", 30 | "attributes" => %{ 31 | "first-name" => "Douglas", 32 | "last-name" => "Engelbart" 33 | } 34 | } 35 | end 36 | 37 | def comment_1 do 38 | %{ 39 | "id" => "1", 40 | "type" => "comment", 41 | "attributes" => %{ 42 | "body" => "This is great!" 43 | } 44 | } 45 | end 46 | 47 | def comment_2 do 48 | %{ 49 | "id" => "2", 50 | "type" => "comment", 51 | "attributes" => %{ 52 | "body" => "This is horrible!" 53 | } 54 | } 55 | end 56 | 57 | def comment_3 do 58 | %{ 59 | "id" => "3", 60 | "type" => "comment", 61 | "attributes" => %{ 62 | "body" => "This is great!" 63 | } 64 | } 65 | end 66 | 67 | def comment_4 do 68 | %{ 69 | "id" => "4", 70 | "type" => "comment", 71 | "attributes" => %{ 72 | "body" => "This is horrible!" 73 | } 74 | } 75 | end 76 | 77 | def comment_5 do 78 | %{ 79 | "id" => "5", 80 | "type" => "comment", 81 | "attributes" => %{ 82 | "body" => "This is OK" 83 | } 84 | } 85 | end 86 | 87 | def payload do 88 | %{ 89 | "jsonapi" => %{ 90 | "version" => "1.0" 91 | }, 92 | "data" => %{ 93 | "id" => "1", 94 | "type" => "post", 95 | "attributes" => %{ 96 | "title" => "Mother of all demos" 97 | }, 98 | "relationships" => %{ 99 | "author" => %{ 100 | "data" => %{ "type" => "author", "id" => "1" } 101 | }, 102 | "comments" => %{ 103 | "data" => [ 104 | %{ "type" => "comment", "id" => "1" }, 105 | %{ "type" => "comment", "id" => "2" }, 106 | %{ "type" => "comment", "id" => "5" } 107 | ] 108 | } 109 | } 110 | }, 111 | "included" => [ 112 | %{ 113 | "id" => "1", 114 | "type" => "author", 115 | "attributes" => %{ 116 | "first-name" => "Douglas", 117 | "last-name" => "Engelbart" 118 | }, 119 | "relationships" => %{ 120 | "posts" => %{ 121 | "data" => [ 122 | %{ "type" => "post", "id" => "1" } 123 | ] 124 | } 125 | } 126 | }, %{ 127 | "id" => "1", 128 | "type" => "comment", 129 | "attributes" => %{ 130 | "body" => "This is great!" 131 | }, 132 | "relationships" => %{ 133 | "post" => %{ 134 | "data" => %{ "type" => "post", "id" => "1" } 135 | } 136 | } 137 | }, %{ 138 | "id" => "2", 139 | "type" => "comment", 140 | "attributes" => %{ 141 | "body" => "This is horrible!" 142 | }, 143 | "relationships" => %{ 144 | "post" => %{ 145 | "data" => %{ "type" => "post", "id" => "1" } 146 | } 147 | } 148 | } 149 | ] 150 | } 151 | end 152 | 153 | def payload_2 do 154 | %{ 155 | "jsonapi" => %{ 156 | "version" => "1.0" 157 | }, 158 | "data" => [%{ 159 | "id" => "1", 160 | "type" => "post", 161 | "attributes" => %{ 162 | "title" => "Mother of all demos" 163 | }, 164 | "relationships" => %{ 165 | "author" => %{ 166 | "data" => %{ "type" => "author", "id" => "1" } 167 | }, 168 | "comments" => %{ 169 | "data" => [ 170 | %{ "type" => "comment", "id" => "1" }, 171 | %{ "type" => "comment", "id" => "2" } 172 | ] 173 | } 174 | } 175 | }, %{ 176 | "id" => "2", 177 | "type" => "post", 178 | "attributes" => %{ 179 | "title" => "Father of all demos" 180 | }, 181 | "relationships" => %{ 182 | "author" => %{ 183 | "data" => %{ "type" => "author", "id" => "1" } 184 | }, 185 | "comments" => %{ 186 | "data" => [ 187 | %{ "type" => "comment", "id" => "3" }, 188 | %{ "type" => "comment", "id" => "4" } 189 | ] 190 | } 191 | } 192 | }], 193 | "included" => [ 194 | %{ 195 | "id" => "1", 196 | "type" => "author", 197 | "attributes" => %{ 198 | "first-name" => "Douglas", 199 | "last-name" => "Engelbart" 200 | }, 201 | "relationships" => %{ 202 | "posts" => %{ 203 | "data" => [ 204 | %{ "type" => "post", "id" => "1" }, 205 | %{ "type" => "post", "id" => "2" } 206 | ] 207 | } 208 | } 209 | }, %{ 210 | "id" => "1", 211 | "type" => "comment", 212 | "attributes" => %{ 213 | "body" => "This is great!" 214 | }, 215 | "relationships" => %{ 216 | "post" => %{ 217 | "data" => %{ "type" => "post", "id" => "1" } 218 | } 219 | } 220 | }, %{ 221 | "id" => "2", 222 | "type" => "comment", 223 | "attributes" => %{ 224 | "body" => "This is horrible!" 225 | }, 226 | "relationships" => %{ 227 | "post" => %{ 228 | "data" => %{ "type" => "post", "id" => "1" } 229 | } 230 | } 231 | }, %{ 232 | "id" => "3", 233 | "type" => "comment", 234 | "attributes" => %{ 235 | "body" => "This is great!" 236 | }, 237 | "relationships" => %{ 238 | "post" => %{ 239 | "data" => %{ "type" => "post", "id" => "2" } 240 | } 241 | } 242 | }, %{ 243 | "id" => "4", 244 | "type" => "comment", 245 | "attributes" => %{ 246 | "body" => "This is horrible!" 247 | }, 248 | "relationships" => %{ 249 | "post" => %{ 250 | "data" => %{ "type" => "post", "id" => "2" } 251 | } 252 | } 253 | } 254 | ] 255 | } 256 | end 257 | 258 | def deep_merge(left, right) when is_map(left) and is_map(right) do 259 | Enum.into right, left, fn({key, value}) -> 260 | if Map.has_key?(left, key) do 261 | {key, deep_merge(left[key], value)} 262 | else 263 | {key, value} 264 | end 265 | end 266 | end 267 | 268 | def deep_merge(left, right) when is_list(left) and is_list(right) do 269 | Enum.reduce right, left, fn({key, value}, data) -> 270 | tuple = if Keyword.has_key?(data, key) do 271 | {key, deep_merge(left[key], value)} 272 | else 273 | {key, value} 274 | end 275 | 276 | Keyword.merge(data, Keyword.new([tuple])) 277 | end 278 | end 279 | 280 | def deep_merge(_left, right), do: right 281 | end 282 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------