├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.js ├── dist └── jsonapi-redux-data.js ├── jsonapi-redux-data.svg ├── package.json ├── redux-json-api.gif ├── src ├── index.js ├── reducers │ └── jsonApiReducer.js ├── selectors │ └── selectors.js ├── services │ ├── apiService.js │ └── reduxApiService.js └── utils │ ├── apiUtils.js │ ├── index.js │ └── jsonApiUtils.js ├── webpack.config.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | stats.json 4 | 5 | .DS_Store 6 | npm-debug.log 7 | .idea 8 | **/coverage/** 9 | **/storybook-static/** 10 | **/server/** 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const prettierOptions = JSON.parse( 4 | fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8') 5 | ) 6 | 7 | module.exports = { 8 | parser: 'babel-eslint', 9 | extends: ['prettier', 'prettier/react'], 10 | plugins: ['prettier'], 11 | env: { 12 | jest: true, 13 | browser: true, 14 | node: true, 15 | es6: true 16 | }, 17 | parserOptions: { 18 | ecmaVersion: 6, 19 | sourceType: 'module', 20 | ecmaFeatures: { 21 | jsx: true 22 | } 23 | }, 24 | rules: { 25 | 'prettier/prettier': ['error', prettierOptions] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't check auto-generated stuff into git 2 | build 3 | node_modules 4 | stats.json 5 | .vscode/ 6 | # Cruft 7 | .DS_Store 8 | npm-debug.log 9 | .idea -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 2 | lts/dubnium 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | internals/generators/ 4 | internals/scripts/ 5 | package-lock.json 6 | yarn.lock 7 | package.json 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-Present Mohammed Ali Chherawalla 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JsonApi Redux Data 2 | 3 | ![](jsonapi-redux-data.svg) 4 | 5 | JsonApi Redux data is a one stop shop for all your jsonapi needs! 6 | Provides methods to make your API call and formats and updates data in the redux store. 7 | It joins all of your relations so that accessing it is as easy as _entity.relationship_ 8 | 9 | **For example** if you made an API call to `base-url/tasks?include=list`, you can access your relationships as easily as **tasks[index].list** 10 | 11 | ![](redux-json-api.gif) 12 | 13 | - Provides methods to make api calls to your JSONApi compliant backend and updates the redux store as well. 14 | 15 | - Combines the data so it is easily accessible 16 | 17 | - Setup in 3 easy steps 18 | 19 | ## Installation 20 | 21 | ``` 22 | yarn add jsonapi-redux-data 23 | ``` 24 | 25 | **OR** 26 | 27 | ``` 28 | npm i jsonapi-redux-data 29 | ``` 30 | 31 | ## Usage 32 | 33 | - Update the reducers / rootReducers like this 34 | 35 | ``` 36 | ... 37 | import { jsonApiReducer } from 'jsonapi-redux-data' 38 | ... 39 | const rootReducer = combineReducers({ 40 | ..., 41 | api: jsonApiReducer, 42 | ... 43 | }) 44 | ... 45 | 46 | ``` 47 | 48 | - Create the api client preferrably in the app.js 49 | 50 | ``` 51 | ... 52 | import { createApiClientWithTransform } from 'jsonapi-redux-data' 53 | ... 54 | // Create redux store with history 55 | const initialState = {}; 56 | const store = configureStore(initialState, history); 57 | ... 58 | 59 | createApiClientWithTransform('', store) 60 | ... 61 | ``` 62 | 63 | - Make api call easily and from anywhere 64 | 65 | ``` 66 | ... 67 | import { getApi } from 'jsonapi-redux-data' 68 | ... 69 | 70 | getApi({ pathname: '', include: '' }) 71 | ... 72 | ``` 73 | 74 | ## Example Usage 75 | 76 | # getApi 77 | 78 | ``` 79 | getApi({ 80 | pathname: 'tasks', 81 | include: 'lists', 82 | levelOfNesting: 3 83 | }); 84 | ``` 85 | 86 | Invoking this method will 87 | 88 | - make an api call to `base-url/tasks` 89 | - include lists in the response 90 | - dispatch an action of type `SUCCESS_API` 91 | - update the api reducer in the redux store with the formatted response. 92 | 93 | ## API Documentation 94 | 95 | ### getApi 96 | 97 | Make a get request and add api response to the redux store. 98 | 99 | ``` 100 | /** 101 | * @param {} requestPayload: { 102 | * include: object 103 | * filter: object, 104 | * pathname: String, 105 | * levelOfNesting: number, 106 | * transformList: object, 107 | * id: String 108 | * } 109 | * @param {} api: Custom Api Client instead of the latest created api client 110 | * @param {} axios: Special axios config 111 | **/ 112 | function getApi (requestPayload, api, axiosConfig) 113 | ``` 114 | 115 | ### postApi 116 | 117 | Make a post request and add api response to the redux store 118 | 119 | ``` 120 | /** 121 | * @param {} requestPayload: { 122 | * include: object 123 | * filter: object, 124 | * pathname: String, 125 | * levelOfNesting: number, 126 | * transformList: object, 127 | * postData: object 128 | * } 129 | * @param {} api: Custom Api Client instead of the latest created api client 130 | * @param {} axios: Special axios config 131 | */ 132 | function postApi (requestPayload, api, axiosConfig) 133 | ``` 134 | 135 | ### patchApi 136 | 137 | Make a patch request and add api response to the redux store 138 | 139 | ``` 140 | /** 141 | * 142 | * @param {} requestPayload: { 143 | * include: object 144 | * filter: object, 145 | * pathname: String, 146 | * levelOfNesting: number, 147 | * transformList: object, 148 | * patchData: object 149 | * } 150 | * @param {} api: Custom Api Client instead of the latest created api client 151 | * @param {} axios: Special axios config 152 | * 153 | * */ 154 | function patchApi (requestPayload, api, axios) 155 | ``` 156 | 157 | ### deleteApi 158 | 159 | Make a delete request and add api response to the redux store 160 | 161 | ``` 162 | 163 | /** 164 | * 165 | * @param {} requestPayload: { 166 | * pathname: String, 167 | * id: String 168 | * } 169 | * @param {} api: Custom Api Client instead of the latest created api client 170 | * @param {} axios: Special axios config 171 | * 172 | * */ 173 | function deleteApi (requestPayload, api, axios) 174 | ``` 175 | 176 | ### getRequest 177 | 178 | GET HTTP REQUEST, uses the apisauce.get underneath the hood. 179 | 180 | ``` 181 | /** 182 | * @param {} pathname: the endpoint path 183 | * @param {} include: the jsonapi include string 184 | * @param {} filter: the jsonapi filter string 185 | * @param {} id: the id of the GET request. 186 | * @param {} api: default is getLatestApiClient() 187 | * @param {} axiosConfig: custom axiosConfig for this request 188 | */ 189 | function getRequest (pathname, include, filter, id, api, axiosConfig) 190 | ``` 191 | 192 | ### postRequest 193 | 194 | POST HTTP REQUEST, uses the apisauce.get underneath the hood. 195 | 196 | ``` 197 | /** 198 | * 199 | * @param {} pathname: the endpoint path 200 | * @param {} include: the jsonapi include string 201 | * @param {} filter: the jsonapi filter string 202 | * @param {} postData: request body 203 | * @param {} api: default is getLatestApiClient() 204 | * @param {} axiosConfig: custom axiosConfig for this request 205 | */ 206 | function postRequest (pathname, include, filter, postData, api, axiosConfig) 207 | ``` 208 | 209 | ### patchRequest 210 | 211 | PATCH HTTP REQUEST, uses the apisauce.get underneath the hood. 212 | 213 | ``` 214 | /** 215 | * @param {} pathname 216 | * @param {} include: the jsonapi include string 217 | * @param {} filter: the jsonapi filter string 218 | * @param {} id: the id of the PATCH request. 219 | * @param {} patchData: request body 220 | * @param {} api: default is getLatestApiClient() 221 | * @param {} axiosConfig: custom axiosConfig for this request 222 | */ 223 | function patchRequest (pathname, include, filter, id, patchData, api,axiosConfig) 224 | ``` 225 | 226 | ### deleteRequest 227 | 228 | DELETE HTTP REQUEST, uses the apisauce.get underneath the hood. 229 | 230 | ``` 231 | /** 232 | * @param {} pathname 233 | * @param {} id: the id of the DELETE request. 234 | * @param {} api: default is getLatestApiClient() 235 | * @param {} axiosConfig: custom axiosConfig for this request 236 | */ 237 | function deleteRequest (pathname, id, api, axiosConfig) 238 | ``` 239 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | modules: false 7 | } 8 | ], 9 | '@babel/preset-react' 10 | ], 11 | plugins: ['styled-components', '@babel/plugin-proposal-class-properties', '@babel/plugin-syntax-dynamic-import'], 12 | env: { 13 | production: { 14 | only: ['app'], 15 | plugins: [ 16 | 'lodash', 17 | 'transform-react-remove-prop-types', 18 | '@babel/plugin-transform-react-inline-elements', 19 | '@babel/plugin-transform-react-constant-elements', 20 | ['import', { libraryName: 'antd', style: 'css' }] 21 | ] 22 | }, 23 | dev: { 24 | plugins: [['import', { libraryName: 'antd', style: 'css' }]] 25 | }, 26 | development: { 27 | plugins: [['import', { libraryName: 'antd', style: 'css' }]] 28 | }, 29 | test: { 30 | plugins: [ 31 | '@babel/plugin-transform-modules-commonjs', 32 | 'dynamic-import-node', 33 | ['import', { libraryName: 'antd', style: 'css' }] 34 | ] 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /jsonapi-redux-data.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | JSONAPI-REDUX-DATA 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsonapi-redux-data", 3 | "version": "1.0.17", 4 | "description": "Library that makes integration of jsonapi with redux effortless and easy", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/wednesday-solutions/jsonapi-redux-data.git" 8 | }, 9 | "engines": { 10 | "npm": ">=5", 11 | "node": ">=8.15.1" 12 | }, 13 | "keywords": [ 14 | "jsonapi", 15 | "redux", 16 | "json-api", 17 | "redux-data", 18 | "reactjs", 19 | "react", 20 | "api", 21 | "apisauce" 22 | ], 23 | "author": "Mac", 24 | "homepage": "https://github.com/wednesday-solutions/jsonapi-redux-data", 25 | "license": "MIT", 26 | "main": "dist/jsonapi-redux-data", 27 | "scripts": { 28 | "lint": "npm run lint:js", 29 | "lint:eslint": "eslint --ignore-path .eslintignore", 30 | "lint:eslint:fix": "eslint --ignore-path .eslintignore --fix", 31 | "lint:js": "npm run lint:eslint -- . ", 32 | "lint:staged": "lint-staged", 33 | "test:clean": "rimraf ./coverage", 34 | "test": "cross-env NODE_ENV=test jest --coverage", 35 | "test:staged": "jest --findRelatedTests", 36 | "test:watch": "cross-env NODE_ENV=test jest --watchAll", 37 | "coveralls": "cat ./coverage/lcov.info | coveralls", 38 | "prettify": "prettier --write", 39 | "webpack:prod": "webpack -p --mode=production", 40 | "prepublishOnly": "yarn webpack:prod" 41 | }, 42 | "browserslist": [ 43 | "last 2 versions", 44 | "> 1%", 45 | "IE 10" 46 | ], 47 | "lint-staged": { 48 | "*.js": [ 49 | "npm run lint:eslint:fix", 50 | "git add --force", 51 | "jest --findRelatedTests $STAGED_FILES" 52 | ], 53 | "*.json": [ 54 | "prettier --write", 55 | "git add --force" 56 | ] 57 | }, 58 | "pre-commit": "lint:staged", 59 | "resolutions": { 60 | "babel-core": "7.0.0-bridge.0" 61 | }, 62 | "dependencies": { 63 | "apisauce": "^1.1.0", 64 | "cross-env": "5.2.0", 65 | "deepmerge": "^4.2.2", 66 | "immer": "3.0.0", 67 | "immutable": "^4.0.0-rc.12", 68 | "lodash": "4.17.11", 69 | "map-keys-deep": "^0.0.2", 70 | "pluralize": "^8.0.0", 71 | "react": "16.8.6", 72 | "react-dom": "16.8.6", 73 | "redux": "4.0.1", 74 | "reduxsauce": "^1.1.0" 75 | }, 76 | "devDependencies": { 77 | "@babel/core": "7.4.3", 78 | "@storybook/react": "^5.2.1", 79 | "babel-loader": "8.0.5", 80 | "coveralls": "3.0.3", 81 | "eslint": "5.16.0", 82 | "gh-pages": "^2.1.1", 83 | "jest-cli": "24.7.1", 84 | "lint-staged": "8.1.5", 85 | "prettier": "1.17.0", 86 | "rimraf": "2.6.3", 87 | "webpack": "4.30.0", 88 | "webpack-cli": "^3.3.10" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /redux-json-api.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wednesday-solutions/jsonapi-redux-data/5b9e54caaf60b591508c32914f50cb8a1a0924c3/redux-json-api.gif -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from 'services/apiService'; 2 | export * from 'reducers/jsonApiReducer'; 3 | export * from 'services/reduxApiService'; 4 | export * from 'selectors/selectors'; 5 | export * from 'utils'; 6 | export * from 'apisauce'; 7 | -------------------------------------------------------------------------------- /src/reducers/jsonApiReducer.js: -------------------------------------------------------------------------------- 1 | import { createActions } from 'reduxsauce'; 2 | import { fromJS } from 'immutable'; 3 | import merge from 'deepmerge'; 4 | import { overwriteMerge, combineMerge } from 'utils'; 5 | 6 | export const createReducerActions = () => 7 | createActions({ 8 | successApi: ['responsePayload'], 9 | deleteSuccessApi: ['responsePayload', 'includeList'] 10 | }); 11 | export const { Types: jsonApiTypes, Creators: jsonApiCreators } = createReducerActions(); 12 | export const initialState = fromJS({}); 13 | 14 | export const jsonApiReducer = (state = initialState, action) => { 15 | switch (action.type) { 16 | case jsonApiTypes.SUCCESS_API: 17 | return fromJS( 18 | merge.all([state.toJS(), action.responsePayload], { 19 | arrayMerge: overwriteMerge 20 | }) 21 | ); 22 | case jsonApiTypes.DELETE_SUCCESS_API: 23 | return fromJS(action.responsePayload); 24 | } 25 | return state; 26 | }; 27 | 28 | export default jsonApiReducer; 29 | -------------------------------------------------------------------------------- /src/selectors/selectors.js: -------------------------------------------------------------------------------- 1 | import { initialState } from 'reducers/jsonApiReducer' 2 | 3 | export const selectApiDomain = state => (state.api || initialState).toJS() 4 | 5 | export default selectApiDomain 6 | -------------------------------------------------------------------------------- /src/services/apiService.js: -------------------------------------------------------------------------------- 1 | import { getLatestApiClient } from 'utils/apiUtils'; 2 | 3 | import { getIncludeFilterAndId } from 'utils'; 4 | 5 | /** 6 | * GET HTTP REQUEST, uses the apisauce.get underneath the hood. 7 | * @param {} pathname: the endpoint path 8 | * @param {} include: the jsonapi include string 9 | * @param {} filter: the jsonapi filter string 10 | * @param {} id: the id of the GET request. 11 | * @param {} api: default is getLatestApiClient() 12 | * @param {} axiosConfig: custom axiosConfig for this request 13 | */ 14 | export const getRequest = (pathname, include = '', filter = '', id = '', api = getLatestApiClient(), axiosConfig) => { 15 | const { includeString, filterString, idString } = getIncludeFilterAndId(include, filter, id); 16 | return api.get(`${pathname}${idString}${includeString}${filterString}`, null, axiosConfig); 17 | }; 18 | 19 | /** 20 | * POST HTTP REQUEST, uses the apisauce.get underneath the hood. 21 | * @param {} pathname: the endpoint path 22 | * @param {} include: the jsonapi include string 23 | * @param {} filter: the jsonapi filter string 24 | * @param {} postData: request body 25 | * @param {} api: default is getLatestApiClient() 26 | * @param {} axiosConfig: custom axiosConfig for this request 27 | */ 28 | export const postRequest = ( 29 | pathname, 30 | include = '', 31 | filter = '', 32 | postData = {}, 33 | api = getLatestApiClient(), 34 | axiosConfig 35 | ) => { 36 | const { includeString, filterString } = getIncludeFilterAndId(include, filter); 37 | return api.post(`${pathname}${includeString}${filterString}`, postData, axiosConfig); 38 | }; 39 | 40 | /** 41 | * DELETE HTTP REQUEST, uses the apisauce.get underneath the hood. 42 | * @param {} pathname 43 | * @param {} id: the id of the DELETE request. 44 | * @param {} api: default is getLatestApiClient() 45 | * @param {} axiosConfig: custom axiosConfig for this request 46 | */ 47 | export const deleteRequest = (pathname, id = '', api = getLatestApiClient(), axiosConfig) => { 48 | const { idString } = getIncludeFilterAndId(null, null, id); 49 | return api.delete(`${pathname}${idString}`, null, axiosConfig); 50 | }; 51 | 52 | /** 53 | * PATCH HTTP REQUEST, uses the apisauce.get underneath the hood. 54 | * @param {} pathname 55 | * @param {} include: the jsonapi include string 56 | * @param {} filter: the jsonapi filter string 57 | * @param {} id: the id of the PATCH request. 58 | * @param {} patchData: request body 59 | * @param {} api: default is getLatestApiClient() 60 | * @param {} axiosConfig: custom axiosConfig for this request 61 | */ 62 | export const patchRequest = ( 63 | pathname, 64 | include = '', 65 | filter = '', 66 | id = '', 67 | patchData = {}, 68 | api = getLatestApiClient(), 69 | axiosConfig 70 | ) => { 71 | const { includeString, filterString, idString } = getIncludeFilterAndId(include, filter, id); 72 | return api.patch(`${pathname}${idString}${includeString}${filterString}`, patchData, axiosConfig); 73 | }; 74 | -------------------------------------------------------------------------------- /src/services/reduxApiService.js: -------------------------------------------------------------------------------- 1 | import { jsonApiCreators } from 'reducers/jsonApiReducer'; 2 | import { selectApiDomain } from 'selectors/selectors'; 3 | import { deleteRequest, getRequest, patchRequest, postRequest } from './apiService'; 4 | import { createDeepInclude } from 'utils/jsonApiUtils'; 5 | import { getIncludeList, getStore } from 'utils'; 6 | 7 | const { successApi, deleteSuccessApi } = jsonApiCreators; 8 | 9 | /** 10 | * 11 | * Make a get request and add api response to the redux store. 12 | * 13 | * @param {} requestPayload: { 14 | * include: object 15 | * filter: object, 16 | * pathname: String, 17 | * levelOfNesting: number, 18 | * transformList: object, 19 | * id: String 20 | * } 21 | * @param {} api: Custom Api Client instead of the latest created api client 22 | * @param {} axios: Special axios config 23 | **/ 24 | export function getApi(requestPayload, api, axiosConfig) { 25 | const { include, filter, pathname, levelOfNesting, transformList, id } = requestPayload; 26 | let includeList = getIncludeList(requestPayload); 27 | return getRequest(pathname, include, filter, id, api, axiosConfig).then(response => 28 | handleApiResponse(response, includeList, transformList, levelOfNesting) 29 | ); 30 | } 31 | /** 32 | * Make a post request and add api response to the redux store 33 | * @param {} requestPayload: { 34 | * include: object 35 | * filter: object, 36 | * pathname: String, 37 | * levelOfNesting: number, 38 | * transformList: object, 39 | * postData: object 40 | * } 41 | * @param {} api: Custom Api Client instead of the latest created api client 42 | * @param {} axios: Special axios config 43 | */ 44 | export function postApi(requestPayload, api, axiosConfig) { 45 | const { include, filter, pathname, levelOfNesting, transformList, postData } = requestPayload; 46 | let includeList = getIncludeList(requestPayload); 47 | return postRequest(pathname, include, filter, postData, api, axiosConfig).then(response => 48 | handleApiResponse(response, includeList, transformList, levelOfNesting) 49 | ); 50 | } 51 | 52 | /** 53 | * Make a patch request and add api response to the redux store 54 | * @param {} requestPayload: { 55 | * include: object 56 | * filter: object, 57 | * pathname: String, 58 | * levelOfNesting: number, 59 | * transformList: object, 60 | * patchData: object 61 | * } 62 | * @param {} api: Custom Api Client instead of the latest created api client 63 | * @param {} axios: Special axios config 64 | * 65 | * */ 66 | export function patchApi(requestPayload, api, axios) { 67 | const { include, filter, pathname, levelOfNesting, transformList, patchData, id } = requestPayload; 68 | let includeList = getIncludeList(requestPayload); 69 | patchData.data.id = id; 70 | return patchRequest(pathname, include, filter, id, patchData, api, axios).then(response => 71 | handleApiResponse(response, includeList, transformList, levelOfNesting) 72 | ); 73 | } 74 | 75 | /** 76 | * Make a delete request and add api response to the redux store 77 | * @param {} requestPayload: { 78 | * pathname: String, 79 | * id: String 80 | * } 81 | * @param {} api: Custom Api Client instead of the latest created api client 82 | * @param {} axios: Special axios config 83 | * 84 | * */ 85 | export function deleteApi(requestPayload, api, axios) { 86 | const { pathname, id } = requestPayload; 87 | return deleteRequest(pathname, id, api, axios).then(response => 88 | handleApiResponse(response, [pathname], null, null, id) 89 | ); 90 | } 91 | /** 92 | * 93 | * Handle the response from the api and update the redux store 94 | * @param {} response: response of the jsonApi 95 | * @param {} includeList: list of keys that need to be included 96 | * @param {} transformList: transformations to normalise data 97 | * @param {} levelOfNesting: level of nesting in the include 98 | * @param {} deletedId: id of deleted element 99 | */ 100 | function handleApiResponse(response, includeList, transformList, levelOfNesting, deletedId) { 101 | const { data, ok } = response; 102 | if (ok) { 103 | const store = getStore(); 104 | const state = selectApiDomain(store.getState()); 105 | const dispatchFn = deletedId ? deleteSuccessApi : successApi; 106 | store.dispatch( 107 | dispatchFn(createDeepInclude(data, includeList, transformList, levelOfNesting, state, deletedId), includeList) 108 | ); 109 | } else { 110 | throw new Error('Api Failure', data); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/utils/apiUtils.js: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash/camelCase' 2 | import snakeCase from 'lodash/snakeCase' 3 | 4 | import { create } from 'apisauce' 5 | import mapKeysDeep from 'map-keys-deep' 6 | import { setStore } from 'utils' 7 | 8 | let latestApiClient = null 9 | /** 10 | * @param baseURL: baseURL for the api client to which all requests will be made. 11 | * @param store: an object of the redux store. It is used to 12 | * dispatch actions and update the redux store 13 | * based on successful api responses 14 | * @param headers: request headers to be added. 15 | * default value = { 'Content-Type': 'application/vnd.api+json' } 16 | */ 17 | export const createApiClientWithTransform = ( 18 | baseURL, 19 | store, 20 | headers = { 'Content-Type': 'application/vnd.api+json' } 21 | ) => { 22 | const apiClient = create({ 23 | baseURL, 24 | headers 25 | }) 26 | apiClient.addResponseTransform(response => { 27 | const { ok, data } = response 28 | if (ok && data) { 29 | response.data = mapKeysDeep(data, keys => camelCase(keys)) 30 | } 31 | return response 32 | }) 33 | 34 | apiClient.addRequestTransform(request => { 35 | const { data } = request 36 | if (data) { 37 | request.data = mapKeysDeep(data, keys => snakeCase(keys)) 38 | } 39 | return request 40 | }) 41 | setStore(store) 42 | setLatestApiClient(apiClient) 43 | return apiClient 44 | } 45 | 46 | export const setLatestApiClient = apiClient => { 47 | latestApiClient = apiClient 48 | } 49 | /** 50 | * Get the latest created Api client. 51 | * This is useful when multiple api clients are required 52 | */ 53 | export const getLatestApiClient = () => { 54 | return latestApiClient 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash/fp/camelCase'; 2 | import uniq from 'lodash/uniq'; 3 | import uniqBy from 'lodash/uniqBy'; 4 | import { plural, singular } from 'pluralize'; 5 | 6 | let store = null; 7 | 8 | export const pluralCamel = item => plural(camelCase(item)); 9 | export const singularCamel = item => singular(camelCase(item)); 10 | 11 | export function getIncludeList(action) { 12 | const { include, pathname } = action; 13 | return uniq( 14 | (include || '') 15 | .split(',') 16 | .join('.') 17 | .split('.') 18 | .concat(pathname) 19 | .map(i => pluralCamel(i)) 20 | ); 21 | } 22 | 23 | export function getIncludeFilterAndId(i, f, id) { 24 | let includeString = ''; 25 | let filterString = ''; 26 | let idString = ''; 27 | if (f) { 28 | filterString += `${i ? `&` : `?`}filter=${f}`; 29 | } 30 | if (i) { 31 | includeString = `?include=${i}`; 32 | } 33 | if (id) { 34 | idString = `/${id}`; 35 | } 36 | return { includeString, filterString, idString }; 37 | } 38 | 39 | export const combineMerge = (destinationArray, sourceArray, options) => { 40 | return sourceArray; 41 | }; 42 | export const overwriteMerge = (destinationArray, sourceArray, options) => { 43 | return uniqBy(destinationArray.concat(...sourceArray), 'id'); 44 | }; 45 | 46 | export function setStore(s) { 47 | store = s; 48 | } 49 | 50 | export function getStore() { 51 | return store; 52 | } 53 | 54 | export * from 'utils/apiUtils'; 55 | export * from 'utils/jsonApiUtils'; 56 | -------------------------------------------------------------------------------- /src/utils/jsonApiUtils.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import uniqBy from 'lodash/uniqBy'; 3 | import isEmpty from 'lodash/isEmpty'; 4 | import range from 'lodash/range'; 5 | import { pluralCamel, singularCamel } from 'utils'; 6 | 7 | function updateStateWithTransfom(state, dataItem, transformList) { 8 | if (transformList[pluralCamel(dataItem.type)]) { 9 | state[transformList[pluralCamel(dataItem.type)]][dataItem.id] = { 10 | ...state[transformList[pluralCamel(dataItem.type)]][dataItem.id], 11 | ...dataItem.attributes, 12 | relationships: dataItem.relationships, 13 | id: dataItem.id 14 | }; 15 | } 16 | if (state[pluralCamel(dataItem.type)]) { 17 | state[pluralCamel(dataItem.type)][dataItem.id] = { 18 | ...state[pluralCamel(dataItem.type)][dataItem.id], 19 | ...dataItem.attributes, 20 | relationships: dataItem.relationships, 21 | id: dataItem.id 22 | }; 23 | } 24 | return state; 25 | } 26 | function createMergedStateObject( 27 | dataArray = [], 28 | includes = [], 29 | transformList = {}, 30 | state = {}, 31 | levelOfNesting, 32 | withTransform 33 | ) { 34 | const callAgain = []; 35 | if (!(dataArray instanceof Array)) { 36 | dataArray = [dataArray]; 37 | } 38 | dataArray.forEach(dataItem => { 39 | // Get the type of the dataItem. 40 | // Use that as the key in the state object and use the @dataItem.id 41 | // as the property of state[dataItem.type] to add all the attributes data. 42 | // Since it is in a map it can be accessed in O(1) complexity 43 | updateStateWithTransfom(state, dataItem, transformList); 44 | // if this particular item has any relationships then we need to iterate over those relationships 45 | Object.keys(get(dataItem, 'relationships', {})).forEach(relationship => { 46 | if (dataItem.relationships[relationship].data) { 47 | // if data is present check if it is of type array if not convert it into an array 48 | // so that it can be iterated easily. 49 | let normalise = pluralCamel; 50 | if (!(dataItem.relationships[relationship].data instanceof Array)) { 51 | dataItem.relationships[relationship].data = [dataItem.relationships[relationship].data]; 52 | normalise = singularCamel; 53 | } 54 | // no need for empty arrays 55 | if (dataItem.relationships[relationship].data.length) { 56 | state[pluralCamel(dataItem.type)][dataItem.id][normalise(relationship)] = { 57 | ...dataItem.relationships[relationship].data, 58 | ...state[pluralCamel(dataItem.type)][dataItem.id][normalise(relationship)] 59 | }; 60 | } 61 | 62 | if (withTransform) { 63 | // it's the data that is being created so add transforms from the previous state 64 | includes.forEach(includedKey => { 65 | if (includedKey === pluralCamel(relationship) || includedKey === singularCamel(relationship)) { 66 | // pluralize this so that we are always comparing lists to lists and not list 67 | includedKey = pluralCamel(relationship); 68 | 69 | state[pluralCamel(dataItem.type)][dataItem.id][normalise(relationship)] = uniqBy( 70 | dataItem.relationships[relationship].data, 71 | 'id' 72 | ).map(relationshipData => { 73 | if (transformList[includedKey]) { 74 | includedKey = transformList[includedKey]; 75 | } 76 | if ( 77 | !state[includedKey][relationshipData.id] || 78 | Object.keys(state[includedKey][relationshipData.id]).length < 2 79 | ) { 80 | // if this object is undefined it means that it hasn't been added to the 81 | // state as well. 82 | // so we push it into an array so that at the end of all the traversals we can 83 | // iterate this array and populate the values that we couldn't find the first time 84 | // around 85 | callAgain.push({ 86 | relationship: normalise(relationship), 87 | id: relationshipData.id, 88 | includedKey, 89 | stateType: pluralCamel(dataItem.type), 90 | stateId: dataItem.id 91 | }); 92 | } 93 | 94 | return { 95 | ...relationshipData, 96 | ...state[includedKey][relationshipData.id] 97 | }; 98 | }); 99 | } 100 | }); 101 | } 102 | } 103 | }); 104 | }); 105 | range(1, levelOfNesting + 1).forEach(() => { 106 | callAgain.forEach(item => { 107 | // withTransform 108 | const transformStateType = transformList[item.stateType] || transformList[pluralCamel(item.stateType)]; 109 | const transformItemRelationship = transformList[item.relationship] || transformList[item.relationship]; 110 | if (transformItemRelationship && state[pluralCamel(item.stateType)][item.stateId][transformItemRelationship]) { 111 | state[pluralCamel(item.stateType)][item.stateId][transformItemRelationship] = state[ 112 | pluralCamel(item.stateType) 113 | ][item.stateId][transformItemRelationship].map(data => ({ 114 | ...data, 115 | ...state[item.includedKey][data.id] 116 | })); 117 | } else if (transformStateType && state[transformStateType][item.stateId][transformItemRelationship]) { 118 | state[transformStateType][item.stateId][transformItemRelationship] = state[transformStateType][item.stateId][ 119 | transformItemRelationship 120 | ].map(data => ({ 121 | ...data, 122 | ...state[item.includedKey][data.id] 123 | })); 124 | } else if (state[pluralCamel(item.stateType)][item.stateId][item.relationship]) { 125 | if (!(state[pluralCamel(item.stateType)][item.stateId][item.relationship] instanceof Array)) { 126 | state[pluralCamel(item.stateType)][item.stateId][item.relationship] = Object.values( 127 | state[pluralCamel(item.stateType)][item.stateId][item.relationship] 128 | ); 129 | } 130 | 131 | state[pluralCamel(item.stateType)][item.stateId][item.relationship] = state[pluralCamel(item.stateType)][ 132 | item.stateId 133 | ][item.relationship].map(data => ({ 134 | ...data, 135 | ...state[item.includedKey][data.id] 136 | })); 137 | } else if (state[item.stateType][item.stateId][item.relationship]) { 138 | if (!(state[item.stateType][item.stateId][item.relationship] instanceof Array)) { 139 | state[item.stateType][item.stateId][item.relationship] = Object.values( 140 | state[item.stateType][item.stateId][item.relationship] 141 | ); 142 | } 143 | state[item.stateType][item.stateId][item.relationship] = state[item.stateType][item.stateId][ 144 | item.relationship 145 | ].map(data => ({ 146 | ...data, 147 | ...state[item.includedKey][data.id] 148 | })); 149 | } 150 | }); 151 | }); 152 | return state; 153 | } 154 | 155 | const addKeysToState = (arr, state = {}) => { 156 | arr.forEach(includedKey => { 157 | if (!state[includedKey]) { 158 | state[includedKey] = {}; 159 | } 160 | }); 161 | return state; 162 | }; 163 | export function createDeepInclude( 164 | jsonApiResponse = {}, 165 | includes = [], 166 | transformList = {}, 167 | levelOfNesting = 0, 168 | state = {}, 169 | deletedId 170 | ) { 171 | // Create a consolidated state object with all the includedKeys 172 | if (deletedId) { 173 | const deletedKey = pluralCamel(includes[0]); 174 | delete state[deletedKey][deletedId]; 175 | Object.keys(state).forEach(stateKey => { 176 | Object.keys(state[stateKey]).forEach(dataItem => { 177 | console.log(stateKey, dataItem, deletedKey); 178 | if (state[stateKey][dataItem] && state[stateKey][dataItem][deletedKey]) { 179 | state[stateKey][dataItem][deletedKey] = state[stateKey][dataItem][deletedKey].filter(i => { 180 | return i.id != deletedId; 181 | }); 182 | } 183 | }); 184 | }); 185 | return state; 186 | } 187 | state = addKeysToState(includes, state); 188 | state = addKeysToState(Object.keys(transformList), state); 189 | state = addKeysToState(Object.values(transformList), state); 190 | // update all keys to plural camel case 191 | Object.keys(transformList).forEach(transformListItem => { 192 | transformList[pluralCamel(transformListItem)] = pluralCamel(transformList[transformListItem]); 193 | delete transformList[transformListItem]; 194 | }); 195 | // Extract data and included from the jsonApiResponse Object 196 | const { data, included } = jsonApiResponse; 197 | 198 | state = createMergedStateObject(included, includes, transformList, state, levelOfNesting, true); 199 | state = createMergedStateObject(data, includes, transformList, state, levelOfNesting, true); 200 | Object.keys(state).forEach(key => { 201 | if (isEmpty(state[key])) { 202 | delete state[key]; 203 | } 204 | }); 205 | return state; 206 | } 207 | 208 | export function createShallowInclude(jsonApiResponse, entities, transformList = {}, levelOfNesting = 0) { 209 | // Create a consolidated state object with all the includedKeys 210 | let state = {}; 211 | entities.forEach(includedKey => { 212 | state[includedKey] = {}; 213 | }); 214 | // Extract data and included from the jsonApiResponse Object 215 | const { data, included } = jsonApiResponse; 216 | 217 | state = createMergedStateObject(included, entities, transformList, state, levelOfNesting); 218 | state = createMergedStateObject(data, entities, transformList, state, levelOfNesting); 219 | return state; 220 | } 221 | 222 | export function handleEmbeddedDocument(data, embeddedDocumentKey) { 223 | data = data.map(dataItem => { 224 | dataItem[embeddedDocumentKey] = { 225 | ...dataItem[embeddedDocumentKey], 226 | ...get(dataItem[embeddedDocumentKey], 'data.attributes') 227 | }; 228 | delete dataItem[embeddedDocumentKey].data; 229 | return dataItem; 230 | }); 231 | return data; 232 | } 233 | 234 | export default { 235 | createDeepInclude, 236 | createShallowInclude 237 | }; 238 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: './src/index.js', 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: 'jsonapi-redux-data.js', 9 | library: 'jsonapi-redux-data', 10 | libraryTarget: 'commonjs2' 11 | }, 12 | externals: { 13 | lodash: { 14 | commonjs: 'lodash', 15 | commonjs2: 'lodash', 16 | amd: 'lodash', 17 | root: '_' 18 | } 19 | }, 20 | resolve: { 21 | modules: ['node_modules', 'app'], 22 | alias: { 23 | src: path.resolve(__dirname, './src'), 24 | services: path.resolve(__dirname, './src/services'), 25 | selectors: path.resolve(__dirname, './src/selectors'), 26 | reducers: path.resolve(__dirname, './src/reducers'), 27 | utils: path.resolve(__dirname, './src/utils') 28 | }, 29 | extensions: ['.js', '.jsx', '.react.js'], 30 | mainFields: ['browser', 'jsnext:main', 'main'] 31 | } 32 | }; 33 | --------------------------------------------------------------------------------