├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── index.js ├── serializer.js └── util.js └── test ├── performance.spec.js ├── serializer.spec.js └── util.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | end_of_line = lf 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | dist 31 | .vscode 32 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | LICENSE 2 | .gitignore 3 | test/**/* 4 | test 5 | src 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6.3" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Media Suite 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # loopback-jsonapi-model-serializer 2 | 3 | JSONAPI Model serializer for loopback 4 | 5 | [![Media Suite](https://mediasuite.co.nz/ms-badge.png)](https://mediasuite.co.nz) 6 | 7 | [![NPM](https://nodei.co/npm/loopback-jsonapi-model-serializer.png?downloads=true&stars=true)](https://nodei.co/npm/loopback-jsonapi-model-serializer/) 8 | 9 | [![Build Status](https://travis-ci.org/digitalsadhu/loopback-jsonapi-model-serializer.svg?branch=master)](https://travis-ci.org/digitalsadhu/loopback-jsonapi-model-serializer) 10 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)]() 11 | 12 | ## About 13 | 14 | The goal of this project is to provide a simple [JSONAPI](http://jsonapi.org/) serialization tool for loopback models. 15 | You should be able to use (for example) loopbacks PersistedModel.find() method with all its various filter options 16 | and pass the returned data (along with the model) into the serializer and have all the intricacies of 17 | the JSON APi serialization process taken care of for you. See the example section below. 18 | 19 | ## Installation 20 | 21 | ``` 22 | npm install loopback-jsonapi-model-serializer --save 23 | ``` 24 | 25 | ## Basic Usage 26 | 27 | Include the module as a dependency 28 | ```js 29 | const serialize = require('loopback-jsonapi-model-serializer') 30 | ``` 31 | 32 | Use it to serialize a data payload 33 | ```js 34 | const serializedData = serialize(data, model) 35 | ``` 36 | 37 | You will almost certainly want to override baseUrl so that the serializer can prepend 38 | urls as neeeded. 39 | 40 | ``` 41 | const serializedData = serialize(data, model, {baseUrl: 'http://myapi.com/api/'}) 42 | ``` 43 | 44 | ## API 45 | 46 | ```js 47 | serialize(data, model, [options]) 48 | ``` 49 | 50 | - `data` a payload of data from a loopback find, findOne, findById etc. 51 | - `model` a loopback model eg. app.models.User 52 | - `options` used to override baseUrl used in serialization process {baseUrl: 'http://localhost:3000/'} 53 | 54 | ## Example 55 | 56 | Given the following loopback models and relationships: 57 | 58 | ``` 59 | const Post = ds.createModel('post', {title: String}) 60 | const Comment = ds.createModel('comment', {title: String}) 61 | const Author = ds.createModel('author', {name: String}) 62 | 63 | app.model(Post) 64 | app.model(Author) 65 | app.model(Comment) 66 | 67 | Post.hasMany(Comment) 68 | Post.belongsTo(Author) 69 | ``` 70 | 71 | We can perform the folliowing query: 72 | 73 | ``` 74 | Post.find().then(data => {...}) 75 | ``` 76 | 77 | Then we serialize the returned data like so: 78 | 79 | ``` 80 | const serializedData = serialize(data, Post) 81 | ``` 82 | 83 | After which `serializedData` should look something like: 84 | 85 | ``` 86 | { 87 | "data": [ 88 | { 89 | "id": 1, 90 | "type": "posts", 91 | "links": { 92 | "self": "/posts/1" 93 | }, 94 | "attributes": { 95 | "title": "post 0" 96 | }, 97 | "relationships": { 98 | "comments": { 99 | "links": { 100 | "related": "/posts/1/comments" 101 | } 102 | }, 103 | "author": { 104 | "links": { 105 | "related": "/posts/1/author" 106 | } 107 | } 108 | } 109 | } 110 | ] 111 | } 112 | ``` 113 | 114 | ## Loopback relations 115 | 116 | ### Without fetching included models 117 | 118 | When you give the serializer data that does not have any included relationships, 119 | The serializer will construct urls that allow consuming clients to fetch related 120 | data with an additional query. 121 | 122 | In our example above, `Post` has Many `Comment` and belongsTo `Author`. The serializer 123 | will construct the following: 124 | 125 | ``` 126 | "relationships": { 127 | "comments": { 128 | "links": { 129 | "related": "/posts/1/comments" 130 | } 131 | }, 132 | "author": { 133 | "links": { 134 | "related": "/posts/1/author" 135 | } 136 | } 137 | } 138 | ``` 139 | 140 | Clients can then use these urls to fetch related data as per the [JSONAPI spec](http://jsonapi.org/) 141 | 142 | ### Fetching included models 143 | 144 | You can use loopbacks include syntax to fetch related data in a single request. 145 | These 'side loaded' relations will be handled according to the [JSONAPI spec](http://jsonapi.org/), 146 | serialized, placed in the `included` block and linked to via the `relationships` `data` object. 147 | 148 | #### Fetching in loopback with relations 149 | 150 | ``` 151 | Post.find({include: ['author', 'comments']}).then(data => { 152 | const serialized = serialize(data, Post) 153 | }) 154 | ``` 155 | 156 | #### Linking in the relationships `data` object 157 | 158 | When relationship data is included, `id` and `type` linkages are made 159 | in the relationships object under the appropriate relationships 160 | 161 | ``` 162 | "relationships": { 163 | "comments": { 164 | "links": { 165 | "related": "/posts/1/comments" 166 | }, 167 | "data": [ 168 | {"id": 1, "type": "comments"}, 169 | {"id": 2, "type": "comments"} 170 | ] 171 | }, 172 | "author": { 173 | "links": { 174 | "related": "/posts/1/author" 175 | }, 176 | "data": {"id": 1, "type": "authors"} 177 | } 178 | } 179 | ``` 180 | 181 | #### Linked resources included in the `included` array 182 | 183 | When relationship data is included, related `authors` and `comments` will be serialized 184 | and placed in an array under the key `included`. See the [JSONAPI spec](http://jsonapi.org/) for more 185 | information. 186 | 187 | ``` 188 | { 189 | "data": [...], 190 | "included": [ 191 | {"id": 1, "type": "comments", "attributes": {}, etc}, 192 | {"id": 1, "type": "authors", "attributes": {}, etc}, 193 | etc. 194 | ] 195 | } 196 | ``` 197 | 198 | ## Other information 199 | 200 | You can also make good sense of the serialization process by reading through the tests. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-jsonapi-model-serializer", 3 | "description": "JSONAPI Model serializer for loopback", 4 | "version": "1.0.1", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/digitalsadhu/loopback-jsonapi-model-serializer.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/digitalsadhu/loopback-jsonapi-model-serializer/issues" 13 | }, 14 | "homepage": "https://github.com/digitalsadhu/loopback-jsonapi-model-serializer", 15 | "author": { 16 | "name": "Richard Walker", 17 | "email": "digitalsadhu@gmail.com", 18 | "url": "https://mediasuite.co.nz" 19 | }, 20 | "engines": { 21 | "node": ">=6.0.0" 22 | }, 23 | "scripts": { 24 | "lint": "standard --verbose | snazzy", 25 | "pretest": "npm run lint", 26 | "test:dev": "ava --watch", 27 | "test": "ava --verbose", 28 | "build": "babel src --presets babel-preset-es2015 --out-dir dist", 29 | "prepublish": "npm test && npm prune && npm run build", 30 | "preversion:patch": "npm run test", 31 | "version:patch": "xyz -i patch", 32 | "preversion:minor": "npm run test", 33 | "version:minor": "xyz -i minor", 34 | "preversion:major": "npm run test", 35 | "version:major": "xyz -i major" 36 | }, 37 | "publishConfig": { 38 | "registry": "http://registry.npmjs.org" 39 | }, 40 | "keywords": [ 41 | "loopback", 42 | "model", 43 | "serializer", 44 | "jsonapi" 45 | ], 46 | "dependencies": { 47 | "lodash": "^4.13.1" 48 | }, 49 | "devDependencies": { 50 | "ava": "^0.15.2", 51 | "babel-cli": "^6.10.1", 52 | "babel-eslint": "^6.1.2", 53 | "babel-preset-es2015": "^6.9.0", 54 | "chai": "^3.5.0", 55 | "loopback": "^2.29.1", 56 | "loopback-datasource-juggler": "^2.47.0", 57 | "mocha": "^2.5.3", 58 | "mocha-given": "^0.1.3", 59 | "snazzy": "^4.0.0", 60 | "standard": "^7.1.2", 61 | "testem": "^1.10.0-1", 62 | "xyz": "^0.5.0" 63 | }, 64 | "peerDependencies": {}, 65 | "standard": { 66 | "parser": "babel-eslint" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const serializer = require('./serializer') 2 | 3 | module.exports = (data, model, options = {baseUrl: '/'}) => { 4 | return serializer(options).serialize(JSON.parse(JSON.stringify(data)), model) 5 | } 6 | -------------------------------------------------------------------------------- /src/serializer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const lib = require('./util') 4 | const _ = require('lodash') 5 | 6 | module.exports = (options = {baseUrl: '/'}) => { 7 | function serialize (data, model) { 8 | let serializedData = [] 9 | let serializedIncluded = new Map() 10 | if (Array.isArray(data)) { 11 | for (let dataPoint of data) { 12 | serializedData.push(resource(dataPoint, model)) 13 | included(dataPoint, model).forEach(item => { 14 | serializedIncluded.set(`${item.type}-${item.id}`, item) 15 | }) 16 | } 17 | } else { 18 | serializedData = resource(data, model) 19 | serializedIncluded = included(data, model) 20 | } 21 | const serialized = {data: serializedData} 22 | if (serializedIncluded.size > 0) serialized.included = [...serializedIncluded.values()] 23 | 24 | return serialized 25 | } 26 | 27 | function resource (data, model) { 28 | const resource = { 29 | id: id(data, model), 30 | type: type(data, model), 31 | links: links(data, model) 32 | } 33 | const attrs = attributes(data, model) 34 | if (!_.isEmpty(attrs)) resource['attributes'] = attrs 35 | 36 | const rels = relationships(data, model) 37 | if (!_.isEmpty(rels)) resource['relationships'] = rels 38 | 39 | return resource 40 | } 41 | 42 | function id (data, model) { 43 | return data[lib().primaryKeyForModel(model)] 44 | } 45 | 46 | function type (data, model) { 47 | return lib().pluralForModel(model) 48 | } 49 | 50 | function attributes (data, model) { 51 | const opts = _.assign({primaryKey: false, foreignKeys: false}, options) 52 | return lib().buildAttributes(data, model, opts) 53 | } 54 | 55 | function links (data, model) { 56 | return lib(options).buildResourceLinks(data, model) 57 | } 58 | 59 | function relationships (data, model) { 60 | return lib(options).buildRelationships(data, model) 61 | } 62 | 63 | function included (data, model) { 64 | let incl = new Map() 65 | const relations = model.relations 66 | for (let name of Object.keys(relations)) { 67 | const relation = relations[name] 68 | if (relation.polymorphic) continue 69 | const model = lib().relatedModelFromRelation(relation) 70 | let relatedData = data[name] || [] 71 | if (!model) return incl 72 | if (!Array.isArray(relatedData)) relatedData = [relatedData] 73 | for (let relatedDataPoint of relatedData) { 74 | const item = resource(relatedDataPoint, model) 75 | incl.set(`${item.type}-${item.id}`, item) 76 | included(relatedDataPoint, model).forEach(item => { 77 | incl.set(`${item.type}-${item.id}`, item) 78 | }) 79 | } 80 | } 81 | return incl 82 | } 83 | 84 | return { 85 | serialize, 86 | resource, 87 | id, 88 | type, 89 | attributes, 90 | links, 91 | relationships, 92 | included 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const _ = require('lodash') 4 | const url = require('url') 5 | 6 | module.exports = (options = {baseUrl: '/'}) => { 7 | function primaryKeyForModel (model) { 8 | return model.getIdName() 9 | } 10 | 11 | function pluralForModel (model) { 12 | return model.pluralModelName 13 | } 14 | 15 | function cleanUrl (urlString) { 16 | return url.format(url.parse(urlString)) 17 | } 18 | 19 | function buildResourceLinks (data, model) { 20 | const pk = data[primaryKeyForModel(model)] 21 | const type = pluralForModel(model) 22 | const baseUrl = cleanUrl(options.baseUrl) 23 | return {self: `${baseUrl}${type}/${pk}`} 24 | } 25 | 26 | function buildRelationships (data, model) { 27 | const relationshipLinks = relationshipLinksFromData(data, model) 28 | const relationshipData = relationshipDataFromData(data, model) 29 | return _.merge(relationshipLinks, relationshipData) 30 | } 31 | 32 | function buildAttributes (data, model, opts = {}) { 33 | const attributeNames = attributesForModel(model, opts) 34 | return attributesFromData(data, attributeNames) 35 | } 36 | 37 | function relationshipLinksFromData (data, model) { 38 | const pk = data[primaryKeyForModel(model)] 39 | const type = pluralForModel(model) 40 | const relations = model.relations 41 | const baseUrl = cleanUrl(options.baseUrl) 42 | 43 | const relationships = {} 44 | for (let name of Object.keys(relations)) { 45 | relationships[name] = {links: {related: `${baseUrl}${type}/${pk}/${name}`}} 46 | } 47 | 48 | return relationships 49 | } 50 | 51 | function relatedModelFromRelation (relation) { 52 | if (!relation.polymorphic) { 53 | return relation.modelTo 54 | } else { 55 | // polymorphic 56 | // can't know up front what modelTo is. 57 | // need to do a lookup in the db using discriminator 58 | // const discriminator = relation.polymorphic.discriminator 59 | // const name = relation.name 60 | } 61 | } 62 | 63 | function relationshipDataFromData (data, model) { 64 | const relations = model.relations 65 | const relationships = {} 66 | for (let name of Object.keys(relations)) { 67 | const relation = relations[name] 68 | const relatedModel = relatedModelFromRelation(relation) 69 | if (relation.polymorphic) continue 70 | const pk = primaryKeyForModel(relatedModel) 71 | const type = pluralForModel(relatedModel) 72 | if (Array.isArray(data[name])) { 73 | relationships[name] = {data: []} 74 | for (let relatedData of data[name]) { 75 | relationships[name].data.push({type, id: relatedData[pk]}) 76 | } 77 | } else if (data[name]) { 78 | relationships[name] = {data: {type, id: data[name][pk]}} 79 | } 80 | } 81 | return relationships 82 | } 83 | 84 | function attributesForModel (model, opts = {}) { 85 | const attributes = _.clone(model.definition.properties) 86 | if (opts.primaryKey === false) delete attributes[primaryKeyForModel(model)] 87 | if (opts.foreignKeys === false) { 88 | for (let foreignKey of foreignKeysForModel(model)) { 89 | delete attributes[foreignKey] 90 | } 91 | } 92 | return Object.keys(attributes) 93 | } 94 | 95 | function foreignKeysForModel (model) { 96 | const relations = model.relations 97 | const keys = [] 98 | Object.keys(relations) 99 | .filter(relationName => relations[relationName].type === 'belongsTo') 100 | .forEach(relationName => { 101 | keys.push(relations[relationName].keyFrom) 102 | if (relations[relationName].polymorphic) { 103 | keys.push(relations[relationName].polymorphic.discriminator) 104 | } 105 | }) 106 | return keys 107 | } 108 | 109 | function attributesFromData (data, attributes) { 110 | const obj = {} 111 | for (let attr of attributes) obj[attr] = data[attr] 112 | return obj 113 | } 114 | 115 | return { 116 | primaryKeyForModel, 117 | pluralForModel, 118 | buildResourceLinks, 119 | buildRelationships, 120 | buildAttributes, 121 | relationshipLinksFromData, 122 | relationshipDataFromData, 123 | attributesForModel, 124 | attributesFromData, 125 | foreignKeysForModel, 126 | relatedModelFromRelation 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /test/performance.spec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import loopback from 'loopback' 3 | import serialize from '../src' 4 | 5 | test.beforeEach(t => { 6 | const app = t.context.app = loopback() 7 | app.set('legacyExplorer', false) 8 | 9 | const ds = loopback.createDataSource('memory') 10 | 11 | const Post = ds.createModel('post', {title: String}) 12 | const Comment = ds.createModel('comment', {title: String}) 13 | const Author = ds.createModel('author', {name: String}) 14 | 15 | app.model(Post) 16 | app.model(Author) 17 | app.model(Comment) 18 | 19 | Post.hasMany(Comment) 20 | Post.belongsTo(Author) 21 | Comment.belongsTo(Author) 22 | 23 | app.use(loopback.rest()) 24 | }) 25 | 26 | test.beforeEach(async t => { 27 | const { Post, Author, Comment } = t.context.app.models 28 | const author = await Author.create({name: 'Bob Smith'}) 29 | for (let i = 0; i < 1000; i++) { 30 | const post = await Post.create({title: `post ${i}`, authorId: author.id}) 31 | for (let x = 0; x < 10; x++) { 32 | await Comment.create({title: 'my comment', postId: post.id, authorId: author.id}) 33 | } 34 | } 35 | }) 36 | 37 | test.beforeEach(async t => { 38 | const { Post } = t.context.app.models 39 | const posts = await Post.find({include: ['author', {comments: 'author'}]}) 40 | t.context.posts = posts 41 | }) 42 | 43 | test('performance test serializing 1000 posts with nested relations', t => { 44 | t.plan(4) 45 | const { Post } = t.context.app.models 46 | const data = t.context.posts 47 | 48 | const posts = serialize(data, Post) 49 | 50 | t.truthy(posts) 51 | t.true(Array.isArray(posts.data)) 52 | t.is(posts.data.length, 1000) 53 | t.true(Array.isArray(posts.included)) 54 | }) 55 | -------------------------------------------------------------------------------- /test/serializer.spec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import loopback from 'loopback' 3 | import serializer from '../src/serializer' 4 | 5 | test.beforeEach(t => { 6 | const app = t.context.app = loopback() 7 | app.set('legacyExplorer', false) 8 | 9 | const ds = loopback.createDataSource('memory') 10 | 11 | const Post = ds.createModel('post', {title: String, content: String}) 12 | const Author = ds.createModel('author', {name: String, email: String}) 13 | const Comment = ds.createModel('comment', {title: String, comment: String}) 14 | const Parent = ds.createModel('parent', {name: String}) 15 | const Critic = ds.createModel('critic', {name: String}) 16 | const Empty = ds.createModel('empty', {}) 17 | 18 | app.model(Post) 19 | app.model(Author) 20 | app.model(Comment) 21 | app.model(Parent) 22 | app.model(Critic) 23 | app.model(Empty) 24 | 25 | Comment.belongsTo(Post) 26 | Post.hasMany(Comment) 27 | Post.belongsTo(Author) 28 | Post.belongsTo('parent', {polymorphic: true}) 29 | Post.hasAndBelongsToMany(Critic) 30 | Author.hasMany(Post) 31 | Author.hasMany(Comment, {through: Post}) 32 | 33 | app.use(loopback.rest()) 34 | }) 35 | 36 | test('id', t => { 37 | t.plan(1) 38 | const { Post } = t.context.app.models 39 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom'} 40 | 41 | const id = serializer().id(data, Post) 42 | 43 | const expected = 1 44 | t.deepEqual(id, expected, `serialized should match ${expected}`) 45 | }) 46 | 47 | test('type', t => { 48 | t.plan(1) 49 | const { Post } = t.context.app.models 50 | const data = {} 51 | 52 | const type = serializer().type(data, Post) 53 | 54 | const expected = 'posts' 55 | t.deepEqual(type, expected, `serialized should match ${expected}`) 56 | }) 57 | 58 | test('attributes', t => { 59 | t.plan(1) 60 | const { Post } = t.context.app.models 61 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom'} 62 | 63 | const attributes = serializer().attributes(data, Post) 64 | 65 | const expected = {title: 'my title', content: 'my content'} 66 | t.deepEqual(attributes, expected, `serialized should match ${JSON.stringify(expected)}`) 67 | }) 68 | 69 | test('attributes keeping foreign keys', t => { 70 | t.plan(1) 71 | const { Post } = t.context.app.models 72 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom', parentId: 1, parentType: 'parent'} 73 | 74 | const attributes = serializer({foreignKeys: true}).attributes(data, Post) 75 | 76 | const expected = {title: 'my title', content: 'my content', authorId: 1, parentId: 1, parentType: 'parent'} 77 | t.deepEqual(attributes, expected, `serialized should match ${JSON.stringify(expected)}`) 78 | }) 79 | 80 | test('attributes keeping primary key', t => { 81 | t.plan(1) 82 | const { Post } = t.context.app.models 83 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom'} 84 | 85 | const attributes = serializer({primaryKey: true}).attributes(data, Post) 86 | 87 | const expected = {id: 1, title: 'my title', content: 'my content'} 88 | t.deepEqual(attributes, expected, `serialized should match ${JSON.stringify(expected)}`) 89 | }) 90 | 91 | test('resource miminal', t => { 92 | t.plan(1) 93 | const { Empty } = t.context.app.models 94 | const data = {id: 1, other: 'custom'} 95 | 96 | const resource = serializer().resource(data, Empty) 97 | 98 | const expected = {id: 1, type: 'empties', links: {self: '/empties/1'}} 99 | t.deepEqual(resource, expected, `serialized should match ${JSON.stringify(expected)}`) 100 | }) 101 | 102 | test('resource miminal with baseUrl', t => { 103 | t.plan(1) 104 | const { Empty } = t.context.app.models 105 | const data = {id: 1, other: 'custom'} 106 | 107 | const resource = serializer({baseUrl: 'http://localhost:3000'}).resource(data, Empty) 108 | 109 | const expected = {id: 1, type: 'empties', links: {self: 'http://localhost:3000/empties/1'}} 110 | t.deepEqual(resource, expected, `serialized should match ${JSON.stringify(expected)}`) 111 | }) 112 | 113 | test('resource with attributes', t => { 114 | t.plan(1) 115 | const { Author } = t.context.app.models 116 | const data = {id: 1, other: 'custom', name: 'joe bloggs'} 117 | const options = {baseUrl: 'http://authors.com/api/'} 118 | 119 | const resource = serializer(options).resource(data, Author) 120 | 121 | const expected = { 122 | id: 1, 123 | type: 'authors', 124 | links: {self: 'http://authors.com/api/authors/1'}, 125 | attributes: {name: 'joe bloggs', email: undefined}, 126 | relationships: { 127 | posts: { 128 | links: { 129 | related: 'http://authors.com/api/authors/1/posts' 130 | } 131 | }, 132 | comments: { 133 | links: { 134 | related: 'http://authors.com/api/authors/1/comments' 135 | } 136 | } 137 | } 138 | } 139 | t.deepEqual(resource, expected, `serialized should match ${JSON.stringify(expected)}`) 140 | }) 141 | 142 | test('included', t => { 143 | t.plan(1) 144 | const { Post } = t.context.app.models 145 | const data = {id: 1, title: 'my title', comments: [ 146 | {id: 1, comment: 'my comment'} 147 | ]} 148 | 149 | const included = serializer().included(data, Post) 150 | 151 | const expected = [{ 152 | id: 1, 153 | type: 'comments', 154 | links: {self: '/comments/1'}, 155 | attributes: {comment: 'my comment', title: undefined}, 156 | relationships: { 157 | post: {links: {related: '/comments/1/post'}} 158 | } 159 | }] 160 | t.deepEqual([...included.values()], expected, `serialized should match ${JSON.stringify(expected)}`) 161 | }) 162 | 163 | test('included comments length 2', t => { 164 | t.plan(2) 165 | const { Post } = t.context.app.models 166 | const data = {id: 1, title: 'my title', comments: [ 167 | {id: 1, comment: 'my comment 1'}, 168 | {id: 2, comment: 'my comment 2'} 169 | ]} 170 | 171 | const included = serializer().included(data, Post) 172 | 173 | t.truthy(included, 'included should be truthy') 174 | t.is(included.size, 2, 'included length should be 2') 175 | }) 176 | 177 | test('included comments with post with critic', t => { 178 | t.plan(2) 179 | const { Post } = t.context.app.models 180 | const data = {id: 1, title: 'my title', comments: [ 181 | { 182 | id: 1, 183 | comment: 'my comment 1', 184 | post: { 185 | id: 2, 186 | title: 'my post 2', 187 | critics: [ 188 | {id: 1, name: 'critic 1'}, 189 | {id: 2, name: 'critic 2'} 190 | ] 191 | } 192 | } 193 | ]} 194 | 195 | const included = serializer().included(data, Post) 196 | t.truthy(included, 'included should be truthy') 197 | t.is(included.size, 4, 'included length should be 4') 198 | }) 199 | 200 | test('included comments with post with critic', t => { 201 | t.plan(2) 202 | const { Post } = t.context.app.models 203 | const data = { 204 | id: 2, 205 | title: 'my post 2', 206 | critics: [ { id: 1, name: 'critic 1' }, { id: 2, name: 'critic 2' } ] 207 | } 208 | 209 | const included = serializer().included(data, Post) 210 | t.truthy(included, 'included should be truthy') 211 | t.is(included.size, 2, 'included length should be 4') 212 | }) 213 | 214 | test('serialize single resource', t => { 215 | t.plan(4) 216 | const { Post } = t.context.app.models 217 | const data = { 218 | id: 2, 219 | title: 'my post 2' 220 | } 221 | 222 | const resource = serializer().serialize(data, Post) 223 | 224 | t.truthy(resource) 225 | t.truthy(resource.data) 226 | t.is(resource.data.id, 2) 227 | t.is(resource.data.type, 'posts') 228 | }) 229 | 230 | test('serialize collection', t => { 231 | t.plan(6) 232 | const { Post } = t.context.app.models 233 | const data = [ 234 | {id: 1, title: 'my post 1'}, 235 | {id: 2, title: 'my post 2'}, 236 | {id: 3, title: 'my post 3'} 237 | ] 238 | 239 | const resource = serializer().serialize(data, Post) 240 | 241 | t.truthy(resource) 242 | t.truthy(Array.isArray(resource.data)) 243 | t.is(resource.data[0].id, 1) 244 | t.is(resource.data[1].id, 2) 245 | t.is(resource.data[2].id, 3) 246 | t.is(resource.data[0].type, 'posts') 247 | }) 248 | 249 | test('serialize collection', t => { 250 | t.plan(8) 251 | const { Post } = t.context.app.models 252 | const data = { 253 | id: 2, 254 | title: 'my post 2', 255 | critics: [ 256 | {id: 1, name: 'critic 1'}, 257 | {id: 2, name: 'critic 2'} 258 | ] 259 | } 260 | 261 | const resource = serializer().serialize(data, Post) 262 | 263 | t.truthy(resource.included) 264 | t.truthy(resource.data) 265 | t.is(resource.data.id, 2) 266 | t.is(resource.data.type, 'posts') 267 | t.true(Array.isArray(resource.included)) 268 | t.is(resource.included.length, 2) 269 | t.is(resource.included[0].id, 1) 270 | t.is(resource.included[0].type, 'critics') 271 | }) 272 | 273 | test('included ensures uniqueness', t => { 274 | t.plan(5) 275 | const { Post } = t.context.app.models 276 | const data = [ 277 | {id: 1, title: 'my post 2', critics: [{id: 1, name: 'critic 1'}, {id: 2, name: 'critic 2'}]}, 278 | {id: 2, title: 'my post 2', author: {id: 1, name: 'critic 1', comments: [{id: 1, comment: 'hi'}, {id: 2}]}}, 279 | {id: 3, title: 'my post 2', author: {id: 1, name: 'critic 1', comments: [{id: 1, comment: 'hi'}, {id: 2}]}} 280 | ] 281 | 282 | const collection = serializer().serialize(data, Post) 283 | 284 | t.true(Array.isArray(collection.included), 'included property should be an array') 285 | t.is(collection.included.length, 5, 'included array should contain 5 items') 286 | t.is(collection.included[2].id, 1, 'Third item should have id 1') 287 | t.is(collection.included[2].type, 'authors', 'third item should be an author') 288 | t.truthy(collection.included[2].relationships.comments.data, 'author should have relationship data') 289 | }) 290 | -------------------------------------------------------------------------------- /test/util.spec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import loopback from 'loopback' 3 | import util from '../src/util' 4 | 5 | test.beforeEach(t => { 6 | const app = t.context.app = loopback() 7 | app.set('legacyExplorer', false) 8 | 9 | const ds = loopback.createDataSource('memory') 10 | 11 | const Post = ds.createModel('post', {title: String, content: String}) 12 | const Author = ds.createModel('author', {name: String, email: String}) 13 | const Comment = ds.createModel('comment', {title: String, comment: String}) 14 | const Parent = ds.createModel('parent', {name: String}) 15 | const Critic = ds.createModel('critic', {name: String}) 16 | const Appointment = ds.createModel('appointment', {name: String}) 17 | const Physician = ds.createModel('physician', {name: String}) 18 | const Patient = ds.createModel('patient', {name: String}) 19 | const House = ds.createModel('house', {name: String}) 20 | const Door = ds.createModel('door', {name: String}) 21 | const Window = ds.createModel('window', {name: String}) 22 | const Tile = ds.createModel('tile', {name: String}) 23 | const Floor = ds.createModel('floor', {name: String}) 24 | 25 | app.model(Post) 26 | app.model(Author) 27 | app.model(Comment) 28 | app.model(Parent) 29 | app.model(Critic) 30 | app.model(Appointment) 31 | app.model(Physician) 32 | app.model(Patient) 33 | app.model(House) 34 | app.model(Door) 35 | app.model(Window) 36 | app.model(Tile) 37 | app.model(Floor) 38 | 39 | Comment.belongsTo(Post) 40 | Post.hasMany(Comment) 41 | Post.belongsTo(Author) 42 | Parent.hasMany(Post, {polymorphic: {discriminator: 'parentType', foreignKey: 'parentId'}}) 43 | Post.belongsTo('parent', {polymorphic: true}) 44 | Post.hasAndBelongsToMany(Critic) 45 | Author.hasMany(Post) 46 | Author.hasMany(Comment, {through: Post}) 47 | House.embedsOne(Door) 48 | House.embedsMany(Window) 49 | House.referencesMany(Tile) 50 | House.hasOne(Floor) 51 | Appointment.belongsTo(Patient) 52 | Appointment.belongsTo(Physician) 53 | Physician.hasMany(Patient, {through: Appointment}) 54 | Patient.hasMany(Physician, {through: Appointment}) 55 | 56 | app.use(loopback.rest()) 57 | }) 58 | 59 | test('pluralForModel', t => { 60 | t.plan(2) 61 | const { Post, Comment } = t.context.app.models 62 | 63 | const postPlural = util().pluralForModel(Post) 64 | const commentPlural = util().pluralForModel(Comment) 65 | 66 | t.is(postPlural, 'posts', 'post plural should be posts') 67 | t.is(commentPlural, 'comments', 'comment plural should be comments') 68 | }) 69 | 70 | test('primaryKeyForModel', t => { 71 | t.plan(2) 72 | const { Post, Comment } = t.context.app.models 73 | 74 | const postPrimaryKey = util().primaryKeyForModel(Post) 75 | const commentPrimaryKey = util().primaryKeyForModel(Comment) 76 | 77 | t.is(postPrimaryKey, 'id', 'post primary key should be id') 78 | t.is(commentPrimaryKey, 'id', 'comment primary key should be id') 79 | }) 80 | 81 | test('attributesFromData', t => { 82 | t.plan(1) 83 | const data = {id: 1, title: 'my title', other: 'other stuff'} 84 | const attributeNames = ['id', 'title'] 85 | 86 | const attributes = util().attributesFromData(data, attributeNames) 87 | 88 | t.deepEqual(attributes, {id: 1, title: 'my title'}, `should match ${JSON.stringify({id: 1, title: 'my title'})}`) 89 | }) 90 | 91 | test('attributesForModel', t => { 92 | t.plan(1) 93 | const { Post } = t.context.app.models 94 | 95 | const attributes = util().attributesForModel(Post) 96 | 97 | const expected = ['title', 'content', 'id', 'authorId', 'parentType', 'parentId'] 98 | t.deepEqual(attributes, expected, `should match ${JSON.stringify(expected)}`) 99 | }) 100 | 101 | test('attributesForModel option: primaryKey: false', t => { 102 | t.plan(1) 103 | const { Post } = t.context.app.models 104 | 105 | const attributes = util().attributesForModel(Post, {primaryKey: false}) 106 | 107 | const expected = ['title', 'content', 'authorId', 'parentType', 'parentId'] 108 | t.deepEqual(attributes, expected, `should match ${JSON.stringify(expected)}`) 109 | }) 110 | 111 | test('attributesForModel option: foreignKeys: false', t => { 112 | t.plan(1) 113 | const { Post } = t.context.app.models 114 | 115 | const attributes = util().attributesForModel(Post, {foreignKeys: false}) 116 | 117 | t.deepEqual(attributes, ['title', 'content', 'id'], 118 | `attributes should match ${JSON.stringify(['id', 'title', 'content'])}`) 119 | }) 120 | 121 | test('foreignKeysForModel', t => { 122 | t.plan(1) 123 | const { Post } = t.context.app.models 124 | 125 | const foreignkeys = util().foreignKeysForModel(Post) 126 | 127 | const expected = ['authorId', 'parentId', 'parentType'] 128 | t.deepEqual(foreignkeys, expected, `should match ${JSON.stringify(expected)}`) 129 | }) 130 | 131 | test('buildAttributes', t => { 132 | t.plan(1) 133 | const { Post } = t.context.app.models 134 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom', parentId: 1, parentType: 'parent'} 135 | 136 | const attributes = util().buildAttributes(data, Post) 137 | 138 | const expected = {id: 1, authorId: 1, title: data.title, content: data.content, parentId: 1, parentType: 'parent'} 139 | t.deepEqual(attributes, expected, `should match ${JSON.stringify(expected)}`) 140 | }) 141 | 142 | test('buildAttributes options: primaryKey: false', t => { 143 | t.plan(1) 144 | const { Post } = t.context.app.models 145 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom', parentId: 1, parentType: 'parent'} 146 | 147 | const attributes = util().buildAttributes(data, Post, {primaryKey: false}) 148 | 149 | const expected = {authorId: 1, title: data.title, content: data.content, parentId: 1, parentType: 'parent'} 150 | t.deepEqual(attributes, expected, `should match ${JSON.stringify(expected)}`) 151 | }) 152 | 153 | test('buildAttributes options: primaryKey: false, foreignKeys: false', t => { 154 | t.plan(1) 155 | const { Post } = t.context.app.models 156 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom'} 157 | 158 | const attributes = util().buildAttributes(data, Post, {primaryKey: false, foreignKeys: false}) 159 | 160 | const expected = {title: data.title, content: data.content} 161 | t.deepEqual(attributes, expected, `should match ${JSON.stringify(expected)}`) 162 | }) 163 | 164 | test('buildResourceLinks', t => { 165 | t.plan(1) 166 | const { Post } = t.context.app.models 167 | const data = {id: 1, authorId: 1, title: 'my title', content: 'my content', other: 'custom'} 168 | const options = {baseUrl: 'http://posts.com/'} 169 | 170 | const links = util(options).buildResourceLinks(data, Post) 171 | 172 | const expected = {self: 'http://posts.com/posts/1'} 173 | t.deepEqual(links, expected, `should match ${JSON.stringify(expected)}`) 174 | }) 175 | 176 | test('relationshipLinksFromData', t => { 177 | t.plan(1) 178 | const { Post } = t.context.app.models 179 | const data = {id: 1} 180 | 181 | const links = util().relationshipLinksFromData(data, Post) 182 | 183 | const expected = { 184 | comments: { 185 | links: { 186 | related: '/posts/1/comments' 187 | } 188 | }, 189 | author: { 190 | links: { 191 | related: '/posts/1/author' 192 | } 193 | }, 194 | parent: { 195 | links: { 196 | related: '/posts/1/parent' 197 | } 198 | }, 199 | critics: { 200 | links: { 201 | related: '/posts/1/critics' 202 | } 203 | } 204 | } 205 | t.deepEqual(links, expected, `should match ${JSON.stringify(expected)}`) 206 | }) 207 | 208 | test('relationshipDataFromData', t => { 209 | t.plan(1) 210 | const { Author } = t.context.app.models 211 | const data = {id: 1, posts: [ 212 | {id: 1, name: 'my name 1'}, 213 | {id: 2, name: 'my name 2'}, 214 | {id: 3, name: 'my name 3'} 215 | ]} 216 | 217 | const links = util().relationshipDataFromData(data, Author) 218 | 219 | const expected = { 220 | posts: { 221 | data: [ 222 | {id: 1, type: 'posts'}, 223 | {id: 2, type: 'posts'}, 224 | {id: 3, type: 'posts'} 225 | ] 226 | } 227 | } 228 | t.deepEqual(links, expected, `should match ${JSON.stringify(expected)}`) 229 | }) 230 | 231 | test('relationshipDataFromData no included data', t => { 232 | t.plan(1) 233 | const { Author } = t.context.app.models 234 | const data = {id: 1} 235 | 236 | const links = util().relationshipDataFromData(data, Author) 237 | 238 | const expected = {} 239 | t.deepEqual(links, expected, `should match ${JSON.stringify(expected)}`) 240 | }) 241 | 242 | test('relationshipDataFromData singular relation', t => { 243 | t.plan(1) 244 | const { Appointment } = t.context.app.models 245 | const data = {id: 1, patient: {id: 1, name: 'my name 1'}} 246 | 247 | const links = util().relationshipDataFromData(data, Appointment) 248 | 249 | const expected = {patient: {data: {id: 1, type: 'patients'}}} 250 | t.deepEqual(links, expected, `should match ${JSON.stringify(expected)}`) 251 | }) 252 | 253 | test('relatedModelFromRelation post.comments', t => { 254 | const { Post } = t.context.app.models 255 | const relation = Post.relations.comments 256 | const lib = util() 257 | 258 | const model = lib.relatedModelFromRelation(relation) 259 | 260 | t.is(lib.pluralForModel(model), 'comments', 'should equal `comment`') 261 | }) 262 | 263 | test('relatedModelFromRelation post.author', t => { 264 | const { Post } = t.context.app.models 265 | const relation = Post.relations.author 266 | const lib = util() 267 | 268 | const model = lib.relatedModelFromRelation(relation) 269 | 270 | t.is(lib.pluralForModel(model), 'authors', 'should equal `authors`') 271 | }) 272 | 273 | test('relatedModelFromRelation post.critics', t => { 274 | const { Post } = t.context.app.models 275 | const relation = Post.relations.critics 276 | const lib = util() 277 | 278 | const model = lib.relatedModelFromRelation(relation) 279 | 280 | t.is(lib.pluralForModel(model), 'critics', 'should equal `critics`') 281 | }) 282 | 283 | test('relatedModelFromRelation House embedsOne Door', t => { 284 | const { House } = t.context.app.models 285 | const relation = House.relations.doorItem 286 | const lib = util() 287 | 288 | const model = lib.relatedModelFromRelation(relation) 289 | 290 | t.is(lib.pluralForModel(model), 'doors', 'should equal `doors`') 291 | }) 292 | 293 | test('relatedModelFromRelation House embedsMany Window', t => { 294 | const { House } = t.context.app.models 295 | const relation = House.relations.windowList 296 | const lib = util() 297 | 298 | const model = lib.relatedModelFromRelation(relation) 299 | 300 | t.is(lib.pluralForModel(model), 'windows', 'should equal `windows`') 301 | }) 302 | 303 | test('relatedModelFromRelation House referencesMany Tile', t => { 304 | const { House } = t.context.app.models 305 | const relation = House.relations.tiles 306 | const lib = util() 307 | 308 | const model = lib.relatedModelFromRelation(relation) 309 | 310 | t.is(lib.pluralForModel(model), 'tiles', 'should equal `tiles`') 311 | }) 312 | 313 | test('relatedModelFromRelation House hasOne Floor', t => { 314 | const { House } = t.context.app.models 315 | const relation = House.relations.floor 316 | const lib = util() 317 | 318 | const model = lib.relatedModelFromRelation(relation) 319 | 320 | t.is(lib.pluralForModel(model), 'floors', 'should equal `floors`') 321 | }) 322 | 323 | test('relatedModelFromRelation Physician hasMany Patient through Appointment', t => { 324 | const { Physician } = t.context.app.models 325 | const relation = Physician.relations.patients 326 | const lib = util() 327 | 328 | const model = lib.relatedModelFromRelation(relation) 329 | 330 | t.is(lib.pluralForModel(model), 'patients', 'should equal `patients`') 331 | }) 332 | 333 | test('buildRelations', t => { 334 | t.plan(1) 335 | const { Author } = t.context.app.models 336 | const data = {id: 1, posts: [{id: 1}, {id: 2}, {id: 3}]} 337 | const opts = {baseUrl: 'http://locahost:3000'} 338 | 339 | const rels = util(opts).buildRelationships(data, Author) 340 | 341 | const expected = { 342 | posts: { 343 | links: { 344 | related: 'http://locahost:3000/authors/1/posts' 345 | }, 346 | data: [ 347 | {id: 1, type: 'posts'}, 348 | {id: 2, type: 'posts'}, 349 | {id: 3, type: 'posts'} 350 | ] 351 | }, 352 | comments: { 353 | links: { 354 | related: 'http://locahost:3000/authors/1/comments' 355 | } 356 | } 357 | } 358 | 359 | t.deepEqual(rels, expected, `should match ${JSON.stringify(expected)}`) 360 | }) 361 | 362 | --------------------------------------------------------------------------------