Redux Majic makes building client-side JavaScript applications using Redux against JsonAPI backends easier.
$ yarn add redux-majic
$ npm install --save redux-majic
This is two separate pieces that play well together, Majic
and Redux
- The
Majic
is a set of functions that parses JsonAPI response objects in to and composes JsonAPI request objects out ofMajicEntities
, or a format that plays very nicely with Redux. - The
Redux
piece is a set of Action Creators, Reducers, Selectors, andtype
strings (for the Action Creators) that can easily ingest and storeMajicEntities
as they come from and go out to the request layer.
When used together, they make interacting with complex JsonAPI entities, requests, and responses in Redux feel... ✨ Magical ✨
In practice, we've used this sitting in the api-layer of an application, abstracting away the need to know about the JsonAPI implementation in the application. We've seen it as an elegant way to uniformly handle and store data delivered via JsonAPI.
In these examples, we're using isomorphic-fetch
as a stand in for the native browesr fetch
.
import fetch from 'isomorphic-fetch';
import {parseResponse} from 'redux-majic';
function getArticle(articleId) {
return fetch(`http://example.com/articles/${articleId}`, {
method: 'GET',
headers: {
'content-type': 'application/vnd.api+json'
}
})
.then(response => response.json())
.then(parseResponse);
}
getArticle('1')
.then(response => console.log(JSON.stringify(response, null, 4)));
This takes the below JsonAPI Response object
{
"jsonapi": {
"version": "1.0"
},
"links": {
"self": "resource-linkage"
},
"meta": {
"revisionNumber": 0
},
"data": [
{
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON API paints my bikeshed!"
},
"links": {
"self": "http://example.com/articles/1"
},
"relationships": {
"author": {
"links": {
"self": "http://example.com/articles/1/relationships/author",
"related": "http://example.com/articles/1/author"
},
"data": {
"type": "people",
"id": "9"
}
},
"comments": {
"links": {
"self": "http://example.com/articles/1/relationships/comments",
"related": "http://example.com/articles/1/comments"
},
"data": [
{
"type": "comments",
"id": "5"
},
{
"type": "comments",
"id": "12"
}
]
}
}
}
],
"included": [
{
"type": "people",
"id": "9",
"attributes": {
"first-name": "Dan",
"last-name": "Gebhardt",
"twitter": "dgeb"
},
"links": {
"self": "http://example.com/people/9"
}
},
{
"type": "comments",
"id": "5",
"attributes": {
"body": "First!"
},
"relationships": {
"author": {
"data": {
"type": "people",
"id": "2"
}
}
},
"links": {
"self": "http://example.com/comments/5"
}
},
{
"type": "comments",
"id": "12",
"attributes": {
"body": "I like XML better"
},
"relationships": {
"author": {
"data": {
"type": "people",
"id": "9"
}
}
},
"links": {
"self": "http://example.com/comments/12"
}
}
]
}
and turns it in to a ParsedMajicEntity
{
"jsonapi": {
"version": "1.0"
},
"links": {
"self": "resource-linkage"
},
"meta": {
"revisionNumber": 0
},
"__primaryEntities": ["articles"],
"articles": {
"keys": ["1"],
"data": {
"1": {
"type": "articles",
"id": "1",
"title": "JSON API paints my bikeshed!",
"links": {
"self": "http://example.com/articles/1"
},
"author": {
"links": {
"self": "http://example.com/articles/1/relationships/author",
"related": "http://example.com/articles/1/author"
},
"data": {
"type": "people",
"id": "9"
}
},
"comments": {
"links": {
"self": "http://example.com/articles/1/relationships/comments",
"related": "http://example.com/articles/1/comments"
},
"data": [
{
"type": "comments",
"id": "5"
},
{
"type": "comments",
"id": "12"
}
]
}
}
}
},
"people": {
"data": {
"9": {
"type": "people",
"id": "9",
"first-name": "Dan",
"last-name": "Gebhardt",
"twitter": "dgeb",
"links": {
"self": "http://example.com/people/9"
}
}
}
},
"comments": {
"data": {
"5": {
"type": "comments",
"id": "5",
"body": "First!",
"author": {
"data": {
"type": "people",
"id": "2"
}
},
"links": {
"self": "http://example.com/comments/5"
}
},
"12": {
"type": "comments",
"id": "12",
"body": "I like XML better",
"author": {
"data": {
"type": "people",
"id": "9"
}
},
"links": {
"self": "http://example.com/comments/12"
}
}
}
}
}
import fetch from 'isomorphic-fetch';
import {composeRequest} from 'redux-majic';
const articleSchema = {
"type": "articles",
"attributes": ["title"],
"topLevelMeta": ["requestId"],
"meta": ["revisionNumber"],
"relationships": [
{
"key": "author",
"defaultType": "people"
},
{
"key": "comments",
"defaultType": "comments"
}
],
"included": [
{
"key": "author",
"attributes": ["first-name", "last-name", "twitter"]
},
{
"key": "comments",
"attributes": ["body"],
"relationships": [
{
"key": "author",
"defaultType": "people"
}
]
}
]
};
const article = {
"type": "articles",
"id": "1",
"title": "JSON API paints my bikeshed!",
"revisionNumber": 1,
"requestId": 42,
"links": {
"self": "http://example.com/articles/1"
},
"author": {
"links": {
"self": "http://example.com/articles/1/relationships/author",
"related": "http://example.com/articles/1/author"
},
"data": {
"type": "people",
"id": "9",
"first-name": "Dan",
"last-name": "Gebhardt",
"twitter": "dgeb",
"links": {
"self": "http://example.com/people/9"
}
}
},
"comments": {
"links": {
"self": "http://example.com/articles/1/relationships/comments",
"related": "http://example.com/articles/1/comments"
},
"data": [
{
"type": "comments",
"id": "5",
"body": "First!",
"author": {
"data": {
"type": "people",
"id": "2"
}
},
"links": {
"self": "http://example.com/comments/5"
}
},
{
"type": "comments",
"id": "12",
"body": "I like XML better",
"author": {
"data": {
"type": "people",
"id": "9"
}
},
"links": {
"self": "http://example.com/comments/12"
}
}
]
}
};
function putArticle(article) {
return fetch(`http://example.com/articles/${article.id}`, {
method: 'PUT',
body: JSON.stringify(composeRequest(article, articleSchema)),
headers: {
'content-type': 'application/vnd.api+json'
}
});
}
putArticle(article);
takes the above MajicDataEntity
of an article (with its related entites expanded) and turns it in to a JsonAPI Request object below. Notice that the object returns an array on the primary data
key. As of version 0.1.9, to return an object on the primary data
key, pass an optional options third parameter: {single: true}
.
{
"meta": {
"requestId": 42
},
"data": [
{
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON API paints my bikeshed!"
},
"relationships": {
"author": {
"data": {
"type": "people",
"id": "9"
}
},
"comments": {
"data": [
{
"type": "comments",
"id": "5"
},
{
"type": "comments",
"id": "12"
}
]
}
},
"meta": {
"revisionNumber": 1
}
}
],
"included": [
{
"type": "people",
"id": "9",
"attributes": {
"first-name": "Dan",
"last-name": "Gebhardt",
"twitter": "dgeb"
}
},
{
"type": "comments",
"id": "5",
"attributes": {
"body": "First!"
},
"relationships": {
"author": {
"data": {
"type": "people",
"id": "2"
}
}
}
},
{
"type": "comments",
"id": "12",
"attributes": {
"body": "I like XML better"
},
"relationships": {
"author": {
"data": {
"type": "people",
"id": "9"
}
}
}
}
]
}
If you need to do something that involves receiving and tracking entites with non-unique ids (i.e., tracking multiple revisions of the same entity), we provide a parseResponseFactory
that accepts an identifier
function that accepts an entity and returns the key used to identify the distinct entities.
For example, if you have article
type entities that have revisionNumbers
on their meta
fields, we could use the below identity function
import fetch from 'isomorphic-fetch';
import {parseResponseFactory} from 'redux-majic';
function revisionNumberIdentifier(entity) {
if (entity.type === 'articles') {
return `${entity.id}${('meta' in entity && 'revisionNumber' in entity.meta)? `:${entity.meta.revisionNumber}`: ''}`;
}
return entity.id;
}
const articleParser = parseResponseFactory(revisionNumberIdentifier);
function getArticle(articleId) {
return fetch(`http://example.com/articles/${articleId}`, {
method: 'GET',
headers: {
'content-type': 'application/vnd.api+json'
}
})
.then(response => response.json())
.then(articleParser);
}
getArticle('1')
.then(response => console.log(JSON.stringify(response, null, 4)));
which would parse the below JsonAPI Resposne
{
"jsonapi": {
"version": "1.0"
},
"links": {
"self": "resource-linkage"
},
"data": [
{
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON API paints my bikeshed! Boom!"
},
"links": {
"self": "http://example.com/articles/1"
},
"meta": {
"revisionNumber": 1
}
}
],
"included": [
{
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON API paints my bikeshed!"
},
"links": {
"self": "http://example.com/articles/1"
},
"meta": {
"revisionNumber": 0
}
}
]
}
into the below object. Note the identifiers in the data
object and keys
array.
{
"jsonapi": {
"version": "1.0"
},
"links": {
"self": "resource-linkage"
},
"__primaryEntities": ["articles"],
"articles": {
"keys": ["1:1"],
"data": {
"1:1": {
"type": "articles",
"id": "1",
"title": "JSON API paints my bikeshed! Boom!",
"links": {
"self": "http://example.com/articles/1"
},
"revisionNumber": 1
},
"1:0": {
"type": "articles",
"id": "1",
"title": "JSON API paints my bikeshed!",
"links": {
"self": "http://example.com/articles/1"
},
"revisionNumber": 0
}
}
}
}
We have several helpers that make working with Redux and MajicEntities
extremely easy.
To start, we have a standard action creator, createMajicAction
, which returns objects of type MajicAction
. As of version 0.1.9, MajicActions
are also valid Flux Standard Actions
!
type MajicAction = {
type: string,
payload: {},
meta: {},
callbacks: {
[string]: ?Function,
},
error: boolean
}
We then built some standard Action Creators which pass their data along to createMajicAction
to ensure all of our actions have the same shape.
receiveMajicEntitiesAction
- Creates a standard receive action, typeRECEIVE_MAJIC_ENTITIES
, that every slice of your store can listen for. Using the provided reducers, every slice can properly receive all entities it is responsible forclearNamespaceAction
- We recommend segmenting or grouping requests in to different namespaces, and our provided Action Creators, Reducers, and Selectors help to make that easier. This Action Creator creates a standard clearing action, typeCLEAR_NAMESPACE
, that every slice of your store can listen for. Using the provided reducers, every slice can properly clear namespaces it is responsible for
We have three Reducer helper functions that you can use to make receiving MajicEntities
in to your store very simple.
-
requestMajicNamespace
- This reducer helper adds the namespace to the slice of a store, as well as sets the namespaceisFetching
totrue
. We recommend using this in the request side of the request-response-error action cycle is typical with Redux -
receiveMajicEntitiesReducer
- This reducer accepts the slice of store, aRECEIVE_MAJIC_ENTITIES
action, and the entities that the specific slice of the store should listen for, and processes all incoming receive requests. For a simple example, see below.This reducer can customize how entities are mapped in to the entity map, as well as mark entity types as non-primary through an optional config object. See the full docstring below
/** * * @param {*} state * @param {MajicAction} action * @param {string|string[]} primaryEntities entity types to listen for as "primary" entities * Primary entities are entity types stored in the associated namespace. Per JSONAPI, every request has at least one primary entity. * @param {{entities: ?string[], mapFunctions: ?{[string]: MajicMapper}}} config (optional) * `entities` is the complete list of entities this reducer should receive. If it is omitted, it defaults to an array of `primaryEntities`. This is useful if a reducer needs to track multiple entities, but will only want to store some of them in the namespace * `mapFunctions` is an object keyed on entity-types with special functions to use to update an entity-type's map if the standard map builder is insufficient. * @return {*} */
-
clearMajicNamespaceReducer
- This reducer listens for aCLEAR_NAMESPACE
action, and then removes the namespace from every slice of store that it's in
Finally, we have four Selectors to help us select entities from the slices of Redux store that we're building
selectEntityById
- Reaches in to the provided slice of the store to grab the entity's map and selects the entity by idselectEntityByNamespaceAndId
- Reaches into the provided slice of the store to select the namespaceselectEntitiesByNamespace
- Reaches in to the namespace and maps the keys array into an array of entitiesselectNamespaceIsFetching
- Reaches in to the namespace and returns the namespace'sisFetching
Using the above data from JsonAPI Requests and Responses, imagine we have slices of our store for articles
, comments
, and people
respectively. Combining the slices in to a single store might look like this:
import {combineReducers} from 'redux';
import {
receiveMajicEntitiesReducer,
clearMajicNamespaceReducer,
RECEIVE_MAJIC_ENTITIES,
CLEAR_NAMESPACE,
} from 'redux-majic';
const articles = function(state = {}, action) {
switch(action.type) {
case RECEIVE_MAJIC_ENTITIES: {
return receiveMajicEntitiesReducer(state, action, 'articles');
}
case CLEAR_NAMESPACE: {
return clearMajicNamespaceReducer(state, action);
}
default:
return state;
}
};
const comments = function(state = {}, action) {
switch(action.type) {
case RECEIVE_MAJIC_ENTITIES: {
return receiveMajicEntitiesReducer(state, action, 'comments');
}
case CLEAR_NAMESPACE: {
return clearMajicNamespaceReducer(state, action);
}
default:
return state;
}
};
const people = function(state = {}, action) {
switch(action.type) {
case RECEIVE_MAJIC_ENTITIES: {
return receiveMajicEntitiesReducer(state, action, 'people');
}
case CLEAR_NAMESPACE: {
return clearMajicNamespaceReducer(state, action);
}
default:
return state;
}
};
export default combineReducers({
articles,
comments,
people,
});
This combined reducer would take the parsed response from Parsing a JsonAPI Response in the below action
const parsedMajicObjects = {/* parsed response */};
const meta = {namespace: 'single-article'};
const action = receiveMajicEntitiesAction(parsedMajicObjects, meta);
and create below State tree
{
"articles": {
"articlesMap": {
"1": {
"type": "articles",
"id": "1",
"title": "JSON API paints my bikeshed!",
"links": {
"self": "http://example.com/articles/1"
},
"author": {
"links": {
"self": "http://example.com/articles/1/relationships/author",
"related": "http://example.com/articles/1/author"
},
"data": {
"type": "people",
"id": "9"
}
},
"comments": {
"links": {
"self": "http://example.com/articles/1/relationships/comments",
"related": "http://example.com/articles/1/comments"
},
"data": [
{
"type": "comments",
"id": "5"
},
{
"type": "comments",
"id": "12"
}
]
}
}
},
"single-article": {
"isFetching": false,
"keys": [
"1"
],
"preservedEntities": {}
},
"namespaces": [
"single-article"
]
},
"comments": {
"commentsMap": {
"5": {
"type": "comments",
"id": "5",
"body": "First!",
"author": {
"data": {
"type": "people",
"id": "2"
}
},
"links": {
"self": "http://example.com/comments/5"
}
},
"12": {
"type": "comments",
"id": "12",
"body": "I like XML better",
"author": {
"data": {
"type": "people",
"id": "9"
}
},
"links": {
"self": "http://example.com/comments/12"
}
}
},
"namespaces": []
},
"people": {
"peopleMap": {
"9": {
"type": "people",
"id": "9",
"first-name": "Dan",
"last-name": "Gebhardt",
"twitter": "dgeb",
"links": {
"self": "http://example.com/people/9"
}
}
},
"namespaces": []
}
}
Thank you to the people behind JsonAPI for the hard work of defining the schema and building the awesome documentation. Also, thank you for the examples you provide in the documentation, as you made building test cases so much easier!
MIT