Skip to content

Commit 315d633

Browse files
committed
Add methods to 'write' to relationship URLs.
1 parent c06cf84 commit 315d633

File tree

6 files changed

+387
-2
lines changed

6 files changed

+387
-2
lines changed

README.md

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ The 3 categories of Vuex methods are used as follows:
208208

209209
The usual way to use this module is to use `actions` wherever possible. All actions are asynchronous, and both query the API and update the store, then return data in a normalized form. Actions can be handled using the `then/catch` methods of promises, or using `async/await`.
210210

211-
#### RESTful actions
211+
#### 'Direct' Actions
212212

213213
There are 4 actions (with aliases): `get` (`fetch`), `post` (`create`), `patch` (`update`), and `delete` which correspond to RESTful methods.
214214

@@ -232,7 +232,7 @@ this.$store.dispatch('jv/get', 'widget').then((data) => {
232232
console.log(data)
233233
})
234234

235-
// Request Query params (JSONAPI options, auth tokens etc)
235+
// axios request query params (JSONAPI options, auth tokens etc)
236236
const params = {
237237
token: 'abcdef123456',
238238
}
@@ -272,6 +272,9 @@ this.$store.dispatch('jv/get', ['widget/1', { params: params }]).then((widget1)
272272
widget1['color'] = 'red'
273273
this.$store.dispatch('jv/patch', [widget1, { params: params }])
274274
})
275+
276+
// Delete a widget from the API
277+
this.$store.dispatch('jv/delete', ['widget/1', { params: params }])
275278
```
276279

277280
#### search
@@ -288,6 +291,10 @@ const widgetSearch = (text) => {
288291
}
289292
```
290293

294+
#### 'Relationship' Actions
295+
296+
There are also 4 'relationship' actions: `getRelated`, `postRelated`, `patchRelated` and `deleteRelated` which modify relationships via an object's relationship URL.
297+
291298
#### getRelated
292299

293300
_Note_ - in many cases you may prefer to use the jsonapi server-side `include` option to get data on relationships included in your original query. (See [Relationships](#relationships)).
@@ -368,6 +375,56 @@ this.$store.dispatch('jv/getRelated', customRels).then((data) => {
368375
})
369376
```
370377

378+
#### (delete|patch|post)Related
379+
380+
The other 3 methods are all for 'writing' to the relationships of an object. they use the `relationship URLs` of an object, rather than writing to the object itself.
381+
382+
* `post` - adds relationships to an item.
383+
* `delete` - removes relationships from an item.
384+
* `patch` - replace all relationships for an item.
385+
386+
All methods return the updated item from the API, and also update the store (by internally calling the `get` action).
387+
388+
These methods take a single argument - an object representing the item, with the `'_jv` section containing relationships that are to be acted on. For example:
389+
390+
```js
391+
const rels = {
392+
_jv: {
393+
type: 'widget',
394+
id: '1',
395+
relationships: {
396+
widgets: {
397+
data: {
398+
type: 'widget',
399+
id: '2',
400+
},
401+
},
402+
doohickeys: {
403+
data: {
404+
type: 'doohickeys',
405+
id: '10',
406+
},
407+
},
408+
},
409+
},
410+
}
411+
412+
// Adds 'widget/2' and 'doohickey/10' relationships to 'widgets' and 'doohickeys' on 'widget/1'
413+
this.$store.dispatch('jv/postRelated', rels).then((data) => {
414+
console.log(data)
415+
})
416+
417+
// Removes 'widget/2' and 'doohickey/10' relationships from 'widgets' and 'doohickeys' on 'widget/1'
418+
this.$store.dispatch('jv/deleteRelated', rels).then((data) => {
419+
console.log(data)
420+
})
421+
422+
// Replaces 'widgets' and 'doohickeys' relationships with just 'widget/1' and 'doohickey/10'
423+
this.$store.dispatch('jv/patchRelated', rels).then((data) => {
424+
console.log(data)
425+
})
426+
```
427+
371428
#### Error Handling
372429

373430
Most errors are likely to be those raised by the API in response to the request. These will take the form of an [Axios Error Handling](https://github.com/axios/axios#handling-errors) object, containing an [JSONAPI Error object](https://jsonapi.org/format/#error-objects).

src/actions.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,52 @@ import { utils } from './jsonapi-vuex'
1818
const actions = (api, conf) => {
1919
// Short var name
2020
let jvtag = conf['jvtag']
21+
22+
/**
23+
* Internal method to 'write' related items from the API.
24+
* This method is wrapped by `(delete|patch|post)Related` actions, and is not available directly as an action.
25+
*
26+
* @async
27+
* @memberof module:jsonapi-vuex.jsonapiModule.actions
28+
* @param {object} context - Vuex context object
29+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
30+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
31+
* @return {object} Restructured representation of the 'parent' item
32+
*/
33+
const writeRelated = async (context, args, method) => {
34+
let [data, config] = utils.unpackArgs(args)
35+
let [type, id] = utils.getTypeId(data)
36+
if (!type || !id) {
37+
throw 'No type/id specified'
38+
}
39+
40+
let rels
41+
if (typeof data === 'object' && utils.hasProperty(data[jvtag], 'relationships')) {
42+
rels = data[jvtag]['relationships']
43+
} else {
44+
throw 'No relationships specified'
45+
}
46+
47+
// Iterate over all records in rels
48+
let relPromises = []
49+
for (let [relName, relItems] of Object.entries(rels)) {
50+
if (utils.hasProperty(relItems, 'data')) {
51+
let path = `${type}/${id}/relationships/${relName}`
52+
const apiConf = {
53+
method: method,
54+
url: path,
55+
data: relItems,
56+
}
57+
merge(apiConf, config)
58+
relPromises.push(api(apiConf))
59+
}
60+
}
61+
// Wait for all individual API calls to complete
62+
await Promise.all(relPromises)
63+
// Get the updated object from the API
64+
return context.dispatch('get', `${type}/${id}`)
65+
}
66+
2167
return {
2268
/**
2369
* Get items from the API
@@ -155,6 +201,45 @@ const actions = (api, conf) => {
155201
return related
156202
})
157203
},
204+
/**
205+
* DELETE an object's relationships via its `relationships URL`
206+
*
207+
* @async
208+
* @memberof module:jsonapi-vuex.jsonapiModule.actions
209+
* @param {object} context - Vuex context object
210+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
211+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
212+
* @return {object} Restructured representation of the 'parent' item
213+
*/
214+
deleteRelated: (context, args) => {
215+
return writeRelated(context, args, 'delete')
216+
},
217+
/**
218+
* PATCH an object's relationships via its `relationships URL`
219+
*
220+
* @async
221+
* @memberof module:jsonapi-vuex.jsonapiModule.actions
222+
* @param {object} context - Vuex context object
223+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
224+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
225+
* @return {object} Restructured representation of the 'parent' item
226+
*/
227+
patchRelated: async (context, args) => {
228+
return writeRelated(context, args, 'patch')
229+
},
230+
/**
231+
* POST to an object's relationships via its `relationships URL`
232+
*
233+
* @async
234+
* @memberof module:jsonapi-vuex.jsonapiModule.actions
235+
* @param {object} context - Vuex context object
236+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
237+
* @param {object} args - A restructured object, specifying relationship(s) - e.g. `{ _jv: { type: "endpoint", id: "1", relationships: {...} } }`
238+
* @return {object} Restructured representation of the 'parent' item
239+
*/
240+
postRelated: async (context, args) => {
241+
return writeRelated(context, args, 'post')
242+
},
158243
/**
159244
* Post an item to the API
160245
*
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { expect } from 'chai'
2+
3+
import createStubContext from '../stubs/context'
4+
import createJsonapiModule from '../utils/createJsonapiModule'
5+
import { jsonFormat as createJsonWidget1, normFormat as createNormWidget1 } from '../fixtures/widget1'
6+
7+
describe('deleteRelated', function () {
8+
let normWidget1, jsonWidget1, jsonapiModule, stubContext
9+
10+
beforeEach(function () {
11+
normWidget1 = createNormWidget1()
12+
jsonWidget1 = createJsonWidget1()
13+
14+
jsonapiModule = createJsonapiModule(this.api)
15+
stubContext = createStubContext(jsonapiModule)
16+
})
17+
18+
it('Should throw an error if passed an object with no type or id', async function () {
19+
try {
20+
await jsonapiModule.actions.deleteRelated(stubContext, { _jv: {} })
21+
throw 'Should have thrown an error (no id)'
22+
} catch (error) {
23+
expect(error).to.equal('No type/id specified')
24+
}
25+
})
26+
27+
it('Should throw an error if passed an object with no relationships', async function () {
28+
try {
29+
await jsonapiModule.actions.deleteRelated(stubContext, { _jv: { type: 'widget', id: 1 } })
30+
throw 'Should have thrown an error (no relationships)'
31+
} catch (error) {
32+
expect(error).to.equal('No relationships specified')
33+
}
34+
})
35+
36+
it('should make a delete request for the object passed in.', async function () {
37+
this.mockApi.onDelete().replyOnce(204)
38+
this.mockApi.onGet().replyOnce(200, { data: jsonWidget1 })
39+
40+
const rel = { data: { type: 'widget', id: '2' } }
41+
normWidget1['_jv']['relationships'] = { widgets: rel }
42+
43+
await jsonapiModule.actions.deleteRelated(stubContext, normWidget1)
44+
// Expect a delete call to rel url, with rel payload, then get object to update store
45+
expect(this.mockApi.history.delete[0].url).to.equal('widget/1/relationships/widgets')
46+
expect(this.mockApi.history.delete[0].data).to.deep.equal(JSON.stringify(rel))
47+
expect(this.mockApi.history.get[0].url).to.equal('widget/1')
48+
})
49+
50+
it('should handle multiple relationships', async function () {
51+
this.mockApi.onDelete().reply(204)
52+
this.mockApi.onGet().replyOnce(200, { data: jsonWidget1 })
53+
54+
const rel1 = { data: { type: 'widget', id: '2' } }
55+
const rel2 = { data: { type: 'doohickey', id: '3' } }
56+
normWidget1['_jv']['relationships'] = { widgets: rel1, doohickeys: rel2 }
57+
58+
await jsonapiModule.actions.deleteRelated(stubContext, normWidget1)
59+
expect(this.mockApi.history.delete[0].url).to.equal('widget/1/relationships/widgets')
60+
expect(this.mockApi.history.delete[0].data).to.deep.equal(JSON.stringify(rel1))
61+
expect(this.mockApi.history.delete[1].url).to.equal('widget/1/relationships/doohickeys')
62+
expect(this.mockApi.history.delete[1].data).to.deep.equal(JSON.stringify(rel2))
63+
expect(this.mockApi.history.get[0].url).to.equal('widget/1')
64+
// Only get the object once at end
65+
expect(this.mockApi.history.get.length).to.equal(1)
66+
})
67+
68+
it('Should handle API errors (in the data)', async function () {
69+
this.mockApi.onDelete().reply(500)
70+
71+
const rel = { data: { type: 'widget', id: '2' } }
72+
normWidget1['_jv']['relationships'] = { widgets: rel }
73+
74+
try {
75+
await jsonapiModule.actions.deleteRelated(stubContext, normWidget1)
76+
} catch (error) {
77+
expect(error.response.status).to.equal(500)
78+
}
79+
})
80+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { expect } from 'chai'
2+
3+
import createStubContext from '../stubs/context'
4+
import createJsonapiModule from '../utils/createJsonapiModule'
5+
import { jsonFormat as createJsonWidget1, normFormat as createNormWidget1 } from '../fixtures/widget1'
6+
7+
describe('patchRelated', function () {
8+
let normWidget1, jsonWidget1, jsonapiModule, stubContext
9+
10+
beforeEach(function () {
11+
normWidget1 = createNormWidget1()
12+
jsonWidget1 = createJsonWidget1()
13+
14+
jsonapiModule = createJsonapiModule(this.api)
15+
stubContext = createStubContext(jsonapiModule)
16+
})
17+
18+
it('Should throw an error if passed an object with no type or id', async function () {
19+
try {
20+
await jsonapiModule.actions.patchRelated(stubContext, { _jv: {} })
21+
throw 'Should have thrown an error (no id)'
22+
} catch (error) {
23+
expect(error).to.equal('No type/id specified')
24+
}
25+
})
26+
27+
it('Should throw an error if passed an object with no relationships', async function () {
28+
try {
29+
await jsonapiModule.actions.patchRelated(stubContext, { _jv: { type: 'widget', id: 1 } })
30+
throw 'Should have thrown an error (no relationships)'
31+
} catch (error) {
32+
expect(error).to.equal('No relationships specified')
33+
}
34+
})
35+
36+
it('should make a patch request for the object passed in.', async function () {
37+
this.mockApi.onPatch().replyOnce(204)
38+
this.mockApi.onGet().replyOnce(200, { data: jsonWidget1 })
39+
40+
const rel = { data: { type: 'widget', id: '2' } }
41+
normWidget1['_jv']['relationships'] = { widgets: rel }
42+
43+
await jsonapiModule.actions.patchRelated(stubContext, normWidget1)
44+
// Expect a patch call to rel url, with rel payload, then get object to update store
45+
expect(this.mockApi.history.patch[0].url).to.equal('widget/1/relationships/widgets')
46+
expect(this.mockApi.history.patch[0].data).to.deep.equal(JSON.stringify(rel))
47+
expect(this.mockApi.history.get[0].url).to.equal('widget/1')
48+
})
49+
50+
it('should handle multiple relationships', async function () {
51+
this.mockApi.onPatch().reply(204)
52+
this.mockApi.onGet().replyOnce(200, { data: jsonWidget1 })
53+
54+
const rel1 = { data: { type: 'widget', id: '2' } }
55+
const rel2 = { data: { type: 'doohickey', id: '3' } }
56+
normWidget1['_jv']['relationships'] = { widgets: rel1, doohickeys: rel2 }
57+
58+
await jsonapiModule.actions.patchRelated(stubContext, normWidget1)
59+
expect(this.mockApi.history.patch[0].url).to.equal('widget/1/relationships/widgets')
60+
expect(this.mockApi.history.patch[0].data).to.deep.equal(JSON.stringify(rel1))
61+
expect(this.mockApi.history.patch[1].url).to.equal('widget/1/relationships/doohickeys')
62+
expect(this.mockApi.history.patch[1].data).to.deep.equal(JSON.stringify(rel2))
63+
expect(this.mockApi.history.get[0].url).to.equal('widget/1')
64+
// Only get the object once at end
65+
expect(this.mockApi.history.get.length).to.equal(1)
66+
})
67+
68+
it('Should handle API errors (in the data)', async function () {
69+
this.mockApi.onPatch().reply(500)
70+
71+
const rel = { data: { type: 'widget', id: '2' } }
72+
normWidget1['_jv']['relationships'] = { widgets: rel }
73+
74+
try {
75+
await jsonapiModule.actions.patchRelated(stubContext, normWidget1)
76+
} catch (error) {
77+
expect(error.response.status).to.equal(500)
78+
}
79+
})
80+
})

0 commit comments

Comments
 (0)