From 37761e97b2a3130bb469481b078b547021edeb53 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 21 Feb 2017 20:58:17 +0200 Subject: [PATCH] Add data module for handling JSONApi responses --- src/util/data.js | 54 ++++++++++++++ src/util/data.test.js | 165 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/util/data.js create mode 100644 src/util/data.test.js diff --git a/src/util/data.js b/src/util/data.js new file mode 100644 index 00000000..4e54c809 --- /dev/null +++ b/src/util/data.js @@ -0,0 +1,54 @@ +/** + * Merge the given relationships objects + * + * See: http://jsonapi.org/format/#document-resource-object-relationships + */ +export const mergeRelationships = (rels1, rels2) => { + if (!rels1 && !rels2) { + // Special case to avoid adding an empty relationships object when + // none of the resource objects had any relationships. + return null; + } + return { ...rels1, ...rels2 }; +}; + +/** + * Merge the given resource objects + * + * See: http://jsonapi.org/format/#document-resource-objects + */ +export const mergeResourceObjects = (res1, res2) => { + const { id, type } = res1; + if (res2.id.uuid !== id.uuid || res2.type !== type) { + throw new Error('Cannot merge resource objects with different ids or types'); + } + const attributes = res2.attributes || res1.attributes; + const attrs = attributes ? { attributes } : null; + const relationships = mergeRelationships(res1.relationships, res2.relationships); + const rels = relationships ? { relationships } : null; + return { id, type, ...attrs, ...rels }; +}; + +/** + * Merge the resource objects form the given api response to the + * existing entities. + */ +export const mergeEntities = (oldEntities, apiResponse) => { + const { data, included = [] } = apiResponse; + const objects = (Array.isArray(data) ? data : [data]).concat(included); + + /* eslint-disable no-param-reassign */ + const newEntities = objects.reduce( + (entities, curr) => { + const { id, type } = curr; + entities[type] = entities[type] || {}; + const entity = entities[type][id.uuid]; + entities[type][id.uuid] = entity ? mergeResourceObjects(entity, curr) : curr; + return entities; + }, + oldEntities, + ); + /* eslint-enable no-param-reassign */ + + return newEntities; +}; diff --git a/src/util/data.test.js b/src/util/data.test.js new file mode 100644 index 00000000..3a7669a9 --- /dev/null +++ b/src/util/data.test.js @@ -0,0 +1,165 @@ +import { types } from 'sharetribe-sdk'; +import { mergeRelationships, mergeResourceObjects, mergeEntities } from './data'; + +const { UUID } = types; + +describe('data utils', () => { + describe('mergeRelationships()', () => { + it('handles no rels', () => { + expect(mergeRelationships(undefined, undefined)).toEqual(null); + }); + + it('takes existings rels if no new rels are given', () => { + const listingRels = { + author: { data: { id: new UUID('author-id'), type: 'author' } }, + images: { + data: [ + { id: new UUID('image1'), type: 'image' }, + { id: new UUID('image2'), type: 'image' }, + ], + }, + }; + expect(mergeRelationships(listingRels, undefined)).toEqual(listingRels); + }); + + it('takes new rels if no existing rels are given', () => { + const listingRels = { + author: { data: { id: new UUID('author-id'), type: 'author' } }, + images: { data: { id: new UUID('image1'), type: 'image' } }, + }; + expect(mergeRelationships(undefined, listingRels)).toEqual(listingRels); + }); + + it('merges two nonempty rels', () => { + const authorRel = { data: { id: new UUID('author-id'), type: 'author' } }; + const imagesRel = { + data: [ + { id: new UUID('image1'), type: 'image' }, + { id: new UUID('image2'), type: 'image' }, + ], + }; + const newImagesRel = { data: { id: new UUID('image1'), type: 'image' } }; + const reviewsRel = { + data: [ + { id: new UUID('review1'), type: 'review' }, + { id: new UUID('review2'), type: 'review' }, + ], + }; + + const oldListingRels = { author: authorRel, images: imagesRel }; + const newListingRels = { images: newImagesRel, reviews: reviewsRel }; + expect(mergeRelationships(oldListingRels, newListingRels)).toEqual({ + author: authorRel, + images: newImagesRel, + reviews: reviewsRel, + }); + }); + }); + + describe('mergeResourceObjects()', () => { + it("throws if ids don't match", () => { + const listing1 = { id: new UUID('listing1'), type: 'listing' }; + const listing2 = { id: new UUID('listing2'), type: 'listing' }; + expect(() => mergeResourceObjects(listing1, listing2)).toThrow( + 'Cannot merge resource objects with different ids or types', + ); + }); + + it("throws if types don't match", () => { + const listing1 = { id: new UUID('listing1'), type: 'listing' }; + const author1 = { id: new UUID('author1'), type: 'author' }; + expect(() => mergeResourceObjects(listing1, author1)).toThrow( + 'Cannot merge resource objects with different ids or types', + ); + }); + + it("doesn't add attributes or relationships keys unnecessarily", () => { + const res1 = { id: new UUID('listing1'), type: 'listing' }; + const res2 = { id: new UUID('listing1'), type: 'listing' }; + const merged = mergeResourceObjects(res1, res2); + expect(merged).toEqual(res1); + const keys = Object.keys(merged).sort(); + expect(keys).toEqual(['id', 'type']); + }); + + it('merges new attributes', () => { + const res1 = { + id: new UUID('listing1'), + type: 'listing', + attributes: { title: 'Listing 1 title', description: 'Listing 1 description' }, + }; + const res2 = { + id: new UUID('listing1'), + type: 'listing', + attributes: { title: 'Changed title', description: 'Some description' }, + }; + expect(mergeResourceObjects(res1, res2)).toEqual(res2); + }); + + it("keeps old attributes if new does't have any", () => { + const res1Attributes = { title: 'Listing 1 title', description: 'Listing 1 description' }; + const res2Relationships = { author: { data: { id: new UUID('author1'), type: 'author' } } }; + + const res1 = { id: new UUID('listing1'), type: 'listing', attributes: res1Attributes }; + const res2 = { id: new UUID('listing1'), type: 'listing', relationships: res2Relationships }; + expect(mergeResourceObjects(res1, res2)).toEqual({ + id: new UUID('listing1'), + type: 'listing', + attributes: res1Attributes, + relationships: res2Relationships, + }); + }); + }); + + describe('mergeEntities()', () => { + it('adds a single entity', () => { + const listing1 = { id: new UUID('listing1'), type: 'listing' }; + const response = { data: listing1 }; + const entities = mergeEntities({}, response); + expect(entities).toEqual({ listing: { listing1 } }); + }); + + it('add multiple entities', () => { + const listing1 = { id: new UUID('listing1'), type: 'listing' }; + const listing2 = { id: new UUID('listing2'), type: 'listing' }; + const response = { data: [listing1, listing2] }; + const entities = mergeEntities({}, response); + expect(entities).toEqual({ listing: { listing1, listing2 } }); + }); + + it('handles a more complex merge', () => { + const listing1 = { + id: new UUID('listing1'), + type: 'listing', + attributes: { title: 'Listing 1 title', description: 'Listing 1 description' }, + }; + const listing2 = { + id: new UUID('listing2'), + type: 'listing', + attributes: { title: 'Listing 2 title', description: 'Listing 2 description' }, + }; + const initialResponse = { data: [listing1, listing2] }; + const initialEntities = mergeEntities({}, initialResponse); + expect(initialEntities).toEqual({ listing: { listing1, listing2 } }); + const author1 = { id: new UUID('author1'), type: 'author' }; + const author1WithAttributes = { + id: new UUID('author1'), + type: 'author', + attributes: { name: 'Author 1 name' }, + }; + const listing2Updated = { + id: new UUID('listing2'), + type: 'listing', + attributes: { title: 'New listing 2 title', description: 'new listing 2 description' }, + relationships: { author: { data: author1 } }, + }; + const included = [author1WithAttributes]; + const response = { data: [listing2Updated], included }; + const entities = mergeEntities(initialEntities, response, true); + expect(entities).toEqual({ + listing: { listing1, listing2: listing2Updated }, + author: { author1: author1WithAttributes }, + }); + }); + }); +});