├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src └── normalize.js ├── test └── normalize.spec.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", { 5 | "corejs": { 6 | "version": "3", 7 | "proposals": true 8 | }, 9 | "useBuiltIns": "usage" 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | dist 39 | 40 | #PhpStorm and WebStorm settings folder 41 | .idea 42 | 43 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | demo 3 | docs 4 | src 5 | test 6 | .babelrc 7 | .eslintrc 8 | .travis.yml 9 | webpack.config.js 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "14" 4 | - "12" 5 | - "10" 6 | - "node" 7 | env: 8 | - CXX=g++-4.8 9 | before_install: 10 | - sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test 11 | - sudo apt-get -qq update 12 | - sudo apt-get -qq install g++-4.8 13 | install: "NODE_ENV=development yarn install && yarn build" 14 | after_success: "yarn global add coveralls && yarn coverage && cat ./coverage/lcov.info | coveralls" 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v 1.0.4 (21st Mar 2021) 2 | - Fixing core-js missing deps 3 | 4 | ### v 1.0.3 (9th Mar 2021) 5 | - Fixing legacy browser support 6 | 7 | ### v 1.0.1 (2nd Dec 2020) 8 | - Deps update 9 | 10 | ### v 1.0.0 (08 Jul 2020) 11 | - Cleaner defaults (https://github.com/yury-dymov/json-api-normalizer/pull/63) 12 | - Given that this library is stable and used by many others, I think it's a good time to make a stable 1.0.0 :) 13 | 14 | ### v 0.4.16 (05 Aug 2019) 15 | - Updated vulnerable deps 16 | 17 | ### v 0.4.15 (17 Jun 2019) 18 | - Updated vulnerable deps 19 | 20 | ### v. 0.4.14 (16 Nov 2018) 21 | - updated deps 22 | 23 | ### v. 0.4.12 (08 Oct 2018) 24 | - camelizeKeys sometimes was not applied to `meta` as expected (https://github.com/yury-dymov/json-api-normalizer/issues/48) 25 | 26 | ### v. 0.4.11 (28 June 2018) 27 | - camelizeKeys is now affecting `links` as well (https://github.com/yury-dymov/json-api-normalizer/pull/41) 28 | 29 | ### v. 0.4.10 (14 Feb 2018) 30 | - new option added `camelizeTypeValues` to control camelization of propogated type (https://github.com/yury-dymov/json-api-normalizer/issues/34) 31 | 32 | ### v. 0.4.9 (14 Feb 2018) 33 | - type is propogated to object (https://github.com/yury-dymov/json-api-normalizer/issues/32) 34 | 35 | ### v. 0.4.8 (01 Feb 2018) 36 | - metadata and links are saved if `data` is null per spec (https://github.com/yury-dymov/json-api-normalizer/pull/31) 37 | 38 | ### v. 0.4.7 (21 Dec 2017) 39 | - camelizeKeys is now affecting `meta` as well (https://github.com/yury-dymov/json-api-normalizer/pull/29) 40 | 41 | ### v. 0.4.6 (29 Nov 2017) 42 | - Meta property is also available for relationship objects (https://github.com/yury-dymov/json-api-normalizer/issues/25) 43 | - cross-env support added 44 | 45 | ### v. 0.4.5 (23 Oct 2017) 46 | While processing nested objects, we should handle dates accordingly (https://github.com/yury-dymov/json-api-normalizer/issues/23) 47 | 48 | ### v. 0.4.4 (23 Oct 2017) 49 | While processing nested objects, we shouldn't change array attributes to object (https://github.com/yury-dymov/json-api-normalizer/issues/22) 50 | 51 | ### v. 0.4.3 (20 Oct 2017) 52 | Nested attribute keys are also camelized now (https://github.com/yury-dymov/json-api-normalizer/issues/21) 53 | 54 | ### v. 0.4.2 (25 Sep 2017) 55 | Added meta support per spec (https://github.com/yury-dymov/json-api-normalizer/issues/19) 56 | 57 | ### v. 0.4.1 (11 Jun 2017) 58 | Including self links in normalization (https://github.com/yury-dymov/json-api-normalizer/pull/16) 59 | 60 | ### v. 0.4.0 (15 Mar 2017) 61 | Relationshop normalization implementation changed [discussion](https://github.com/yury-dymov/json-api-normalizer/issues/11) 62 | 63 | ### v. 0.3.0 (09 Mar 2017) 64 | IDs now preserved in entities. [discussion](https://github.com/yury-dymov/json-api-normalizer/issues/3) 65 | 66 | ### v. 0.2.4 (08 Mar 2017) 67 | Store links for subqueries in meta [#7](https://github.com/yury-dymov/json-api-normalizer/issues/6) 68 | 69 | ### v. 0.2.3 (06 Mar 2017) 70 | Fixed issue, when data is null for the meta [#5](https://github.com/yury-dymov/json-api-normalizer/pull/5) 71 | 72 | ### v. 0.2.1 (28 Feb 2017) 73 | Fixed issue, when data is null [#4](https://github.com/yury-dymov/json-api-normalizer/issues/4) 74 | 75 | ### v. 0.2.0 (09 Feb 2017) 76 | Format changed for `filterEndpoint` option equals `false` for metadata. 77 | 78 | #### Was 79 | 80 | ```js 81 | { 82 | "meta": { 83 | "/test?page=1": {...}, 84 | "/test?page=2": {...}, 85 | "/anotherTest": {...} 86 | } 87 | } 88 | ``` 89 | 90 | #### Now 91 | 92 | ```json 93 | { 94 | "meta": { 95 | "/test": { 96 | "?page=1": {...}, 97 | "?page=2": {...} 98 | }, 99 | "/anotherTest": {...} 100 | } 101 | } 102 | ``` 103 | 104 | ### v. 0.1.1 (03 Feb 2017) 105 | Added lazy loading support according to [#2](https://github.com/yury-dymov/json-api-normalizer/issues/2) 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Yury Dymov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-api-normalizer 2 | 3 | Utility to normalize JSON API data for redux applications 4 | 5 | [![npm version](https://img.shields.io/npm/v/json-api-normalizer.svg?style=flat)](https://www.npmjs.com/package/json-api-normalizer) 6 | [![Downloads](http://img.shields.io/npm/dm/json-api-normalizer.svg?style=flat-square)](https://npmjs.org/package/json-api-normalizer) 7 | [![Build Status](https://img.shields.io/travis/yury-dymov/json-api-normalizer/master.svg?style=flat)](https://travis-ci.org/yury-dymov/json-api-normalizer) 8 | [![Coverage Status](https://coveralls.io/repos/github/yury-dymov/json-api-normalizer/badge.svg?branch=master)](https://coveralls.io/github/yury-dymov/json-api-normalizer?branch=master) 9 | 10 | # Description 11 | 12 | json-api-normalizer helps awesome [JSON API](http://jsonapi.org/) and [redux](http://redux.js.org/) work together. 13 | Unlike [normalizr](https://github.com/paularmstrong/normalizr) json-api-normalizer supports JSON API specification, which means that you don't have to care about schemes. It also converts collections into maps, which is a lot more suitable for redux. 14 | 15 | Demo - [https://yury-dymov.github.io/json-api-react-redux-example/](https://yury-dymov.github.io/json-api-react-redux-example/) 16 | 17 | Demo sources - [https://github.com/yury-dymov/json-api-react-redux-example](https://github.com/yury-dymov/json-api-react-redux-example) 18 | 19 | Works great together with [redux-object](https://github.com/yury-dymov/redux-object), which helps to fetch and denormalize data from the store. 20 | 21 | json-api-normalizer was recently featured in SmashingMagazine: https://www.smashingmagazine.com/2017/05/json-api-normalizer-redux/ 22 | 23 | # Install 24 | 25 | ```shell 26 | $ npm install json-api-normalizer 27 | ``` 28 | 29 | # Example 30 | 31 | ```JavaScript 32 | import normalize from 'json-api-normalizer'; 33 | 34 | const json = { 35 | data: [{ 36 | "type": "post-block", 37 | "relationships": { 38 | "question": { 39 | "data": { 40 | "type": "question", 41 | "id": "295" 42 | } 43 | } 44 | }, 45 | "id": "2620", 46 | "attributes": { 47 | "text": "I am great!", 48 | "id": 2620 49 | } 50 | }], 51 | included: [{ 52 | "type": "question", 53 | "id": "295", 54 | "attributes": { 55 | "text": "How are you?", 56 | id: 295 57 | } 58 | }] 59 | }; 60 | 61 | console.log(normalize(json)); 62 | /* Output: 63 | { 64 | question: { 65 | "295": { 66 | id: 295, 67 | type: "question" 68 | attributes: { 69 | text: "How are you?" 70 | } 71 | } 72 | }, 73 | postBlock: { 74 | "2620": { 75 | id: 2620, 76 | type: "postBlock", 77 | attributes: { 78 | text: "I am great!" 79 | }, 80 | relationships: { 81 | question: { 82 | type: "question", 83 | id: "295" 84 | } 85 | } 86 | } 87 | } 88 | } 89 | */ 90 | ``` 91 | 92 | # Options 93 | 94 | ## Endpoint And Metadata 95 | 96 | While using redux, it is supposed that cache is incrementally updated during the application lifecycle. However, you might face an issue if two different requests are working with the same data objects, and after normalization, it is not clear how to distinguish, which data objects are related to which request. json-api-normalizer can handle such situations by saving the API response structure as metadata, so you can easily get only data corresponding to the certain request. 97 | 98 | ```JavaScript 99 | console.log(normalize(json, { endpoint: '/post-block/2620' })); 100 | /* Output: 101 | { 102 | question: { 103 | ... 104 | }, 105 | postBlock: { 106 | ... 107 | }, 108 | meta: { 109 | "/post-block/2620": { 110 | data: [{ 111 | type: "postBlock", 112 | id: 2620, 113 | relationships: { 114 | "question": { 115 | type: "question", 116 | id: "295" 117 | } 118 | }] 119 | } 120 | } 121 | } 122 | */ 123 | ``` 124 | 125 | ## Endpoint And Query Options 126 | 127 | By default request query options are ignored as it is supposed that data is incrementally updated. You can override this behavior by setting `filterEndpoint` option value to `false`. 128 | 129 | ```JavaScript 130 | const d1 = normalize(json, { endpoint: '/post-block/2620?page[cursor]=0' }); 131 | const d2 = normalize(json, { endpoint: '/post-block/2620?page[cursor]=20' }); 132 | console.log(Object.assign({}, d1, d2)); 133 | /* Output: 134 | { 135 | question: { 136 | ... 137 | }, 138 | postBlock: { 139 | ... 140 | }, 141 | meta: { 142 | "/post-block/2620": { 143 | ... 144 | } 145 | } 146 | } 147 | */ 148 | 149 | const d1 = normalize(json, { endpoint: '/post-block/2620?page[cursor]=0', filterEndpoint: false }); 150 | const d2 = normalize(json, { endpoint: '/post-block/2620?page[cursor]=20', filterEndpoint: false }); 151 | console.log(someFunctionWhichMergesStuff({}, d1, d2)); 152 | /* Output: 153 | { 154 | question: { 155 | ... 156 | }, 157 | postBlock: { 158 | ... 159 | }, 160 | meta: { 161 | "/post-block/2620: { 162 | "?page[cursor]=0": { 163 | ... 164 | }, 165 | "?page[cursor]=20": { 166 | ... 167 | } 168 | } 169 | } 170 | } 171 | */ 172 | ``` 173 | 174 | ## Pagination And Links 175 | 176 | If JSON API returns links section and you define the endpoint, then links are also stored in metadata. 177 | 178 | ```JavaScript 179 | const json = { 180 | data: [{ 181 | ... 182 | }], 183 | included: [{ 184 | ... 185 | }], 186 | links: { 187 | first: "http://example.com/api/v1/post-block/2620?page[cursor]=0", 188 | next: "http://example.com/api/v1/post-block/2620?page[cursor]=20" 189 | } 190 | }; 191 | 192 | console.log(normalize(json, { endpoint: '/post-block/2620?page[cursor]=0'})); 193 | /* Output: 194 | { 195 | question: { 196 | ... 197 | }, 198 | postBlock: { 199 | ... 200 | }, 201 | meta: { 202 | "/post-block/2620": { 203 | data: [{ 204 | ... 205 | }], 206 | links: { 207 | first: "http://example.com/api/v1/post-block/2620?page[cursor]=0", 208 | next: "http://example.com/api/v1/post-block/2620?page[cursor]=20" 209 | } 210 | } 211 | } 212 | } 213 | */ 214 | ``` 215 | 216 | ## Lazy Loading 217 | 218 | If you want to lazy load nested objects, json-api-normalizer will store links for that 219 | 220 | ```JavaScript 221 | const json = { 222 | data: [{ 223 | attributes: { 224 | ... 225 | }, 226 | id: "29", 227 | relationships: { 228 | "movie": { 229 | "links": { 230 | "self": "http://...", 231 | "related": "http://..." 232 | } 233 | }, 234 | }, 235 | type: "question" 236 | }] 237 | }; 238 | 239 | console.log(normalize(json)); 240 | /* Output: 241 | { 242 | question: { 243 | "29": { 244 | attributes: { 245 | ... 246 | }, 247 | relationships: { 248 | movie: { 249 | links: { 250 | "self": "http://...", 251 | "related": "http://..." 252 | } 253 | } 254 | } 255 | } 256 | } 257 | } 258 | */ 259 | ``` 260 | 261 | ## Camelize Keys 262 | 263 | By default all object keys and type names are camelized, however, you can disable this with `camelizeKeys` option. 264 | 265 | ```JavaScript 266 | const json = { 267 | data: [{ 268 | type: "post-block", 269 | id: "1", 270 | attributes: { 271 | "camel-me": 1, 272 | id: 1 273 | } 274 | }] 275 | } 276 | 277 | console.log(normalize(json)); 278 | /* Output: 279 | { 280 | postBlock: { 281 | "1": { 282 | id: 1, 283 | type: "postBlock", 284 | attributes: { 285 | camelMe: 1 286 | } 287 | } 288 | } 289 | } 290 | */ 291 | 292 | console.log(normalize(json, { camelizeKeys: false })); 293 | /* Output: 294 | { 295 | "post-block": { 296 | "1": { 297 | id: 1, 298 | type: "postBlock", 299 | attributes: { 300 | "camel-me": 1 301 | } 302 | } 303 | } 304 | } 305 | */ 306 | ``` 307 | 308 | ## Camelize Type Values 309 | 310 | By default propagated type values are camelized but original value may be also preserved 311 | 312 | ```JavaScript 313 | const json = { 314 | data: [{ 315 | type: "post-block", 316 | id: "1", 317 | attributes: { 318 | "camel-me": 1, 319 | id: 1 320 | } 321 | }] 322 | } 323 | 324 | console.log(normalize(json, { camelizeTypeValues: false })); 325 | /* Output: 326 | { 327 | postBlock: { 328 | "1": { 329 | id: 1, 330 | type: "post-block", // <-- this 331 | attributes: { 332 | camelMe: 1 333 | } 334 | } 335 | } 336 | } 337 | */ 338 | ``` 339 | 340 | # Copyright 341 | 342 | MIT (c) Yury Dymov 343 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-api-normalizer", 3 | "version": "1.0.4", 4 | "description": "JSON API response normalizer", 5 | "main": "dist/bundle.js", 6 | "scripts": { 7 | "build": "cross-env NODE_ENV=production webpack", 8 | "clean": "cross-env rimraf dist coverage lib", 9 | "coverage": "nyc _mocha --require @babel/register", 10 | "lint": "cross-env eslint src --ext .js", 11 | "test": "cross-env mocha --require @babel/register" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/yury-dymov/json-api-normalizer.git" 16 | }, 17 | "keywords": [ 18 | "JSON", 19 | "API", 20 | "normalize", 21 | "redux" 22 | ], 23 | "author": "Yury Dymov", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/yury-dymov/json-api-normalizer/issues" 27 | }, 28 | "homepage": "https://github.com/yury-dymov/json-api-normalizer#readme", 29 | "devDependencies": { 30 | "@babel/core": "^7.1.6", 31 | "@babel/preset-env": "^7.13", 32 | "@babel/register": "^7.0.0", 33 | "babel-eslint": "^10.0.1", 34 | "babel-loader": "^8.0.4", 35 | "chai": "^4.2.0", 36 | "cross-env": "^5.2.0", 37 | "eslint": "^7.14.0", 38 | "eslint-config-airbnb": "^17.1.0", 39 | "eslint-plugin-import": "^2.14.0", 40 | "eslint-plugin-jsx-a11y": "^6.1.2", 41 | "eslint-plugin-react": "^7.11.1", 42 | "eslint-webpack-plugin": "^2.4.1", 43 | "immutable": "^4.0.0-rc.12", 44 | "mocha": "^8.2.1", 45 | "nyc": "^14.1.1", 46 | "rimraf": "^2.6.2", 47 | "webpack": "^5.9.0", 48 | "webpack-cli": "^4.2.0", 49 | "webpack-node-externals": "^2.5.2" 50 | }, 51 | "dependencies": { 52 | "core-js": "3", 53 | "lodash": "^4.17.15" 54 | }, 55 | "browserslist": [ 56 | "edge 16", 57 | "safari 9", 58 | "firefox 57", 59 | "ie 11", 60 | "ios 9", 61 | "chrome 49" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /src/normalize.js: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash/camelCase'; 2 | import isArray from 'lodash/isArray'; 3 | import isNull from 'lodash/isNull'; 4 | import keys from 'lodash/keys'; 5 | import merge from 'lodash/merge'; 6 | 7 | function wrap(json) { 8 | if (isArray(json)) { 9 | return json; 10 | } 11 | 12 | return [json]; 13 | } 14 | 15 | function isDate(attributeValue) { 16 | return Object.prototype.toString.call(attributeValue) === '[object Date]'; 17 | } 18 | 19 | function camelizeNestedKeys(attributeValue) { 20 | if (attributeValue === null || typeof attributeValue !== 'object' || isDate(attributeValue)) { 21 | return attributeValue; 22 | } 23 | 24 | if (isArray(attributeValue)) { 25 | return attributeValue.map(camelizeNestedKeys); 26 | } 27 | 28 | const copy = {}; 29 | 30 | keys(attributeValue).forEach((k) => { 31 | copy[camelCase(k)] = camelizeNestedKeys(attributeValue[k]); 32 | }); 33 | 34 | return copy; 35 | } 36 | 37 | function extractRelationships(relationships, { camelizeKeys, camelizeTypeValues }) { 38 | const ret = {}; 39 | keys(relationships).forEach((key) => { 40 | const relationship = relationships[key]; 41 | const name = camelizeKeys ? camelCase(key) : key; 42 | ret[name] = {}; 43 | 44 | if (typeof relationship.data !== 'undefined') { 45 | if (isArray(relationship.data)) { 46 | ret[name].data = relationship.data.map(e => ({ 47 | id: e.id, 48 | type: camelizeTypeValues ? camelCase(e.type) : e.type, 49 | })); 50 | } else if (!isNull(relationship.data)) { 51 | ret[name].data = { 52 | id: relationship.data.id, 53 | type: camelizeTypeValues ? camelCase(relationship.data.type) : relationship.data.type, 54 | }; 55 | } else { 56 | ret[name].data = relationship.data; 57 | } 58 | } 59 | 60 | if (relationship.links) { 61 | ret[name].links = camelizeKeys ? camelizeNestedKeys(relationship.links) : relationship.links; 62 | } 63 | 64 | if (relationship.meta) { 65 | ret[name].meta = camelizeKeys ? camelizeNestedKeys(relationship.meta) : relationship.meta; 66 | } 67 | }); 68 | return ret; 69 | } 70 | 71 | function processMeta(metaObject, { camelizeKeys }) { 72 | if (camelizeKeys) { 73 | const meta = {}; 74 | 75 | keys(metaObject).forEach((key) => { 76 | meta[camelCase(key)] = camelizeNestedKeys(metaObject[key]); 77 | }); 78 | 79 | return meta; 80 | } 81 | 82 | return metaObject; 83 | } 84 | 85 | function extractEntities(json, { camelizeKeys, camelizeTypeValues }) { 86 | const ret = {}; 87 | 88 | wrap(json).forEach((elem) => { 89 | const type = camelizeKeys ? camelCase(elem.type) : elem.type; 90 | 91 | ret[type] = ret[type] || {}; 92 | ret[type][elem.id] = ret[type][elem.id] || { 93 | id: elem.id, 94 | }; 95 | ret[type][elem.id].type = camelizeTypeValues ? camelCase(elem.type) : elem.type; 96 | 97 | if (camelizeKeys) { 98 | ret[type][elem.id].attributes = {}; 99 | 100 | keys(elem.attributes).forEach((key) => { 101 | ret[type][elem.id].attributes[camelCase(key)] = camelizeNestedKeys(elem.attributes[key]); 102 | }); 103 | } else { 104 | ret[type][elem.id].attributes = elem.attributes; 105 | } 106 | 107 | if (elem.links) { 108 | ret[type][elem.id].links = {}; 109 | 110 | keys(elem.links).forEach((key) => { 111 | const newKey = camelizeKeys ? camelCase(key) : key; 112 | ret[type][elem.id].links[newKey] = elem.links[key]; 113 | }); 114 | } 115 | 116 | if (elem.relationships) { 117 | ret[type][elem.id].relationships = extractRelationships(elem.relationships, { 118 | camelizeKeys, 119 | camelizeTypeValues, 120 | }); 121 | } 122 | 123 | if (elem.meta) { 124 | ret[type][elem.id].meta = processMeta(elem.meta, { camelizeKeys }); 125 | } 126 | }); 127 | 128 | return ret; 129 | } 130 | 131 | function doFilterEndpoint(endpoint) { 132 | return endpoint.replace(/\?.*$/, ''); 133 | } 134 | 135 | function extractMetaData(json, endpoint, { camelizeKeys, camelizeTypeValues, filterEndpoint }) { 136 | const ret = {}; 137 | 138 | ret.meta = {}; 139 | 140 | let metaObject; 141 | 142 | if (!filterEndpoint) { 143 | const filteredEndpoint = doFilterEndpoint(endpoint); 144 | 145 | ret.meta[filteredEndpoint] = {}; 146 | ret.meta[filteredEndpoint][endpoint.slice(filteredEndpoint.length)] = {}; 147 | metaObject = ret.meta[filteredEndpoint][endpoint.slice(filteredEndpoint.length)]; 148 | } else { 149 | ret.meta[endpoint] = {}; 150 | metaObject = ret.meta[endpoint]; 151 | } 152 | 153 | metaObject.data = {}; 154 | 155 | if (json.data) { 156 | const meta = []; 157 | 158 | wrap(json.data).forEach((object) => { 159 | const pObject = { 160 | id: object.id, 161 | type: camelizeTypeValues ? camelCase(object.type) : object.type, 162 | }; 163 | 164 | if (object.relationships) { 165 | pObject.relationships = extractRelationships(object.relationships, { 166 | camelizeKeys, 167 | camelizeTypeValues, 168 | }); 169 | } 170 | 171 | meta.push(pObject); 172 | }); 173 | 174 | metaObject.data = meta; 175 | } 176 | 177 | if (json.links) { 178 | metaObject.links = json.links; 179 | ret.meta[doFilterEndpoint(endpoint)].links = json.links; 180 | } 181 | 182 | if (json.meta) { 183 | metaObject.meta = processMeta(json.meta, { camelizeKeys }); 184 | } 185 | 186 | return ret; 187 | } 188 | 189 | export default function normalize(json, { 190 | filterEndpoint = true, 191 | camelizeKeys = true, 192 | camelizeTypeValues = true, 193 | endpoint, 194 | } = {}) { 195 | const ret = {}; 196 | 197 | if (json.data) { 198 | merge(ret, extractEntities(json.data, { camelizeKeys, camelizeTypeValues })); 199 | } 200 | 201 | if (json.included) { 202 | merge(ret, extractEntities(json.included, { camelizeKeys, camelizeTypeValues })); 203 | } 204 | 205 | if (endpoint) { 206 | const endpointKey = filterEndpoint ? doFilterEndpoint(endpoint) : endpoint; 207 | 208 | merge(ret, extractMetaData(json, endpointKey, { 209 | camelizeKeys, 210 | camelizeTypeValues, 211 | filterEndpoint, 212 | })); 213 | } 214 | 215 | return ret; 216 | } 217 | -------------------------------------------------------------------------------- /test/normalize.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import normalize from '../dist/bundle'; 3 | 4 | describe('data is normalized', () => { 5 | const json = { 6 | data: [ 7 | { 8 | type: 'post', 9 | id: 3, 10 | attributes: { 11 | text: 'hello', 12 | number: 3, 13 | }, 14 | links: { 15 | self: 'http://www.example.com/post/3', 16 | }, 17 | meta: { 18 | likes: 35, 19 | }, 20 | }, 21 | { 22 | type: 'post', 23 | id: 4, 24 | attributes: { 25 | text: 'hello world', 26 | number: 4, 27 | }, 28 | links: { 29 | self: 'http://www.example.com/post/4', 30 | }, 31 | }, 32 | ], 33 | }; 34 | 35 | const output = { 36 | post: { 37 | 3: { 38 | type: 'post', 39 | id: 3, 40 | attributes: { 41 | text: 'hello', 42 | number: 3, 43 | }, 44 | links: { 45 | self: 'http://www.example.com/post/3', 46 | }, 47 | meta: { 48 | likes: 35, 49 | }, 50 | }, 51 | 4: { 52 | type: 'post', 53 | id: 4, 54 | attributes: { 55 | text: 'hello world', 56 | number: 4, 57 | }, 58 | links: { 59 | self: 'http://www.example.com/post/4', 60 | }, 61 | }, 62 | }, 63 | }; 64 | 65 | it('data attributes => map: %{id => Object}', () => { 66 | const result = normalize(json); 67 | 68 | expect(result).to.deep.equal(output); 69 | }); 70 | 71 | it("data is empty shouldn't fail", () => { 72 | const result = normalize({}); 73 | 74 | expect(result).to.deep.equal({}); 75 | }); 76 | 77 | it('keys camelized', () => { 78 | const input = { 79 | data: [ 80 | { 81 | type: 'post', 82 | id: 1, 83 | attributes: { 84 | 'key-is-camelized': 2, 85 | }, 86 | meta: { 87 | 'this-key-too': 3, 88 | }, 89 | links: { 90 | this_link: 'http://link.com' 91 | } 92 | }, 93 | ], 94 | }; 95 | 96 | const camelizedOutput = { 97 | post: { 98 | 1: { 99 | type: 'post', 100 | id: 1, 101 | attributes: { 102 | keyIsCamelized: 2, 103 | }, 104 | meta: { 105 | thisKeyToo: 3, 106 | }, 107 | links: { 108 | thisLink: 'http://link.com' 109 | } 110 | }, 111 | }, 112 | }; 113 | 114 | const result = normalize(input); 115 | 116 | expect(result).to.deep.equal(camelizedOutput); 117 | }); 118 | 119 | it('nested keys camelized', () => { 120 | const input = { 121 | data: [ 122 | { 123 | type: 'post', 124 | id: 1, 125 | attributes: { 126 | key_is_camelized: 2, 127 | another_key: { 128 | and_yet_another: 3, 129 | }, 130 | }, 131 | }, 132 | ], 133 | }; 134 | 135 | const camelizedOutput = { 136 | post: { 137 | 1: { 138 | type: 'post', 139 | id: 1, 140 | attributes: { 141 | keyIsCamelized: 2, 142 | anotherKey: { 143 | andYetAnother: 3, 144 | }, 145 | }, 146 | }, 147 | }, 148 | }; 149 | 150 | const result = normalize(input); 151 | 152 | expect(result).to.deep.equal(camelizedOutput); 153 | }); 154 | 155 | it('arrays are still array after camelization', () => { 156 | const input = { 157 | data: [ 158 | { 159 | type: 'post', 160 | id: 1, 161 | attributes: { 162 | key_is_camelized: ['a', 'b'], 163 | }, 164 | }, 165 | ], 166 | }; 167 | 168 | const camelizedOutput = { 169 | post: { 170 | 1: { 171 | type: 'post', 172 | id: 1, 173 | attributes: { 174 | keyIsCamelized: ['a', 'b'], 175 | }, 176 | }, 177 | }, 178 | }; 179 | 180 | const result = normalize(input); 181 | 182 | expect(result).to.deep.equal(camelizedOutput); 183 | }); 184 | 185 | it('dates should not be affected by camilization', () => { 186 | const date = new Date(); 187 | 188 | const obj = { 189 | data: { 190 | id: 1, 191 | type: 'projects', 192 | attributes: { 193 | 'started-at': date, 194 | }, 195 | }, 196 | }; 197 | 198 | const output = { 199 | projects: { 200 | 1: { 201 | type: 'projects', 202 | id: 1, 203 | attributes: { 204 | startedAt: date, 205 | }, 206 | }, 207 | }, 208 | }; 209 | 210 | const result = normalize(obj); 211 | 212 | expect(result).to.deep.equal(output); 213 | }); 214 | }); 215 | 216 | describe('included is normalized', () => { 217 | const json = { 218 | included: [ 219 | { 220 | type: 'post', 221 | id: 3, 222 | attributes: { 223 | text: 'hello', 224 | number: 3, 225 | }, 226 | }, 227 | { 228 | type: 'post', 229 | id: 4, 230 | attributes: { 231 | text: 'hello world', 232 | number: 4, 233 | }, 234 | }, 235 | ], 236 | }; 237 | 238 | const json2 = { 239 | included: [ 240 | { 241 | type: 'post', 242 | id: 3, 243 | attributes: { 244 | text: 'hello', 245 | number: 3, 246 | }, 247 | }, 248 | ], 249 | data: [ 250 | { 251 | type: 'post', 252 | id: 4, 253 | attributes: { 254 | text: 'hello world', 255 | number: 4, 256 | }, 257 | }, 258 | ], 259 | }; 260 | 261 | const output = { 262 | post: { 263 | 3: { 264 | type: 'post', 265 | id: 3, 266 | attributes: { 267 | text: 'hello', 268 | number: 3, 269 | }, 270 | }, 271 | 4: { 272 | type: 'post', 273 | id: 4, 274 | attributes: { 275 | text: 'hello world', 276 | number: 4, 277 | }, 278 | }, 279 | }, 280 | }; 281 | 282 | it('included => map: %{id => Object}', () => { 283 | const result = normalize(json); 284 | 285 | expect(result).to.deep.equal(output); 286 | }); 287 | 288 | it('data & included => map: %{id => Object}', () => { 289 | const result = normalize(json2); 290 | 291 | expect(result).to.deep.equal(output); 292 | }); 293 | }); 294 | 295 | describe('relationships', () => { 296 | it('empty to-one', () => { 297 | const json = { 298 | data: [ 299 | { 300 | type: 'post', 301 | relationships: { 302 | question: { 303 | data: null, 304 | }, 305 | }, 306 | id: 2620, 307 | attributes: { 308 | text: 'hello', 309 | }, 310 | }, 311 | ], 312 | }; 313 | 314 | const output = { 315 | post: { 316 | 2620: { 317 | type: 'post', 318 | id: 2620, 319 | attributes: { 320 | text: 'hello', 321 | }, 322 | relationships: { 323 | question: { 324 | data: null, 325 | }, 326 | }, 327 | }, 328 | }, 329 | }; 330 | 331 | const result = normalize(json); 332 | 333 | expect(result).to.deep.equal(output); 334 | }); 335 | 336 | it('empty to-many', () => { 337 | const json = { 338 | data: [ 339 | { 340 | type: 'post', 341 | relationships: { 342 | tags: { 343 | data: [], 344 | }, 345 | }, 346 | id: 2620, 347 | attributes: { 348 | text: 'hello', 349 | }, 350 | }, 351 | ], 352 | }; 353 | 354 | const output = { 355 | post: { 356 | 2620: { 357 | type: 'post', 358 | id: 2620, 359 | attributes: { 360 | text: 'hello', 361 | }, 362 | relationships: { 363 | tags: { 364 | data: [], 365 | }, 366 | }, 367 | }, 368 | }, 369 | }; 370 | 371 | const result = normalize(json); 372 | 373 | expect(result).to.deep.equal(output); 374 | }); 375 | 376 | it('non-empty to-one', () => { 377 | const json = { 378 | data: [ 379 | { 380 | type: 'post', 381 | relationships: { 382 | question: { 383 | data: { 384 | id: 7, 385 | type: 'question', 386 | }, 387 | }, 388 | }, 389 | id: 2620, 390 | attributes: { 391 | text: 'hello', 392 | }, 393 | }, 394 | ], 395 | }; 396 | 397 | const output = { 398 | post: { 399 | 2620: { 400 | type: 'post', 401 | id: 2620, 402 | attributes: { 403 | text: 'hello', 404 | }, 405 | relationships: { 406 | question: { 407 | data: { 408 | id: 7, 409 | type: 'question', 410 | }, 411 | }, 412 | }, 413 | }, 414 | }, 415 | }; 416 | 417 | const result = normalize(json); 418 | 419 | expect(result).to.deep.equal(output); 420 | }); 421 | 422 | it('non-empty to-many', () => { 423 | const json = { 424 | data: [ 425 | { 426 | type: 'post', 427 | relationships: { 428 | tags: { 429 | data: [ 430 | { 431 | id: 4, 432 | type: 'tag', 433 | }, 434 | ], 435 | }, 436 | }, 437 | id: 2620, 438 | attributes: { 439 | text: 'hello', 440 | }, 441 | }, 442 | ], 443 | }; 444 | 445 | const output = { 446 | post: { 447 | 2620: { 448 | type: 'post', 449 | id: 2620, 450 | attributes: { 451 | text: 'hello', 452 | }, 453 | relationships: { 454 | tags: { 455 | data: [ 456 | { 457 | id: 4, 458 | type: 'tag', 459 | }, 460 | ], 461 | }, 462 | }, 463 | }, 464 | }, 465 | }; 466 | 467 | const result = normalize(json); 468 | 469 | expect(result).to.deep.equal(output); 470 | }); 471 | 472 | it('keys camelized', () => { 473 | const json = { 474 | data: [ 475 | { 476 | type: 'post', 477 | relationships: { 478 | 'rel1-to-camelize': { 479 | data: [ 480 | { 481 | id: 4, 482 | type: 'type1-to-camelize', 483 | }, 484 | ], 485 | }, 486 | 'rel2-to-camelize': { 487 | data: [], 488 | }, 489 | 'rel3-to-camelize': { 490 | data: { 491 | id: 4, 492 | type: 'type3-to-camelize', 493 | }, 494 | }, 495 | 'rel4-to-camelize': { 496 | data: null, 497 | }, 498 | }, 499 | id: 2620, 500 | attributes: { 501 | text: 'hello', 502 | }, 503 | }, 504 | ], 505 | }; 506 | 507 | const output = { 508 | post: { 509 | 2620: { 510 | type: 'post', 511 | id: 2620, 512 | attributes: { 513 | text: 'hello', 514 | }, 515 | relationships: { 516 | rel1ToCamelize: { 517 | data: [ 518 | { 519 | id: 4, 520 | type: 'type1ToCamelize', 521 | }, 522 | ], 523 | }, 524 | rel2ToCamelize: { 525 | data: [], 526 | }, 527 | rel3ToCamelize: { 528 | data: { 529 | id: 4, 530 | type: 'type3ToCamelize', 531 | }, 532 | }, 533 | rel4ToCamelize: { 534 | data: null, 535 | }, 536 | }, 537 | }, 538 | }, 539 | }; 540 | 541 | const result = normalize(json); 542 | 543 | expect(result).to.deep.equal(output); 544 | }); 545 | 546 | it('keep links', () => { 547 | const json = { 548 | data: [ 549 | { 550 | type: 'post', 551 | relationships: { 552 | tags: { 553 | data: [ 554 | { 555 | id: 4, 556 | type: 'tag', 557 | }, 558 | ], 559 | links: { 560 | self: 'http://example.com/api/v1/post/2620/tags', 561 | }, 562 | }, 563 | }, 564 | id: 2620, 565 | attributes: { 566 | text: 'hello', 567 | }, 568 | }, 569 | ], 570 | }; 571 | 572 | const output = { 573 | post: { 574 | 2620: { 575 | type: 'post', 576 | id: 2620, 577 | attributes: { 578 | text: 'hello', 579 | }, 580 | relationships: { 581 | tags: { 582 | data: [ 583 | { 584 | id: 4, 585 | type: 'tag', 586 | }, 587 | ], 588 | links: { 589 | self: 'http://example.com/api/v1/post/2620/tags', 590 | }, 591 | }, 592 | }, 593 | }, 594 | }, 595 | }; 596 | 597 | const result = normalize(json); 598 | 599 | expect(result).to.deep.equal(output); 600 | }); 601 | 602 | it('camelize links', () => { 603 | const json = { 604 | data: [ 605 | { 606 | type: 'post', 607 | relationships: { 608 | tags: { 609 | data: [ 610 | { 611 | id: 4, 612 | type: 'tag', 613 | }, 614 | ], 615 | links: { 616 | camel_case: 'http://example.com/api/v1/post/2620/tags', 617 | }, 618 | }, 619 | }, 620 | id: 2620, 621 | attributes: { 622 | text: 'hello', 623 | }, 624 | }, 625 | ], 626 | }; 627 | 628 | const output = { 629 | post: { 630 | 2620: { 631 | type: 'post', 632 | id: 2620, 633 | attributes: { 634 | text: 'hello', 635 | }, 636 | relationships: { 637 | tags: { 638 | data: [ 639 | { 640 | id: 4, 641 | type: 'tag', 642 | }, 643 | ], 644 | links: { 645 | camelCase: 'http://example.com/api/v1/post/2620/tags', 646 | }, 647 | }, 648 | }, 649 | }, 650 | }, 651 | }; 652 | 653 | const result = normalize(json); 654 | 655 | expect(result).to.deep.equal(output); 656 | }); 657 | 658 | it('keeps meta', () => { 659 | const json = { 660 | data: [ 661 | { 662 | type: 'post', 663 | relationships: { 664 | tags: { 665 | data: [ 666 | { 667 | id: 4, 668 | type: 'tag', 669 | }, 670 | ], 671 | links: { 672 | self: 'http://example.com/api/v1/post/2620/tags', 673 | }, 674 | meta: { 675 | count: 2 676 | } 677 | }, 678 | }, 679 | id: 2620, 680 | attributes: { 681 | text: 'hello', 682 | }, 683 | }, 684 | ], 685 | }; 686 | 687 | const output = { 688 | post: { 689 | 2620: { 690 | type: 'post', 691 | id: 2620, 692 | attributes: { 693 | text: 'hello', 694 | }, 695 | relationships: { 696 | tags: { 697 | data: [ 698 | { 699 | id: 4, 700 | type: 'tag', 701 | }, 702 | ], 703 | links: { 704 | self: 'http://example.com/api/v1/post/2620/tags', 705 | }, 706 | meta: { 707 | count: 2 708 | } 709 | }, 710 | }, 711 | }, 712 | }, 713 | }; 714 | 715 | const result = normalize(json); 716 | 717 | expect(result).to.deep.equal(output); 718 | }); 719 | }); 720 | 721 | describe('meta', () => { 722 | const json = { 723 | data: [ 724 | { 725 | type: 'post', 726 | relationships: { 727 | question: { 728 | data: { 729 | type: 'question', 730 | id: '295', 731 | }, 732 | }, 733 | }, 734 | id: 2620, 735 | attributes: { 736 | text: 'hello', 737 | }, 738 | }, 739 | ], 740 | }; 741 | 742 | const output = { 743 | post: { 744 | 2620: { 745 | type: 'post', 746 | id: 2620, 747 | attributes: { 748 | text: 'hello', 749 | }, 750 | relationships: { 751 | question: { 752 | data: { 753 | id: '295', 754 | type: 'question', 755 | }, 756 | }, 757 | }, 758 | }, 759 | }, 760 | meta: { 761 | 'posts/me': { 762 | data: [ 763 | { 764 | id: 2620, 765 | type: 'post', 766 | relationships: { 767 | question: { 768 | data: { 769 | type: 'question', 770 | id: '295', 771 | }, 772 | }, 773 | }, 774 | }, 775 | ], 776 | }, 777 | }, 778 | }; 779 | 780 | const json2 = { 781 | data: [ 782 | { 783 | type: 'post', 784 | relationships: { 785 | question: { 786 | data: { 787 | type: 'question', 788 | id: '295', 789 | }, 790 | }, 791 | }, 792 | id: 2620, 793 | attributes: { 794 | text: 'hello', 795 | }, 796 | }, 797 | ], 798 | links: { 799 | next: 'http://example.com/api/v1/posts/friends_feed/superyuri?page[cursor]=5037', 800 | first: 'http://api.postie.loc/v1/posts/friends_feed/superyuri?page[cursor]=0', 801 | }, 802 | }; 803 | 804 | const output2 = { 805 | post: { 806 | 2620: { 807 | type: 'post', 808 | id: 2620, 809 | attributes: { 810 | text: 'hello', 811 | }, 812 | relationships: { 813 | question: { 814 | data: { 815 | id: '295', 816 | type: 'question', 817 | }, 818 | }, 819 | }, 820 | }, 821 | }, 822 | meta: { 823 | 'posts/me': { 824 | data: [ 825 | { 826 | type: 'post', 827 | id: 2620, 828 | relationships: { 829 | question: { 830 | data: { 831 | type: 'question', 832 | id: '295', 833 | }, 834 | }, 835 | }, 836 | }, 837 | ], 838 | links: { 839 | next: 'http://example.com/api/v1/posts/friends_feed/superyuri?page[cursor]=5037', 840 | first: 'http://api.postie.loc/v1/posts/friends_feed/superyuri?page[cursor]=0', 841 | }, 842 | }, 843 | }, 844 | }; 845 | 846 | const output3 = { 847 | post: { 848 | 2620: { 849 | type: 'post', 850 | id: 2620, 851 | attributes: { 852 | text: 'hello', 853 | }, 854 | relationships: { 855 | question: { 856 | data: { 857 | id: '295', 858 | type: 'question', 859 | }, 860 | }, 861 | }, 862 | }, 863 | }, 864 | meta: { 865 | 'posts/me': { 866 | '?some=query': { 867 | data: [ 868 | { 869 | type: 'post', 870 | id: 2620, 871 | relationships: { 872 | question: { 873 | data: { 874 | type: 'question', 875 | id: '295', 876 | }, 877 | }, 878 | }, 879 | }, 880 | ], 881 | links: { 882 | next: 'http://example.com/api/v1/posts/friends_feed/superyuri?page[cursor]=5037', 883 | first: 'http://api.postie.loc/v1/posts/friends_feed/superyuri?page[cursor]=0', 884 | }, 885 | }, 886 | links: { 887 | next: 'http://example.com/api/v1/posts/friends_feed/superyuri?page[cursor]=5037', 888 | first: 'http://api.postie.loc/v1/posts/friends_feed/superyuri?page[cursor]=0', 889 | }, 890 | }, 891 | }, 892 | }; 893 | 894 | it('meta, no links', () => { 895 | const result = normalize(json, { endpoint: 'posts/me' }); 896 | 897 | expect(result).to.deep.equal(output); 898 | }); 899 | 900 | it('meta, with links', () => { 901 | const result = normalize(json2, { endpoint: 'posts/me' }); 902 | 903 | expect(result).to.deep.equal(output2); 904 | }); 905 | 906 | it('meta, filter works', () => { 907 | const result = normalize(json2, { endpoint: 'posts/me?some=query' }); 908 | 909 | expect(result).to.deep.equal(output2); 910 | }); 911 | 912 | it('meta, disable filter option works', () => { 913 | const result = normalize(json2, { endpoint: 'posts/me?some=query', filterEndpoint: false }); 914 | 915 | expect(result).to.deep.equal(output3); 916 | }); 917 | 918 | it('meta, meta is provided by JSON API service', () => { 919 | const json3 = { 920 | data: [ 921 | { 922 | type: 'post', 923 | relationships: { 924 | question: { 925 | data: { 926 | type: 'question', 927 | id: '295', 928 | }, 929 | }, 930 | }, 931 | id: 2620, 932 | attributes: { 933 | text: 'hello', 934 | }, 935 | }, 936 | ], 937 | meta: { 938 | next: 'http://example.com/api/v1/posts/friends_feed/superyuri?page[cursor]=5037', 939 | first: 'http://api.postie.loc/v1/posts/friends_feed/superyuri?page[cursor]=0', 940 | }, 941 | }; 942 | 943 | const output3 = { 944 | post: { 945 | 2620: { 946 | type: 'post', 947 | id: 2620, 948 | attributes: { 949 | text: 'hello', 950 | }, 951 | relationships: { 952 | question: { 953 | data: { 954 | id: '295', 955 | type: 'question', 956 | }, 957 | }, 958 | }, 959 | }, 960 | }, 961 | meta: { 962 | 'posts/me': { 963 | data: [ 964 | { 965 | type: 'post', 966 | id: 2620, 967 | relationships: { 968 | question: { 969 | data: { 970 | type: 'question', 971 | id: '295', 972 | }, 973 | }, 974 | }, 975 | }, 976 | ], 977 | meta: { 978 | next: 'http://example.com/api/v1/posts/friends_feed/superyuri?page[cursor]=5037', 979 | first: 'http://api.postie.loc/v1/posts/friends_feed/superyuri?page[cursor]=0', 980 | }, 981 | }, 982 | }, 983 | }; 984 | const result = normalize(json3, { endpoint: 'posts/me' }); 985 | 986 | expect(result).to.deep.equal(output3); 987 | }); 988 | 989 | it('empty collection', () => { 990 | const emptyJson = { 991 | data: [ 992 | { 993 | type: 'post', 994 | id: 1, 995 | attributes: { 996 | text: 'hello', 997 | }, 998 | relationships: { 999 | comments: { 1000 | data: [], 1001 | }, 1002 | }, 1003 | }, 1004 | ], 1005 | }; 1006 | 1007 | const output = { 1008 | post: { 1009 | 1: { 1010 | type: 'post', 1011 | id: 1, 1012 | attributes: { 1013 | text: 'hello', 1014 | }, 1015 | relationships: { 1016 | comments: { 1017 | data: [], 1018 | }, 1019 | }, 1020 | }, 1021 | }, 1022 | }; 1023 | 1024 | const result = normalize(emptyJson); 1025 | 1026 | expect(result).to.deep.equal(output); 1027 | }); 1028 | }); 1029 | 1030 | describe('complex', () => { 1031 | const json = { 1032 | data: [ 1033 | { 1034 | attributes: { 1035 | yday: 228, 1036 | text: 'Какие качества Вы больше всего цените в женщинах?', 1037 | slug: 'tbd', 1038 | }, 1039 | id: 29, 1040 | relationships: { 1041 | 'post-blocks': { 1042 | data: [ 1043 | { 1044 | type: 'post-block', 1045 | id: 4601, 1046 | }, 1047 | { 1048 | type: 'post-block', 1049 | id: 2454, 1050 | }, 1051 | ], 1052 | }, 1053 | }, 1054 | type: 'question', 1055 | }, 1056 | ], 1057 | included: [ 1058 | { 1059 | attributes: {}, 1060 | id: 4601, 1061 | relationships: { 1062 | user: { 1063 | data: { 1064 | type: 'user', 1065 | id: 1, 1066 | }, 1067 | }, 1068 | posts: { 1069 | data: [ 1070 | { 1071 | type: 'post', 1072 | id: 4969, 1073 | }, 1074 | { 1075 | type: 'post', 1076 | id: 1606, 1077 | }, 1078 | ], 1079 | }, 1080 | }, 1081 | type: 'post-block', 1082 | }, 1083 | { 1084 | attributes: {}, 1085 | id: 2454, 1086 | relationships: { 1087 | user: { 1088 | data: { 1089 | type: 'user', 1090 | id: 1, 1091 | }, 1092 | }, 1093 | posts: { 1094 | data: [ 1095 | { 1096 | type: 'post', 1097 | id: 4969, 1098 | }, 1099 | { 1100 | type: 'post', 1101 | id: 1606, 1102 | }, 1103 | ], 1104 | }, 1105 | }, 1106 | links: { 1107 | post_blocks: 'http://link.com' 1108 | }, 1109 | type: 'post-block', 1110 | }, 1111 | { 1112 | type: 'user', 1113 | attributes: { 1114 | slug: 'superyuri', 1115 | }, 1116 | id: 1, 1117 | }, 1118 | { 1119 | type: 'post', 1120 | id: 1606, 1121 | attributes: { 1122 | text: 'hello1', 1123 | }, 1124 | }, 1125 | { 1126 | type: 'post', 1127 | id: 4969, 1128 | attributes: { 1129 | text: 'hello2', 1130 | }, 1131 | meta: { 1132 | expires_at: 1513868982, 1133 | }, 1134 | }, 1135 | ], 1136 | }; 1137 | 1138 | const output = { 1139 | question: { 1140 | 29: { 1141 | type: 'question', 1142 | id: 29, 1143 | attributes: { 1144 | yday: 228, 1145 | text: 'Какие качества Вы больше всего цените в женщинах?', 1146 | slug: 'tbd', 1147 | }, 1148 | relationships: { 1149 | 'post-blocks': { 1150 | data: [ 1151 | { 1152 | id: 4601, 1153 | type: 'postBlock', 1154 | }, 1155 | { 1156 | id: 2454, 1157 | type: 'postBlock', 1158 | }, 1159 | ], 1160 | }, 1161 | }, 1162 | }, 1163 | }, 1164 | 'post-block': { 1165 | 2454: { 1166 | type: 'postBlock', 1167 | id: 2454, 1168 | links: { 1169 | post_blocks: 'http://link.com' 1170 | }, 1171 | attributes: {}, 1172 | relationships: { 1173 | user: { 1174 | data: { 1175 | type: 'user', 1176 | id: 1, 1177 | }, 1178 | }, 1179 | posts: { 1180 | data: [ 1181 | { 1182 | type: 'post', 1183 | id: 4969, 1184 | }, 1185 | { 1186 | type: 'post', 1187 | id: 1606, 1188 | }, 1189 | ], 1190 | }, 1191 | }, 1192 | }, 1193 | 4601: { 1194 | type: 'postBlock', 1195 | id: 4601, 1196 | attributes: {}, 1197 | relationships: { 1198 | user: { 1199 | data: { 1200 | type: 'user', 1201 | id: 1, 1202 | }, 1203 | }, 1204 | posts: { 1205 | data: [ 1206 | { 1207 | type: 'post', 1208 | id: 4969, 1209 | }, 1210 | { 1211 | type: 'post', 1212 | id: 1606, 1213 | }, 1214 | ], 1215 | }, 1216 | }, 1217 | }, 1218 | }, 1219 | user: { 1220 | 1: { 1221 | type: 'user', 1222 | id: 1, 1223 | attributes: { 1224 | slug: 'superyuri', 1225 | }, 1226 | }, 1227 | }, 1228 | post: { 1229 | 1606: { 1230 | type: 'post', 1231 | id: 1606, 1232 | attributes: { 1233 | text: 'hello1', 1234 | }, 1235 | }, 1236 | 4969: { 1237 | type: 'post', 1238 | id: 4969, 1239 | attributes: { 1240 | text: 'hello2', 1241 | }, 1242 | meta: { 1243 | expires_at: 1513868982, 1244 | }, 1245 | }, 1246 | }, 1247 | }; 1248 | 1249 | const output2 = { 1250 | question: { 1251 | 29: { 1252 | type: 'question', 1253 | id: 29, 1254 | attributes: { 1255 | yday: 228, 1256 | text: 'Какие качества Вы больше всего цените в женщинах?', 1257 | slug: 'tbd', 1258 | }, 1259 | relationships: { 1260 | postBlocks: { 1261 | data: [ 1262 | { 1263 | id: 4601, 1264 | type: 'postBlock', 1265 | }, 1266 | { 1267 | id: 2454, 1268 | type: 'postBlock', 1269 | }, 1270 | ], 1271 | }, 1272 | }, 1273 | }, 1274 | }, 1275 | postBlock: { 1276 | 2454: { 1277 | type: 'postBlock', 1278 | id: 2454, 1279 | links: { 1280 | postBlocks: 'http://link.com' 1281 | }, 1282 | attributes: {}, 1283 | relationships: { 1284 | user: { 1285 | data: { 1286 | type: 'user', 1287 | id: 1, 1288 | }, 1289 | }, 1290 | posts: { 1291 | data: [ 1292 | { 1293 | type: 'post', 1294 | id: 4969, 1295 | }, 1296 | { 1297 | type: 'post', 1298 | id: 1606, 1299 | }, 1300 | ], 1301 | }, 1302 | }, 1303 | }, 1304 | 4601: { 1305 | type: 'postBlock', 1306 | id: 4601, 1307 | attributes: {}, 1308 | relationships: { 1309 | user: { 1310 | data: { 1311 | type: 'user', 1312 | id: 1, 1313 | }, 1314 | }, 1315 | posts: { 1316 | data: [ 1317 | { 1318 | type: 'post', 1319 | id: 4969, 1320 | }, 1321 | { 1322 | type: 'post', 1323 | id: 1606, 1324 | }, 1325 | ], 1326 | }, 1327 | }, 1328 | }, 1329 | }, 1330 | user: { 1331 | 1: { 1332 | type: 'user', 1333 | id: 1, 1334 | attributes: { 1335 | slug: 'superyuri', 1336 | }, 1337 | }, 1338 | }, 1339 | post: { 1340 | 1606: { 1341 | type: 'post', 1342 | id: 1606, 1343 | attributes: { 1344 | text: 'hello1', 1345 | }, 1346 | }, 1347 | 4969: { 1348 | type: 'post', 1349 | id: 4969, 1350 | attributes: { 1351 | text: 'hello2', 1352 | }, 1353 | meta: { 1354 | expiresAt: 1513868982, 1355 | }, 1356 | }, 1357 | }, 1358 | }; 1359 | 1360 | const output3 = { 1361 | question: { 1362 | 29: { 1363 | type: 'question', 1364 | id: 29, 1365 | attributes: { 1366 | yday: 228, 1367 | text: 'Какие качества Вы больше всего цените в женщинах?', 1368 | slug: 'tbd', 1369 | }, 1370 | relationships: { 1371 | postBlocks: { 1372 | data: [ 1373 | { 1374 | id: 4601, 1375 | type: 'post-block', 1376 | }, 1377 | { 1378 | id: 2454, 1379 | type: 'post-block', 1380 | }, 1381 | ], 1382 | }, 1383 | }, 1384 | }, 1385 | }, 1386 | postBlock: { 1387 | 2454: { 1388 | type: 'post-block', 1389 | id: 2454, 1390 | links: { 1391 | postBlocks: 'http://link.com' 1392 | }, 1393 | attributes: {}, 1394 | relationships: { 1395 | user: { 1396 | data: { 1397 | type: 'user', 1398 | id: 1, 1399 | }, 1400 | }, 1401 | posts: { 1402 | data: [ 1403 | { 1404 | type: 'post', 1405 | id: 4969, 1406 | }, 1407 | { 1408 | type: 'post', 1409 | id: 1606, 1410 | }, 1411 | ], 1412 | }, 1413 | }, 1414 | }, 1415 | 4601: { 1416 | type: 'post-block', 1417 | id: 4601, 1418 | attributes: {}, 1419 | relationships: { 1420 | user: { 1421 | data: { 1422 | type: 'user', 1423 | id: 1, 1424 | }, 1425 | }, 1426 | posts: { 1427 | data: [ 1428 | { 1429 | type: 'post', 1430 | id: 4969, 1431 | }, 1432 | { 1433 | type: 'post', 1434 | id: 1606, 1435 | }, 1436 | ], 1437 | }, 1438 | }, 1439 | }, 1440 | }, 1441 | user: { 1442 | 1: { 1443 | type: 'user', 1444 | id: 1, 1445 | attributes: { 1446 | slug: 'superyuri', 1447 | }, 1448 | }, 1449 | }, 1450 | post: { 1451 | 1606: { 1452 | type: 'post', 1453 | id: 1606, 1454 | attributes: { 1455 | text: 'hello1', 1456 | }, 1457 | }, 1458 | 4969: { 1459 | type: 'post', 1460 | id: 4969, 1461 | attributes: { 1462 | text: 'hello2', 1463 | }, 1464 | meta: { 1465 | expiresAt: 1513868982, 1466 | }, 1467 | }, 1468 | }, 1469 | }; 1470 | 1471 | it('test data camelizeKeys: false', () => { 1472 | const result = normalize(json, { camelizeKeys: false }); 1473 | 1474 | expect(result).to.deep.eql(output); 1475 | }); 1476 | 1477 | it('test data camelizeKeys: true', () => { 1478 | const result = normalize(json, { camelizeKeys: true }); 1479 | 1480 | expect(result).to.deep.eql(output2); 1481 | }); 1482 | 1483 | it('test data camelizeTypeValues: false', () => { 1484 | const result = normalize(json, { camelizeTypeValues: false }); 1485 | 1486 | expect(result).to.deep.eql(output3); 1487 | }); 1488 | 1489 | it('test data camelizeTypeValues: true', () => { 1490 | const result = normalize(json, { camelizeTypeValues: true }); 1491 | 1492 | expect(result).to.deep.eql(output2); 1493 | }); 1494 | 1495 | const outputMeta = { 1496 | '/post': { 1497 | data: [ 1498 | { 1499 | type: 'question', 1500 | id: 29, 1501 | relationships: { 1502 | 'post-blocks': { 1503 | data: [ 1504 | { 1505 | type: 'postBlock', 1506 | id: 4601, 1507 | }, 1508 | { 1509 | type: 'postBlock', 1510 | id: 2454, 1511 | }, 1512 | ], 1513 | }, 1514 | }, 1515 | }, 1516 | ], 1517 | }, 1518 | }; 1519 | 1520 | const outputMeta2 = { 1521 | '/post': { 1522 | data: [ 1523 | { 1524 | type: 'question', 1525 | id: 29, 1526 | relationships: { 1527 | postBlocks: { 1528 | data: [ 1529 | { 1530 | type: 'postBlock', 1531 | id: 4601, 1532 | }, 1533 | { 1534 | type: 'postBlock', 1535 | id: 2454, 1536 | }, 1537 | ], 1538 | }, 1539 | }, 1540 | }, 1541 | ], 1542 | }, 1543 | }; 1544 | 1545 | const outputMeta3 = { 1546 | '/post': { 1547 | data: [ 1548 | { 1549 | type: 'question', 1550 | id: 29, 1551 | relationships: { 1552 | 'postBlocks': { 1553 | data: [ 1554 | { 1555 | type: 'post-block', 1556 | id: 4601, 1557 | }, 1558 | { 1559 | type: 'post-block', 1560 | id: 2454, 1561 | }, 1562 | ], 1563 | }, 1564 | }, 1565 | }, 1566 | ], 1567 | }, 1568 | }; 1569 | 1570 | it('test meta, camelizeKeys: false', () => { 1571 | const result = normalize(json, { endpoint: '/post', camelizeKeys: false }); 1572 | 1573 | expect(result.meta).to.deep.eql(outputMeta); 1574 | }); 1575 | 1576 | it('test meta, camelizeKeys: true', () => { 1577 | const result = normalize(json, { endpoint: '/post', camelizeKeys: true }); 1578 | 1579 | expect(result.meta).to.deep.eql(outputMeta2); 1580 | }); 1581 | 1582 | it('test meta, camelizeTypeValues: false', () => { 1583 | const result = normalize(json, { endpoint: '/post', camelizeTypeValues: false }); 1584 | 1585 | expect(result.meta).to.deep.eql(outputMeta3); 1586 | }); 1587 | 1588 | it('test meta, camelizeTypeValues: true', () => { 1589 | const result = normalize(json, { endpoint: '/post', camelizeTypeValues: true }); 1590 | 1591 | expect(result.meta).to.deep.eql(outputMeta2); 1592 | }); 1593 | }); 1594 | 1595 | describe('lazy loading', () => { 1596 | const json = { 1597 | data: [ 1598 | { 1599 | id: 29, 1600 | attributes: { 1601 | yday: 228, 1602 | text: 'Какие качества Вы больше всего цените в женщинах?', 1603 | slug: 'tbd', 1604 | }, 1605 | relationships: { 1606 | movie: { 1607 | links: { 1608 | self: 1609 | 'http://localhost:3000/api/v1/actor/1c9d234b-66c4-411e-b785-955d57db5536/relationships/movie', 1610 | related: 1611 | 'http://localhost:3000/api/v1/actor/1c9d234b-66c4-411e-b785-955d57db5536/movie', 1612 | }, 1613 | }, 1614 | }, 1615 | type: 'question', 1616 | }, 1617 | ], 1618 | }; 1619 | 1620 | const output = { 1621 | question: { 1622 | 29: { 1623 | type: 'question', 1624 | id: 29, 1625 | attributes: { 1626 | yday: 228, 1627 | text: 'Какие качества Вы больше всего цените в женщинах?', 1628 | slug: 'tbd', 1629 | }, 1630 | relationships: { 1631 | movie: { 1632 | links: { 1633 | self: 1634 | 'http://localhost:3000/api/v1/actor/1c9d234b-66c4-411e-b785-955d57db5536/relationships/movie', 1635 | related: 1636 | 'http://localhost:3000/api/v1/actor/1c9d234b-66c4-411e-b785-955d57db5536/movie', 1637 | }, 1638 | }, 1639 | }, 1640 | }, 1641 | }, 1642 | }; 1643 | 1644 | it('basic test', () => { 1645 | const result = normalize(json); 1646 | 1647 | expect(result).to.deep.equal(output); 1648 | }); 1649 | }); 1650 | 1651 | describe('relationship meta', () => { 1652 | const json1 = { 1653 | data: [ 1654 | { 1655 | type: 'post', 1656 | relationships: { 1657 | questions: { 1658 | data: [ 1659 | { 1660 | type: 'question', 1661 | id: '295', 1662 | }, 1663 | ], 1664 | meta: { 1665 | membership: [ 1666 | { 1667 | post_id: '2620', 1668 | question_id: '295', 1669 | created_at: '2017-11-22', 1670 | updated_at: '2017-11-26', 1671 | }, 1672 | ], 1673 | 'review-status': { 1674 | content_flags: '4', 1675 | }, 1676 | }, 1677 | }, 1678 | }, 1679 | id: '2620', 1680 | attributes: { 1681 | text: 'hello', 1682 | }, 1683 | }, 1684 | ], 1685 | }; 1686 | 1687 | const output1 = { 1688 | post: { 1689 | 2620: { 1690 | type: 'post', 1691 | id: '2620', 1692 | attributes: { 1693 | text: 'hello', 1694 | }, 1695 | relationships: { 1696 | questions: { 1697 | data: [ 1698 | { 1699 | id: '295', 1700 | type: 'question', 1701 | }, 1702 | ], 1703 | meta: { 1704 | membership: [ 1705 | { 1706 | postId: '2620', 1707 | questionId: '295', 1708 | createdAt: '2017-11-22', 1709 | updatedAt: '2017-11-26', 1710 | }, 1711 | ], 1712 | reviewStatus: { 1713 | contentFlags: '4', 1714 | }, 1715 | }, 1716 | }, 1717 | }, 1718 | }, 1719 | }, 1720 | }; 1721 | 1722 | it('meta in relationship', () => { 1723 | const result = normalize(json1); 1724 | 1725 | expect(result).to.deep.equal(output1); 1726 | }); 1727 | 1728 | it('we should store links and metadata if no data received', () => { 1729 | const input = { 1730 | meta: { 1731 | 'total-pages': 13, 1732 | }, 1733 | links: { 1734 | self: 'http://example.com/articles?page[number]=3&page[size]=1', 1735 | first: 'http://example.com/articles?page[number]=1&page[size]=1', 1736 | prev: 'http://example.com/articles?page[number]=2&page[size]=1', 1737 | next: 'http://example.com/articles?page[number]=4&page[size]=1', 1738 | last: 'http://example.com/articles?page[number]=13&page[size]=1', 1739 | }, 1740 | }; 1741 | 1742 | const output = { 1743 | meta: { 1744 | '/post': { 1745 | data: {}, 1746 | links: { 1747 | self: 'http://example.com/articles?page[number]=3&page[size]=1', 1748 | first: 'http://example.com/articles?page[number]=1&page[size]=1', 1749 | prev: 'http://example.com/articles?page[number]=2&page[size]=1', 1750 | next: 'http://example.com/articles?page[number]=4&page[size]=1', 1751 | last: 'http://example.com/articles?page[number]=13&page[size]=1', 1752 | }, 1753 | meta: { totalPages: 13 }, 1754 | }, 1755 | }, 1756 | }; 1757 | 1758 | const result = normalize(input, { endpoint: '/post' }); 1759 | 1760 | expect(result).to.deep.equal(output); 1761 | }); 1762 | }); 1763 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | const ESLintPlugin = require('eslint-webpack-plugin'); 5 | 6 | module.exports = { 7 | externals: [nodeExternals()], 8 | entry: [ 9 | './src/normalize' 10 | ], 11 | output: { 12 | path: path.join(__dirname, 'dist'), 13 | filename: 'bundle.js', 14 | libraryTarget: 'commonjs2' 15 | }, 16 | plugins: [ 17 | new webpack.DefinePlugin({ 18 | 'process.env': { 19 | 'NODE_ENV': JSON.stringify('production') 20 | } 21 | }), 22 | new ESLintPlugin() 23 | ], 24 | module: { 25 | rules: [ 26 | { test: /\.js?$/, use: ['babel-loader'], exclude: /node_modules/ }, 27 | ] 28 | }, 29 | resolve: { 30 | modules: [ 31 | path.join(__dirname, 'src'), 32 | 'node_modules' 33 | ] 34 | } 35 | }; 36 | --------------------------------------------------------------------------------