├── .npmignore ├── .eslintignore ├── index.js ├── .gitignore ├── test ├── helpers │ └── tick-counter.js ├── fixture │ ├── mixed.data.js │ └── articles.data.js ├── unit │ ├── helpers.test.js │ ├── lru-cache.test.js │ └── validator.test.js └── integration │ └── examples.js ├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── build.yml ├── LICENSE ├── package.json ├── lib ├── lru-cache.js ├── helpers.js ├── validator.js └── JSONAPISerializer.js ├── benchmark └── index.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !index.js 3 | !lib/** 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/JSONAPISerializer'); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | .nyc_output/ 4 | .idea/ 5 | -------------------------------------------------------------------------------- /test/helpers/tick-counter.js: -------------------------------------------------------------------------------- 1 | class TickCounter { 2 | constructor(max) { 3 | this.ticks = 0; 4 | this.max = max; 5 | 6 | this.countTicks(); 7 | } 8 | 9 | countTicks() { 10 | setImmediate(() => { 11 | this.ticks += 1; 12 | if (this.max > this.ticks) { 13 | this.countTicks(); 14 | } 15 | }); 16 | } 17 | } 18 | 19 | module.exports = TickCounter; 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org/ 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "airbnb-base", 5 | "plugin:prettier/recommended", 6 | "plugin:jsdoc/recommended" 7 | ], 8 | "parserOptions": { 9 | "ecmaVersion": 2018 10 | }, 11 | "env": { 12 | "node": true, 13 | "mocha": true 14 | }, 15 | "rules": { 16 | "prettier/prettier": ["error", { "singleQuote": true, "printWidth": 100 }], 17 | "no-underscore-dangle": 0, 18 | "no-param-reassign": 0, 19 | "class-methods-use-this": 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x, 18.x, 20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - run: npm install 26 | - run: npm run lint 27 | - run: npm test 28 | 29 | - name: coveralls 30 | uses: coverallsapp/github-action@master 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kévin Danielo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | 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 LIABILITY, 19 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/fixture/mixed.data.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | id: '1', 4 | type: 'article', 5 | title: 'JSON API paints my bikeshed!', 6 | body: 'The shortest article. Ever.', 7 | created: '2015-05-22T14:56:29.000Z', 8 | updated: '2015-05-22T14:56:28.000Z', 9 | author: { 10 | id: '1', 11 | firstName: 'Kaley', 12 | lastName: 'Maggio', 13 | email: 'Kaley-Maggio@example.com', 14 | age: '80', 15 | gender: 'male', 16 | }, 17 | tags: ['1', '2'], 18 | photos: [ 19 | 'ed70cf44-9a34-4878-84e6-0c0e4a450cfe', 20 | '24ba3666-a593-498c-9f5d-55a4ee08c72e', 21 | 'f386492d-df61-4573-b4e3-54f6f5d08acf', 22 | ], 23 | comments: [ 24 | { 25 | _id: '1', 26 | body: 'First !', 27 | created: '2015-08-14T18:42:16.475Z', 28 | }, 29 | { 30 | _id: '2', 31 | body: 'I Like !', 32 | created: '2015-09-14T18:42:12.475Z', 33 | }, 34 | { 35 | _id: '3', 36 | body: 'Awesome', 37 | created: '2015-09-15T18:42:12.475Z', 38 | }, 39 | ], 40 | }, 41 | { 42 | id: '1', 43 | type: 'people', 44 | firstName: 'Harold', 45 | lastName: 'Marvin', 46 | email: 'Harold-Marvin@example.com', 47 | age: '30', 48 | gender: 'male', 49 | }, 50 | ]; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-api-serializer", 3 | "version": "2.6.6", 4 | "description": "Framework agnostic JSON API serializer.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "./node_modules/.bin/eslint .", 8 | "test": "./node_modules/.bin/nyc --reporter=lcov --reporter=text --reporter=text-summary ./node_modules/mocha/bin/_mocha -R spec ./test/**/*.js", 9 | "coveralls": "cat ./coverage/lcov.info | node ./node_modules/coveralls/bin/coveralls.js", 10 | "bench": "node benchmark/index.js" 11 | }, 12 | "keywords": [ 13 | "json", 14 | "json-api", 15 | "jsonapi", 16 | "json api", 17 | "serializer" 18 | ], 19 | "author": "Kévin Danielo", 20 | "repository": "danivek/json-api-serializer", 21 | "license": "MIT", 22 | "engines": { 23 | "node": ">= 8.0.0" 24 | }, 25 | "devDependencies": { 26 | "benchmark": "^2.1.4", 27 | "bson-objectid": "^2.0.1", 28 | "chai": "^4.1.2", 29 | "coveralls": "^3.1.1", 30 | "eslint": "^8.10.0", 31 | "eslint-config-airbnb-base": "^15.0.0", 32 | "eslint-config-prettier": "^9.0.0", 33 | "eslint-plugin-import": "^2.25.4", 34 | "eslint-plugin-jsdoc": "^46.9.0", 35 | "eslint-plugin-prettier": "^5.0.1", 36 | "mocha": "^10.2.0", 37 | "nyc": "^15.1.0", 38 | "prettier": "^3.1.0" 39 | }, 40 | "dependencies": { 41 | "setimmediate": "^1.0.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/unit/helpers.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | 3 | const { set, toCamelCase, toSnakeCase, toKebabCase } = require('../../lib/helpers'); 4 | 5 | describe('Helpers', () => { 6 | describe('set', () => { 7 | it('should do nothing if it is not an object', () => { 8 | const object = 1; 9 | set(object, 'exist', true); 10 | expect(object).to.equal(1); 11 | }); 12 | 13 | it('should set a value by path', () => { 14 | const object = { exist: {} }; 15 | set(object, 'exist', true); 16 | set(object, 'a[0].b.c', 4); 17 | set(object, ['x', '0', 'y', 'z'], 5); 18 | expect(object).to.deep.equal({ 19 | exist: true, 20 | a: [ 21 | { 22 | b: { 23 | c: 4, 24 | }, 25 | }, 26 | ], 27 | x: [ 28 | { 29 | y: { 30 | z: 5, 31 | }, 32 | }, 33 | ], 34 | }); 35 | }); 36 | }); 37 | 38 | describe('toCamelCase', () => { 39 | it('should not choke on non-alpha characters', () => { 40 | expect(toCamelCase('*')).to.equal('*'); 41 | }); 42 | }); 43 | 44 | describe('toSnakeCase', () => { 45 | it('should not choke on non-alphanumeric characters', () => { 46 | expect(toSnakeCase('*')).to.equal('*'); 47 | }); 48 | }); 49 | 50 | describe('toKebabCase', () => { 51 | it('should not choke on non-alphanumeric characters', () => { 52 | expect(toKebabCase('*')).to.equal('*'); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /lib/lru-cache.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // Influenced by http://jsfiddle.net/2baax9nk/5/ 4 | 5 | class Node { 6 | constructor(key, data) { 7 | this.key = key; 8 | this.data = data; 9 | this.previous = null; 10 | this.next = null; 11 | } 12 | } 13 | 14 | module.exports = class LRU { 15 | constructor(capacity) { 16 | this.capacity = capacity === 0 ? Infinity : capacity; 17 | this.map = {}; 18 | this.head = null; 19 | this.tail = null; 20 | } 21 | 22 | get(key) { 23 | // Existing item 24 | if (this.map[key] !== undefined) { 25 | // Move to the first place 26 | const node = this.map[key]; 27 | this._moveFirst(node); 28 | 29 | // Return 30 | return node.data; 31 | } 32 | 33 | // Not found 34 | return undefined; 35 | } 36 | 37 | set(key, value) { 38 | // Existing item 39 | if (this.map[key] !== undefined) { 40 | // Move to the first place 41 | const node = this.map[key]; 42 | node.data = value; 43 | this._moveFirst(node); 44 | return; 45 | } 46 | 47 | // Ensuring the cache is within capacity 48 | if (Object.keys(this.map).length >= this.capacity) { 49 | const id = this.tail.key; 50 | this._removeLast(); 51 | delete this.map[id]; 52 | } 53 | 54 | // New Item 55 | const node = new Node(key, value); 56 | this._add(node); 57 | this.map[key] = node; 58 | } 59 | 60 | _add(node) { 61 | node.next = null; 62 | node.previous = node.next; 63 | 64 | // first item 65 | if (this.head === null) { 66 | this.head = node; 67 | this.tail = node; 68 | } else { 69 | // adding to existing items 70 | this.head.previous = node; 71 | node.next = this.head; 72 | this.head = node; 73 | } 74 | } 75 | 76 | _remove(node) { 77 | // only item in the cache 78 | if (this.head === node && this.tail === node) { 79 | this.tail = null; 80 | this.head = this.tail; 81 | return; 82 | } 83 | 84 | // remove from head 85 | if (this.head === node) { 86 | this.head.next.previous = null; 87 | this.head = this.head.next; 88 | return; 89 | } 90 | 91 | // remove from tail 92 | if (this.tail === node) { 93 | this.tail.previous.next = null; 94 | this.tail = this.tail.previous; 95 | return; 96 | } 97 | 98 | // remove from middle 99 | node.previous.next = node.next; 100 | node.next.previous = node.previous; 101 | } 102 | 103 | _moveFirst(node) { 104 | this._remove(node); 105 | this._add(node); 106 | } 107 | 108 | _removeLast() { 109 | this._remove(this.tail); 110 | } 111 | }; 112 | -------------------------------------------------------------------------------- /test/unit/lru-cache.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | const { expect } = require('chai'); 3 | 4 | const LRU = require('../../lib/lru-cache'); 5 | 6 | describe('LRU Cache', function () { 7 | it('should create an LRU', function () { 8 | const lru = new LRU(5); 9 | expect(lru).to.have.property('head'); 10 | expect(lru).to.have.property('tail'); 11 | expect(lru.capacity).to.equal(5); 12 | }); 13 | 14 | it('should set a single node, and be able to retreive it', function () { 15 | const lru = new LRU(5); 16 | lru.set('myKey', 'my-key'); 17 | 18 | expect(lru.head.data).to.equal('my-key'); 19 | expect(lru.head.previous).to.equal(null); 20 | expect(lru.head.next).to.equal(null); 21 | 22 | const myKey = lru.get('myKey'); 23 | expect(myKey).to.equal('my-key'); 24 | }); 25 | 26 | it('should add new nodes to the head and move last fetched node to the head', function () { 27 | const lru = new LRU(5); 28 | lru.set(1, 1); 29 | lru.set(2, 2); 30 | lru.set(3, 3); 31 | lru.set(4, 4); 32 | 33 | let { head } = lru; 34 | expect(head.previous).to.equal(null); 35 | expect(head.data).to.equal(4); 36 | expect(head.next.data).to.equal(3); 37 | expect(head.next.next.data).to.equal(2); 38 | expect(head.next.next.next.data).to.equal(1); 39 | expect(head.next.next.next.next).to.equal(null); 40 | 41 | const result = lru.get(2); 42 | ({ head } = lru); 43 | expect(result).to.equal(2); 44 | expect(head.previous).to.equal(null); 45 | expect(head.data).to.equal(2); 46 | expect(head.next.data).to.equal(4); 47 | expect(head.next.next.data).to.equal(3); 48 | expect(head.next.next.next.data).to.equal(1); 49 | expect(head.next.next.next.next).to.equal(null); 50 | }); 51 | 52 | it('should remove nodes after hitting capacity', function () { 53 | const lru = new LRU(5); 54 | lru.set(1, 1); 55 | lru.set(2, 2); 56 | lru.set(3, 3); 57 | lru.set(4, 4); 58 | lru.set(5, 5); 59 | lru.get(1); 60 | lru.set(6, 6); 61 | 62 | const { head } = lru; 63 | expect(head.previous).to.equal(null); 64 | expect(head.data).to.equal(6); 65 | expect(head.next.data).to.equal(1); 66 | expect(head.next.next.data).to.equal(5); 67 | expect(head.next.next.next.data).to.equal(4); 68 | expect(head.next.next.next.next.data).to.equal(3); 69 | expect(head.next.next.next.next.next).to.equal(null); 70 | }); 71 | 72 | it('should create an LRU of infinite capacity', function () { 73 | const lru = new LRU(0); 74 | 75 | expect(lru.capacity).to.equal(Infinity); 76 | }); 77 | 78 | it('should replace a node if the capacity is 1', function () { 79 | const lru = new LRU(1); 80 | 81 | lru.set(1, 1); 82 | lru.set(2, 2); 83 | 84 | const { head } = lru; 85 | expect(head.data).to.equal(2); 86 | expect(head.previous).to.equal(null); 87 | expect(head.next).to.equal(null); 88 | 89 | expect(lru.get(1)).to.equal(undefined); 90 | expect(lru.get(2)).to.equal(2); 91 | }); 92 | 93 | it('should reset a nodes value if it already exists', function () { 94 | const lru = new LRU(5); 95 | lru.set(1, 1); 96 | lru.set(2, 2); 97 | lru.set(3, 3); 98 | 99 | lru.set(1, 10); 100 | 101 | expect(lru.head.data).to.equal(10); 102 | expect(lru.get(1)).to.equal(10); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/fixture/articles.data.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | id: '1', 4 | title: 'JSON API paints my bikeshed!', 5 | body: 'The shortest article. Ever.', 6 | created: '2015-05-22T14:56:29.000Z', 7 | updated: '2015-05-22T14:56:28.000Z', 8 | author: { 9 | id: '1', 10 | firstName: 'Kaley', 11 | lastName: 'Maggio', 12 | email: 'Kaley-Maggio@example.com', 13 | age: '80', 14 | gender: 'male', 15 | }, 16 | tags: ['1', '2'], 17 | photos: [ 18 | 'ed70cf44-9a34-4878-84e6-0c0e4a450cfe', 19 | '24ba3666-a593-498c-9f5d-55a4ee08c72e', 20 | 'f386492d-df61-4573-b4e3-54f6f5d08acf', 21 | ], 22 | comments: [ 23 | { 24 | _id: '1', 25 | body: 'First !', 26 | created: '2015-08-14T18:42:16.475Z', 27 | }, 28 | { 29 | _id: '2', 30 | body: 'I Like !', 31 | created: '2015-09-14T18:42:12.475Z', 32 | }, 33 | { 34 | _id: '3', 35 | body: 'Awesome', 36 | created: '2015-09-15T18:42:12.475Z', 37 | }, 38 | ], 39 | translations: [ 40 | { 41 | id: '1', 42 | lang: 'es', 43 | title: '¡JSON API pinta la caseta de la bici!', 44 | body: 'El artículo más corto jamás escrito', 45 | created: '2015-06-07T18:01:02.123Z', 46 | }, 47 | { 48 | id: '1', 49 | lang: 'pt', 50 | title: 'API JSON pinta a garagem da bicicleta!', 51 | body: 'O artículo más corto jamás escrito', 52 | created: '2015-06-07T20:01:02.123Z', 53 | }, 54 | { 55 | id: '1', 56 | lang: 'de', 57 | title: 'JSON API malt meinen Fahrradschuppen!', 58 | body: 'Der kürzeste Artikel, der jemals geschrieben wurde', 59 | created: '2015-06-07T22:01:02.123Z', 60 | }, 61 | ], 62 | }, 63 | { 64 | id: '2', 65 | title: 'JSON API 1.0', 66 | body: 'JSON API specifications', 67 | created: '2015-05-23T14:56:29.000Z', 68 | updated: '2015-05-24T14:56:28.000Z', 69 | author: { 70 | id: '2', 71 | firstName: 'Harold', 72 | lastName: 'Marvin', 73 | email: 'Harold-Marvin@example.com', 74 | age: '30', 75 | gender: 'male', 76 | }, 77 | tags: [ 78 | { 79 | id: '3', 80 | title: 'Tag 3', 81 | }, 82 | { 83 | id: '4', 84 | title: 'Tag 4', 85 | }, 86 | ], 87 | photos: [ 88 | 'ed70cf44-9a34-4878-84e6-0c0e4a450cfe', 89 | '24ba3666-a593-498c-9f5d-55a4ee08c72e', 90 | 'f386492d-df61-4573-b4e3-54f6f5d08acf', 91 | ], 92 | comments: [ 93 | { 94 | _id: '4', 95 | body: 'Recommended', 96 | created: '2015-08-14T18:42:16.475Z', 97 | }, 98 | { 99 | _id: '5', 100 | body: 'Really nice', 101 | created: '2015-09-14T18:42:12.475Z', 102 | }, 103 | { 104 | _id: '6', 105 | body: 'Awesome', 106 | created: '2015-09-15T18:42:12.475Z', 107 | }, 108 | ], 109 | translations: [ 110 | { 111 | id: '2', 112 | lang: 'es', 113 | title: 'JSON API 1.0', 114 | body: 'Especificaciones de JSON API', 115 | created: '2015-06-07T18:01:02.123Z', 116 | }, 117 | { 118 | id: '2', 119 | lang: 'pt', 120 | title: 'JSON API 1.0', 121 | body: 'Especificações de JSON API', 122 | created: '2015-06-07T20:01:02.123Z', 123 | }, 124 | { 125 | id: '2', 126 | lang: 'de', 127 | title: 'JSON API 1.0', 128 | body: 'JSON API Spezifikationen', 129 | created: '2015-06-07T22:01:02.123Z', 130 | }, 131 | ], 132 | }, 133 | ]; 134 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sequences */ 2 | /* eslint-disable no-return-assign */ 3 | 4 | const LRU = require('./lru-cache'); 5 | 6 | // https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get 7 | const get = (obj, path, defaultValue) => { 8 | const result = String.prototype.split 9 | .call(path, /[,[\].]+?/) 10 | .filter(Boolean) 11 | .reduce((res, key) => (res !== null && res !== undefined ? res[key] : res), obj); 12 | return result === undefined || result === obj ? defaultValue : result; 13 | }; 14 | 15 | // https://stackoverflow.com/questions/54733539/javascript-implementation-of-lodash-set-method 16 | const set = (obj, path, value) => { 17 | if (Object(obj) !== obj) return obj; // When obj is not an object 18 | // If not yet an array, get the keys from the string-path 19 | if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || []; 20 | path.slice(0, -1).reduce( 21 | ( 22 | a, 23 | c, 24 | i, // Iterate all of them except the last one 25 | ) => 26 | Object(a[c]) === a[c] // Does the key exist and is its value an object? 27 | ? // Yes: then follow that path 28 | a[c] 29 | : // No: create the key. Is the next key a potential array-index? 30 | (a[c] = 31 | // eslint-disable-next-line no-bitwise 32 | Math.abs(path[i + 1]) >> 0 === +path[i + 1] 33 | ? [] // Yes: assign a new array object 34 | : {}), // No: assign a new plain object 35 | obj, 36 | )[path[path.length - 1]] = value; // Finally assign the value to the last key 37 | return obj; // Return the top-level object to allow chaining 38 | }; 39 | 40 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/pick.md 41 | const pick = (obj, arr) => 42 | arr.reduce((acc, curr) => (curr in obj && (acc[curr] = obj[curr]), acc), {}); 43 | 44 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/isEmpty.md 45 | const isEmpty = (val) => val == null || !(Object.keys(val) || val).length; 46 | 47 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/omit.md 48 | const omit = (obj, arr) => 49 | Object.keys(obj) 50 | .filter((k) => !arr.includes(k)) 51 | .reduce((acc, key) => ((acc[key] = obj[key]), acc), {}); 52 | 53 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/isObjectLike.md 54 | const isObjectLike = (val) => val !== null && typeof val === 'object'; 55 | 56 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/isPlainObject.md 57 | const isPlainObject = (val) => !!val && typeof val === 'object' && val.constructor === Object; 58 | 59 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/transform.md 60 | const transform = (obj, fn, acc) => Object.keys(obj).reduce((a, k) => fn(a, obj[k], k, obj), acc); 61 | 62 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/toKebabCase.md 63 | const toKebabCase = (str) => { 64 | const match = 65 | str && str.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g); 66 | 67 | if (!match) { 68 | return str; 69 | } 70 | 71 | return match.map((x) => x.toLowerCase()).join('-'); 72 | }; 73 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/toSnakeCase.md 74 | const toSnakeCase = (str) => { 75 | const match = 76 | str && str.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g); 77 | 78 | if (!match) { 79 | return str; 80 | } 81 | 82 | return match.map((x) => x.toLowerCase()).join('_'); 83 | }; 84 | 85 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/toCamelCase.md 86 | const toCamelCase = (str) => { 87 | const match = 88 | str && str.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g); 89 | 90 | if (!match) { 91 | return str; 92 | } 93 | 94 | const s = match.map((x) => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase()).join(''); 95 | return s.slice(0, 1).toLowerCase() + s.slice(1); 96 | }; 97 | 98 | module.exports = { 99 | get, 100 | set, 101 | pick, 102 | isEmpty, 103 | omit, 104 | isPlainObject, 105 | isObjectLike, 106 | transform, 107 | toKebabCase, 108 | toSnakeCase, 109 | toCamelCase, 110 | LRU, 111 | }; 112 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable no-console */ 3 | 4 | const Benchmark = require('benchmark'); 5 | const os = require('os'); 6 | 7 | const JSONAPISerializer = require('..'); 8 | 9 | const suite = new Benchmark.Suite(); 10 | 11 | const serializer = new JSONAPISerializer(); 12 | const serializerConvert = new JSONAPISerializer({ 13 | convertCase: 'kebab-case', 14 | unconvertCase: 'camelCase', 15 | }); 16 | 17 | const data = [ 18 | { 19 | id: '1', 20 | title: 'JSON API paints my bikeshed!', 21 | body: 'The shortest article. Ever.', 22 | created: '2015-05-22T14:56:29.000Z', 23 | updated: '2015-05-22T14:56:28.000Z', 24 | author: { 25 | id: '1', 26 | firstName: 'Kaley', 27 | lastName: 'Maggio', 28 | email: 'Kaley-Maggio@example.com', 29 | age: '80', 30 | gender: 'male', 31 | }, 32 | tags: ['1', '2'], 33 | photos: [ 34 | 'ed70cf44-9a34-4878-84e6-0c0e4a450cfe', 35 | '24ba3666-a593-498c-9f5d-55a4ee08c72e', 36 | 'f386492d-df61-4573-b4e3-54f6f5d08acf', 37 | ], 38 | comments: [ 39 | { 40 | _id: '1', 41 | body: 'First !', 42 | created: '2015-08-14T18:42:16.475Z', 43 | }, 44 | { 45 | _id: '2', 46 | body: 'I Like !', 47 | created: '2015-09-14T18:42:12.475Z', 48 | }, 49 | { 50 | _id: '3', 51 | body: 'Awesome', 52 | created: '2015-09-15T18:42:12.475Z', 53 | }, 54 | ], 55 | }, 56 | ]; 57 | 58 | const articleSchema = { 59 | id: 'id', 60 | links: { 61 | // An object or a function that describes links. 62 | self(d) { 63 | // Can be a function or a string value ex: { self: '/articles/1'} 64 | return `/articles/${d.id}`; 65 | }, 66 | }, 67 | relationships: { 68 | // An object defining some relationships. 69 | author: { 70 | type: 'people', 71 | links(d) { 72 | // An object or a function that describes Relationships links 73 | return { 74 | self: `/articles/${d.id}/relationships/author`, 75 | related: `/articles/${d.id}/author`, 76 | }; 77 | }, 78 | }, 79 | tags: { 80 | type: 'tag', 81 | }, 82 | photos: { 83 | type: 'photo', 84 | }, 85 | comments: { 86 | type: 'comment', 87 | schema: 'only-body', // A custom schema 88 | }, 89 | }, 90 | topLevelMeta(d, extraData) { 91 | // An object or a function that describes top level meta. 92 | return { 93 | count: extraData.count, 94 | total: d.length, 95 | }; 96 | }, 97 | topLevelLinks: { 98 | // An object or a function that describes top level links. 99 | self: '/articles', 100 | }, 101 | }; 102 | serializer.register('article', articleSchema); 103 | serializerConvert.register('article', articleSchema); 104 | 105 | // Register 'people' type 106 | const peopleSchema = { 107 | id: 'id', 108 | links: { 109 | self(d) { 110 | return `/peoples/${d.id}`; 111 | }, 112 | }, 113 | }; 114 | serializer.register('people', peopleSchema); 115 | serializerConvert.register('people', peopleSchema); 116 | 117 | // Register 'tag' type 118 | const tagSchema = { 119 | id: 'id', 120 | }; 121 | serializer.register('tag', tagSchema); 122 | serializerConvert.register('tag', tagSchema); 123 | 124 | // Register 'photo' type 125 | const photoSchema = { 126 | id: 'id', 127 | }; 128 | serializer.register('photo', photoSchema); 129 | serializerConvert.register('photo', photoSchema); 130 | 131 | // Register 'comment' type with a custom schema 132 | const commentSchema = { 133 | id: '_id', 134 | }; 135 | serializer.register('comment', 'only-body', commentSchema); 136 | serializerConvert.register('comment', 'only-body', commentSchema); 137 | 138 | let serialized; 139 | let serializedConvert; 140 | 141 | // Plateform 142 | console.log('Platform info:'); 143 | console.log('=============='); 144 | 145 | console.log(`${os.type()} ${os.release()} ${os.arch()}`); 146 | console.log('Node.JS:', process.versions.node); 147 | console.log('V8:', process.versions.v8); 148 | 149 | let cpus = os 150 | .cpus() 151 | .map((cpu) => cpu.model) 152 | .reduce((o, model) => { 153 | if (!o[model]) o[model] = 0; 154 | o[model] += 1; 155 | return o; 156 | }, {}); 157 | 158 | cpus = Object.keys(cpus) 159 | .map((key) => `${key} \u00d7 ${cpus[key]}`) 160 | .join('\n'); 161 | 162 | console.info(cpus); 163 | 164 | console.log('\nSuite:'); 165 | console.log('=============='); 166 | suite 167 | .add('serializeAsync', { 168 | defer: true, 169 | fn(deferred) { 170 | serializer.serializeAsync('article', data, { count: 2 }).then(() => { 171 | deferred.resolve(); 172 | }); 173 | }, 174 | }) 175 | .add('serialize', () => { 176 | serialized = serializer.serialize('article', data, { count: 2 }); 177 | }) 178 | .add('serializeConvertCase', () => { 179 | serializedConvert = serializerConvert.serialize('article', data, { count: 2 }); 180 | }) 181 | .add('deserializeAsync', { 182 | defer: true, 183 | fn(deferred) { 184 | serializer.deserializeAsync('article', serialized).then(() => { 185 | deferred.resolve(); 186 | }); 187 | }, 188 | }) 189 | .add('deserialize', () => { 190 | serializer.deserialize('article', serialized); 191 | }) 192 | .add('deserializeConvertCase', () => { 193 | serializerConvert.deserialize('article', serializedConvert); 194 | }) 195 | .add('serializeError', () => { 196 | const error = new Error('An error occured'); 197 | error.status = 500; 198 | error.code = 'ERROR'; 199 | 200 | serializer.serializeError(error); 201 | }) 202 | .add('serializeError with a JSON API error object', () => { 203 | const jsonapiError = { 204 | status: '422', 205 | source: { pointer: '/data/attributes/error' }, 206 | title: 'Error', 207 | detail: 'An error occured', 208 | }; 209 | 210 | serializer.serializeError(jsonapiError); 211 | }) 212 | // add listeners 213 | .on('cycle', (event) => { 214 | console.log(String(event.target)); 215 | }) 216 | .on('complete', () => {}) 217 | // run async 218 | .run({ async: false }); 219 | -------------------------------------------------------------------------------- /test/unit/validator.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | const { expect } = require('chai'); 3 | 4 | const validator = require('../../lib/validator'); 5 | 6 | describe('validator', function () { 7 | describe('validateDynamicTypeOptions', function () { 8 | it('no type provided', (done) => { 9 | expect(function () { 10 | validator.validateDynamicTypeOptions({}); 11 | }).to.throw(Error, "option 'type' is required"); 12 | 13 | done(); 14 | }); 15 | 16 | it('incorrect type', (done) => { 17 | expect(function () { 18 | validator.validateDynamicTypeOptions({ type: {} }); 19 | }).to.throw(Error, 'must be a string or a function'); 20 | 21 | done(); 22 | }); 23 | 24 | it('incorrect jsonapiObject', (done) => { 25 | expect(function () { 26 | validator.validateDynamicTypeOptions({ type: 'test', jsonapiObject: {} }); 27 | }).to.throw(Error, "option 'jsonapiObject' must a boolean"); 28 | 29 | done(); 30 | }); 31 | 32 | it('incorrect topLevelLinks', (done) => { 33 | expect(function () { 34 | validator.validateDynamicTypeOptions({ type: 'test', topLevelLinks: 'test' }); 35 | }).to.throw(Error, "option 'topLevelLinks' must be an object or a function"); 36 | 37 | done(); 38 | }); 39 | 40 | it('incorrect topLevelMeta', (done) => { 41 | expect(function () { 42 | validator.validateDynamicTypeOptions({ type: 'test', topLevelMeta: 'test' }); 43 | }).to.throw(Error, "option 'topLevelMeta' must be an object or a function"); 44 | 45 | done(); 46 | }); 47 | 48 | it('incorrect meta', (done) => { 49 | expect(function () { 50 | validator.validateDynamicTypeOptions({ type: 'test', meta: 'test' }); 51 | }).to.throw(Error, "option 'meta' must be an object or a function"); 52 | 53 | done(); 54 | }); 55 | }); 56 | 57 | describe('validateOptions', function () { 58 | it('incorrect blacklist', (done) => { 59 | expect(function () { 60 | validator.validateOptions({ 61 | blacklist: {}, 62 | }); 63 | }).to.throw(Error, "option 'blacklist' must be an array"); 64 | 65 | done(); 66 | }); 67 | 68 | it('incorrect whitelist', (done) => { 69 | expect(function () { 70 | validator.validateOptions({ 71 | whitelist: {}, 72 | }); 73 | }).to.throw(Error, "option 'whitelist' must be an array"); 74 | 75 | done(); 76 | }); 77 | 78 | it('incorrect links', (done) => { 79 | expect(function () { 80 | validator.validateOptions({ 81 | links: 'test', 82 | }); 83 | }).to.throw(Error, "option 'links' must be an object or a function"); 84 | 85 | done(); 86 | }); 87 | 88 | it('incorrect meta', (done) => { 89 | expect(function () { 90 | validator.validateOptions({ 91 | meta: 'test', 92 | }); 93 | }).to.throw(Error, "option 'meta' must be an object or a function"); 94 | 95 | done(); 96 | }); 97 | 98 | it('incorrect blacklistOnDeserialize', (done) => { 99 | expect(function () { 100 | validator.validateOptions({ 101 | blacklistOnDeserialize: {}, 102 | }); 103 | }).to.throw(Error, "option 'blacklistOnDeserialize' must be an array"); 104 | 105 | done(); 106 | }); 107 | 108 | it('incorrect whitelistOnDeserialize', (done) => { 109 | expect(function () { 110 | validator.validateOptions({ 111 | whitelistOnDeserialize: {}, 112 | }); 113 | }).to.throw(Error, "option 'whitelistOnDeserialize' must be an array"); 114 | 115 | done(); 116 | }); 117 | 118 | it('incorrect topLevelLinks', (done) => { 119 | expect(function () { 120 | validator.validateOptions({ 121 | topLevelLinks: 'test', 122 | }); 123 | }).to.throw(Error, "option 'topLevelLinks' must be an object or a function"); 124 | 125 | done(); 126 | }); 127 | 128 | it('incorrect topLevelMeta', (done) => { 129 | expect(function () { 130 | validator.validateOptions({ 131 | topLevelMeta: 'test', 132 | }); 133 | }).to.throw(Error, "option 'topLevelMeta' must be an object or a function"); 134 | 135 | done(); 136 | }); 137 | 138 | it('incorrect convertCase', (done) => { 139 | expect(function () { 140 | validator.validateOptions({ 141 | convertCase: 'TOCAMELCASE', 142 | }); 143 | }).to.throw( 144 | Error, 145 | "option 'convertCase' must be one of 'kebab-case', 'snake_case', 'camelCase'", 146 | ); 147 | 148 | done(); 149 | }); 150 | 151 | it('incorrect unconvertCase', (done) => { 152 | expect(function () { 153 | validator.validateOptions({ 154 | unconvertCase: 'TOCAMELCASE', 155 | }); 156 | }).to.throw( 157 | Error, 158 | "option 'unconvertCase' must be one of 'kebab-case', 'snake_case', 'camelCase'", 159 | ); 160 | 161 | done(); 162 | }); 163 | 164 | it('incorrect jsonapiObject', (done) => { 165 | expect(function () { 166 | validator.validateOptions({ 167 | jsonapiObject: {}, 168 | }); 169 | }).to.throw(Error, "'jsonapiObject' must a boolean"); 170 | 171 | done(); 172 | }); 173 | 174 | it('incorrect beforeSerialize', (done) => { 175 | expect(function () { 176 | validator.validateOptions({ 177 | beforeSerialize: 'test', 178 | }); 179 | }).to.throw(Error, "option 'beforeSerialize' must be function"); 180 | 181 | done(); 182 | }); 183 | 184 | it('incorrect afterDeserialize', (done) => { 185 | expect(function () { 186 | validator.validateOptions({ 187 | afterDeserialize: 'test', 188 | }); 189 | }).to.throw(Error, "option 'afterDeserialize' must be function"); 190 | 191 | done(); 192 | }); 193 | 194 | it('no type provided on relationship', (done) => { 195 | expect(function () { 196 | validator.validateOptions({ 197 | relationships: { 198 | test: {}, 199 | }, 200 | }); 201 | }).to.throw(Error, "option 'type' for relationship 'test' is required"); 202 | 203 | done(); 204 | }); 205 | 206 | it('incorrect type on relationship', (done) => { 207 | expect(function () { 208 | validator.validateOptions({ 209 | relationships: { 210 | test: { 211 | type: {}, 212 | }, 213 | }, 214 | }); 215 | }).to.throw(Error, "option 'type' for relationship 'test' must be a string or a function"); 216 | 217 | done(); 218 | }); 219 | 220 | it('incorrect schema on relationship', (done) => { 221 | expect(function () { 222 | validator.validateOptions({ 223 | relationships: { 224 | test: { 225 | type: 'test', 226 | schema: {}, 227 | }, 228 | }, 229 | }); 230 | }).to.throw(Error, "option 'schema' for relationship 'test' must be a string"); 231 | 232 | done(); 233 | }); 234 | 235 | it('incorrect links on relationship', (done) => { 236 | expect(function () { 237 | validator.validateOptions({ 238 | relationships: { 239 | test: { 240 | type: 'test', 241 | links: '', 242 | }, 243 | }, 244 | }); 245 | }).to.throw(Error, "option 'links' for relationship 'test' must be an object or a function"); 246 | 247 | done(); 248 | }); 249 | 250 | it('incorrect alternativeKey on relationship', (done) => { 251 | expect(function () { 252 | validator.validateOptions({ 253 | relationships: { 254 | test: { 255 | type: 'test', 256 | alternativeKey: {}, 257 | }, 258 | }, 259 | }); 260 | }).to.throw(Error, "option 'alternativeKey' for relationship 'test' must be a string"); 261 | 262 | done(); 263 | }); 264 | 265 | it('incorrect meta on relationship', (done) => { 266 | expect(function () { 267 | validator.validateOptions({ 268 | relationships: { 269 | test: { 270 | type: 'test', 271 | meta: '', 272 | }, 273 | }, 274 | }); 275 | }).to.throw(Error, "option 'meta' for relationship 'test' must be an object or a function"); 276 | 277 | done(); 278 | }); 279 | 280 | it('incorrect deserialize on relationship', (done) => { 281 | expect(function () { 282 | validator.validateOptions({ 283 | relationships: { 284 | test: { 285 | type: 'test', 286 | deserialize: 'test', 287 | }, 288 | }, 289 | }); 290 | }).to.throw(Error, "option 'deserialize' for relationship 'test' must be a function"); 291 | 292 | done(); 293 | }); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /test/integration/examples.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const expect = require('chai').expect; 4 | const _ = require('lodash'); 5 | const JSONAPISerializer = require('../../'); 6 | const articlesData = require('../fixture/articles.data'); 7 | const mixedData = require('../fixture/mixed.data.js'); 8 | 9 | describe('Examples', function() { 10 | var Serializer = new JSONAPISerializer(); 11 | Serializer.register('article', { 12 | id: 'id', 13 | blacklist: ['updated'], 14 | links: function(data) { 15 | return { 16 | self: '/articles/' + data.id 17 | } 18 | }, 19 | meta: function(data) { 20 | return { 21 | meta: 'metadata' 22 | } 23 | }, 24 | relationships: { 25 | author: { 26 | type: 'people', 27 | links: function(data) { 28 | return { 29 | self: '/articles/' + data.id + '/relationships/author', 30 | related: '/articles/' + data.id + '/author' 31 | } 32 | }, 33 | meta: (data) => ({ 34 | id: data.author 35 | }) 36 | }, 37 | tags: { 38 | type: 'tag' 39 | }, 40 | photos: { 41 | type: 'photo' 42 | }, 43 | comments: { 44 | type: 'comment', 45 | schema: 'only-body' 46 | }, 47 | translations: { 48 | type: 'translation' 49 | }, 50 | }, 51 | topLevelMeta: function(extraOptions) { 52 | return { 53 | count: extraOptions.count 54 | } 55 | }, 56 | topLevelLinks: { 57 | self: '/articles' 58 | } 59 | }); 60 | Serializer.register('people', { 61 | id: 'id', 62 | links: function(data) { 63 | return { 64 | self: '/peoples/' + data.id 65 | } 66 | } 67 | }); 68 | Serializer.register('tag', { 69 | id: 'id', 70 | }); 71 | Serializer.register('photo', { 72 | id: 'id', 73 | }); 74 | Serializer.register('comment', 'only-body', { 75 | id: '_id', 76 | whitelist: ['body'] 77 | }); 78 | Serializer.register('translation', { 79 | beforeSerialize: (data) => { 80 | const { id: articleId, lang, ...attributes } = data; 81 | const id = `${articleId}-${lang}`; 82 | return { 83 | ...attributes, 84 | id 85 | } 86 | }, 87 | afterDeserialize: (data) => { 88 | const { id, ...attributes } = data; 89 | const [articleId, lang] = id.split('-'); 90 | return { 91 | ...attributes, 92 | id: articleId, 93 | lang, 94 | }; 95 | }, 96 | }); 97 | 98 | it('should serialize articles data', function(done) { 99 | var serializedData = Serializer.serialize('article', articlesData, { 100 | count: 2 101 | }); 102 | expect(serializedData).to.have.property('jsonapi').to.have.property('version').to.eql('1.0'); 103 | expect(serializedData).to.have.property('meta').to.have.property('count').to.eql(2); 104 | expect(serializedData).to.have.property('links').to.have.property('self').to.eql('/articles'); 105 | expect(serializedData).to.have.property('data'); 106 | expect(serializedData.data).to.be.instanceof(Array).to.have.lengthOf(2); 107 | expect(serializedData.data[0]).to.have.property('type').to.eql('article'); 108 | expect(serializedData.data[0]).to.have.property('id').to.be.a('string').to.eql('1'); 109 | expect(serializedData.data[0]).to.have.property('attributes'); 110 | expect(serializedData.data[0].attributes).to.have.property('title'); 111 | expect(serializedData.data[0].attributes).to.have.property('body'); 112 | expect(serializedData.data[0].attributes).to.have.property('created'); 113 | expect(serializedData.data[0].attributes).to.not.have.property('updated'); 114 | expect(serializedData.data[0]).to.have.property('relationships'); 115 | expect(serializedData.data[0].relationships).to.have.property('author'); 116 | expect(serializedData.data[0].relationships.author).to.have.property('data'); 117 | expect(serializedData.data[0].relationships.author.data).to.have.property('type').to.eql('people'); 118 | expect(serializedData.data[0].relationships.author.data).to.have.property('id'); 119 | expect(serializedData.data[0].relationships.author).to.have.property('links'); 120 | expect(serializedData.data[0].relationships.author.links).to.have.property('self').to.eql('/articles/1/relationships/author'); 121 | expect(serializedData.data[0].relationships.author.links).to.have.property('related').to.eql('/articles/1/author'); 122 | expect(serializedData.data[0].relationships.author.meta).to.have.property('id'); 123 | expect(serializedData.data[0].relationships).to.have.property('tags'); 124 | expect(serializedData.data[0].relationships.tags).to.have.property('data'); 125 | expect(serializedData.data[0].relationships.tags.data).to.be.instanceof(Array).to.have.lengthOf(2); 126 | expect(serializedData.data[0].relationships.tags.data[0]).to.have.property('type').to.eql('tag'); 127 | expect(serializedData.data[0].relationships.tags.data[0]).to.have.property('id').to.be.a('string'); 128 | expect(serializedData.data[0].relationships).to.have.property('photos'); 129 | expect(serializedData.data[0].relationships.photos).to.have.property('data'); 130 | expect(serializedData.data[0].relationships.photos.data).to.be.instanceof(Array).to.have.lengthOf(3); 131 | expect(serializedData.data[0].relationships.photos.data[0]).to.have.property('type').to.eql('photo'); 132 | expect(serializedData.data[0].relationships.photos.data[0]).to.have.property('id').to.be.a('string'); 133 | expect(serializedData.data[0].relationships).to.have.property('comments'); 134 | expect(serializedData.data[0].relationships.comments.data).to.be.instanceof(Array).to.have.lengthOf(3); 135 | expect(serializedData.data[0].relationships.comments.data[0]).to.have.property('type').to.eql('comment'); 136 | expect(serializedData.data[0].relationships.comments.data[0]).to.have.property('id').to.be.a('string'); 137 | expect(serializedData.data[0]).to.have.property('links'); 138 | expect(serializedData.data[0].links).to.have.property('self').to.eql('/articles/1'); 139 | expect(serializedData.data[0].meta).to.have.property('meta').to.eql('metadata'); 140 | expect(serializedData).to.have.property('included'); 141 | expect(serializedData.included).to.be.instanceof(Array).to.have.lengthOf(16); 142 | var includedAuhor1 = _.find(serializedData.included, { 143 | 'type': 'people', 144 | 'id': '1' 145 | }); 146 | expect(includedAuhor1).to.have.property('attributes'); 147 | expect(includedAuhor1.attributes).to.have.property('firstName'); 148 | expect(includedAuhor1.attributes).to.have.property('lastName'); 149 | expect(includedAuhor1.attributes).to.have.property('email'); 150 | expect(includedAuhor1.attributes).to.have.property('age'); 151 | expect(includedAuhor1.attributes).to.have.property('gender'); 152 | expect(includedAuhor1).to.have.property('links'); 153 | expect(includedAuhor1.links).to.have.property('self').to.eql('/peoples/1'); 154 | var includedComment1 = _.find(serializedData.included, { 155 | 'type': 'comment', 156 | 'id': '1' 157 | }); 158 | expect(includedComment1).to.have.property('attributes'); 159 | expect(includedComment1.attributes).to.have.property('body'); 160 | expect(includedComment1.attributes).to.not.have.property('created'); 161 | var includedPublishing1 = _.find(serializedData.included, { 162 | 'type': 'translation', 163 | 'id': '1-es' 164 | }); 165 | expect(includedPublishing1).to.have.property('attributes'); 166 | expect(includedPublishing1.attributes).to.have.property('title'); 167 | expect(includedPublishing1.attributes).to.have.property('body'); 168 | expect(includedPublishing1.attributes).to.have.property('created'); 169 | expect(includedPublishing1.attributes).to.not.have.property('id'); 170 | expect(includedPublishing1.attributes).to.not.have.property('lang'); 171 | done(); 172 | }); 173 | 174 | it('should serialize articles data (async)', () => { 175 | var expected = Serializer.serialize('article', articlesData, { 176 | count: 2 177 | }); 178 | return Serializer.serializeAsync('article', articlesData, { count: 2 }) 179 | .then((actual) => { 180 | expect(actual).to.deep.equal(expected); 181 | }) 182 | }); 183 | 184 | it('should serialize with global options on \'JSONAPISerializer\' instance', (done) => { 185 | const SerializerWithGlobalOptions = new JSONAPISerializer({ 186 | convertCase: 'kebab-case' 187 | }); 188 | 189 | SerializerWithGlobalOptions.register('article'); 190 | 191 | const serializedData = SerializerWithGlobalOptions.serialize('article', { id: '1', articleBody: 'JSON API specifications' }); 192 | expect(serializedData.data.attributes).to.have.property('article-body'); 193 | done(); 194 | }); 195 | 196 | it('should serialize mixed data', (done) => { 197 | const dynamicTypeOption = { 198 | type: 'type', 199 | topLevelMeta: function(extraOptions) { 200 | return { 201 | count: extraOptions.count 202 | } 203 | }, 204 | topLevelLinks: { 205 | self: '/mixed' 206 | } 207 | } 208 | var serializedData = Serializer.serialize(dynamicTypeOption, mixedData, { 209 | count: 2 210 | }); 211 | expect(serializedData).to.have.property('jsonapi').to.have.property('version'); 212 | expect(serializedData).to.have.property('meta').to.have.property('count').to.eql(2); 213 | expect(serializedData).to.have.property('links').to.have.property('self').to.eql('/mixed'); 214 | expect(serializedData).to.have.property('data'); 215 | expect(serializedData.data).to.be.instanceof(Array).to.have.lengthOf(2); 216 | expect(serializedData.data[0]).to.have.property('type').to.eql('article'); 217 | expect(serializedData.data[1]).to.have.property('type').to.eql('people'); 218 | done(); 219 | }); 220 | 221 | it('should serialize mixed data (async)', () => { 222 | const dynamicTypeOption = { 223 | type: 'type', 224 | topLevelMeta: function(extraOptions) { 225 | return { 226 | count: extraOptions.count 227 | } 228 | }, 229 | topLevelLinks: { 230 | self: '/mixed' 231 | } 232 | } 233 | var expected = Serializer.serialize(dynamicTypeOption, mixedData, { 234 | count: 2 235 | }); 236 | return Serializer.serializeAsync(dynamicTypeOption, mixedData, { count: 2 }) 237 | .then((actual) => { 238 | expect(actual).to.deep.equal(expected); 239 | }) 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /lib/validator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validate and apply default values to resource's configuration options. 3 | * @function validateOptions 4 | * @private 5 | * @param {object} options Configuration options. 6 | * @returns {object} valid configuration options. 7 | */ 8 | function validateOptions(options) { 9 | options = { 10 | id: 'id', 11 | blacklist: [], 12 | whitelist: [], 13 | links: {}, 14 | relationships: {}, 15 | topLevelLinks: {}, 16 | topLevelMeta: {}, 17 | meta: {}, 18 | blacklistOnDeserialize: [], 19 | whitelistOnDeserialize: [], 20 | jsonapiObject: true, 21 | ...options, 22 | }; 23 | 24 | if (!Array.isArray(options.blacklist)) throw new Error("option 'blacklist' must be an array"); 25 | if (!Array.isArray(options.whitelist)) throw new Error("option 'whitelist' must be an array"); 26 | if (typeof options.links !== 'object' && typeof options.links !== 'function') 27 | throw new Error("option 'links' must be an object or a function"); 28 | if (!Array.isArray(options.blacklistOnDeserialize)) 29 | throw new Error("option 'blacklistOnDeserialize' must be an array"); 30 | if (!Array.isArray(options.whitelistOnDeserialize)) 31 | throw new Error("option 'whitelistOnDeserialize' must be an array"); 32 | if ( 33 | options.topLevelLinks && 34 | typeof options.topLevelLinks !== 'object' && 35 | typeof options.topLevelLinks !== 'function' 36 | ) 37 | throw new Error("option 'topLevelLinks' must be an object or a function"); 38 | if ( 39 | options.topLevelMeta && 40 | typeof options.topLevelMeta !== 'object' && 41 | typeof options.topLevelMeta !== 'function' 42 | ) 43 | throw new Error("option 'topLevelMeta' must be an object or a function"); 44 | if (options.meta && typeof options.meta !== 'object' && typeof options.meta !== 'function') 45 | throw new Error("option 'meta' must be an object or a function"); 46 | if (typeof options.jsonapiObject !== 'boolean') 47 | throw new Error("option 'jsonapiObject' must a boolean"); 48 | if ( 49 | options.convertCase && 50 | !['kebab-case', 'snake_case', 'camelCase'].includes(options.convertCase) 51 | ) 52 | throw new Error("option 'convertCase' must be one of 'kebab-case', 'snake_case', 'camelCase'"); 53 | 54 | if ( 55 | options.unconvertCase && 56 | !['kebab-case', 'snake_case', 'camelCase'].includes(options.unconvertCase) 57 | ) 58 | throw new Error( 59 | "option 'unconvertCase' must be one of 'kebab-case', 'snake_case', 'camelCase'", 60 | ); 61 | 62 | if (options.beforeSerialize && typeof options.beforeSerialize !== 'function') 63 | throw new Error("option 'beforeSerialize' must be function"); 64 | 65 | if (options.afterDeserialize && typeof options.afterDeserialize !== 'function') 66 | throw new Error("option 'afterDeserialize' must be function"); 67 | 68 | const { relationships } = options; 69 | Object.keys(relationships).forEach((key) => { 70 | relationships[key] = { 71 | schema: 'default', 72 | links: {}, 73 | meta: {}, 74 | ...relationships[key], 75 | }; 76 | 77 | if (!relationships[key].type) 78 | throw new Error(`option 'type' for relationship '${key}' is required`); 79 | if ( 80 | typeof relationships[key].type !== 'string' && 81 | typeof relationships[key].type !== 'function' 82 | ) 83 | throw new Error(`option 'type' for relationship '${key}' must be a string or a function`); 84 | if (relationships[key].alternativeKey && typeof relationships[key].alternativeKey !== 'string') 85 | throw new Error(`option 'alternativeKey' for relationship '${key}' must be a string`); 86 | 87 | if (relationships[key].schema && typeof relationships[key].schema !== 'string') 88 | throw new Error(`option 'schema' for relationship '${key}' must be a string`); 89 | 90 | if ( 91 | typeof relationships[key].links !== 'object' && 92 | typeof relationships[key].links !== 'function' 93 | ) 94 | throw new Error(`option 'links' for relationship '${key}' must be an object or a function`); 95 | 96 | if ( 97 | typeof relationships[key].meta !== 'object' && 98 | typeof relationships[key].meta !== 'function' 99 | ) 100 | throw new Error(`option 'meta' for relationship '${key}' must be an object or a function`); 101 | 102 | if (relationships[key].deserialize && typeof relationships[key].deserialize !== 'function') 103 | throw new Error(`option 'deserialize' for relationship '${key}' must be a function`); 104 | }); 105 | 106 | return options; 107 | } 108 | 109 | /** 110 | * Validate and apply default values to the dynamic type object option. 111 | * @function validateDynamicTypeOptions 112 | * @private 113 | * @param {object} options dynamic type object option. 114 | * @returns {object} valid dynamic type options. 115 | */ 116 | function validateDynamicTypeOptions(options) { 117 | options = { topLevelLinks: {}, topLevelMeta: {}, jsonapiObject: true, ...options }; 118 | 119 | if (!options.type) throw new Error("option 'type' is required"); 120 | if (typeof options.type !== 'string' && typeof options.type !== 'function') { 121 | throw new Error("option 'type' must be a string or a function"); 122 | } 123 | 124 | if ( 125 | options.topLevelLinks && 126 | typeof options.topLevelLinks !== 'object' && 127 | typeof options.topLevelLinks !== 'function' 128 | ) 129 | throw new Error("option 'topLevelLinks' must be an object or a function"); 130 | if ( 131 | options.topLevelMeta && 132 | typeof options.topLevelMeta !== 'object' && 133 | typeof options.topLevelMeta !== 'function' 134 | ) 135 | throw new Error("option 'topLevelMeta' must be an object or a function"); 136 | if (options.meta && typeof options.meta !== 'object' && typeof options.meta !== 'function') 137 | throw new Error("option 'meta' must be an object or a function"); 138 | if (typeof options.jsonapiObject !== 'boolean') 139 | throw new Error("option 'jsonapiObject' must a boolean"); 140 | 141 | return options; 142 | } 143 | 144 | /** 145 | * Validate a JSON:API error object 146 | * @function validateError 147 | * @private 148 | * @throws Will throw an error if the argument is not an object 149 | * @param {object} err a JSONAPI error object 150 | * @returns {object} JSONAPI valid error object 151 | */ 152 | function validateError(err) { 153 | if (typeof err !== 'object') { 154 | throw new Error('error must be an object'); 155 | } 156 | 157 | const { id, links, status, statusCode, code, title, detail, source, meta } = err; 158 | 159 | /** 160 | * Validates the `links` property, ensuring that it is an object and, 161 | * if present, contains valid members. From the JSON:API spec: 162 | * 163 | * links: a links object containing the following members: 164 | * about: a link that leads to further details about this particular 165 | * occurrence of the problem. 166 | * @function isValidLink 167 | * @private 168 | * @see https://jsonapi.org/format/#error-objects 169 | * @throws Will throw an error if the argument is not an object 170 | * @throws Will throw an error if the argument contains unpermitted members 171 | * @throws Will throw an error if `links.about` is present but not a string 172 | * @param {object} linksObj links object 173 | * @returns {object} validated links 174 | */ 175 | const isValidLink = function isValidLink(linksObj) { 176 | const permittedMembers = ['about']; 177 | 178 | if (typeof linksObj !== 'object') { 179 | throw new Error("error 'link' property must be an object"); 180 | } 181 | 182 | Object.keys(linksObj).forEach((key) => { 183 | if (!permittedMembers.includes(key)) { 184 | throw new Error(`error 'links.${key}' is not permitted`); 185 | } 186 | }); 187 | 188 | if (linksObj.about && typeof linksObj.about !== 'string') { 189 | throw new Error("'links.about' property must be a string"); 190 | } 191 | 192 | return links; 193 | }; 194 | 195 | /** 196 | * Validates the `source` property, ensuring that it is an object and, 197 | * if present, contains valid members. From the JSON:API spec: 198 | * 199 | * source: an object containing references to the source of the error, 200 | * optionally including any of the following members: 201 | * pointer: a JSON Pointer [RFC6901] to the associated entity in the 202 | * request document [e.g. "/data" for a primary data object, 203 | * or "/data/attributes/title" for a specific attribute]. 204 | * parameter: a string indicating which URI query parameter caused the 205 | * error. 206 | * @function isValidSource 207 | * @private 208 | * @see https://jsonapi.org/format/#error-objects 209 | * @param {object} sourceObj source object 210 | * @throws Will throw an error if the argument is not an object 211 | * @throws Will throw an error if the argument contains unpermitted members 212 | * @throws Will throw an error if `sourceObj.pointer` is present but not a string 213 | * @throws Will throw an error if `sourceObj.parameter` is present but not a string 214 | * @returns {object} validated source 215 | */ 216 | const isValidSource = function isValidSource(sourceObj) { 217 | const permittedMembers = ['pointer', 'parameter']; 218 | 219 | if (typeof sourceObj !== 'object') { 220 | throw new Error("error 'source' property must be an object"); 221 | } 222 | 223 | Object.keys(sourceObj).forEach((key) => { 224 | if (!permittedMembers.includes(key)) { 225 | throw new Error(`error 'source.${key}' is not permitted`); 226 | } 227 | }); 228 | 229 | if (sourceObj.pointer && typeof sourceObj.pointer !== 'string') { 230 | throw new Error("error 'source.pointer' property must be a string"); 231 | } 232 | 233 | if (sourceObj.parameter && typeof sourceObj.parameter !== 'string') { 234 | throw new Error("error 'source.parameter' property must be a string"); 235 | } 236 | 237 | return source; 238 | }; 239 | 240 | /** 241 | * Validates the `meta` property, ensuring that it is an object. From 242 | * the JSON:API spec: 243 | * 244 | * meta: a meta object containing non-standard meta-information about 245 | * the error. 246 | * @function isValidMeta 247 | * @private 248 | * @see https://jsonapi.org/format/#error-objects 249 | * @param {object} metaObj meta object 250 | * @throws Will throw an error if the argument is not an object 251 | * @returns {object} validated meta 252 | */ 253 | const isValidMeta = function isValidMeta(metaObj) { 254 | if (typeof metaObj !== 'object') { 255 | throw new Error("error 'meta' property must be an object"); 256 | } 257 | 258 | return meta; 259 | }; 260 | 261 | /** 262 | * Determines if the provided number is a valid HTTP status code. 263 | * @function isValidHttpStatusCode 264 | * @private 265 | * @see https://jsonapi.org/format/#error-objects 266 | * @param {number} theStatusCode The status code to validate 267 | * @returns {boolean} is valid 268 | */ 269 | const isValidHttpStatusCode = function isValidHttpStatusCode(theStatusCode) { 270 | const validHttpStatusCodes = [ 271 | // 1XX: Informational 272 | 100,101, 102, // eslint-disable-line 273 | 274 | // 2XX: Success 275 | 200, 201, 202, 203, 204, 205, 206, 207, 208, 226, // eslint-disable-line 276 | 277 | // 3XX: Redirection 278 | 300, 301, 302, 303, 304, 305, 307, 308, // eslint-disable-line 279 | 280 | // 4XX: Client Error 281 | 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, // eslint-disable-line 282 | 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 426, 428, 429, // eslint-disable-line 283 | 431, 444, 451, 499, // eslint-disable-line 284 | 285 | // 5XX: Server Error 286 | 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511, 599 // eslint-disable-line 287 | ]; 288 | 289 | return validHttpStatusCodes.includes(theStatusCode); 290 | }; 291 | 292 | /** 293 | * Validates a status code, ensuring that it is both a number and a valid 294 | * HTTP status code. From the JSON:API spec: 295 | * 296 | * status: the HTTP status code applicable to this problem, expressed 297 | * as a string value. 298 | * @function isValidStatus 299 | * @private 300 | * @see https://jsonapi.org/format/#error-objects 301 | * @param {string|number} theStatusCode status code 302 | * @throws Will throw an error if the argument is not number-like 303 | * @throws Will throw an error if the argument is not a valid HTTP status code 304 | * @returns {string} validated status 305 | */ 306 | const isValidStatus = function isValidStatus(theStatusCode) { 307 | const statusAsNumber = Number(theStatusCode); 308 | 309 | if (Number.isNaN(statusAsNumber)) { 310 | throw new Error("error 'status' must be a number"); 311 | } 312 | 313 | if (!isValidHttpStatusCode(statusAsNumber)) { 314 | throw new Error("error 'status' must be a valid HTTP status code"); 315 | } 316 | 317 | return statusAsNumber.toString(); 318 | }; 319 | 320 | const error = {}; 321 | if (id) error.id = id; 322 | if (links && Object.keys(links).length) error.links = isValidLink(links); 323 | if (status || statusCode) error.status = isValidStatus(status || statusCode); 324 | if (code) error.code = code.toString(); 325 | if (title) { 326 | error.title = title.toString(); 327 | } else if (err.constructor.name !== 'Object') { 328 | error.title = err.constructor.name; 329 | } 330 | error.detail = detail ? detail.toString() : err.message; 331 | if (source && Object.keys(source).length) error.source = isValidSource(source); 332 | if (meta && Object.keys(meta).length) error.meta = isValidMeta(meta); 333 | 334 | return error; 335 | } 336 | 337 | module.exports = { 338 | validateOptions, 339 | validateDynamicTypeOptions, 340 | validateError, 341 | }; 342 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-api-serializer 2 | 3 | [![build](https://github.com/danivek/json-api-serializer/workflows/build/badge.svg)](https://github.com/danivek/json-api-serializer/actions?query=workflow%3Abuild) 4 | [![Coverage Status](https://coveralls.io/repos/github/danivek/json-api-serializer/badge.svg?branch=master)](https://coveralls.io/github/danivek/json-api-serializer?branch=master) 5 | [![npm](https://img.shields.io/npm/v/json-api-serializer.svg)](https://www.npmjs.org/package/json-api-serializer) 6 | 7 | A Node.js/browser framework agnostic library for serializing your data to [JSON API](http://jsonapi.org/) compliant responses (a specification for building APIs in JSON). 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install --save json-api-serializer 13 | ``` 14 | 15 | ## Documentation 16 | 17 | ### Register 18 | 19 | ```javascript 20 | var JSONAPISerializer = require("json-api-serializer"); 21 | var Serializer = new JSONAPISerializer(); 22 | Serializer.register(type, options); 23 | ``` 24 | 25 | **Serialization options:** 26 | 27 | * **id** (optional): The key to use as the reference. Default = 'id'. 28 | * **blacklist** (optional): An array of blacklisted attributes. Default = []. 29 | * **whitelist** (optional): An array of whitelisted attributes. Default = []. 30 | * **jsonapiObject** (optional): Enable/Disable [JSON API Object](http://jsonapi.org/format/#document-jsonapi-object). Default = true. 31 | * **links** (optional): Describes the links inside data. It can be: 32 | * An _object_ (values can be string or function). 33 | * A _function_ with one argument `function(data) { ... }` or with two arguments `function(data, extraData) { ... }` 34 | * **topLevelMeta** (optional): Describes the top-level meta. It can be: 35 | * An _object_ (values can be string or function). 36 | * A _function_ with one argument `function(extraData) { ... }` or with two arguments `function(data, extraData) { ... }` 37 | * **topLevelLinks** (optional): Describes the top-level links. It can be: 38 | * An _object_ (values can be string or function). 39 | * A _function_ with one argument `function(extraData) { ... }` or with two arguments `function(data, extraData) { ... }` 40 | * **meta** (optional): Describes resource-level meta. It can be: 41 | * An _object_ (values can be string or function). 42 | * A _function_ with one argument `function(data) { ... }` or with two arguments `function(data, extraData) { ... }` 43 | * **relationships** (optional): An object defining some relationships 44 | * relationship: The property in data to use as a relationship 45 | * **type**: A _string_ or a _function_ `function(relationshipData, data) { ... }` for the type to use for serializing the relationship (type need to be register). 46 | * **alternativeKey** (optional): An alternative key (string or path) to use if relationship key not exist (example: 'author_id' as an alternative key for 'author' relationship). See [issue #12](https://github.com/danivek/json-api-serializer/issues/12). 47 | * **schema** (optional): A custom schema for serializing the relationship. If no schema define, it use the default one. 48 | * **links** (optional): Describes the links for the relationship. It can be: 49 | * An _object_ (values can be string or function). 50 | * A _function_ with one argument `function(data) { ... }` or with two arguments `function(data, extraData) { ... }` 51 | * **meta** (optional): Describes meta that contains non-standard meta-information about the relationship. It can be: 52 | * An _object_ (values can be string or function). 53 | * A _function_ with one argument `function(data) { ... }` or with two arguments `function(data, extraData) { ... }` 54 | * **deserialize** (optional): Describes the function which should be used to deserialize a related property which is not included in the JSON:API document. It should be: 55 | * A _function_ with one argument `function(data) { ... }`which defines the format to which a relation should be deserialized. By default, the ID of the related object is returned, which would be equal to `function(data) {return data.id}`. See [issue #65](https://github.com/danivek/json-api-serializer/issues/65). 56 | * **convertCase** (optional): Case conversion for serializing data. Value can be : `kebab-case`, `snake_case`, `camelCase` 57 | * **beforeSerialize** (optional): A _function_ with one argument `beforeSerialize(data) => newData` to transform data before serialization. 58 | 59 | **Deserialization options:** 60 | 61 | * **unconvertCase** (optional): Case conversion for deserializing data. Value can be : `kebab-case`, `snake_case`, `camelCase` 62 | * **blacklistOnDeserialize** (optional): An array of blacklisted attributes. Default = []. 63 | * **whitelistOnDeserialize** (optional): An array of whitelisted attributes. Default = []. 64 | * **afterDeserialize** (optional): A _function_ with one argument `afterDeserialize(data) => newData` to transform data after deserialization. 65 | 66 | **Global options:** 67 | 68 | To avoid repeating the same options for each type, it's possible to add global options on `JSONAPISerializer` instance: 69 | 70 | When using convertCase, a LRU cache is utilized for optimization. The default size of the cache is 5000 per conversion type. The size of the cache can be set with the `convertCaseCacheSize` option. Passing in 0 will result in a LRU cache of infinite size. 71 | 72 | ```javascript 73 | var JSONAPISerializer = require("json-api-serializer"); 74 | var Serializer = new JSONAPISerializer({ 75 | convertCase: "kebab-case", 76 | unconvertCase: "camelCase", 77 | convertCaseCacheSize: 0 78 | }); 79 | ``` 80 | 81 | ## Usage 82 | 83 | input data (can be an object or an array of objects) 84 | 85 | ```javascript 86 | // Data 87 | var data = [ 88 | { 89 | id: "1", 90 | title: "JSON API paints my bikeshed!", 91 | body: "The shortest article. Ever.", 92 | created: "2015-05-22T14:56:29.000Z", 93 | updated: "2015-05-22T14:56:28.000Z", 94 | author: { 95 | id: "1", 96 | firstName: "Kaley", 97 | lastName: "Maggio", 98 | email: "Kaley-Maggio@example.com", 99 | age: "80", 100 | gender: "male" 101 | }, 102 | tags: ["1", "2"], 103 | photos: [ 104 | "ed70cf44-9a34-4878-84e6-0c0e4a450cfe", 105 | "24ba3666-a593-498c-9f5d-55a4ee08c72e", 106 | "f386492d-df61-4573-b4e3-54f6f5d08acf" 107 | ], 108 | comments: [ 109 | { 110 | _id: "1", 111 | body: "First !", 112 | created: "2015-08-14T18:42:16.475Z" 113 | }, 114 | { 115 | _id: "2", 116 | body: "I Like !", 117 | created: "2015-09-14T18:42:12.475Z" 118 | }, 119 | { 120 | _id: "3", 121 | body: "Awesome", 122 | created: "2015-09-15T18:42:12.475Z" 123 | } 124 | ] 125 | } 126 | ]; 127 | ``` 128 | 129 | ### Register 130 | 131 | Register your resources types : 132 | 133 | ```javascript 134 | var JSONAPISerializer = require("json-api-serializer"); 135 | var Serializer = new JSONAPISerializer(); 136 | 137 | // Register 'article' type 138 | Serializer.register("article", { 139 | id: "id", // The attributes to use as the reference. Default = 'id'. 140 | blacklist: ["updated"], // An array of blacklisted attributes. Default = [] 141 | links: { 142 | // An object or a function that describes links. 143 | self: function(data) { 144 | // Can be a function or a string value ex: { self: '/articles/1'} 145 | return "/articles/" + data.id; 146 | } 147 | }, 148 | relationships: { 149 | // An object defining some relationships. 150 | author: { 151 | type: "people", // The type of the resource 152 | links: function(data) { 153 | // An object or a function that describes Relationships links 154 | return { 155 | self: "/articles/" + data.id + "/relationships/author", 156 | related: "/articles/" + data.id + "/author" 157 | }; 158 | } 159 | }, 160 | tags: { 161 | type: "tag" 162 | }, 163 | photos: { 164 | type: "photo" 165 | }, 166 | comments: { 167 | type: "comment", 168 | schema: "only-body" // A custom schema 169 | } 170 | }, 171 | topLevelMeta: function(data, extraData) { 172 | // An object or a function that describes top level meta. 173 | return { 174 | count: extraData.count, 175 | total: data.length 176 | }; 177 | }, 178 | topLevelLinks: { 179 | // An object or a function that describes top level links. 180 | self: "/articles" // Can be a function (with extra data argument) or a string value 181 | } 182 | }); 183 | 184 | // Register 'people' type 185 | Serializer.register("people", { 186 | id: "id", 187 | links: { 188 | self: function(data) { 189 | return "/peoples/" + data.id; 190 | } 191 | } 192 | }); 193 | 194 | // Register 'tag' type 195 | Serializer.register("tag", { 196 | id: "id" 197 | }); 198 | 199 | // Register 'photo' type 200 | Serializer.register("photo", { 201 | id: "id" 202 | }); 203 | 204 | // Register 'comment' type with a custom schema 205 | Serializer.register("comment", "only-body", { 206 | id: "_id" 207 | }); 208 | ``` 209 | 210 | ### Serialize 211 | 212 | Serialize it with the corresponding resource type, data and optional extra data : 213 | 214 | ```javascript 215 | // Synchronously (blocking) 216 | const result = Serializer.serialize('article', data, {count: 2}); 217 | 218 | // Asynchronously (non-blocking) 219 | Serializer.serializeAsync('article', data, {count: 2}) 220 | .then((result) => { 221 | ... 222 | }); 223 | ``` 224 | 225 | The output data will be : 226 | 227 | ```JSON 228 | { 229 | "jsonapi": { 230 | "version": "1.0" 231 | }, 232 | "meta": { 233 | "count": 2, 234 | "total": 1 235 | }, 236 | "links": { 237 | "self": "/articles" 238 | }, 239 | "data": [{ 240 | "type": "article", 241 | "id": "1", 242 | "attributes": { 243 | "title": "JSON API paints my bikeshed!", 244 | "body": "The shortest article. Ever.", 245 | "created": "2015-05-22T14:56:29.000Z" 246 | }, 247 | "relationships": { 248 | "author": { 249 | "data": { 250 | "type": "people", 251 | "id": "1" 252 | }, 253 | "links": { 254 | "self": "/articles/1/relationships/author", 255 | "related": "/articles/1/author" 256 | } 257 | }, 258 | "tags": { 259 | "data": [{ 260 | "type": "tag", 261 | "id": "1" 262 | }, { 263 | "type": "tag", 264 | "id": "2" 265 | }] 266 | }, 267 | "photos": { 268 | "data": [{ 269 | "type": "photo", 270 | "id": "ed70cf44-9a34-4878-84e6-0c0e4a450cfe" 271 | }, { 272 | "type": "photo", 273 | "id": "24ba3666-a593-498c-9f5d-55a4ee08c72e" 274 | }, { 275 | "type": "photo", 276 | "id": "f386492d-df61-4573-b4e3-54f6f5d08acf" 277 | }] 278 | }, 279 | "comments": { 280 | "data": [{ 281 | "type": "comment", 282 | "id": "1" 283 | }, { 284 | "type": "comment", 285 | "id": "2" 286 | }, { 287 | "type": "comment", 288 | "id": "3" 289 | }] 290 | } 291 | }, 292 | "links": { 293 | "self": "/articles/1" 294 | } 295 | }], 296 | "included": [{ 297 | "type": "people", 298 | "id": "1", 299 | "attributes": { 300 | "firstName": "Kaley", 301 | "lastName": "Maggio", 302 | "email": "Kaley-Maggio@example.com", 303 | "age": "80", 304 | "gender": "male" 305 | }, 306 | "links": { 307 | "self": "/peoples/1" 308 | } 309 | }, { 310 | "type": "comment", 311 | "id": "1", 312 | "attributes": { 313 | "body": "First !" 314 | } 315 | }, { 316 | "type": "comment", 317 | "id": "2", 318 | "attributes": { 319 | "body": "I Like !" 320 | } 321 | }, { 322 | "type": "comment", 323 | "id": "3", 324 | "attributes": { 325 | "body": "Awesome" 326 | } 327 | }] 328 | } 329 | ``` 330 | 331 | There is an available argument `excludeData` that will exclude the `data` 332 | property from the serialized object. This can be used in cases where you may 333 | want to only include the `topLevelMeta` in your response, such as a `DELETE` 334 | response with only a `meta` property, or other cases defined in the 335 | JSON:API spec. 336 | 337 | ```javascript 338 | // Synchronously (blocking) 339 | const result = Serializer.serialize('article', data, 'default', {count: 2}, true); 340 | 341 | // Asynchronously (non-blocking) 342 | Serializer.serializeAsync('article', data, 'default', {count: 2}, true) 343 | .then((result) => { 344 | ... 345 | }); 346 | ``` 347 | 348 | #### Override schema options 349 | 350 | On each individual call to `serialize` or `serializeAsync`, there is an parameter to override the options of any registered type. For example on a call to serialize, if a whitelist was not defined on the registered schema options, a whitelist (or any other options) for that type can be provided. This parameter is an object, where the key are the registered type names, and the values are the objects to override the registered schema. 351 | 352 | In the following example, only the attribute `name` will be serialized on the article, and if there is a relationship for `person`, it will be serialized with `camelCase` even if the registered schema has a different value. 353 | ``` 354 | const result = Serializer.serialize('article', data, 'default', {count: 2}, true), { 355 | article: { 356 | whitelist: ['name'] 357 | }, 358 | person: { 359 | convertCase: 'camelCase' 360 | } 361 | }; 362 | ``` 363 | 364 | 365 | Some others examples are available in [tests folders](https://github.com/danivek/json-api-serializer/blob/master/test/) 366 | 367 | ### Deserialize 368 | 369 | input data (can be an simple object or an array of objects) 370 | 371 | ```javascript 372 | var data = { 373 | data: { 374 | type: 'article', 375 | id: '1', 376 | attributes: { 377 | title: 'JSON API paints my bikeshed!', 378 | body: 'The shortest article. Ever.', 379 | created: '2015-05-22T14:56:29.000Z' 380 | }, 381 | relationships: { 382 | author: { 383 | data: { 384 | type: 'people', 385 | id: '1' 386 | } 387 | }, 388 | comments: { 389 | data: [{ 390 | type: 'comment', 391 | id: '1' 392 | }, { 393 | type: 'comment', 394 | id: '2' 395 | }] 396 | } 397 | } 398 | } 399 | }; 400 | 401 | // Synchronously (blocking) 402 | Serializer.deserialize('article', data); 403 | 404 | // Asynchronously (non-blocking) 405 | Serializer.deserializeAsync('article', data) 406 | .then((result) => { 407 | // ... 408 | }); 409 | ``` 410 | 411 | ```JSON 412 | { 413 | "id": "1", 414 | "title": "JSON API paints my bikeshed!", 415 | "body": "The shortest article. Ever.", 416 | "created": "2015-05-22T14:56:29.000Z", 417 | "author": "1", 418 | "comments": [ 419 | "1", 420 | "2" 421 | ] 422 | } 423 | ``` 424 | 425 | ### serializeError 426 | 427 | Serializes any error into a JSON API error document. 428 | 429 | Input data can be: 430 | - An instance of `Error` or an array of `Error` instances. 431 | - A [JSON API error object](http://jsonapi.org/format/#error-objects) or an array of [JSON API error objects](http://jsonapi.org/format/#error-objects). 432 | 433 | Using an instance of `Error`: 434 | 435 | ```javascript 436 | const error = new Error('An error occurred'); 437 | error.id = 123 438 | error.links = { about: 'https://example.com/errors/123' } 439 | error.status = 500; // or `statusCode` 440 | error.code = 'xyz' 441 | error.meta = { time: Date.now() } 442 | 443 | Serializer.serializeError(error); 444 | ``` 445 | 446 | The result will be: 447 | 448 | ```JSON 449 | { 450 | "errors": [ 451 | { 452 | "id": 123, 453 | "links": { 454 | "about": "https://example.com/errors/123" 455 | }, 456 | "status": "500", 457 | "code": "xyz", 458 | "title": "Error", 459 | "detail": "An error occurred", 460 | "meta": { 461 | "time": 1593561258853 462 | } 463 | } 464 | ] 465 | } 466 | ``` 467 | 468 | Using an instance of a class that inherits from `Error`: 469 | 470 | ```js 471 | class MyCustomError extends Error { 472 | constructor(message = 'Something went wrong') { 473 | super(message) 474 | this.id = 123 475 | this.links = { 476 | about: 'https://example.com/errors/123' 477 | } 478 | this.status = 500 // or `statusCode` 479 | this.code = 'xyz' 480 | this.meta = { 481 | time: Date.now() 482 | } 483 | } 484 | } 485 | 486 | Serializer.serializeError(new MyCustomError()); 487 | ``` 488 | 489 | The result will be: 490 | 491 | ```JSON 492 | { 493 | "errors": [ 494 | { 495 | "id": 123, 496 | "links": { 497 | "about": "https://example.com/errors/123" 498 | }, 499 | "status": "500", 500 | "code": "xyz", 501 | "title": "MyCustomError", 502 | "detail": "Something went wrong", 503 | "meta": { 504 | "time": 1593561258853 505 | } 506 | } 507 | ] 508 | } 509 | ``` 510 | 511 | Using a POJO: 512 | 513 | ```js 514 | Serializer.serializeError({ 515 | id: 123, 516 | links: { 517 | about: 'https://example.com/errors/123' 518 | }, 519 | status: 500, // or `statusCode` 520 | code: 'xyz', 521 | title: 'UserNotFound', 522 | detail: 'Unable to find a user with the provided ID', 523 | meta: { 524 | time: Date.now() 525 | } 526 | }); 527 | ``` 528 | 529 | The result will be: 530 | 531 | ```JSON 532 | { 533 | "errors": [ 534 | { 535 | "id": 123, 536 | "links": { 537 | "about": "https://example.com/errors/123" 538 | }, 539 | "status": "500", 540 | "code": "xyz", 541 | "title": "UserNotFound", 542 | "detail": "Unable to find a user with the provided ID", 543 | "meta": { 544 | "time": 1593561258853 545 | } 546 | } 547 | ] 548 | } 549 | ``` 550 | 551 | ## Custom schemas 552 | 553 | It is possible to define multiple custom schemas for a resource type : 554 | 555 | ```javascript 556 | Serializer.register(type, "customSchema", options); 557 | ``` 558 | 559 | Then you can apply this schema on the primary data when serialize or deserialize : 560 | 561 | ```javascript 562 | Serializer.serialize("article", data, "customSchema", { count: 2 }); 563 | Serializer.serializeAsync("article", data, "customSchema", { count: 2 }); 564 | Serializer.deserialize("article", jsonapiData, "customSchema"); 565 | Serializer.deserializeAsync("article", jsonapiData, "customSchema"); 566 | ``` 567 | 568 | Or if you want to apply this schema on a relationship data, define this schema on relationships options with the key `schema` : 569 | 570 | Example : 571 | 572 | ```javascript 573 | relationships: { 574 | comments: { 575 | type: "comment"; 576 | schema: "customSchema"; 577 | } 578 | } 579 | ``` 580 | 581 | ## Mixed data (dynamic type) 582 | 583 | ### Serialize 584 | 585 | If your data contains one or multiple objects of different types, it's possible to define a configuration object instead of the type-string as the first argument of `serialize` and `serializeAsync` with these options: 586 | 587 | * **type** (required): A _string_ for the path to the key to use to determine type or a _function_ deriving a type-string from each data-item. 588 | * **jsonapiObject** (optional): Enable/Disable [JSON API Object](http://jsonapi.org/format/#document-jsonapi-object). Default = true. 589 | * **topLevelMeta** (optional): Describes the top-level meta. It can be: 590 | * An _object_ (values can be string or function). 591 | * A _function_ with one argument `function(extraData) { ... }` or with two arguments `function(data, extraData) { ... }` 592 | * **topLevelLinks** (optional): Describes the top-level links. It can be: 593 | * An _object_ (values can be string or function). 594 | * A _function_ with one argument `function(extraData) { ... }` or with two arguments `function(data, extraData) { ... }` 595 | 596 | Example : 597 | 598 | ```javascript 599 | const typeConfig = { 600 | // Same as type: 'type' 601 | type: data => data.type // Can be very complex to determine different types of items. 602 | }; 603 | 604 | Serializer.serializeAsync(typeConfig, data, { count: 2 }).then(result => { 605 | // ... 606 | }); 607 | ``` 608 | 609 | ### Deserialize 610 | 611 | If your data contains one or multiple objects of different types, it's possible to define a configuration object instead of the type-string as the first argument of `deserialize` with these options: 612 | 613 | * **type** (required): A _string_ for the path to the key to use to determine type or a _function_ deriving a type-string from each data-item. 614 | 615 | Example : 616 | 617 | ```javascript 618 | const typeConfig = { 619 | // Same as type: 'type' 620 | type: data => data.type // Can be very complex to determine different types of items. 621 | }; 622 | 623 | const deserialized = Serializer.deserializeAsync(typeConfig, data).then(result => { 624 | // ... 625 | }); 626 | ``` 627 | 628 | ## Custom serialization and deserialization 629 | 630 | If your data requires some specific transformations, those can be applied using `beforeSerialize` and `afterDeserialize` 631 | 632 | Example for composite primary keys: 633 | 634 | ```javascript 635 | Serializer.register('translation', { 636 | beforeSerialize: (data) => { 637 | // Exclude pk1 and pk2 from data 638 | const { pk1, pk2, ...attributes } = data; 639 | 640 | // Compute external id 641 | const id = `${pk1}-${pk2}`; 642 | 643 | // Return data with id 644 | return { 645 | ...attributes, 646 | id 647 | }; 648 | }, 649 | afterDeserialize: (data) => { 650 | // Exclude id from data 651 | const { id, ...attributes } = data; 652 | 653 | // Recover PKs 654 | const [pk1, pk2] = id.split('-'); 655 | 656 | // Return data with PKs 657 | return { 658 | ...attributes, 659 | pk1, 660 | pk2, 661 | }; 662 | }, 663 | }); 664 | ``` 665 | 666 | ## Benchmark 667 | 668 | ```bash 669 | Platform info: 670 | ============== 671 | Darwin 21.6.0 x64 672 | Node.JS: 20.9.0 673 | V8: 11.3.244.8-node.16 674 | Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz × 8 675 | 676 | Suite: 677 | ============== 678 | serializeAsync x 17,846 ops/sec ±1.38% (79 runs sampled) 679 | serialize x 147,769 ops/sec ±0.54% (93 runs sampled) 680 | serializeConvertCase x 111,373 ops/sec ±0.72% (96 runs sampled) 681 | deserializeAsync x 20,925 ops/sec ±1.39% (78 runs sampled) 682 | deserialize x 271,116 ops/sec ±0.32% (95 runs sampled) 683 | deserializeConvertCase x 109,091 ops/sec ±0.32% (96 runs sampled) 684 | serializeError x 105,983 ops/sec ±0.71% (93 runs sampled) 685 | serializeError with a JSON API error object x 7,431,126 ops/sec ±0.47% (92 runs sampled) 686 | ``` 687 | 688 | ## License 689 | 690 | [MIT](https://github.com/danivek/json-api-serializer/blob/master/LICENSE) 691 | -------------------------------------------------------------------------------- /lib/JSONAPISerializer.js: -------------------------------------------------------------------------------- 1 | require('setimmediate'); 2 | 3 | const { 4 | pick, 5 | isEmpty, 6 | omit, 7 | isPlainObject, 8 | isObjectLike, 9 | transform, 10 | get, 11 | set, 12 | toCamelCase, 13 | toKebabCase, 14 | toSnakeCase, 15 | LRU, 16 | } = require('./helpers'); 17 | 18 | const { validateOptions, validateDynamicTypeOptions, validateError } = require('./validator'); 19 | 20 | /** 21 | * JSONAPISerializer class. 22 | * @example 23 | * const JSONAPISerializer = require('json-api-serializer'); 24 | * 25 | * // Create an instance of JSONAPISerializer with default settings 26 | * const serializer = new JSONAPISerializer(); 27 | * @class JSONAPISerializer 28 | * @param {Options} [opts] Global options. 29 | */ 30 | module.exports = class JSONAPISerializer { 31 | constructor(opts) { 32 | this.opts = opts || {}; 33 | this.schemas = {}; 34 | 35 | // Size of cache used for convertCase, 0 results in an infinitely sized cache 36 | const { convertCaseCacheSize = 5000 } = this.opts; 37 | // Cache of strings to convert to their converted values per conversion type 38 | this.convertCaseMap = { 39 | camelCase: new LRU(convertCaseCacheSize), 40 | kebabCase: new LRU(convertCaseCacheSize), 41 | snakeCase: new LRU(convertCaseCacheSize), 42 | }; 43 | } 44 | 45 | /** 46 | * Register a resource with its type, schema name, and configuration options. 47 | * @function JSONAPISerializer#register 48 | * @param {string} type resource's type. 49 | * @param {string|Options} [schema] schema name. 50 | * @param {Options} [options] options. 51 | */ 52 | register(type, schema, options) { 53 | if (typeof schema === 'object') { 54 | options = schema; 55 | schema = 'default'; 56 | } 57 | 58 | schema = schema || 'default'; 59 | options = { ...this.opts, ...options }; 60 | 61 | this.schemas[type] = this.schemas[type] || {}; 62 | this.schemas[type][schema] = validateOptions(options); 63 | } 64 | 65 | /** 66 | * Serialze input data to a JSON API compliant response. 67 | * Input data can be a simple object or an array of objects. 68 | * @see {@link http://jsonapi.org/format/#document-top-level} 69 | * @function JSONAPISerializer#serialize 70 | * @param {string|DynamicTypeOptions} type resource's type as string or a dynamic type options as object. 71 | * @param {object|object[]} data input data. 72 | * @param {string|object} [schema] resource's schema name. 73 | * @param {object} [extraData] additional data that can be used in topLevelMeta options. 74 | * @param {boolean} [excludeData] boolean that can be set to exclude the `data` property in serialized data. 75 | * @param {object} [overrideSchemaOptions] additional schema options, a map of types with options to override. 76 | * @returns {object|object[]} serialized data. 77 | */ 78 | serialize(type, data, schema, extraData, excludeData, overrideSchemaOptions = {}) { 79 | // Support optional arguments (schema) 80 | if (arguments.length === 3) { 81 | if (typeof schema === 'object') { 82 | extraData = schema; 83 | schema = 'default'; 84 | } 85 | } 86 | 87 | schema = schema || 'default'; 88 | extraData = extraData || {}; 89 | 90 | const included = new Map(); 91 | const isDynamicType = typeof type === 'object'; 92 | const options = this._getSchemaOptions(type, schema, overrideSchemaOptions); 93 | 94 | let dataProperty; 95 | 96 | if (excludeData) { 97 | dataProperty = undefined; 98 | } else if (isDynamicType) { 99 | dataProperty = this.serializeMixedResource( 100 | options, 101 | data, 102 | included, 103 | extraData, 104 | overrideSchemaOptions, 105 | ); 106 | } else { 107 | dataProperty = this.serializeResource( 108 | type, 109 | data, 110 | options, 111 | included, 112 | extraData, 113 | overrideSchemaOptions, 114 | ); 115 | } 116 | 117 | return { 118 | jsonapi: options.jsonapiObject ? { version: '1.0' } : undefined, 119 | meta: this.processOptionsValues(data, extraData, options.topLevelMeta, 'extraData'), 120 | links: this.processOptionsValues(data, extraData, options.topLevelLinks, 'extraData'), 121 | data: dataProperty, 122 | included: included.size ? [...included.values()] : undefined, 123 | }; 124 | } 125 | 126 | /** 127 | * Asynchronously serialize input data to a JSON API compliant response. 128 | * Input data can be a simple object or an array of objects. 129 | * @see {@link http://jsonapi.org/format/#document-top-level} 130 | * @function JSONAPISerializer#serializeAsync 131 | * @param {string|DynamicTypeOptions} type resource's type as string or a dynamic type options as object. 132 | * @param {object|object[]} data input data. 133 | * @param {string} [schema] resource's schema name. 134 | * @param {object} [extraData] additional data that can be used in topLevelMeta options. 135 | * @param {boolean} [excludeData] boolean that can be set to exclude the `data` property in serialized data. 136 | * @param {object} [overrideSchemaOptions] additional schema options, a map of types with options to override. 137 | * @returns {Promise} resolves with serialized data. 138 | */ 139 | serializeAsync(type, data, schema, extraData, excludeData, overrideSchemaOptions = {}) { 140 | // Support optional arguments (schema) 141 | if (arguments.length === 3) { 142 | if (typeof schema === 'object') { 143 | extraData = schema; 144 | schema = 'default'; 145 | } 146 | } 147 | 148 | schema = schema || 'default'; 149 | extraData = extraData || {}; 150 | 151 | const included = new Map(); 152 | const isDataArray = Array.isArray(data); 153 | const isDynamicType = typeof type === 'object'; 154 | const arrayData = isDataArray ? data : [data]; 155 | const serializedData = []; 156 | const that = this; 157 | let i = 0; 158 | const options = this._getSchemaOptions(type, schema, overrideSchemaOptions); 159 | 160 | return new Promise((resolve, reject) => { 161 | /** 162 | * Non-blocking serialization using the immediate queue. 163 | * @see {@link https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/} 164 | */ 165 | function next() { 166 | setImmediate(() => { 167 | if (excludeData) { 168 | return resolve(); 169 | } 170 | if (i >= arrayData.length) { 171 | return resolve(serializedData); 172 | } 173 | 174 | try { 175 | // Serialize a single item of the data-array. 176 | const serializedItem = isDynamicType 177 | ? that.serializeMixedResource( 178 | type, 179 | arrayData[i], 180 | included, 181 | extraData, 182 | overrideSchemaOptions, 183 | ) 184 | : that.serializeResource( 185 | type, 186 | arrayData[i], 187 | options, 188 | included, 189 | extraData, 190 | overrideSchemaOptions, 191 | ); 192 | 193 | if (serializedItem !== null) { 194 | serializedData.push(serializedItem); 195 | } 196 | 197 | i += 1; 198 | 199 | return next(); 200 | } catch (e) { 201 | return reject(e); 202 | } 203 | }); 204 | } 205 | 206 | next(); 207 | }).then((result) => { 208 | let dataProperty; 209 | 210 | if (typeof result === 'undefined') { 211 | dataProperty = undefined; 212 | } else if (isDataArray) { 213 | dataProperty = result; 214 | } else { 215 | dataProperty = result[0] || null; 216 | } 217 | 218 | return { 219 | jsonapi: options.jsonapiObject ? { version: '1.0' } : undefined, 220 | meta: this.processOptionsValues(data, extraData, options.topLevelMeta, 'extraData'), 221 | links: this.processOptionsValues(data, extraData, options.topLevelLinks, 'extraData'), 222 | // If the source data was an array, we just pass the serialized data array. 223 | // Otherwise we try to take the first (and only) item of it or pass null. 224 | data: dataProperty, 225 | included: included.size ? [...included.values()] : undefined, 226 | }; 227 | }); 228 | } 229 | 230 | /** 231 | * Deserialize JSON API document data. 232 | * Input data can be a simple object or an array of objects. 233 | * @function JSONAPISerializer#deserialize 234 | * @param {string|DynamicTypeOptions} type resource's type as string or a dynamic type options as object. 235 | * @param {object} data JSON API input data. 236 | * @param {string} [schema] resource's schema name. 237 | * @returns {object} deserialized data. 238 | */ 239 | deserialize(type, data, schema) { 240 | schema = schema || 'default'; 241 | 242 | if (typeof type === 'object') { 243 | type = validateDynamicTypeOptions(type); 244 | } else { 245 | if (!this.schemas[type]) { 246 | throw new Error(`No type registered for ${type}`); 247 | } 248 | 249 | if (schema && !this.schemas[type][schema]) { 250 | throw new Error(`No schema ${schema} registered for ${type}`); 251 | } 252 | } 253 | 254 | let deserializedData = {}; 255 | 256 | if (data.data) { 257 | deserializedData = Array.isArray(data.data) 258 | ? data.data.map((resource) => 259 | this.deserializeResource(type, resource, schema, data.included), 260 | ) 261 | : this.deserializeResource(type, data.data, schema, data.included); 262 | } 263 | 264 | return deserializedData; 265 | } 266 | 267 | /** 268 | * Asynchronously Deserialize JSON API document data. 269 | * Input data can be a simple object or an array of objects. 270 | * @function JSONAPISerializer#deserializeAsync 271 | * @param {string|DynamicTypeOptions} type resource's type as string or a dynamic type options as object. 272 | * @param {object} data JSON API input data. 273 | * @param {string} [schema] resource's schema name. 274 | * @returns {Promise} resolves with serialized data. 275 | */ 276 | deserializeAsync(type, data, schema) { 277 | schema = schema || 'default'; 278 | 279 | if (typeof type === 'object') { 280 | type = validateDynamicTypeOptions(type); 281 | } else { 282 | if (!this.schemas[type]) { 283 | throw new Error(`No type registered for ${type}`); 284 | } 285 | 286 | if (schema && !this.schemas[type][schema]) { 287 | throw new Error(`No schema ${schema} registered for ${type}`); 288 | } 289 | } 290 | 291 | const isDataArray = Array.isArray(data.data); 292 | let i = 0; 293 | const arrayData = isDataArray ? data.data : [data.data]; 294 | const deserializedData = []; 295 | const that = this; 296 | 297 | return new Promise((resolve, reject) => { 298 | /** 299 | * Non-blocking deserialization using the immediate queue. 300 | * @see {@link https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/} 301 | */ 302 | function next() { 303 | setImmediate(() => { 304 | if (i >= arrayData.length) { 305 | return resolve(isDataArray ? deserializedData : deserializedData[0]); 306 | } 307 | 308 | try { 309 | // Serialize a single item of the data-array. 310 | const deserializedItem = that.deserializeResource( 311 | type, 312 | arrayData[i], 313 | schema, 314 | data.included, 315 | ); 316 | 317 | deserializedData.push(deserializedItem); 318 | 319 | i += 1; 320 | 321 | return next(); 322 | } catch (e) { 323 | return reject(e); 324 | } 325 | }); 326 | } 327 | 328 | next(); 329 | }); 330 | } 331 | 332 | /** 333 | * Serialize any error into a JSON API error document. 334 | * Input data can be: 335 | * - An `Error` or an array of `Error` instances. 336 | * - A JSON API error object or an array of JSON API error objects. 337 | * @see {@link http://jsonapi.org/format/#errors} 338 | * @function JSONAPISerializer#serializeError 339 | * @param {Error|Error[]|object|object[]} error an Error, an array of Error, a JSON API error object, an array of JSON API error object. 340 | * @returns {object} resolves with serialized error. 341 | */ 342 | serializeError(error) { 343 | return { 344 | errors: Array.isArray(error) 345 | ? error.map((err) => validateError(err)) 346 | : [validateError(error)], 347 | }; 348 | } 349 | 350 | /** 351 | * Deserialize a single JSON API resource. 352 | * Input data must be a simple object. 353 | * @function JSONAPISerializer#deserializeResource 354 | * @private 355 | * @param {string|DynamicTypeOptions} type resource's type as string or an object with a dynamic type resolved from data. 356 | * @param {object} data JSON API resource data. 357 | * @param {string} [schema] resource's schema name. 358 | * @param {Map} included Included resources. 359 | * @param {string[]} lineage resource identifiers already deserialized to prevent circular references. 360 | * @returns {object} deserialized data. 361 | */ 362 | // eslint-disable-next-line default-param-last 363 | deserializeResource(type, data, schema = 'default', included, lineage = []) { 364 | if (typeof type === 'object') { 365 | type = typeof type.type === 'function' ? type.type(data) : get(data, type.type); 366 | } 367 | 368 | if (!type) { 369 | throw new Error(`No type can be resolved from data: ${JSON.stringify(data)}`); 370 | } 371 | 372 | if (!this.schemas[type]) { 373 | throw new Error(`No type registered for ${type}`); 374 | } 375 | 376 | const options = this.schemas[type][schema]; 377 | 378 | let deserializedData = {}; 379 | if (data.id !== undefined) { 380 | deserializedData[options.id] = data.id; 381 | } 382 | 383 | if (data.attributes && options.whitelistOnDeserialize.length) { 384 | data.attributes = pick(data.attributes, options.whitelistOnDeserialize); 385 | } 386 | 387 | if (data.attributes && options.blacklistOnDeserialize.length) { 388 | data.attributes = omit(data.attributes, options.blacklistOnDeserialize); 389 | } 390 | 391 | Object.assign(deserializedData, data.attributes); 392 | 393 | // Deserialize relationships 394 | if (data.relationships) { 395 | Object.keys(data.relationships).forEach((relationshipProperty) => { 396 | const relationship = data.relationships[relationshipProperty]; 397 | const relationshipType = this._getRelationshipDataType(relationship.data); 398 | 399 | const relationshipKey = options.unconvertCase 400 | ? this._convertCase(relationshipProperty, options.unconvertCase) 401 | : relationshipProperty; 402 | 403 | const relationshipOptions = 404 | options.relationships[relationshipKey] || this.schemas[relationshipType]; 405 | 406 | const deserializeFunction = (relationshipData) => { 407 | if (relationshipOptions && relationshipOptions.deserialize) { 408 | return relationshipOptions.deserialize(relationshipData); 409 | } 410 | return relationshipData.id; 411 | }; 412 | 413 | if (relationship.data !== undefined) { 414 | if (relationship.data === null) { 415 | // null data 416 | set( 417 | deserializedData, 418 | (relationshipOptions && relationshipOptions.alternativeKey) || relationshipKey, 419 | null, 420 | ); 421 | } else { 422 | if ((relationshipOptions && relationshipOptions.alternativeKey) || !included) { 423 | set( 424 | deserializedData, 425 | (relationshipOptions && relationshipOptions.alternativeKey) || relationshipKey, 426 | Array.isArray(relationship.data) 427 | ? relationship.data.map((d) => deserializeFunction(d)) 428 | : deserializeFunction(relationship.data), 429 | ); 430 | } 431 | 432 | if (included) { 433 | const deserializeIncludedRelationship = (relationshipData) => { 434 | const lineageCopy = [...lineage]; 435 | // Prevent circular relationships 436 | const lineageKey = `${relationshipData.type}-${relationshipData.id}`; 437 | const isCircular = lineageCopy.includes(lineageKey); 438 | 439 | if (isCircular) { 440 | return deserializeFunction(relationshipData); 441 | } 442 | 443 | lineageCopy.push(lineageKey); 444 | return this.deserializeIncluded( 445 | relationshipData.type, 446 | relationshipData.id, 447 | relationshipOptions, 448 | included, 449 | lineageCopy, 450 | deserializeFunction, 451 | ); 452 | }; 453 | 454 | const deserializedIncludedRelationship = Array.isArray(relationship.data) 455 | ? relationship.data.map((d) => deserializeIncludedRelationship(d)) 456 | : deserializeIncludedRelationship(relationship.data); 457 | 458 | // not set to deserializedData if alternativeKey is set and relationship data is not in the included array (value is the same as alternativeKey value) 459 | if ( 460 | !( 461 | relationshipOptions && 462 | relationshipOptions.alternativeKey && 463 | deserializedIncludedRelationship.toString() === 464 | get(deserializedData, relationshipOptions.alternativeKey).toString() 465 | ) 466 | ) { 467 | set(deserializedData, relationshipKey, deserializedIncludedRelationship); 468 | } 469 | } 470 | } 471 | } 472 | }); 473 | } 474 | 475 | if (options.unconvertCase) { 476 | deserializedData = this._convertCase(deserializedData, options.unconvertCase); 477 | } 478 | 479 | if (data.links) { 480 | deserializedData.links = data.links; 481 | } 482 | 483 | if (data.meta) { 484 | deserializedData.meta = data.meta; 485 | } 486 | 487 | if (options.afterDeserialize) { 488 | return options.afterDeserialize(deserializedData); 489 | } 490 | 491 | return deserializedData; 492 | } 493 | 494 | /** 495 | * Deserialize included 496 | * @function JSONAPISerializer#deserializeIncluded 497 | * @private 498 | * @param {string} type resource's type as string or an object with a dynamic type resolved from data. 499 | * @param {string} id identifier of the resource. 500 | * @param {RelationshipOptions} relationshipOpts relationship option. 501 | * @param {Map} included Included resources. 502 | * @param {string[]} lineage resource identifiers already deserialized to prevent circular references. 503 | * @param {Function} deserializeFunction a deserialize function 504 | * @returns {object} deserialized data. 505 | */ 506 | deserializeIncluded(type, id, relationshipOpts, included, lineage, deserializeFunction) { 507 | const includedResource = included.find( 508 | (resource) => resource.type === type && resource.id === id, 509 | ); 510 | 511 | if (!includedResource) { 512 | return deserializeFunction({ type, id }); 513 | } 514 | 515 | if (!relationshipOpts) { 516 | throw new Error(`No type registered for ${type}`); 517 | } 518 | 519 | return this.deserializeResource( 520 | type, 521 | includedResource, 522 | relationshipOpts.schema, 523 | included, 524 | lineage, 525 | ); 526 | } 527 | 528 | /** 529 | * Serialize resource objects. 530 | * @see {@link http://jsonapi.org/format/#document-resource-objects} 531 | * @function JSONAPISerializer#serializeDocument 532 | * @private 533 | * @param {string} type resource's type. 534 | * @param {object|object[]} data input data. 535 | * @param {Options} options resource's configuration options. 536 | * @param {Map} [included] Included resources. 537 | * @param {object} [extraData] additional data. 538 | * @param {object} [overrideSchemaOptions] additional schema options, a map of types with options to override. 539 | * @returns {object|object[]} serialized data. 540 | */ 541 | serializeResource(type, data, options, included, extraData, overrideSchemaOptions = {}) { 542 | if (isEmpty(data)) { 543 | // Return [] or null 544 | return Array.isArray(data) ? data : null; 545 | } 546 | 547 | if (Array.isArray(data)) { 548 | return data.map((d) => 549 | this.serializeResource(type, d, options, included, extraData, overrideSchemaOptions), 550 | ); 551 | } 552 | 553 | if (options.beforeSerialize) { 554 | data = options.beforeSerialize(data); 555 | } 556 | 557 | return { 558 | type, 559 | id: data[options.id] ? data[options.id].toString() : undefined, 560 | attributes: this.serializeAttributes(data, options), 561 | relationships: this.serializeRelationships( 562 | data, 563 | options, 564 | included, 565 | extraData, 566 | overrideSchemaOptions, 567 | ), 568 | meta: this.processOptionsValues(data, extraData, options.meta), 569 | links: this.processOptionsValues(data, extraData, options.links), 570 | }; 571 | } 572 | 573 | /** 574 | * Serialize mixed resource object with a dynamic type resolved from data 575 | * @see {@link http://jsonapi.org/format/#document-resource-objects} 576 | * @function JSONAPISerializer#serializeMixedResource 577 | * @private 578 | * @param {DynamicTypeOptions} typeOption a dynamic type options. 579 | * @param {object|object[]} data input data. 580 | * @param {Map} [included] Included resources. 581 | * @param {object} [extraData] additional data. 582 | * @param {object} [overrideSchemaOptions] additional schema options, a map of types with options to override. 583 | * @returns {object|object[]} serialized data. 584 | */ 585 | serializeMixedResource(typeOption, data, included, extraData, overrideSchemaOptions = {}) { 586 | if (isEmpty(data)) { 587 | // Return [] or null 588 | return Array.isArray(data) ? data : null; 589 | } 590 | 591 | if (Array.isArray(data)) { 592 | return data.map((d) => 593 | this.serializeMixedResource(typeOption, d, included, extraData, overrideSchemaOptions), 594 | ); 595 | } 596 | 597 | // Resolve type from data (can be a string or a function deriving a type-string from each data-item) 598 | const type = 599 | typeof typeOption.type === 'function' ? typeOption.type(data) : get(data, typeOption.type); 600 | 601 | if (!type) { 602 | throw new Error(`No type can be resolved from data: ${JSON.stringify(data)}`); 603 | } 604 | 605 | if (!this.schemas[type]) { 606 | throw new Error(`No type registered for ${type}`); 607 | } 608 | 609 | const options = this._getSchemaOptions(type, 'default', overrideSchemaOptions); 610 | 611 | return this.serializeResource(type, data, options, included, extraData, overrideSchemaOptions); 612 | } 613 | 614 | /** 615 | * Serialize 'attributes' key of resource objects: an attributes object representing some of the resource's data. 616 | * @see {@link http://jsonapi.org/format/#document-resource-object-attributes} 617 | * @function JSONAPISerializer#serializeAttributes 618 | * @private 619 | * @param {object|object[]} data input data. 620 | * @param {Options} options resource's configuration options. 621 | * @returns {object} serialized attributes. 622 | */ 623 | serializeAttributes(data, options) { 624 | if (options.whitelist && options.whitelist.length) { 625 | data = pick(data, options.whitelist); 626 | } 627 | 628 | // Support alternativeKey options for relationships 629 | const alternativeKeys = []; 630 | Object.keys(options.relationships).forEach((key) => { 631 | const rOptions = options.relationships[key]; 632 | if (rOptions.alternativeKey) { 633 | alternativeKeys.push(rOptions.alternativeKey); 634 | } 635 | }); 636 | 637 | // Remove unwanted keys 638 | let serializedAttributes = omit(data, [ 639 | options.id, 640 | ...Object.keys(options.relationships), 641 | ...alternativeKeys, 642 | ...options.blacklist, 643 | ]); 644 | 645 | if (options.convertCase) { 646 | serializedAttributes = this._convertCase(serializedAttributes, options.convertCase); 647 | } 648 | 649 | return Object.keys(serializedAttributes).length ? serializedAttributes : undefined; 650 | } 651 | 652 | /** 653 | * Serialize 'relationships' key of resource objects: a relationships object describing relationships between the resource and other JSON API resources. 654 | * @see {@link http://jsonapi.org/format/#document-resource-object-relationships} 655 | * @function JSONAPISerializer#serializeRelationships 656 | * @private 657 | * @param {object|object[]} data input data. 658 | * @param {Options} options resource's configuration options. 659 | * @param {Map} [included] Included resources. 660 | * @param {object} [extraData] additional data. 661 | * @param {object} [overrideSchemaOptions] additional schema options, a map of types with options to override. 662 | * @returns {object} serialized relationships. 663 | */ 664 | serializeRelationships(data, options, included, extraData, overrideSchemaOptions = {}) { 665 | const serializedRelationships = {}; 666 | 667 | Object.keys(options.relationships).forEach((relationship) => { 668 | const relationshipOptions = options.relationships[relationship]; 669 | 670 | // Support alternativeKey options for relationships 671 | let relationshipKey = relationship; 672 | if (!data[relationship] && relationshipOptions.alternativeKey) { 673 | relationshipKey = relationshipOptions.alternativeKey; 674 | } 675 | 676 | const serializeRelationship = { 677 | links: this.processOptionsValues(data, extraData, relationshipOptions.links), 678 | meta: this.processOptionsValues(data, extraData, relationshipOptions.meta), 679 | data: this.serializeRelationship( 680 | relationshipOptions.type, 681 | relationshipOptions.schema, 682 | get(data, relationshipKey), 683 | included, 684 | data, 685 | extraData, 686 | overrideSchemaOptions, 687 | ), 688 | }; 689 | 690 | if ( 691 | serializeRelationship.data !== undefined || 692 | serializeRelationship.links !== undefined || 693 | serializeRelationship.meta !== undefined 694 | ) { 695 | // Convert case 696 | relationship = options.convertCase 697 | ? this._convertCase(relationship, options.convertCase) 698 | : relationship; 699 | 700 | serializedRelationships[relationship] = serializeRelationship; 701 | } 702 | }); 703 | 704 | return Object.keys(serializedRelationships).length ? serializedRelationships : undefined; 705 | } 706 | 707 | /** 708 | * Serialize 'data' key of relationship's resource objects. 709 | * @see {@link http://jsonapi.org/format/#document-resource-object-linkage} 710 | * @function JSONAPISerializer#serializeRelationship 711 | * @private 712 | * @param {string|Function} rType the relationship's type. 713 | * @param {string} rSchema the relationship's schema 714 | * @param {object|object[]} rData relationship's data. 715 | * @param {Map} [included] Included resources. 716 | * @param {object} [data] the entire resource's data. 717 | * @param {object} [extraData] additional data. 718 | * @param {object} [overrideSchemaOptions] additional schema options, a map of types with options to override. 719 | * @returns {object|object[]} serialized relationship data. 720 | */ 721 | serializeRelationship( 722 | rType, 723 | rSchema, 724 | rData, 725 | included, 726 | data, 727 | extraData, 728 | overrideSchemaOptions = {}, 729 | ) { 730 | included = included || new Map(); 731 | const schema = rSchema || 'default'; 732 | 733 | // No relationship data 734 | if (rData === undefined || rData === null) { 735 | return rData; 736 | } 737 | 738 | if (typeof rData === 'object' && isEmpty(rData)) { 739 | // Return [] or null 740 | return Array.isArray(rData) ? [] : null; 741 | } 742 | 743 | if (Array.isArray(rData)) { 744 | return rData.map((d) => 745 | this.serializeRelationship( 746 | rType, 747 | schema, 748 | d, 749 | included, 750 | data, 751 | extraData, 752 | overrideSchemaOptions, 753 | ), 754 | ); 755 | } 756 | 757 | // Resolve relationship type 758 | const type = typeof rType === 'function' ? rType(rData, data) : rType; 759 | 760 | if (!type) { 761 | throw new Error(`No type can be resolved from relationship's data: ${JSON.stringify(rData)}`); 762 | } 763 | 764 | if (!this.schemas[type]) { 765 | throw new Error(`No type registered for "${type}"`); 766 | } 767 | 768 | if (!this.schemas[type][schema]) { 769 | throw new Error(`No schema "${schema}" registered for type "${type}"`); 770 | } 771 | 772 | let rOptions = this.schemas[type][schema]; 773 | 774 | if (overrideSchemaOptions[type]) { 775 | // Merge default (registered) options and extra options into new options object 776 | rOptions = { ...rOptions, ...overrideSchemaOptions[type] }; 777 | } 778 | 779 | const serializedRelationship = { type }; 780 | 781 | // Support for unpopulated relationships (an id, or array of ids) 782 | if (!isObjectLike(rData)) { 783 | serializedRelationship.id = rData.toString(); 784 | } else { 785 | const serializedIncluded = this.serializeResource( 786 | type, 787 | rData, 788 | rOptions, 789 | included, 790 | extraData, 791 | overrideSchemaOptions, 792 | ); 793 | 794 | serializedRelationship.id = serializedIncluded.id; 795 | const identifier = `${type}-${serializedRelationship.id}`; 796 | 797 | // Not include relationship object which only contains an id 798 | if ( 799 | (serializedIncluded.attributes && Object.keys(serializedIncluded.attributes).length) || 800 | (serializedIncluded.relationships && Object.keys(serializedIncluded.relationships).length) 801 | ) { 802 | // Merge relationships data if already included 803 | if (included.has(identifier)) { 804 | const alreadyIncluded = included.get(identifier); 805 | 806 | if (serializedIncluded.relationships) { 807 | alreadyIncluded.relationships = { 808 | ...alreadyIncluded.relationships, 809 | ...serializedIncluded.relationships, 810 | }; 811 | included.set(identifier, alreadyIncluded); 812 | } 813 | } else { 814 | included.set(identifier, serializedIncluded); 815 | } 816 | } 817 | } 818 | return serializedRelationship; 819 | } 820 | 821 | /** 822 | * Process options values. 823 | * Allows options to be an object or a function with 1 or 2 arguments 824 | * @function JSONAPISerializer#processOptionsValues 825 | * @private 826 | * @param {object} data data passed to functions options. 827 | * @param {object} extraData additional data passed to functions options. 828 | * @param {object} options configuration options. 829 | * @param {string} [fallbackModeIfOneArg] fallback mode if only one argument is passed to function. 830 | * Avoid breaking changes with issue https://github.com/danivek/json-api-serializer/issues/27. 831 | * @returns {object} processed options. 832 | */ 833 | processOptionsValues(data, extraData, options, fallbackModeIfOneArg) { 834 | let processedOptions = {}; 835 | if (options && typeof options === 'function') { 836 | // Backward compatible with functions with one 'extraData' argument 837 | processedOptions = 838 | fallbackModeIfOneArg === 'extraData' && options.length === 1 839 | ? options(extraData) 840 | : options(data, extraData); 841 | } else { 842 | Object.keys(options).forEach((key) => { 843 | let processedValue = {}; 844 | if (options[key] && typeof options[key] === 'function') { 845 | // Backward compatible with functions with one 'extraData' argument 846 | processedValue = 847 | fallbackModeIfOneArg === 'extraData' && options[key].length === 1 848 | ? options[key](extraData) 849 | : options[key](data, extraData); 850 | } else { 851 | processedValue = options[key]; 852 | } 853 | Object.assign(processedOptions, { [key]: processedValue }); 854 | }); 855 | } 856 | 857 | return processedOptions && Object.keys(processedOptions).length ? processedOptions : undefined; 858 | } 859 | 860 | /** 861 | * Get the schema options for the given type and optional schema. 862 | * @function JSONAPISerializer#_getSchemaOptions 863 | * @private 864 | * @param {string|object} [type] the type to get schema options for. 865 | * @param {schema} [schema] the schema name to get options for. 866 | * @param {object} [overrideSchemaOptions] optional options to override schema options. 867 | * @returns {object} the schema options for the given type. 868 | */ 869 | _getSchemaOptions(type, schema, overrideSchemaOptions = {}) { 870 | const isDynamicType = typeof type === 'object'; 871 | const overrideType = isDynamicType ? type.type : type; 872 | const overrideOptions = { ...(overrideSchemaOptions[overrideType] || {}) }; 873 | 874 | if (isDynamicType) { 875 | return validateDynamicTypeOptions(type); 876 | } 877 | 878 | if (!this.schemas[type]) { 879 | throw new Error(`No type registered for ${type}`); 880 | } 881 | 882 | if (schema && !this.schemas[type][schema]) { 883 | throw new Error(`No schema ${schema} registered for ${type}`); 884 | } 885 | 886 | return { ...this.schemas[type][schema], ...overrideOptions }; 887 | } 888 | 889 | _getRelationshipDataType(data) { 890 | if (data === null || typeof data === 'undefined') { 891 | return null; 892 | } 893 | 894 | if (Array.isArray(data)) { 895 | return get(data[0], 'type'); 896 | } 897 | 898 | return data.type; 899 | } 900 | 901 | /** 902 | * Recursively convert object keys case 903 | * @function JSONAPISerializer#_convertCase 904 | * @private 905 | * @param {object|object[]|string} data to convert 906 | * @param {string} convertCaseOptions can be snake_case', 'kebab-case' or 'camelCase' format. 907 | * @returns {object} Object with it's keys converted as per the convertCaseOptions 908 | */ 909 | _convertCase(data, convertCaseOptions) { 910 | if (Array.isArray(data)) { 911 | return data.map((item) => { 912 | if (item && (Array.isArray(item) || isPlainObject(item))) { 913 | return this._convertCase(item, convertCaseOptions); 914 | } 915 | return item; 916 | }); 917 | } 918 | 919 | if (isPlainObject(data)) { 920 | return transform( 921 | data, 922 | (result, value, key) => { 923 | let converted; 924 | if (value && (Array.isArray(value) || isPlainObject(value))) { 925 | converted = this._convertCase(value, convertCaseOptions); 926 | } else { 927 | converted = value; 928 | } 929 | 930 | result[this._convertCase(key, convertCaseOptions)] = converted; 931 | return result; 932 | }, 933 | {}, 934 | ); 935 | } 936 | 937 | if (typeof data === 'string') { 938 | let converted; 939 | 940 | switch (convertCaseOptions) { 941 | case 'snake_case': 942 | converted = this.convertCaseMap.snakeCase.get(data); 943 | if (!converted) { 944 | converted = toSnakeCase(data); 945 | this.convertCaseMap.snakeCase.set(data, converted); 946 | } 947 | break; 948 | case 'kebab-case': 949 | converted = this.convertCaseMap.kebabCase.get(data); 950 | if (!converted) { 951 | converted = toKebabCase(data); 952 | this.convertCaseMap.kebabCase.set(data, converted); 953 | } 954 | break; 955 | case 'camelCase': 956 | converted = this.convertCaseMap.camelCase.get(data); 957 | if (!converted) { 958 | converted = toCamelCase(data); 959 | this.convertCaseMap.camelCase.set(data, converted); 960 | } 961 | break; 962 | default: // Do nothing 963 | } 964 | 965 | return converted; 966 | } 967 | 968 | return data; 969 | } 970 | }; 971 | 972 | /** 973 | * @typedef {object} Options 974 | * @property {string} [id='id'] the key to use as the reference. Default = 'id' 975 | * @property {string[]} [blacklist=[]] an array of blacklisted attributes. Default = [] 976 | * @property {string[]} [whitelist=[]] an array of whitelisted attributes. Default = [] 977 | * @property {boolean} [jsonapiObject=true] enable/Disable JSON API Object. Default = true 978 | * @property {Function|object} [links] describes the links inside data 979 | * @property {Function|object} [topLevelLinks] describes the top-level links 980 | * @property {Function|object} [topLevelMeta] describes the top-level meta 981 | * @property {Function|object} [meta] describes resource-level meta 982 | * @property {{[key: string]: RelationshipOptions}} [relationships] an object defining some relationships 983 | * @property {string[]} [blacklistOnDeserialize=[]] an array of blacklisted attributes. Default = [] 984 | * @property {string[]} [whitelistOnDeserialize=[]] an array of whitelisted attributes. Default = [] 985 | * @property {('kebab-case'|'snake_case'|'camelCase')} [convertCase] case conversion for serializing data 986 | * @property {('kebab-case'|'snake_case'|'camelCase')} [unconvertCase] case conversion for deserializing data 987 | * @property {number} [convertCaseCacheSize=5000] When using convertCase, a LRU cache is utilized for optimization. The default size of the cache is 5000 per conversion type. 988 | * @property {Function} [beforeSerialize] a function to transform data before serialization. 989 | * @property {Function} [afterDeserialize] a function to transform data after deserialization. 990 | */ 991 | 992 | /** 993 | * @typedef {object} RelationshipOptions 994 | * @property {string|Function} type a string or a function for the type to use for serializing the relationship (type need to be register) 995 | * @property {string} [alternativeKey] an alternative key (string or path) to use if relationship key not exist (example: 'author_id' as an alternative key for 'author' relationship) 996 | * @property {string} [schema] a custom schema for serializing the relationship. If no schema define, it use the default one. 997 | * @property {Function|object} [links] describes the links for the relationship 998 | * @property {Function|object} [meta] describes meta that contains non-standard meta-information about the relationship 999 | * @property {Function} [deserialize] describes the function which should be used to deserialize a related property which is not included in the JSON:API document 1000 | */ 1001 | 1002 | /** 1003 | * 1004 | * @typedef {object} DynamicTypeOptions 1005 | * @property {string} id a string for the path to the key to use to determine type or a function deriving a type-string from each data-item. 1006 | * @property {boolean} [jsonapiObject=true] enable/Disable JSON API Object. 1007 | * @property {Function|object} [topLevelLinks] describes the top-level links 1008 | * @property {Function|object} [topLevelMeta] describes the top-level meta. 1009 | */ 1010 | --------------------------------------------------------------------------------