diff --git a/packages/jsonapi/src/apis.js b/packages/jsonapi/src/apis.js index 2438fff5..271973a3 100644 --- a/packages/jsonapi/src/apis.js +++ b/packages/jsonapi/src/apis.js @@ -1,7 +1,7 @@ import _ from 'lodash'; /* eslint-disable-line max-classes-per-file */ import axios from 'axios'; -import { isNull, isResource } from './utils'; +import { isResource } from './utils'; import { JsonApiException } from './errors'; import Resource from './resources'; @@ -25,7 +25,7 @@ export function validateStatus(status) { * * const familyApi = new FamilyApi({ auth: 'MYTOKEN' }); * - * After this, you can access the `Resouce` subclass and its methods on the + * After this, you can access the `Resource` subclass and its methods on the * connection instance directly. You can use either the name you supplied as a * second argument to `.register()` or its TYPE static field: * @@ -190,7 +190,7 @@ export default class JsonApi { * Appropriate Resource subclass. * */ asResource(value) { - if (isNull(value) || isResource(value)) { + if (!value || isResource(value)) { return value; } let actualValue = value; diff --git a/packages/jsonapi/src/collections.js b/packages/jsonapi/src/collections.js index 773a4845..a789521a 100644 --- a/packages/jsonapi/src/collections.js +++ b/packages/jsonapi/src/collections.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import { DoesNotExist, MultipleObjectsReturned } from './errors'; -import { hasData, isNull } from './utils'; /* eslint-disable-line import/no-cycle */ +import { hasData } from './utils'; /* eslint-disable-line import/no-cycle */ import Resource from './resources'; /* eslint-disable-line import/no-cycle */ /** @@ -45,7 +45,7 @@ export default class Collection { } async fetch() { - if (!isNull(this.data)) { + if (this.data) { return; } @@ -69,7 +69,7 @@ export default class Collection { Object .entries(item.relationships || {}) .forEach(([name, relationship]) => { - if (isNull(relationship) || !hasData(relationship)) { + if (!relationship || !hasData(relationship)) { return; } const key = `${relationship.data.type}__${relationship.data.id}`; diff --git a/packages/jsonapi/src/resources.js b/packages/jsonapi/src/resources.js index 495740d8..91d89822 100644 --- a/packages/jsonapi/src/resources.js +++ b/packages/jsonapi/src/resources.js @@ -1,16 +1,6 @@ import _ from 'lodash'; /* eslint-disable-line max-classes-per-file */ -import { /* eslint-disable-line import/no-cycle */ - hasData, - hasLinks, - isList, - isNull, - isObject, - isPluralFetched, - isResource, - isResourceIdentifier, - isSingularFetched, -} from './utils'; +import * as utils from './utils'; /* eslint-disable-line import/no-cycle */ import Collection from './collections'; /* eslint-disable-line import/no-cycle */ /** @@ -87,38 +77,41 @@ export default class Resource { const actualRelationships = relationships; Object.entries(props).forEach(([key, value]) => { + // Lets try to determine if the value "looks like" a relationship if ( - // Parent: { type: 'parents', id: '1' } - isResourceIdentifier(value) + // Looks like: parent: new Parent({ id: '1' }) + utils.isResource(value) - // Parent: new Parent({ id: '1' }) - || isResource(value) + // Looks like: parent: { type: 'parents', id: '1' } + || utils.isResourceIdentifier(value) - || (isObject(value) && ( + || (_.isPlainObject(value) && ( - // Parent: { links: { related: 'related' } } - hasLinks(value) + // Looks like: parent: { links: { related: 'related' } } + utils.hasLinks(value) - || (hasData(value) && ( + || (utils.hasData(value) && ( - // Parent: { data: { type: 'parents', id: '1' } } - isResourceIdentifier(value.data) + // Looks like: parent: { data: { type: 'parents', id: '1' } } + utils.isResourceIdentifier(value.data) - // Parent: { data: new Parent({ id: '1' }) } - || isResource(value.data) + // Looks like: parent: { data: new Parent({ id: '1' }) } + || utils.isResource(value.data) - // Children: { data: [{ type: 'children', id: '1' }, - // New Child({ id: '1' })] } - || _.every(value.data, (item) => ( - isResourceIdentifier(item) || isResource(item) - )) - )) + // Looks like: children: { data: [{ type: 'children', id: '1' }, + // New Child({ id: '1' })] } + || ( + _.isArray(value.data) + && value.data.length > 0 + && _.every(value.data, (item) => ( + utils.isResourceIdentifier(item) || utils.isResource(item) + )) + ))) )) - // Children: [{ type: 'children', id: '1' }, new Child({ id: '1' })] - || (isList(value) && value.length > 0 && _.every(value, (item) => ( - isResourceIdentifier(item) - || isResource(item) + // Looks like: children: [{ type: 'children', id: '1' }, new Child({ id: '1' })] + || (_.isArray(value) && value.length > 0 && _.every(value, (item) => ( + utils.isResourceIdentifier(item) || utils.isResource(item) ))) ) { actualRelationships[key] = value; @@ -152,6 +145,11 @@ export default class Resource { _setRelated(relationshipName, value, includedMap = null) { let actualValue = value; + + if (utils.isCollection(actualValue)) { + actualValue = actualValue.data; + } + let actualIncludedMap = includedMap; if (!actualIncludedMap) { actualIncludedMap = {}; @@ -160,19 +158,23 @@ export default class Resource { this.relationships[relationshipName] = null; this.related[relationshipName] = null; } else if ( - isList(actualValue) - || (isObject(actualValue) && isList(actualValue.data)) - || (isObject(actualValue) && hasLinks(actualValue) && !hasData(actualValue)) + _.isArray(actualValue) + || (_.isPlainObject(actualValue) && _.isArray(actualValue.data)) + || ( + _.isPlainObject(actualValue) + && utils.hasLinks(actualValue) + && !utils.hasData(actualValue) + ) ) { this.relationships[relationshipName] = {}; const relationship = this.relationships[relationshipName]; - if (isObject(actualValue) && hasLinks(actualValue)) { + if (_.isPlainObject(actualValue) && utils.hasLinks(actualValue)) { relationship.links = actualValue.links; } - if (hasData(actualValue)) { + if (utils.hasData(actualValue)) { actualValue = actualValue.data; } - if (isList(actualValue)) { + if (_.isArray(actualValue)) { const datas = []; const resources = []; actualValue.forEach((item) => { @@ -215,8 +217,8 @@ export default class Resource { let resource; let data; let links = null; - if (isObject(actualValue)) { - if (hasData(actualValue)) { + if (_.isPlainObject(actualValue)) { + if (utils.hasData(actualValue)) { data = actualValue.data; if ('links' in actualValue) { links = actualValue.links; @@ -225,7 +227,7 @@ export default class Resource { data = actualValue; } resource = this.constructor.API.new(data); - } else if (isResource(actualValue)) { + } else if (utils.isResource(actualValue)) { resource = actualValue; data = resource.asResourceIdentifier(); } else { @@ -380,11 +382,13 @@ export default class Resource { ); } const relationship = this.relationships[relationshipName]; - if (isNull(relationship)) { + if (!relationship) { return null; } const related = this.related[relationshipName]; - if ((isSingularFetched(related) || isPluralFetched(related)) && !force) { + if ( + (utils.isSingularFetched(related) || utils.isPluralFetched(related)) && !force + ) { return related; } if (_.isObject(relationship.data)) { @@ -451,7 +455,7 @@ export default class Resource { async _saveExisting(fields = []) { if (fields.length === 0) { - Object.keys(this.attribute).forEach((field) => { + Object.keys(this.attributes).forEach((field) => { fields.push(field); }); Object.keys(this.related).forEach((field) => { @@ -506,9 +510,21 @@ export default class Resource { if (!('relationships' in result)) { result.relationships = {}; } - result.relationships[field] = this.constructor.API.asResource( - this.relationships[field], - ).asRelationship(); + if ( + 'data' in this.relationships[field] + && _.isArray(this.relationships[field].data) + ) { + result.relationships[field] = { data: [] }; + this.relationships[field].data.forEach((item) => { + result.relationships[field].data.push( + this.constructor.API.asResource(item).asResourceIdentifier(), + ); + }); + } else { + result.relationships[field] = this.constructor.API.asResource( + this.relationships[field], + ).asRelationship(); + } } else { throw new Error(`Unknown field '${field}'`); } @@ -517,23 +533,29 @@ export default class Resource { } _postSave(response) { + if (response.status === 204) { return; } const { data } = response.data; const related = { ...this.related }; Object.entries(related).forEach(([relationshipName, relatedInstance]) => { - if (data.relationships[relationshipName] === null) { + const relationship = (data.relationships || {})[relationshipName]; + + if (!relationship) { related[relationshipName] = null; - } else { + } else if (utils.hasData(relationship) && !_.isArray(relationship.data)) { const oldId = relatedInstance.id; - const newId = data.relationships[relationshipName].data.id; + const newId = relationship.data.id; if (oldId !== newId) { if (newId) { - related[relationshipName] = this.constructor.API.new( - data.relationships[relationshipName], - ); + related[relationshipName] = this.constructor.API.new(relationship); } else { delete related[relationshipName]; } } + } else if (utils.hasData(relationship) && _.isArray(relationship.data)) { + related[relationshipName] = []; + relationship.data.forEach((item) => { + related[relationshipName].push(this.constructor.API.new(item)); + }); } }); const relationships = data.relationships || {}; @@ -789,7 +811,7 @@ export default class Resource { static async bulkDelete(args) { const data = []; args.forEach((arg) => { - if (isResource(arg)) { + if (utils.isResource(arg)) { data.push(arg.asResourceIdentifier()); } else if (_.isPlainObject(arg)) { data.push(this.API.asResource(arg).asResourceIdentifier()); diff --git a/packages/jsonapi/src/utils.js b/packages/jsonapi/src/utils.js index 16945b01..686167ca 100644 --- a/packages/jsonapi/src/utils.js +++ b/packages/jsonapi/src/utils.js @@ -4,40 +4,31 @@ import Collection from './collections'; /* eslint-disable-line import/no-cycle * import Resource from './resources'; /* eslint-disable-line import/no-cycle */ export function hasData(value) { - return _.isObject(value) && 'data' in value; + return _.isPlainObject(value) && 'data' in value; } export function hasLinks(value) { - return _.isObject(value) && 'links' in value; + return _.isPlainObject(value) && 'links' in value; } -export function isNull(value) { - return !value; +export function isResource(value) { + return value instanceof Resource; } export function isSingularFetched(value) { - return (!isNull(value) - && value instanceof Resource + return (value + && isResource(value) && (_.size(value.attributes) > 0 || _.size(value.relationships) > 0)); } export function isPluralFetched(value) { - return (!isNull(value) - && value instanceof Collection); -} - -export function isList(value) { - return _.isArray(value); + return value && value instanceof Collection; } -export function isObject(value) { - return _.isPlainObject(value); -} - -export function isResource(value) { - return value instanceof Resource; +export function isCollection(value) { + return value instanceof Collection; } export function isResourceIdentifier(value) { - return _.isObject(value) && 'type' in value && 'id' in value; + return _.isPlainObject(value) && 'type' in value && 'id' in value; } diff --git a/packages/jsonapi/tests/resources.spec.js b/packages/jsonapi/tests/resources.spec.js index a8c894c3..f65726f4 100644 --- a/packages/jsonapi/tests/resources.spec.js +++ b/packages/jsonapi/tests/resources.spec.js @@ -412,6 +412,81 @@ test('save new', async () => { }); }); +test('save new with plural relationship', async () => { + const parent = new api.Parent({ + name: 'Bill', + children: [new api.Child({ id: '1' }), new api.Child({ id: '2' })], + }); + axios.request.mockResolvedValue({ + method: 'post', + url: '/parents', + data: { + data: { + type: 'parents', + id: '1', + attributes: { name: 'Bill', created: 'now' }, + relationships: { + children: { links: { related: '/parents/1/children' } }, + }, + }, + }, + }); + await parent.save(); + expectRequestMock({ + url: '/parents', + method: 'post', + data: { + data: { + type: 'parents', + attributes: { name: 'Bill' }, + relationships: { + children: { + data: [{ type: 'children', id: '1' }, { type: 'children', id: '2' }], + }, + }, + }, + }, + }); + expect(parent).toEqual({ + id: '1', + attributes: { name: 'Bill', created: 'now' }, + links: {}, + redirect: null, + relationships: { + children: { + data: [{ type: 'children', id: '1' }, { type: 'children', id: '2' }], + }, + }, + related: { + children: { + _API: api, + _url: null, + _params: null, + data: [ + { + id: '1', + attributes: {}, + links: {}, + redirect: null, + relationships: {}, + related: {}, + }, + { + id: '2', + attributes: {}, + links: {}, + redirect: null, + relationships: {}, + related: {}, + }, + ], + next: null, + previous: null, + }, + }, + }); +}); + test('create', async () => { axios.request.mockResolvedValue({ data: { @@ -437,6 +512,80 @@ test('create', async () => { }); }); +test('create with plural relationship', async () => { + axios.request.mockResolvedValue({ + method: 'post', + url: '/parents', + data: { + data: { + type: 'parents', + id: '1', + attributes: { name: 'Bill', created: 'now' }, + relationships: { + children: { links: { related: '/parents/1/children' } }, + }, + }, + }, + }); + const parent = await api.Parent.create({ + name: 'Bill', + children: [new api.Child({ id: '1' }), new api.Child({ id: '2' })], + }); + expectRequestMock({ + url: '/parents', + method: 'post', + data: { + data: { + type: 'parents', + attributes: { name: 'Bill' }, + relationships: { + children: { + data: [{ type: 'children', id: '1' }, { type: 'children', id: '2' }], + }, + }, + }, + }, + }); + expect(parent).toEqual({ + id: '1', + attributes: { name: 'Bill', created: 'now' }, + links: {}, + redirect: null, + relationships: { + children: { + data: [{ type: 'children', id: '1' }, { type: 'children', id: '2' }], + }, + }, + related: { + children: { + _API: api, + _url: null, + _params: null, + data: [ + { + id: '1', + attributes: {}, + links: {}, + redirect: null, + relationships: {}, + related: {}, + }, + { + id: '2', + attributes: {}, + links: {}, + redirect: null, + relationships: {}, + related: {}, + }, + ], + next: null, + previous: null, + }, + }, + }); +}); + test('delete', async () => { const child = new api.Child({ id: '1', name: 'John' }); axios.request.mockResolvedValue({});