diff --git a/package.json b/package.json index 20e530c..afc9000 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "eslint-plugin-import": "^2.2.0", "gitbook-cli": "^2.3.0", "mocha": "^2.5.3", - "nyc": "^10.1.2", + "nyc": "10.2.0", "sinon": "^1.17.7", "sinon-chai": "^2.8.0", "webpack": "^2.4.1" diff --git a/src/builder.js b/src/builder.js index 2d3c3e1..1728930 100644 --- a/src/builder.js +++ b/src/builder.js @@ -102,16 +102,38 @@ const setApiConfigDefaults = ec => { return { ...ec, - api: ec.api ? mapValues(setDefaults, ec.api) : ec.api + api: mapValues(setDefaults, ec.api) }; }; +const createNotifyFunction = (operation) => { + const fn = (x) => Promise.resolve(x); + fn.operation = operation; + fn.isNotifier = true; + fn.invalidates = []; + return fn; +}; + +const addNotifyFunctions = (entityConfig) => { + if (!entityConfig.api) { + entityConfig.api = {}; + } + + entityConfig.api._notifyCreate = createNotifyFunction('CREATE'); + entityConfig.api._notifyRead = createNotifyFunction('READ'); + entityConfig.api._notifyUpdate = createNotifyFunction('UPDATE'); + entityConfig.api._notifyDelete = createNotifyFunction('DELETE'); + + return entityConfig; +}; + // Config -> Map String EntityConfig -export const getEntityConfigs = compose( // exported for testing +export const createEntityConfigs = compose( // exported for testing toObject(prop('name')), mapObject(toEntity), mapValues(setApiConfigDefaults), mapValues(setEntityConfigDefaults), + mapValues(addNotifyFunctions), filterObject(compose(not, isEqual('__config'))) ); @@ -128,13 +150,13 @@ const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) => }); // Config -> Api -export const build = (c, ps = []) => { - const config = getGlobalConfig(c); - const entityConfigs = getEntityConfigs(c); - validateConfig(console, entityConfigs, config); - const listenerStore = createListenerStore(config); - const applyPlugin_ = applyPlugin(listenerStore.addChangeListener, config); +export const build = (config, plugins = []) => { + const globalConfig = getGlobalConfig(config); + const entityConfigs = createEntityConfigs(config); + validateConfig(console, entityConfigs, globalConfig); + const listenerStore = createListenerStore(globalConfig); + const applyPlugin_ = applyPlugin(listenerStore.addChangeListener, globalConfig); const applyPlugins = reduce(applyPlugin_, entityConfigs); const createApi = compose(toApi, applyPlugins); - return createApi([cachePlugin(listenerStore.onChange), ...ps, dedupPlugin]); + return createApi([cachePlugin(listenerStore.onChange), ...plugins, dedupPlugin]); }; diff --git a/src/builder.spec.js b/src/builder.spec.js index 8ef8a3e..b37e0f0 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -45,6 +45,34 @@ describe('builder', () => { .then(() => api.user.getUsers()) .then(expectOnlyOneApiCall); }); + it('Adds notifiers to APIs', () => { + const myConfig = config(); + myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); + const api = build(myConfig); + expect(api.user._notifyCreate).to.not.be.undefined; + expect(api.user._notifyRead).to.not.be.undefined; + expect(api.user._notifyUpdate).to.not.be.undefined; + expect(api.user._notifyDelete).to.not.be.undefined; + }); + it('Adds notifiers to APIs even if no API is specified', () => { + const myConfig = config(); + myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); + const api = build(myConfig); + delete api.user.api; + expect(api.user._notifyCreate).to.not.be.undefined; + expect(api.user._notifyRead).to.not.be.undefined; + expect(api.user._notifyUpdate).to.not.be.undefined; + expect(api.user._notifyDelete).to.not.be.undefined; + }); + it('Notifiers are just identity functions lifted to promises', (done) => { + const myConfig = config(); + myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); + const api = build(myConfig); + api.user._notifyCreate('hello').then((value) => { + expect(value).to.be.equal('hello'); + done(); + }); + }); it('Two read api calls will return the same output', (done) => { const myConfig = config(); myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); @@ -215,5 +243,12 @@ describe('builder', () => { expect(spy).not.to.have.been.called; }); }); + + it('works without global config', () => { + const conf = config(); + delete conf.__config; + const api = build(conf); + expect(api).to.be.defined; + }); }); }); diff --git a/src/plugins/cache/index.js b/src/plugins/cache/index.js index ff26949..03f66f0 100644 --- a/src/plugins/cache/index.js +++ b/src/plugins/cache/index.js @@ -11,13 +11,22 @@ const HANDLERS = { READ: decorateRead, UPDATE: decorateUpdate, DELETE: decorateDelete, - NO_OPERATION: decorateNoOperation + NO_OPERATION: decorateNoOperation, + NOTIFIER: decorateNoOperation +}; + +const getHandler = (fn) => { + if (fn.isNotifier === true) { + return HANDLERS.NOTIFIER; + } + + return HANDLERS[fn.operation]; }; export const cachePlugin = (onChange) => ({ config, entityConfigs }) => { const cache = createCache(values(entityConfigs), onChange); return ({ entity, fn }) => { - const handler = HANDLERS[fn.operation]; + const handler = getHandler(fn); return handler(config, cache, entity, fn); }; }; diff --git a/src/plugins/cache/operations/notifier.js b/src/plugins/cache/operations/notifier.js new file mode 100644 index 0000000..0ce24d3 --- /dev/null +++ b/src/plugins/cache/operations/notifier.js @@ -0,0 +1,9 @@ +import {passThrough} from 'ladda-fp'; +import {invalidateQuery} from '../cache'; + +export function decorateNotifier(c, cache, e, aFn) { + return (...args) => { + return aFn(...args) + .then(passThrough(() => invalidateQuery(cache, e, aFn))); + }; +} diff --git a/src/plugins/cache/operations/notifier.spec.js b/src/plugins/cache/operations/notifier.spec.js new file mode 100644 index 0000000..8799a63 --- /dev/null +++ b/src/plugins/cache/operations/notifier.spec.js @@ -0,0 +1,62 @@ +/* eslint-disable no-unused-expressions */ + +import sinon from 'sinon'; +import {decorateNotifier} from './notifier'; +import * as Cache from '../cache'; +import {createSampleConfig} from '../test-helper'; + +const config = createSampleConfig(); + +describe('DecorateNotifier', () => { + it('Invalidates as if it was the specified operation', (done) => { + const cache = Cache.createCache(config); + const e = config[0]; + e.invalidatesOn = ['READ']; + const xOrg = {__ladda__id: 1, name: 'Kalle'}; + const aFn = sinon.spy(() => Promise.resolve({})); + const getUsers = () => Promise.resolve(xOrg); + aFn.operation = 'READ'; + aFn.isNotifier = true; + aFn.invalidates = []; + Cache.storeQueryResponse(cache, e, getUsers, ['args'], xOrg); + const res = decorateNotifier({}, cache, e, aFn); + res(xOrg).then(() => { + const killedCache = !Cache.containsQueryResponse(cache, e, getUsers, ['args']); + expect(killedCache).to.be.true; + done(); + }); + }); + it('Does not invalidate if operation does not cause invalidation', (done) => { + const cache = Cache.createCache(config); + const e = config[0]; + e.invalidatesOn = ['READ']; + const xOrg = {__ladda__id: 1, name: 'Kalle'}; + const aFn = sinon.spy(() => Promise.resolve({})); + const getUsers = () => Promise.resolve(xOrg); + aFn.operation = 'DELETE'; + aFn.isNotifier = true; + aFn.invalidates = []; + Cache.storeQueryResponse(cache, e, getUsers, ['args'], xOrg); + const res = decorateNotifier({}, cache, e, aFn); + res(xOrg).then(() => { + const killedCache = !Cache.containsQueryResponse(cache, e, getUsers, ['args']); + expect(killedCache).to.be.false; + done(); + }); + }); + it('Does not write to cache', (done) => { + const cache = Cache.createCache(config); + const e = config[0]; + const xOrg = {__ladda__id: 1, name: 'Kalle'}; + const aFn = sinon.spy(() => Promise.resolve({})); + aFn.operation = 'CREATE'; + aFn.isNotifier = false; + aFn.invalidates = []; + const res = decorateNotifier({}, cache, e, aFn); + res(xOrg).then(() => { + const hasEntity = Cache.containsEntity(cache, e, 1); + expect(hasEntity).to.be.false; + done(); + }); + }); +}); diff --git a/src/validator.js b/src/validator.js index 9f25e30..d49ba96 100644 --- a/src/validator.js +++ b/src/validator.js @@ -14,11 +14,6 @@ const isValidLogger = (logger) => logger && typeof logger.error === 'function'; const getEntityNames = (entityConfigs) => Object.keys(entityConfigs); const checkApiDeclaration = (logger, entityConfigs, entityName, entity) => { - if (typeof entity.api !== 'object') { - warn(logger, `No api definition found for entity ${entityName}`); - return; - } - const warnApi = (msg, ...args) => warn( logger, `Invalid api config. ${msg}`, diff --git a/src/validator.spec.js b/src/validator.spec.js index 346c86f..113299f 100644 --- a/src/validator.spec.js +++ b/src/validator.spec.js @@ -1,7 +1,7 @@ /* eslint-disable no-unused-expressions */ import sinon from 'sinon'; import { validateConfig } from './validator'; -import { getEntityConfigs } from './builder'; +import { createEntityConfigs } from './builder'; const createLogger = () => ({ error: sinon.spy() @@ -17,7 +17,7 @@ const createGlobalConfig = (conf) => ({ describe('validateConfig', () => { it('does not do anything when using production build', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: {} }); const config = createGlobalConfig({ useProductionBuild: true }); @@ -29,7 +29,7 @@ describe('validateConfig', () => { it('does not do anything when invalid logger is passed', () => { const invalidLogger = { x: sinon.spy() }; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: {} }); const config = createGlobalConfig({ useProductionBuild: true }); @@ -41,7 +41,7 @@ describe('validateConfig', () => { it('checks the global config object - idField', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll: () => {} @@ -58,7 +58,7 @@ describe('validateConfig', () => { it('checks the global config object - enableDeduplication', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll: () => {} @@ -72,28 +72,10 @@ describe('validateConfig', () => { expect(logger.error.args[0][0]).to.match(/enableDeduplication.*boolean.*was.*string/); }); - it('checks for missing api declarations', () => { - const logger = createLogger(); - - const eConfigs = getEntityConfigs({ - user: { - api: { - getAll: () => {} - } - }, - activity: {} - }); - const config = createGlobalConfig({}); - - validateConfig(logger, eConfigs, config); - expect(logger.error).to.have.been.called; - expect(logger.error.args[0][0]).to.match(/No api definition.*activity/); - }); - it('checks for non-configured views', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll: () => {} @@ -122,7 +104,7 @@ describe('validateConfig', () => { it('checks for wrong invalidation targets', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll: () => {} }, invalidates: ['activity', 'ntification'] // typo! @@ -146,7 +128,7 @@ describe('validateConfig', () => { it('checks for wrong invalidation operations', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll: () => {} } }, @@ -170,7 +152,7 @@ describe('validateConfig', () => { it('checks for wrong ttl values', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll: () => {} }, ttl: 300 @@ -190,7 +172,7 @@ describe('validateConfig', () => { it('checks for wrong enableDeduplication value', () => { const logger = createLogger(); - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll: () => {} }, enableDeduplication: true @@ -216,7 +198,7 @@ describe('validateConfig', () => { const getAll = () => {}; getAll.operation = 'X'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll } } @@ -235,7 +217,7 @@ describe('validateConfig', () => { getAll.operation = 'READ'; getAll.byId = 'xxx'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll } } @@ -254,7 +236,7 @@ describe('validateConfig', () => { getAll.operation = 'READ'; getAll.byIds = 'xxx'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll } } @@ -273,7 +255,7 @@ describe('validateConfig', () => { getAll.operation = 'READ'; getAll.enableDeduplication = 'X'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll } } @@ -292,7 +274,7 @@ describe('validateConfig', () => { getAll.operation = 'READ'; getAll.idFrom = true; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll } } @@ -311,7 +293,7 @@ describe('validateConfig', () => { getAll.operation = 'READ'; getAll.idFrom = 'X'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll } } @@ -336,7 +318,7 @@ describe('validateConfig', () => { const getSome = () => {}; getSome.operation = 'READ'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll, getSome, getOne } } @@ -355,7 +337,7 @@ describe('validateConfig', () => { getAll.operation = 'READ'; getAll.idFrom = 'X'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll }, invalidates: ['X'] @@ -374,7 +356,7 @@ describe('validateConfig', () => { getAll.operation = 'READ'; getAll.idFrom = 'ENTITY'; - const eConfigs = getEntityConfigs({ + const eConfigs = createEntityConfigs({ user: { api: { getAll }, invalidates: ['activity']