A module to access JSONAPI data from an API, using a Vuex store, restructured to make life easier.
- Creates a Vuex module to store API data.
- High-level methods to wrap common RESTful operations (GET, POST, PUT, DELETE). (See Actions)
- Restructures/normalizes data, making record handling easier. (See Restructured Data)
- Makes fetching related objects easy. (See getRelated)
- Relationships can be followed and expanded into records automatically. (See Related Items)
- Uses Axios (or your own axios-like module) as the HTTP client.
- Uses jsonpath for filtering when getting objects from the store.
- Records the status of actions (LOADING, SUCCESS, ERROR).
- New data can overwrite, or be merged onto, existing records. (See mergeRecords)
- Override endpoint names per-request (for plural names etc). Use
links.self
when available/applicable (See Endpoints) - Patches can be 'cleaned' to ensure they only contain new or modified data. (See cleanPatch)
- Can query the API and receive restructured data, without modifying the store. (See the Search action)
Having created a Vue project, simply add the module to your store.js, passing it an axios-like instance:
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import { jsonapiModule } from 'jsonapi-vuex'
Vue.use(Vuex)
const api = axios.create({
baseURL: 'https://api.example.com/1/api/',
headers: {
'Content-Type': 'application/vnd.api+json',
},
})
export default new Vuex.Store({
modules: {
jv: jsonapiModule(api),
},
})
The most common way to access the API and update the store is through high-level actions
- though getters
(and mutations
) can be used directly if required.
There are a number of features which are worth explaining in more detail. Many of these can be configured - see the Configuration section.
-
Follow relationships - If enabled then any
relationships
specified asdata
resources in the JSONAPI data will be added alongside the attributes in the restructured data 'root' as aget
getter property. Querying this key will return the record from the store, if present. Additionally, helper methods will be added to_jv
to make dealing with these easier (see Helper functions) -
Clear on update - If enabled, then each new set of records is considered to be definitive for that
type
, and any other records of thattype
in the store will be removed. This option is useful for cases where you expect the API response to contain the full set of records from the server, as it avoids the need for manual cache expiry. The code will first apply the new records to the store, and then for eachtype
which has had new records added, remove old ones. This is designed to be more efficient in terms of updating computed properties and UI redraws than emptying then repopulating the store. -
Included - If the JSONAPI record contains an
includes
section, the data in this will be added to the store alongisde the 'main' records. (If includes are not used, then you will need to usegetRelated
to fetch relationships). -
Merging - By default, data returned from the API overwrites records already in the store. However, this may lead to inconsistencies if using Sparse fieldsets or otherwise obtaining only a subset of data from the API. If merging is enabled, then new data will be merged onto existing data. this does however mean that you are responsible for explicitly calling the
deleteRecord
mutation in cases where attributes ahve been removed in the API, as they will never be removed from the store, only added to. -
Preserve JSON - The original JSONAPI record(s) can optionally be preserved in
_jv
if needed - for example if you need access tometa
or other sections. To avoid duplication, thedata
section (attributes
,relationships
etc) is removed. -
Endpoints - by default this module assumes that object types and API endpoints (item and collection) all share the same name. however, some APIs use plurals or other variations on the endpoint names. You can override the endpoint name via the
axios
url
config option or thelinks.self
attribute (see Endpoints) -
Clean Patches - by default, data passed to the
patch
action is used as-is. IfcleanPatch
is enabled, then the patch object is compared to the record in the store (if it exists), and any attributes with identical values are removed. This means that the finalpatch
will only contain new or modified attributes, which is safer and more efficient, as it avoids sending unnecessary or 'stale' data.
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. Every action call's state is tracked as it progresses, and this status can be easily queried (see the status
getter).
There are 4 actions (with aliases): get
(fetch
), post
(create
), patch
(update
), and delete
which correspond to RESTful methods. There is also a getRelated action which fetches a record's related items.
These actions take 2 arguments: the path/object to be acted on, and an (optional) axios
config object. The first argument is an object containing restructured data.
Note - Since the dispatch
method can only accept a single argument, if both arguments are used, the argument must be an array.
The first argument is an object containing restructured data. Actions which take no data
argument apart from the record (get
and delete
) can also accept a URL to fetch (relative to axios
baseURL
(if set) leading slash is optional). This means you don't need to create an 'empty' restructured data object to get or delete a record. Some examples:
// Restructured representation of a record
const newWidget = {
name: 'sprocket',
color: 'black',
_jv: {
type: 'widget',
id: '1',
},
}
// Request Query params (JSONAPI options, auth tokens etc)
const params = {
token: 'abcdef123456',
}
// To get all items in a collection, using a sring path:
this.$store.dispatch('jv/get', 'widget').then((data) => {
console.log(data)
})
// Get a specific record from the 'widget' endpoint, passing parameters to axios:
this.$store
.dispatch('jv/get', ['widget/1', { params: params }])
.then((data) => {
console.log(data)
})
// Create a new widget in the API, using a restructured object, and passing parameters to axios:
this.$store
.dispatch('jv/post', [newWidget, { params: params }])
.then((data) => {
console.log(data)
})
The search
action is trhe same as the get
action, except that it does not result in any updates to the store. This action exists for efficiency purposes - for example to do 'search-as-you-type' queries without continually updating the store with all the results.
const widgetSearch = (text) => {
const params = { 'filter[text_contains]': text }
this.$store
.dispatch('jv/search', 'widget', { params: params })
.then((data) => {
return data
})
}
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 Related Items).
Like the RESTful actions, this takes 2 arguments - the string or object to be acted on, and an axios config object. It returns a deeply nested restructured tree - relationship -> type -> id -> data
.
Note - getRelated only works on specific items, not collections.
By default this action will fetch the record specified from the API, then work out it's relationships and also fetch those.
If the argument is a string, it can optionally take a 3rd argument, e.g. type/id/relationship
to cause only the named relationship to be followed.
If the argument is an object, then if the object contains a _jv/relationships
section, then only these relationships will are followed. If the relationships section contains keys (i.e relationship names
) but no values (i.e. resource linkage) then these will be fetched from the API.
// Assuming the API holds the following data
jsonapi = {
data: {
type: 'widget',
id: '1',
},
relationships: {
widgets: {
data: {
type: 'widget',
id: '2',
},
},
doohickeys: {
data: {
type: 'doohickey',
id: '10',
},
},
},
}
// Get all of widget 1's related items (widgets and doohickeys)
this.$store.dispatch('jv/getRelated', 'widget/1').then((data) => {
console.log(data)
})
// Get only the items in the 'widgets' relationship
this.$store.dispatch('jv/getRelated', 'widget/1/widgets').then((data) => {
console.log(data)
})
// Equivalent, using object instead of string argument
const customRels = {
_jv: {
type: 'widget',
id: '1',
relationships: {
widgets: {
data: {
type: 'widget',
id: '2',
},
},
},
},
}
// Equivalent, but 'doohickeys' resource linkage will be fetched from the server
// i.e. { data: { type: 'doohickey', id: '10' }}
const customRelsNoData = {
_jv: {
type: 'widget',
id: '1',
relationships: {
doohickeys: undefined,
},
},
}
this.$store.dispatch('jv/getRelated', customRels).then((data) => {
console.log(data)
})
There are 2 getters available. get
and status
.
Get returns information directly from the store for previously cached records. This is useful for performance reasons, or for use in computed properties.
computed: {
...mapGetters({
// Map 'jv/get' as a computed property 'get'
get: 'jv/get',
}),
// Create a computed property that calls the getter with normalized data
getWidget: function() {
return this.$store.getters['jv/get']({ _jv: { type: 'Widget' } })
},
},
Like actions, get
takes an object or string indicating the desired resources. This can be an empty string, type, or type and id, to return the whole store, a collection, or an item.
get
takes an optional 2nd argument - a jsonpath
string to filter the record(s) which are being retrieved. See the project page for JSONPath Syntax
// Assuming the store is as follows:
store = {
widget: {
'1': {
name: 'sprocket',
color: 'black',
},
'2': {
name: 'cog',
color: 'red',
},
},
}
// Get all records (of any type) with id = 10 (useful if your API has globally unique UUIDs)
this.$store.getters['jv/get']('', '$.*.10')
// Get all widgets that are red:
this.$store.getters['jv/get']('widget', '[?(@.color=="red")]')
// Note that filters can create impossible conditions
// The following will return empty, as widget 1 is not red
this.$store.getters['jv/get']('widget/1', '[?(@.color=="red")]')
Every action is given a unique id, and this is both returned as a property of the promise, and preserved in state
under the jvtag
(as defined in config).
The status
getter accepts either an id, or a promise returned by an action, and returns the stored state of the action. This can be one of:
- LOADING
- SUCCESS
- ERROR
For example, to determine the state of an action:
// Get a promise from calling an action
let action = this.$store.dispatch('jv/get', 'widget')
// Check the status of the action (and assuming it hasn't yet completed)
let status = this.$store.getters['jv/status'](action)
console.log(status) // LOADING
// Continue to handle the action results in the usual way
action.then((data) => {
// The action has returned
console.log(status) // SUCCESS
// Continue as usual
console.log(data)
})
The status
getter is primarily designed to use useful for handling UI changes based on actions.
For example, you might want to disable an attribute while an action is happening by 'watching' status
:
<input type="text" :disabled="status === 'LOADING'">
There are several mutations which can be used to directly modify the store.
Note - in most cases mutations are called from actions as a result of querying the API, and it is not necessary to call mutations directly.
Mutations take normalised data as an argument.
Deletes a single record from the store.
store.commit('deleteRecord', { _jv: { type: 'widget', id: '1' } })
Updates records in the store. Replaces or merges with existing records, depending on the value of mergeRecords
As addRecords
, but explicitly replaces existing records.
As addRecords
, but explicitly merges onto existing records.
Will remove all records from the store (of a given type) which aren't contained in given response. (See clearOnUpdate).
Sets the session status information in the store.
Deletes a session status record from the store.
By default the get
action and getter are both configured to follow and expand out relationships, if they are provided as data
entries (i.e. {type: 'widget', id: '1'}
). This behaviour is controlled with the followRelationshipsData
config option.
Note - If using the action
you may wish to also set the include
parameter on the server query to include the relationships you are interested in. Any records returned in the included
section of the jsonapi data will be automatically added to the store.
This expansion is done by adding an attribute where the key is the relationship name, and the value is a javascript getter
that calls the get
vuex getter for that record. the value will therefore be the value of the related object (if in the store), or an empty object (if not). For items with a single relationship, the object is placed directly under the relName
- for mutiple items, they are indexed by id:
// Assuming the store is as follows:
store = {
widget: {
'1': {
name: 'sprocket',
_jv: {
relationships: {
parts: {
data: {
type: 'widget',
id: '2',
},
},
},
},
},
'2': {
name: 'cog',
},
},
}
// Get widget/1, with related items
// Either:
// (Note the use of include to ensure `parts` is in the store)
let item1 = await this.$store.dispatch('jv/get', 'widget/1', [{ include: 'parts' }])
// OR...
let item1 = this.$store.getters['jv/get']('widget/1')
// This will return:
{
name: 'sprocket',
parts: { // getter property, which will return:
name: 'cog'
_jv: { /* ... */ }
},
_jv: {
id: '1',
type: 'widget',
},
}
Note - since relationships can be recursive, calling methods on such objects which try to walk the entire tree will cause recursion errors (e.g. JSON.stringify
). In order to prevent this common error, a toJSON
function is added to spot and remove any potentially recursive relationships. In these cases the getter is relpaced with just the _jv
section, containing type
and id
as an indicator of the unfollowed relationship. This behaviour can be disabled - see toJSON.
Distinguishing between the attributes
and relationships
in the 'root' is simplified by a number of 'helper' functions which are provided in the _jv
(jvtag
) object:
-
attrs
- a getter property which returns an object containing all attributes. -
rels
- a getter property which returns an object containing all relationships. -
isAttr
- a function which returns True/False for a given name. -
isRel
- a function which returns True/False for a given name.
These are particularly useful in Vue
templates. For example to iterate over an item, picking out just the attributes:
<li v-for="(val, key) in widget._jv.attrs">{{ key }} {{ val }}</li>
<!-- Or -->
<li v-for="(val, key) in widget" v-if="widget._jv.isAttr(key)">{{ key }} {{ val }}</li>
A config object can be passed to jsonapiModule
when instantiating. It will override the default options:
const config = { jvtag: '_splat' }
jm = jsonapiModule(api, config)
For many of these options, more information is provided in the Usage section.
jvtag
- The tag in restructured objects to hold object metadata (defaults to_jv
)followRelationshipsData
- Whether to follow and expand relationships and store them alongside attributes in the item 'root' (defaults totrue
).preserveJson
- Whether actions should return the API response json (minusdata
) in_jv/json
(for access tometa
etc) (defaults tofalse
)actionStatusCleanAge
- What age must action status records be before they are removed (defaults to 600 seconds). Set to0
to disable.mergeRecords
- Whether new records should be merged onto existing records in the store, or just replace them (defaults tofalse
).clearOnUpdate
- Whether the store should clear old records and only keep new records when updating. Applies to thetype(s)
associated with the new records. (defaults to false).cleanPatch
- If enabled, patch object is compared to the record in the store, and only unique or modified attributes are kept in the patch. (defaults to false).toJSON
- Add atoJSON
function to records to remove potentially recursive relationships when serialising to JSON. (defaults to true).
By default jsonapi-vuex
assumes that object type and API endpoint are the same. For example, type: person
would have endpoint URLs of /person
and /person/1
for collections and single items.
When performing request on an already known single item (like an update), jsonapi-vuex
will use the links.self
attribute of an item to determine the API endpoint, if it is present.
However many APIs vary how endpoints are named - for example plurals (e.g. type:person
, /person/1
and /people
), or cases where the endpoint doesn't match the type (e.g. type: person
/author
and /author/1
) or even a combination (e.g. type: person
/author/1
and /authors
)
To solve this it is possible to override the endpoint for a request by explicitly setting the axios
url
configuration option:
data = { _jv: { type: 'person' } }
// Default behaviour - will always treat type = itemEP = collectionEP
this.$store.dispatch('jv/get', data)
// GETs /person
// Explicitly override the endpoint url
this.$store.dispatch('jv/get', [data, { url: '/people' }])
this.$store.dispatch('jv/get', [data, { url: '/author/1' }])
// Override using a dynamic function
const customUrl = (data) => {
if (data.hasOwnProperty('id')) {
// single item (singular)
return `person/${data.id}`
} else {
// collection (plural)
return '/people'
}
}
this.$store.dispatch('jv/get', [{ _jv: { type: 'widget' } }, { url: customUrl(data) }])
Note - If provided the url
option is used as-is - you are responsible for setting a valid collection or item url (with id
) as appropriate.
JSONAPI is an extremely useful format for clearly interacting with an API - however it is less useful for the end developer, who is generally more interested in the data contained in a record than the structure surrounding it.
In this module we 'reverse' the JSONAPI data into a form where data attributes become top-level keys, and JSONAPI-specific data is moved down under another key: _jv
.
For example, the JSONAPI record:
{
"id": "1",
"type": "widget",
"attributes": {
"name": "sprocket",
"color": "black"
},
"meta": {}
}
has to be accessed as record.attributes.color
This module would restructure this record to be:
{
"name": "sprocket",
"color": "black",
"_jv": {
"id": "1",
"type": "widget",
"meta": {}
}
}
Which is easier to work with, as lookups are now top-level, e.g. record.name
In cases where there are multiple records being returned in an object, the id is used as the key (though this is ignored in the code, and the 'real' id is always read from _jv
):
{
"1": {
"name": "sprocket",
"color": "black"
},
"2": {
"name": "cog",
"color": "red"
}
}
These are accessed as record.1.name
or record.2.color
, or if a list is needed, via Object.values(record)
.
The above structure is actually how records are maintained in the store, nested below the endpoint
:
{
"widget": {
"1": {},
"2": {}
},
"doohickey": {
"20": {}
}
}
Any bugs, enhancements or questions welcome as Issues (or even PRs!)
Development is currently being done with yarn - npm
should work, but if you hit unexpected issues, please try yarn
before filing a bug.
Having cloned this repository, simply run:
yarn
This should pull in all dependencies and development dependencies.
There are several scripts set up in package.json
:
yarn unit
- Run the unit tests (uses karma
, mocha
, chai
, sinon
)
yarn e2e
- Run the e2e tests (uses nightwatch
)
yarn testapp
- Runs the example testapp
used in e2e testing for interactive testing/debugging.
yarn fakeapiserver
- Runs a fake JSONAPI server used by the testapp for interactive testing/debugging.
yarn test
- Runs both unit and e2e tests. (Used by travis
).
Note - All code is pre-processed with babel
and eslint
when testing for backwards compatability and linting.
Please follow these guidelines when writing and submitting code:
-
eslint - This is run over both the main code and the test suite during tests. See
.eslint.rc.js
for changes to the default rules. -
>= ES6 - Please try to use ES6 and newer methods (matching the policy that
Vue
has). -
Tests - This project aspires to test-driven development. Please submit unit tests (and ideally e2e tests) with all PRs (unless there's a good reason not to).
-
Versioning - Semantic versioning should be used, see https://semver.org for details.
-
Continuous Integration - The project uses [travis(https://travis-ci.com) to run tests against all submissions - PRs that are not passing will not be accepted (without good reason).
-
Specific Commits - Please make all commits/PRs as atomic and specific as possible.