├── .babelrc ├── .npmignore ├── src ├── index.js ├── create-adapter.js ├── map-paths.js ├── create-adapter.test.js └── map-paths.test.js ├── .gitignore ├── .eslintrc.js ├── LICENSE ├── package.json └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "es2017", "stage-3"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintrc.js 3 | .gitignore 4 | examples 5 | src 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createAdapter from './create-adapter'; 2 | 3 | export { createAdapter }; 4 | -------------------------------------------------------------------------------- /src/create-adapter.js: -------------------------------------------------------------------------------- 1 | import { invert } from 'lodash/fp'; 2 | import mapPaths from './map-paths'; 3 | 4 | const createAdapter = pathMap => ({ 5 | fromApi(apiObj) { 6 | return this.mapPaths(pathMap, apiObj); 7 | }, 8 | 9 | mapPaths, 10 | 11 | toApi(clientObj) { 12 | return this.mapPaths(invert(pathMap), clientObj); 13 | }, 14 | }); 15 | 16 | export default createAdapter; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | dist 40 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | }, 5 | extends: [ 6 | 'airbnb-base', 7 | 'plugin:import/errors', 8 | 'plugin:import/warnings', 9 | 'plugin:lodash-fp/recommended', 10 | ], 11 | parser: "babel-eslint", 12 | parserOptions: { 13 | ecmaFeatures: { 14 | experimentalObjectRestSpread: true, 15 | }, 16 | }, 17 | plugins: [ 18 | 'lodash-fp', 19 | ], 20 | rules: { 21 | 'import/no-extraneous-dependencies': [ 22 | 'error', 23 | { 24 | devDependencies: [ 25 | '**/*.stories.js', 26 | '**/*.test.js', 27 | '**/webpack-*.config.js', 28 | 'wallaby.js', 29 | ], 30 | }, 31 | ], 32 | 'import/prefer-default-export': 0, 33 | 'linebreak-style': 0, 34 | 'new-cap': 0, 35 | 'no-use-before-define': [ 36 | 'error', 37 | { 38 | classes: true, 39 | functions: false, 40 | }, 41 | ], 42 | 'prefer-const': 2, 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nick Johnson 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 | -------------------------------------------------------------------------------- /src/map-paths.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash/fp'; 2 | // import { 3 | // filter, 4 | // first, 5 | // get, 6 | // has, 7 | // includes, 8 | // isObject, 9 | // keys, 10 | // map, 11 | // omitBy, 12 | // pipe, 13 | // reduce, 14 | // set, 15 | // split, 16 | // } from 'lodash/fp'; 17 | 18 | 19 | const mapPaths = (pathMap, source) => { 20 | if (_.isArray(source)) { 21 | const handleSourceItem = s => 22 | (_.isObject(s) 23 | ? mapObjectPaths(pathMap, s) 24 | : s); 25 | return _.map(handleSourceItem, source); 26 | } 27 | 28 | if (_.isObject(source)) { 29 | return mapObjectPaths(pathMap, source); 30 | } 31 | 32 | throw new Error('Source must be an object or array'); 33 | }; 34 | 35 | function mapObjectPaths(pathMap, source) { 36 | const withoutNesting = _.map(_.pipe(_.split('.'), _.first)); 37 | const isUntouched = (value, key) => _.includes(key, withoutNesting(_.keys(pathMap))); 38 | 39 | return _.pipe( 40 | _.filter(path => _.has(path, source)), 41 | _.reduce((acc, path) => 42 | _.set(pathMap[path], _.get(path, source), acc) 43 | , _.omitBy(isUntouched, source)), 44 | )(_.keys(pathMap)); 45 | } 46 | 47 | export default mapPaths; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-adapter", 3 | "version": "1.1.0", 4 | "description": "Decouple the shape of your data on the client and server", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir dist --ignore test.js", 8 | "clean": "rimraf dist", 9 | "lint": "eslint src", 10 | "prebuild": "npm run clean -s", 11 | "prerelease": "npm run lint -s && npm run testonce -s && npm run build -s", 12 | "release": "npm publish", 13 | "test": "ava --watch", 14 | "testonce": "ava" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/nickjohnson-dev/api-adapter.git" 19 | }, 20 | "keywords": [], 21 | "author": { 22 | "name": "Nick Johnson", 23 | "email": "nickjohnson.dev@gmail.com" 24 | }, 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/nickjohnson-dev/api-adapter/issues" 28 | }, 29 | "homepage": "https://github.com/nickjohnson-dev/api-adapter#readme", 30 | "devDependencies": { 31 | "ava": "0.18.1", 32 | "babel-cli": "6.22.2", 33 | "babel-core": "6.22.0", 34 | "babel-eslint": "7.1.1", 35 | "babel-preset-es2015": "6.22.0", 36 | "babel-preset-es2017": "6.22.0", 37 | "babel-preset-stage-3": "6.22.0", 38 | "eslint": "3.15.0", 39 | "eslint-config-airbnb-base": "11.1.0", 40 | "eslint-plugin-import": "2.2.0", 41 | "eslint-plugin-lodash-fp": "2.1.3", 42 | "sinon": "2.0.0-pre.5" 43 | }, 44 | "ava": { 45 | "require": [ 46 | "babel-register" 47 | ] 48 | }, 49 | "dependencies": { 50 | "lodash": "4.17.11" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/create-adapter.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import mapPaths from './map-paths'; 4 | import createAdapter from './create-adapter'; 5 | 6 | test('should return object with mapPaths method equal to mapPaths function', (t) => { 7 | const adapter = createAdapter({}); 8 | t.is(adapter.mapPaths, mapPaths); 9 | }); 10 | 11 | test('should return object with fromApi method that invokes mapPaths with given path map', (t) => { 12 | const pathMap = { title: 'name' }; 13 | const adapter = createAdapter(pathMap); 14 | adapter.mapPaths = sinon.spy(); 15 | const source = { title: 'Blue in Green' }; 16 | adapter.fromApi(source); 17 | t.deepEqual(adapter.mapPaths.lastCall.args, [pathMap, source]); 18 | }); 19 | 20 | test('should return object with fromApi method that returns the return value of mapPaths', (t) => { 21 | const adapter = createAdapter({}); 22 | const mappedObject = { key: 'value' }; 23 | adapter.mapPaths = () => mappedObject; 24 | const result = adapter.fromApi({}); 25 | t.deepEqual(result, mappedObject); 26 | }); 27 | 28 | test('should return object with toApi method that invokes mapPaths with inverted version ofgiven path map', (t) => { 29 | const pathMap = { title: 'name' }; 30 | const invertedPathMap = { name: 'title' }; 31 | const adapter = createAdapter(pathMap); 32 | adapter.mapPaths = sinon.spy(); 33 | const source = { title: 'Blue in Green' }; 34 | adapter.toApi(source); 35 | t.deepEqual(adapter.mapPaths.lastCall.args, [invertedPathMap, source]); 36 | }); 37 | 38 | test('should return object with toApi method that returns the return value of mapPaths', (t) => { 39 | const adapter = createAdapter({}); 40 | const mappedObject = { key: 'value' }; 41 | adapter.mapPaths = () => mappedObject; 42 | const result = adapter.toApi({}); 43 | t.deepEqual(result, mappedObject); 44 | }); 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # api-adapter 2 | 3 | [![npm version](https://badge.fury.io/js/api-adapter.svg)](https://badge.fury.io/js/api-adapter) 4 | 5 | Decouple the shape of your data on the client and server. 6 | 7 | Allows you to easily convert unfortunately named properties on server responses so that they're more pleasant to work with on the client. Even supports deep flattening and nesting of values. 8 | 9 | Defining the mapping of server to client data in a single spot means no more renaming in hundreds of files when someone changes the property names on the backend. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install api-adapter 15 | ``` 16 | 17 | ## Usage 18 | 19 | The helper `createAdapter` takes an object with keys equal to unwanted [property paths](https://lodash.com/docs#get) in your API response, and values equal to desired property paths to use in your client. 20 | 21 | ```javascript 22 | import { createAdapter } from 'api-adapter'; 23 | const adapter = createAdapter({ 24 | name: 'title', 25 | 'other.band': 'artist', 26 | }); 27 | ``` 28 | 29 | The adapter's `fromApi` method will convert your data into a pleasant format you can use in the client. 30 | 31 | ```javascript 32 | const clientData = adapter.fromApi({ 33 | name: 'Blue in Green', 34 | other: { 35 | band: 'Bill Evans', 36 | }, 37 | }); 38 | 39 | console.log(clientData); 40 | // { artist: 'Bill Evans', title: 'Blue in Green' } 41 | ``` 42 | 43 | Then, the `toApi` method will then convert back to the API structure when you're ready to send an update to the server. 44 | 45 | ```javascript 46 | const apiData = adapter.toApi({ 47 | artist: 'Bill Evans', 48 | title: 'Blue in Green', 49 | }); 50 | 51 | console.log(apiData); 52 | // { name: 'Blue in Green', other: { band: 'Bill Evans' } } 53 | ``` 54 | 55 | Both methods can also handle arrays! 56 | 57 | ```javascript 58 | const clientData = adapter.fromApi([ 59 | { name: 'Blue in Green', other: { band: 'Bill Evans' } }, 60 | { name: 'マイワールド', other: { band: 'ASIAN KUNG-FU GENERATION' } }, 61 | ]); 62 | 63 | console.log(clientData); 64 | // [ 65 | // { artist: 'Bill Evans', title: 'Blue in Green' }, 66 | // { artist: 'ASIAN KUNG-FU GENERATION', title: 'マイワールド' }, 67 | // ] 68 | ``` 69 | -------------------------------------------------------------------------------- /src/map-paths.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import mapPaths from './map-paths'; 3 | 4 | test('should move values from key paths to value paths using given path map as reference', (t) => { 5 | const pathMap = { 6 | band: 'artist', 7 | track: 'title', 8 | }; 9 | const original = { 10 | band: 'Bill Evans', 11 | track: 'Blue in Green', 12 | }; 13 | const expected = { 14 | artist: 'Bill Evans', 15 | title: 'Blue in Green', 16 | }; 17 | const result = mapPaths(pathMap, original); 18 | t.deepEqual(result, expected); 19 | }); 20 | 21 | test('should handle converting to nested values', (t) => { 22 | const pathMap = { 23 | band: 'staff.artist', 24 | track: 'title', 25 | }; 26 | const original = { 27 | band: 'Bill Evans', 28 | track: 'Blue in Green', 29 | }; 30 | const expected = { 31 | staff: { 32 | artist: 'Bill Evans', 33 | }, 34 | title: 'Blue in Green', 35 | }; 36 | const result = mapPaths(pathMap, original); 37 | t.deepEqual(result, expected); 38 | }); 39 | 40 | test('should handle converting from nested values', (t) => { 41 | const pathMap = { 42 | 'other.band': 'artist', 43 | track: 'title', 44 | }; 45 | const original = { 46 | other: { 47 | band: 'Bill Evans', 48 | }, 49 | track: 'Blue in Green', 50 | }; 51 | const expected = { 52 | artist: 'Bill Evans', 53 | title: 'Blue in Green', 54 | }; 55 | const result = mapPaths(pathMap, original); 56 | t.deepEqual(result, expected); 57 | }); 58 | 59 | test('should retain values not included in path map when converting', (t) => { 60 | const pathMap = { 61 | band: 'artist', 62 | track: 'title', 63 | }; 64 | const original = { 65 | band: 'Bill Evans', 66 | stars: 5, 67 | track: 'Blue in Green', 68 | }; 69 | const expected = { 70 | artist: 'Bill Evans', 71 | stars: 5, 72 | title: 'Blue in Green', 73 | }; 74 | const result = mapPaths(pathMap, original); 75 | t.deepEqual(result, expected); 76 | }); 77 | 78 | test('should map each object when given an array of objects', (t) => { 79 | const pathMap = { 80 | band: 'artist', 81 | track: 'title', 82 | }; 83 | const original = [ 84 | { band: 'ASIAN KUNG-FU GENERATION', track: 'Re:Re' }, 85 | { band: 'Bill Evans', track: 'Blue in Green' }, 86 | ]; 87 | const expected = [ 88 | { artist: 'ASIAN KUNG-FU GENERATION', title: 'Re:Re' }, 89 | { artist: 'Bill Evans', title: 'Blue in Green' }, 90 | ]; 91 | const result = mapPaths(pathMap, original); 92 | t.deepEqual(result, expected, 'result should be deep equal to expected'); 93 | }); 94 | 95 | test('should map objects and allow non-objects to pass through when given an array of objects', (t) => { 96 | const pathMap = { 97 | band: 'artist', 98 | track: 'title', 99 | }; 100 | const original = [ 101 | { band: 'ASIAN KUNG-FU GENERATION', track: 'Re:Re' }, 102 | { band: 'Bill Evans', track: 'Blue in Green' }, 103 | 3, 104 | ]; 105 | const expected = [ 106 | { artist: 'ASIAN KUNG-FU GENERATION', title: 'Re:Re' }, 107 | { artist: 'Bill Evans', title: 'Blue in Green' }, 108 | 3, 109 | ]; 110 | const result = mapPaths(pathMap, original); 111 | t.deepEqual(result, expected, 'result should be deep equal to expected'); 112 | }); 113 | --------------------------------------------------------------------------------