├── .babelrc ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .npmignore ├── README.md ├── lib ├── apicase.js ├── defaults.js ├── index.js ├── merge.js └── normalize.js ├── package-lock.json ├── package.json ├── test ├── apicase.test.js ├── merge.test.js └── normalize.test.js ├── types ├── adapter.d.ts └── adapter.js.flow └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "cjs": { 4 | "presets": ["env"], 5 | "plugins": [ 6 | [ 7 | "nanoutils", 8 | { 9 | "cjs": true 10 | } 11 | ] 12 | ] 13 | }, 14 | "es": { 15 | "presets": [["env", { "modules": false }]], 16 | "plugins": ["nanoutils"] 17 | }, 18 | "test": { 19 | "presets": [["env"]], 20 | "plugins": [["nanoutils", { "cjs": true }]] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'extends': 'standard', 3 | 4 | 'env': { 5 | 'jest': true, 6 | 'node': true, 7 | 'browser': true 8 | }, 9 | 10 | 'rules': { 11 | /* It's better to have indent for call expr with multi-lines */ 12 | 'indent': ['error', 2, { 'CallExpression': { 'arguments': 1 } }], 13 | /* Prettier doesn't support space before paren */ 14 | 'space-before-function-paren': ['error', 'never'] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # VS Code dir 61 | .vscode 62 | 63 | # Build details 64 | es/ 65 | cjs/ 66 | 67 | # MacOS 68 | .DS_Store 69 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | tests/ 3 | .babelrc 4 | build.js 5 | rollup.config.js 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apicase-core 2 | 3 | A **2 KB** library to organize your APIs in a smart way. 4 | 5 | ## Introduction 6 | 7 | There are so many questions about how to properly organize and work with APIs in frontend applications. 8 | 9 | Some people just don't think about it much; they use native `fetch`, but it's not very flexible or extensible. Some people create their own wrappers (classes, functions, or json objects), but those often become unusable in other projects because they were made for specific APIs. 10 | 11 | There's another problem—the API is often not separated from the application into an isolated layer. It means that you can't reuse your APIs with different projects or frameworks. 12 | 13 | Here is apicase—a unified way to create that isolated API layer. 14 | 15 | ## General features 16 | 17 | * **events-based** requests handling 18 | * **middlewares** to update/change-on-fly/undo/redo API calls 19 | * **adapters** instead of concrete tools (fetch/xhr) 20 | * **services** with unlimited inheritance 21 | 22 | ## Browser supports & restrictions 23 | 24 | Library sources are transpiled with [babel-preset-env](https://babeljs.io/docs/en/babel-preset-env/) but we don't add polyfills to our library to save its size and avoid code duplicates if your project already has polyfills. 25 | So here's the list of features you need to know: 26 | - You have to add [Promises polyfill](https://www.npmjs.com/package/promise-polyfill) or use [babel-polyfill](https://www.npmjs.com/package/babel-polyfill) to work with IE 11 [**[caniuse]**](https://caniuse.com/#feat=promises) 27 | - Fetch is used in `@apicase/adapter-fetch`. You might need [fetch polyfill](https://github.com/github/fetch) to work with IE 11 or you can just use `@apicase/adapter-xhr` [**[caniuse]**](https://caniuse.com/#feat=fetch) 28 | - AbortController is used in `@apicase/adapter-fetch` to implement `req.cancel()` and hasn't polyfills. Apicase will work well if AbortController is not supported but note that request just won't be really cancelled [**[caniuse]**](https://caniuse.com/#feat=abortcontroller) 29 | 30 | ## Documentation 31 | 32 | ### Full docs 33 | 34 | [**Read on GitHub pages**](https://apicase.github.io) 35 | 36 | ### Basic request 37 | 38 | Wrap adapter into `apicase` method and use it like it's Axios 39 | 40 | ```javascript 41 | import { apicase } from '@apicase/core' 42 | import fetch from '@apicase/adapter-fetch' 43 | 44 | const doRequest = apicase(fetch) 45 | 46 | const { success, result } = await doRequest({ 47 | url: '/api/posts/:id', 48 | method: 'POST', 49 | params: { id: 1 }, 50 | body: { 51 | title: 'Hello', 52 | text: 'This is Apicase' 53 | }, 54 | headers: { 55 | token: localStorage.getItem('token') 56 | } 57 | }) 58 | 59 | if (success) { 60 | console.log('Yay!', result) 61 | } else { 62 | console.log('Hey...', result) 63 | } 64 | ``` 65 | 66 | ### Events-based requests handling 67 | 68 | Following _"Business logic failures are not exceptions"_ principle, 69 | Apicase separates error handling from request fails: 70 | 71 | ```javascript 72 | doRequest({ url: "/api/posts" }) 73 | .on("done", res => { 74 | console.log("Done", res) 75 | }) 76 | .on("fail", res => { 77 | console.log("Fail", res) 78 | }) 79 | .on("error", err => { 80 | console.error(err) 81 | }) 82 | ``` 83 | 84 | ### Apicase services 85 | 86 | Move your API logic outside the main application code 87 | Check out `@apicase/services` [**repository**](https://github.com/apicase/services) and [**docs page**](https://kelin2025.gitbooks.io/apicase/content/anatomy/services.html) for more info 88 | 89 | ```javascript 90 | import fetch from "@apicase/adapter-fetch" 91 | import { ApiService } from "@apicase/services" 92 | 93 | const ApiRoot = new ApiService({ 94 | adapter: fetch, 95 | url: "/api" 96 | }) 97 | .on("done", logSucccess) 98 | .on("fail", logFailure) 99 | 100 | const AuthService = ApiRoot.extend({ url: "auth" }).on("done", res => { 101 | localStorage.setItem("token", res.body.token) 102 | }) 103 | 104 | AuthService.doRequest({ 105 | body: { login: "Apicase", password: "*****" } 106 | }) 107 | ``` 108 | 109 | ### Request queues 110 | 111 | Keep correct order of requests using queues 112 | Check out [**docs page**](https://kelin2025.gitbooks.io/apicase/content/anatomy/queues.html) for more info 113 | 114 | ```javascript 115 | import { ApiQueue } from "@apicase/core" 116 | 117 | const queue = new ApiQueue() 118 | 119 | queue.push(SendMessage.doRequest, { body: { message: "that stuff" } }) 120 | queue.push(SendMessage.doRequest, { body: { message: "really" } }) 121 | queue.push(SendMessage.doRequest, { body: { message: "works" } }) 122 | ``` 123 | 124 | ## TODO 125 | 126 | * [ ] Add plugins support to make work much easier 127 | * [ ] Create `apicase-devtools` 128 | 129 | ## Author 130 | 131 | [Anton Kosykh](https://github.com/Kelin2025) 132 | 133 | ## License 134 | 135 | MIT 136 | -------------------------------------------------------------------------------- /lib/apicase.js: -------------------------------------------------------------------------------- 1 | import EventBus from 'delightful-bus' 2 | import { pick } from 'nanoutils' 3 | import { normalizeOptions } from './normalize' 4 | 5 | const composeHooks = function composeHooks(options, debugStack = []) { 6 | let idx = 0 7 | 8 | const withPayload = payload => { 9 | if (!options.hooks[idx]) { 10 | options.update(payload) 11 | 12 | return Promise.resolve().then(() => options.final(payload)) 13 | } 14 | return new Promise(resolve => { 15 | let isResolved = false 16 | 17 | const tryResolve = payload => { 18 | if (process.env.NODE_ENV !== 'production' && isResolved) { 19 | console.error( 20 | `[Apicase: hooks] Attempt to resolve ${ 21 | options.type 22 | }[${idx}] hook twice:` 23 | ) 24 | return 25 | } 26 | 27 | isResolved = true 28 | resolve(payload) 29 | return payload 30 | } 31 | 32 | const changeFlow = newFlow => payload => tryResolve(newFlow(payload)) 33 | 34 | const ctx = Object.assign( 35 | options.createContext(payload, { changeFlow }), 36 | { 37 | next(payload) { 38 | idx++ 39 | options.update(payload) 40 | return tryResolve(withPayload(payload)) 41 | } 42 | } 43 | ) 44 | 45 | options.hooks[idx](ctx) 46 | }) 47 | } 48 | 49 | return withPayload 50 | } 51 | 52 | const pickState = pick([ 53 | 'success', 54 | 'pending', 55 | 'cancelled', 56 | 'payload', 57 | 'result', 58 | 'meta' 59 | ]) 60 | 61 | /** 62 | * Function that starts request 63 | * 64 | * @function doRequest 65 | * @param {Object} request Object with payload, hooks and meta info 66 | * @returns {Request} Thenable object with state, .on() and .cancel() methods 67 | */ 68 | 69 | /** 70 | * @typedef {Object} Request 71 | * @property {State} state State of request 72 | * @property {EventHandler} on Subscrube to request events 73 | * @property {CancelRequest} cancel Cancel request 74 | */ 75 | 76 | /** 77 | * State of query 78 | * 79 | * @typedef {Object} State 80 | * @property {boolean} success Sets to true when request is resolved 81 | * @property {boolean} pending true until request is finished 82 | * @property {Object} payload Request payload 83 | * @property {Result} result Adapter state 84 | */ 85 | 86 | /** 87 | * Subscribe to request events 88 | * 89 | * @callback EventHandler 90 | * @param {string} type Event type 91 | * @param {Function} callback Event handler 92 | */ 93 | 94 | /** 95 | * Cancel request 96 | * 97 | * @callback CancelRequest 98 | */ 99 | 100 | /** 101 | * Adapter object 102 | * 103 | * @typedef {Object} Adapter 104 | * @property {Function} createState Creates adapter state 105 | * @property {Function} callback Contains adapter logic 106 | * @property {Function} merge Contains merge strategies 107 | * @property {Function} convert Is applied to request payload before request 108 | */ 109 | 110 | /** 111 | * Create a request callback with choosed adapter 112 | * 113 | * @param {Adapter} adapter Adapter instance 114 | * @returns {doRequest} Callback that starts request 115 | */ 116 | 117 | const apicase = adapter => req => { 118 | req.adapter = adapter 119 | // Prepare 120 | req = req._isNormalized ? req : normalizeOptions(req) 121 | 122 | if ('options' in req) { 123 | console.warn( 124 | `[Apicase.deprecated] req.options and delayed requests are now deprecated. Use https://github.com/apicase/spawner instead` 125 | ) 126 | } 127 | 128 | // All data 129 | const bus = new EventBus() 130 | 131 | let cancelCallback = () => {} 132 | 133 | const throwError = error => { 134 | bus.emit('error', error) 135 | throw error 136 | } 137 | 138 | const res = { 139 | meta: req.meta, 140 | success: false, 141 | pending: true, 142 | cancelled: false, 143 | payload: {}, 144 | result: 'createState' in req.adapter ? req.adapter.createState() : null, 145 | onDone: cb => { 146 | bus.on('done', cb) 147 | return res 148 | }, 149 | onFail: cb => { 150 | bus.on('fail', cb) 151 | return res 152 | }, 153 | cancel: () => { 154 | return Promise.resolve(cancelCallback()).then(() => { 155 | setState({ success: false, pending: false, cancelled: true }) 156 | bus.emit('cancel', pickState(res)) 157 | }) 158 | } 159 | } 160 | 161 | bus.injectObserverTo(res) 162 | 163 | const doRequest = function doRequest(payload) { 164 | return new Promise(function request(resolve, reject) { 165 | try { 166 | const cb = req.adapter.callback({ 167 | emit: bus.emit, 168 | payload: req.adapter.convert ? req.adapter.convert(payload) : payload, 169 | result: res.result, 170 | resolve: function resolveAdapter(result) { 171 | resolve(done(result)) 172 | }, 173 | reject: function rejectAdapter(result) { 174 | resolve(fail(result)) 175 | }, 176 | setCancelCallback: cb => { 177 | cancelCallback = cb 178 | } 179 | }) 180 | if (cb && cb.then) { 181 | cb.catch(reject) 182 | } 183 | } catch (err) { 184 | reject(err) 185 | } 186 | }).catch(throwError) 187 | } 188 | 189 | const setState = diff => { 190 | const prev = pickState(res) 191 | const next = Object.assign(prev, diff) 192 | bus.emit('change:state', { 193 | prev: prev, 194 | next: next, 195 | diff: diff 196 | }) 197 | Object.assign(res, next) 198 | } 199 | 200 | const before = composeHooks({ 201 | hooks: req.hooks.before, 202 | update: next => { 203 | const prev = res.payload 204 | bus.emit('change:payload', { 205 | prev: prev, 206 | next: next 207 | }) 208 | 209 | res.payload = next 210 | }, 211 | createContext: (payload, { changeFlow }) => ({ 212 | meta: req.meta, 213 | payload: payload, 214 | done: changeFlow(done), 215 | fail: changeFlow(fail) 216 | }), 217 | final: doRequest 218 | }) 219 | 220 | const done = composeHooks({ 221 | hooks: req.hooks.done, 222 | update: next => { 223 | const prev = res.result 224 | bus.emit('change:result', { 225 | prev: prev, 226 | next: next 227 | }) 228 | 229 | res.result = next 230 | res.pending = false 231 | res.success = true 232 | }, 233 | createContext: (result, { changeFlow }) => ({ 234 | meta: req.meta, 235 | payload: res.payload, 236 | result: result, 237 | fail: changeFlow(fail) 238 | }), 239 | final: result => { 240 | bus.emit('done', result, pickState(res)) 241 | bus.emit('finish', result, pickState(res)) 242 | return pickState(res) 243 | } 244 | }) 245 | 246 | const fail = composeHooks({ 247 | hooks: req.hooks.fail, 248 | update: next => { 249 | const prev = res.result 250 | bus.emit('change:result', { 251 | prev: prev, 252 | next: next 253 | }) 254 | 255 | res.result = next 256 | res.pending = false 257 | res.success = false 258 | }, 259 | createContext: (result, { changeFlow }) => ({ 260 | meta: req.meta, 261 | payload: res.payload, 262 | result: result, 263 | done: changeFlow(done), 264 | retry: changeFlow(before) 265 | }), 266 | final: result => { 267 | bus.emit('fail', result, pickState(res)) 268 | bus.emit('finish', result, pickState(res)) 269 | return pickState(res) 270 | } 271 | }) 272 | 273 | res.promise = before(req.payload) 274 | res.then = cb => res.promise.then(cb) 275 | res.catch = cb => res.promise.catch(cb) 276 | 277 | return res 278 | } 279 | 280 | export { apicase } 281 | -------------------------------------------------------------------------------- /lib/defaults.js: -------------------------------------------------------------------------------- 1 | export default { 2 | adapter: null, 3 | payload: {}, 4 | meta: {}, 5 | hooks: { 6 | before: [], 7 | done: [], 8 | fail: [] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | export { apicase } from './apicase' 2 | export { mergeOptions } from './merge' 3 | export { normalizeOptions } from './normalize' 4 | -------------------------------------------------------------------------------- /lib/merge.js: -------------------------------------------------------------------------------- 1 | import defaults from './defaults' 2 | import { clone, mergeWith, mapObjIndexed } from 'nanoutils' 3 | import { normalizeOptions } from './normalize' 4 | 5 | const mergers = { 6 | adapter: (from, to) => to || from, 7 | 8 | hooks: mergeWith((from, to) => (from || []).concat(to)), 9 | 10 | meta: mergeWith((from, to) => to) 11 | } 12 | 13 | const createMergeReducer = cbs => (res, cur) => 14 | Object.assign( 15 | res, 16 | mapObjIndexed((val, key) => (cbs[key] ? cbs[key](res[key], val) : val), cur) 17 | ) 18 | 19 | export const mergeOptions = opts => { 20 | opts = opts.map(opt => 21 | normalizeOptions(typeof opt === 'function' ? opt() : opt) 22 | ) 23 | const def = clone(defaults) 24 | const reducer = createMergeReducer(mergers) 25 | 26 | const newOptions = opts.reduce(reducer, def) 27 | const payloadMerger = 28 | (newOptions.adapter && newOptions.adapter.merge) || Object.assign 29 | newOptions.payload = opts.reduce( 30 | (res, cur) => payloadMerger(res, cur.payload), 31 | {} 32 | ) 33 | return newOptions 34 | } 35 | -------------------------------------------------------------------------------- /lib/normalize.js: -------------------------------------------------------------------------------- 1 | import defaultOptions from './defaults' 2 | import { omit, clone, mergeWith, mapObjIndexed } from 'nanoutils' 3 | 4 | const normalize = (callbacks, obj) => 5 | Object.keys(obj).reduce( 6 | (res, key) => { 7 | res[key] = callbacks[key] ? callbacks[key](obj[key], res, obj) : obj[key] 8 | return res 9 | }, 10 | { _isNormalized: true } 11 | ) 12 | 13 | const defaults = mergeWith((a, b) => (b === undefined ? a : b)) 14 | 15 | const normalizers = { 16 | // Return adapter or null 17 | adapter: adapter => adapter || null, 18 | 19 | // Convert payload with 20 | payload(payload, res, prev) { 21 | if (process.env.NODE_ENV !== 'production') { 22 | if (payload) { 23 | if (payload.meta || payload.hooks || payload.payload) { 24 | console.warn( 25 | '[Apicase] Using reserved properties names (adapter, meta, hooks, payload) in payload is not recommended' 26 | ) 27 | } 28 | } 29 | } 30 | return !payload ? {} : payload 31 | }, 32 | 33 | hooks: hooks => { 34 | if (!hooks) return {} 35 | return mapObjIndexed((hooks, k) => { 36 | const hooksArr = Array.isArray(hooks) ? hooks : [hooks] 37 | if (process.env.NODE_ENV !== 'production') { 38 | hooksArr.forEach((hook, idx) => { 39 | if (typeof hook !== 'function') { 40 | throw new TypeError( 41 | '[Apicase] ' + k + ' hook #' + idx + ' is not a function' 42 | ) 43 | } 44 | }) 45 | } 46 | return hooksArr 47 | }, Object.assign(clone(defaultOptions.hooks), hooks)) 48 | } 49 | } 50 | 51 | const format = opts => ({ 52 | adapter: opts.adapter, 53 | meta: opts.meta, 54 | hooks: opts.hooks, 55 | payload: omit(['adapter', 'meta', 'hooks'], opts) 56 | }) 57 | 58 | export const normalizeOptions = opts => 59 | !opts 60 | ? { _isNormalized: true } 61 | : opts._isNormalized 62 | ? opts 63 | : normalize( 64 | normalizers, 65 | defaults(clone(defaultOptions), format(opts || {})) 66 | ) 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apicase/core", 3 | "version": "0.17.2", 4 | "description": "Core library to make API calls with any adapter", 5 | "keywords": [ 6 | "api", 7 | "fetch", 8 | "request", 9 | "api layer", 10 | "axios" 11 | ], 12 | "main": "cjs/index.js", 13 | "module": "es/index.js", 14 | "private": false, 15 | "repository": "https://github.com/apicase/core", 16 | "author": "Anton Kosykh ", 17 | "license": "MIT", 18 | "config": { 19 | "ghooks": { 20 | "pre-commit": "npm run check" 21 | } 22 | }, 23 | "scripts": { 24 | "lint": "eslint --fix lib/*.js test/*.js", 25 | "size": "size-limit", 26 | "test": "jest", 27 | "check": "npm run lint && npm run test && npm run size", 28 | "build": "BABEL_ENV=cjs babel lib --out-dir cjs --ignore test.js && BABEL_ENV=es babel lib --out-dir es --ignore test.js", 29 | "prepublish": "npm run build" 30 | }, 31 | "size-limit": [ 32 | { 33 | "path": "es/index.js", 34 | "limit": "2.5 KB" 35 | } 36 | ], 37 | "dependencies": { 38 | "delightful-bus": "^0.7.3", 39 | "nanoutils": "^0.0.x" 40 | }, 41 | "devDependencies": { 42 | "babel-cli": "^6.26.0", 43 | "babel-core": "^6.26.0", 44 | "babel-jest": "^22.4.0", 45 | "babel-plugin-nanoutils": "^0.1.1", 46 | "babel-plugin-transform-flow-comments": "^6.22.0", 47 | "babel-preset-env": "^1.6.1", 48 | "eslint": "^4.16.0", 49 | "eslint-config-standard": "^11.0.0-beta.0", 50 | "eslint-plugin-import": "^2.8.0", 51 | "eslint-plugin-node": "^5.2.1", 52 | "eslint-plugin-promise": "^3.6.0", 53 | "eslint-plugin-standard": "^3.0.1", 54 | "flow-bin": "^0.70.0", 55 | "ghooks": "^2.0.2", 56 | "jest": "^22.1.4", 57 | "regenerator-runtime": "^0.11.1", 58 | "rimraf": "^2.6.2", 59 | "rollup": "^0.56.5", 60 | "rollup-plugin-babel": "^3.0.3", 61 | "rollup-plugin-commonjs": "8.3.0", 62 | "rollup-plugin-node-resolve": "^3.2.0", 63 | "size-limit": "^0.14.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/apicase.test.js: -------------------------------------------------------------------------------- 1 | import { pick } from 'nanoutils' 2 | import { apicase } from '../lib/apicase' 3 | 4 | const pickState = pick(['success', 'pending', 'cancelled', 'payload', 'result']) 5 | 6 | const resolveAdapter = { 7 | callback: ({ payload, resolve }) => setTimeout(resolve, 25, payload) 8 | } 9 | 10 | const rejectAdapter = { 11 | callback: ({ payload, reject }) => setTimeout(reject, 25, payload) 12 | } 13 | 14 | describe('Calls', () => { 15 | it('calls adapter callback() with payload', done => { 16 | const callback = jest.fn(({ payload, resolve }) => { 17 | expect(payload).toEqual({ a: 1 }) 18 | resolve(payload) 19 | }) 20 | 21 | apicase({ callback })({ a: 1 }).on('done', res => { 22 | expect(callback).toBeCalled() 23 | done() 24 | }) 25 | }) 26 | 27 | it('returns thenable object with state, cancel() and on() methods', async done => { 28 | const res = apicase(resolveAdapter)({ a: 1 }) 29 | expect(pickState(res)).toEqual({ 30 | success: false, 31 | pending: true, 32 | cancelled: false, 33 | payload: { a: 1 }, 34 | result: null 35 | }) 36 | await res 37 | done() 38 | }) 39 | }) 40 | 41 | describe('Adapters', () => { 42 | it('has payload, result resolve and reject callbacks', done => { 43 | const callback = jest.fn(({ payload, result, resolve, reject }) => { 44 | expect(payload).toEqual({ a: 1 }) 45 | expect(result).toBe(null) 46 | expect(typeof resolve).toBe('function') 47 | expect(typeof reject).toBe('function') 48 | resolve(payload) 49 | }) 50 | 51 | apicase({ callback })({ a: 1 }).on('finish', res => { 52 | done() 53 | }) 54 | }) 55 | 56 | it('creates state if createState() callback provided', done => { 57 | const createState = () => ({ a: 1, b: 2 }) 58 | const callback = jest.fn(({ payload, result, resolve }) => { 59 | expect(result).toEqual({ a: 1, b: 2 }) 60 | setTimeout(resolve, 100, payload) 61 | }) 62 | 63 | apicase({ callback, createState })({ a: 2 }).on('done', res => { 64 | done() 65 | }) 66 | }) 67 | 68 | // it('has setResult() callback to set new state', done => { 69 | // const createState = () => ({ a: 1, b: 2 }) 70 | // const callback = jest.fn(({ payload, result, resolve, setResult }) => { 71 | // setResult({ b: 3 }) 72 | // resolve(payload) 73 | // }) 74 | 75 | // apicase({ callback, createState })({ a: 2 }).on('done', res => { 76 | // expect(res).toEqual({ a: 2 }) 77 | // done() 78 | // }) 79 | // }) 80 | 81 | it('converts payload before callback if convert() provided', done => { 82 | const convert = payload => ({ ...payload, a: 2 }) 83 | const callback = jest.fn(({ payload, result, resolve, setResult }) => { 84 | expect(payload).toEqual({ a: 2, b: 3 }) 85 | resolve(payload) 86 | }) 87 | 88 | apicase({ callback, convert })({ b: 3 }).on('done', res => { 89 | done() 90 | }) 91 | }) 92 | 93 | it('can be cancelled if used setCancelCallback() and emits cencel event', done => { 94 | const cancelCallback = jest.fn(timer => () => clearTimeout(timer)) 95 | const callback = jest.fn( 96 | ({ payload, result, resolve, setCancelCallback }) => { 97 | const timer = setTimeout(resolve, 1000, payload) 98 | setCancelCallback(cancelCallback(timer)) 99 | } 100 | ) 101 | 102 | const resolve = jest.fn(() => {}) 103 | const cancel = res => { 104 | expect(resolve).not.toBeCalled() 105 | expect(cancelCallback).toBeCalled() 106 | done() 107 | } 108 | 109 | const call = apicase({ callback })({ b: 3 }) 110 | .on('done', resolve) 111 | .on('cancel', cancel) 112 | 113 | call.cancel() 114 | }) 115 | 116 | it('resolves request on resolve() call and rejects on reject()', done => { 117 | apicase(resolveAdapter)({ a: 1 }).on('done', res => { 118 | apicase(rejectAdapter)(res).on('fail', res => { 119 | expect(res).toEqual({ a: 1 }) 120 | done() 121 | }) 122 | }) 123 | }) 124 | }) 125 | 126 | describe('Hooks', () => { 127 | describe('Before hooks', () => { 128 | it('is called before request', doneCb => { 129 | const before = jest.fn(({ payload, next }) => { 130 | expect(callback).not.toBeCalled() 131 | next(payload) 132 | }) 133 | const callback = jest.fn(({ payload, resolve }) => { 134 | expect(before).toBeCalled() 135 | resolve(payload) 136 | }) 137 | 138 | apicase({ callback })({ a: 1, hooks: { before } }).on('done', res => { 139 | doneCb() 140 | }) 141 | }) 142 | 143 | it('updates payload before adapter call', done => { 144 | const before = jest.fn(({ payload, next }) => 145 | next({ 146 | ...payload, 147 | a: payload.a + 1 148 | }) 149 | ) 150 | const callback = jest.fn(({ payload, resolve }) => { 151 | expect(payload).toEqual({ a: 2, b: 1 }) 152 | resolve(payload) 153 | }) 154 | const payload = { a: 1, b: 1, hooks: { before } } 155 | apicase({ callback })(payload).on('done', () => { 156 | done() 157 | }) 158 | }) 159 | 160 | it('resolves/rejects request without adapter call', done => { 161 | const success = jest.fn(({ payload, done }) => done(payload)) 162 | const fail = jest.fn(({ payload, fail }) => fail(payload)) 163 | const callback = jest.fn(({ payload, resolve }) => resolve(payload)) 164 | 165 | const doReq = apicase({ callback }) 166 | 167 | doReq({ a: 1, hooks: { before: success } }).on('done', res => { 168 | expect(res).toEqual({ a: 1 }) 169 | doReq({ a: 2, hooks: { before: fail } }).on('fail', res => { 170 | expect(res).toEqual({ a: 2 }) 171 | expect(callback).not.toBeCalled() 172 | done() 173 | }) 174 | }) 175 | }) 176 | }) 177 | 178 | describe('Done hooks', () => { 179 | it('is called on request done', doneCb => { 180 | const done = jest.fn(({ payload, next }) => next({ a: 2 })) 181 | 182 | apicase(resolveAdapter)({ a: 1, hooks: { done } }).on('done', res => { 183 | expect(res).toEqual({ a: 2 }) 184 | expect(done).toBeCalled() 185 | doneCb() 186 | }) 187 | }) 188 | 189 | it('is not called on request fail', doneCb => { 190 | const done = jest.fn(({ payload, next }) => next({ a: 2 })) 191 | 192 | apicase(rejectAdapter)({ a: 1, hooks: { done } }).on('fail', res => { 193 | expect(res).toEqual({ a: 1 }) 194 | expect(done).not.toBeCalled() 195 | doneCb() 196 | }) 197 | }) 198 | 199 | it('can fail call', doneCb => { 200 | const done = jest.fn(({ payload, fail }) => fail({ a: 2 })) 201 | const fail = jest.fn(({ payload, next }) => next({ a: 3 })) 202 | const payload = { a: 1, hooks: { done, fail } } 203 | 204 | apicase(resolveAdapter)(payload).on('fail', res => { 205 | expect(res).toEqual({ a: 3 }) 206 | expect(done).toBeCalled() 207 | expect(fail).toBeCalled() 208 | doneCb() 209 | }) 210 | }) 211 | }) 212 | 213 | describe('Fail hooks', () => { 214 | it('is called on request reject', doneCb => { 215 | const fail = jest.fn(({ payload, next }) => next({ a: 2 })) 216 | 217 | apicase(rejectAdapter)({ a: 1, hooks: { fail } }).on('fail', res => { 218 | expect(res).toEqual({ a: 2 }) 219 | expect(fail).toBeCalled() 220 | doneCb() 221 | }) 222 | }) 223 | 224 | it('is not called on request done', done => { 225 | const fail = jest.fn(({ payload, next }) => next({ a: 2 })) 226 | 227 | apicase(resolveAdapter)({ a: 1, hooks: { fail } }).on('done', res => { 228 | expect(res).toEqual({ a: 1 }) 229 | expect(fail).not.toBeCalled() 230 | done() 231 | }) 232 | }) 233 | 234 | it('can done call', doneCb => { 235 | const fail = jest.fn(({ payload, done }) => done({ a: 2 })) 236 | const done = jest.fn(({ payload, next }) => next({ a: 3 })) 237 | const payload = { a: 1, hooks: { done, fail } } 238 | 239 | apicase(rejectAdapter)(payload).on('done', res => { 240 | expect(res).toEqual({ a: 3 }) 241 | expect(done).toBeCalled() 242 | expect(fail).toBeCalled() 243 | doneCb() 244 | }) 245 | }) 246 | }) 247 | }) 248 | 249 | describe('Events', () => { 250 | it('emits finish event on request finish', async done => { 251 | const finishA = jest.fn(payload => expect(payload).toEqual({ a: 1 })) 252 | const finishB = jest.fn(payload => expect(payload).toEqual({ a: 1 })) 253 | 254 | await apicase(resolveAdapter)({ a: 1 }).on('finish', finishA) 255 | await apicase(rejectAdapter)({ a: 1 }).on('finish', finishB) 256 | expect(finishA).toBeCalled() 257 | expect(finishB).toBeCalled() 258 | done() 259 | }) 260 | 261 | it('emits cancel event on request cancel', async done => { 262 | const cancel = jest.fn(() => {}) 263 | const callback = ({ setCancelCallback, payload, resolve }) => { 264 | setCancelCallback(cancel) 265 | setTimeout(resolve, 500, payload) 266 | } 267 | 268 | const a = apicase({ callback })({ a: 1 }).on('cancel', cancel) 269 | await a.cancel() 270 | expect(cancel).toBeCalled() 271 | done() 272 | }) 273 | 274 | it('emits done event on adapter done', async doneCb => { 275 | const done1 = jest.fn(result => { 276 | expect(result).toEqual({ a: 1 }) 277 | }) 278 | const done2 = jest.fn(result => { 279 | expect(result).toEqual({ a: 1 }) 280 | }) 281 | 282 | await apicase(resolveAdapter)({ a: 1 }).on('done', done1) 283 | expect(done1).toBeCalled() 284 | await apicase(rejectAdapter)({ a: 1 }).on('done', done2) 285 | expect(done2).not.toBeCalled() 286 | doneCb() 287 | }) 288 | 289 | it('emits fail event on adapter fail', async done => { 290 | const fail1 = jest.fn(result => { 291 | expect(result).toEqual({ a: 1 }) 292 | }) 293 | const fail2 = jest.fn(result => { 294 | expect(result).toEqual({ a: 1 }) 295 | }) 296 | 297 | await apicase(rejectAdapter)({ a: 1 }).on('fail', fail1) 298 | expect(fail1).toBeCalled() 299 | await apicase(resolveAdapter)({ a: 1 }).on('fail', fail2) 300 | expect(fail2).not.toBeCalled() 301 | done() 302 | }) 303 | 304 | it('emits change:state event on state change', () => {}) 305 | 306 | it('emits change:payload event on payload change', () => {}) 307 | 308 | it('emits change:result event on result change', () => {}) 309 | }) 310 | 311 | describe('State', () => { 312 | describe('success', () => {}) 313 | 314 | describe('pending', () => {}) 315 | 316 | describe('started', () => {}) 317 | 318 | describe('payload', () => {}) 319 | 320 | describe('result', () => {}) 321 | }) 322 | -------------------------------------------------------------------------------- /test/merge.test.js: -------------------------------------------------------------------------------- 1 | import { mergeOptions } from '../lib/merge' 2 | 3 | it('normalizes obj before merge', () => { 4 | expect(mergeOptions([{}, {}])).toEqual({ 5 | _isNormalized: true, 6 | adapter: null, 7 | payload: {}, 8 | meta: {}, 9 | hooks: { 10 | before: [], 11 | done: [], 12 | fail: [] 13 | } 14 | }) 15 | }) 16 | 17 | describe('Hooks', () => { 18 | it('merges hooks with concatenation', () => { 19 | const p1 = { 20 | hooks: { 21 | done: [() => {}] 22 | } 23 | } 24 | const p2 = { 25 | hooks: { 26 | done: [() => {}] 27 | } 28 | } 29 | const merged = mergeOptions([p1, p2]) 30 | expect(merged).toHaveProperty('hooks') 31 | expect(merged.hooks).toHaveProperty('done') 32 | expect(merged.hooks.done).toEqual([p1.hooks.done[0], p2.hooks.done[0]]) 33 | }) 34 | 35 | it('correctly works with hooks declared as just a function (not array)', () => { 36 | const p1 = { 37 | hooks: { 38 | before: () => {}, 39 | done: [() => {}] 40 | } 41 | } 42 | const p2 = { 43 | hooks: { 44 | done: [() => {}], 45 | fail: () => {} 46 | } 47 | } 48 | expect(mergeOptions([p1, p2]).hooks).toEqual({ 49 | before: [p1.hooks.before], 50 | done: [p1.hooks.done[0], p2.hooks.done[0]], 51 | fail: [p2.hooks.fail] 52 | }) 53 | }) 54 | }) 55 | 56 | describe('Meta', () => { 57 | it('works like assign', () => { 58 | const p1 = { 59 | meta: { 60 | a: 1, 61 | b: 1 62 | } 63 | } 64 | const p2 = { meta: { b: 2, c: 2 } } 65 | expect(mergeOptions([p1, p2]).meta).toEqual({ 66 | a: 1, 67 | b: 2, 68 | c: 2 69 | }) 70 | }) 71 | }) 72 | 73 | describe('Payload', () => { 74 | it('uses merge strategy of adapter if adapter.merge is provided', () => { 75 | const adapter = { 76 | merge: (from, to) => ({ a: (from.a || 0) + to.a }) 77 | } 78 | const p1 = { adapter, a: 1 } 79 | const p2 = { 80 | a: 2 81 | } 82 | expect(mergeOptions([p1, p2]).payload).toEqual({ 83 | a: 3 84 | }) 85 | }) 86 | 87 | it('otherwise, just merges it', () => { 88 | const p1 = { 89 | a: 1, 90 | b: 1 91 | } 92 | const p2 = { 93 | b: 2, 94 | c: 2 95 | } 96 | expect(mergeOptions([p1, p2]).payload).toEqual({ 97 | a: 1, 98 | b: 2, 99 | c: 2 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /test/normalize.test.js: -------------------------------------------------------------------------------- 1 | import { normalizeOptions } from '../lib/normalize' 2 | 3 | describe('Hooks', () => { 4 | it('converts hooks to array of hooks', () => { 5 | const opts = { 6 | hooks: { 7 | before() {}, 8 | done: [() => {}, () => {}] 9 | } 10 | } 11 | const h = normalizeOptions(opts).hooks 12 | expect(h.before).toEqual([opts.hooks.before]) 13 | expect(h.done).toEqual([opts.hooks.done[0], opts.hooks.done[1]]) 14 | }) 15 | 16 | it('creates default hooks properties', () => { 17 | const h = normalizeOptions({}).hooks 18 | expect(h).toEqual({ 19 | before: [], 20 | done: [], 21 | fail: [] 22 | }) 23 | }) 24 | }) 25 | 26 | describe('Meta', () => { 27 | it('leaves meta "as is"', () => { 28 | expect(normalizeOptions({ meta: { a: 2 } }).meta).toEqual({ a: 2 }) 29 | }) 30 | 31 | it("creates empty object when it's not passed", () => { 32 | expect(normalizeOptions({}).meta).toEqual({}) 33 | }) 34 | }) 35 | 36 | describe('Payload', () => { 37 | it('passes all another properties to payload', () => { 38 | const opts = { a: 1, b: 2 } 39 | const normalized = normalizeOptions(opts) 40 | expect('a' in normalized).toBeFalsy() 41 | expect('b' in normalized).toBeFalsy() 42 | expect(normalized.payload).toEqual(opts) 43 | }) 44 | }) 45 | 46 | describe('Applying normalize 2+ times', () => { 47 | it('adds _isNormalized flag', () => { 48 | expect(normalizeOptions({})._isNormalized).toBeTruthy() 49 | }) 50 | 51 | it('does not normalize object that has already done', () => { 52 | const opts = { 53 | a: 1, 54 | b: 2, 55 | hooks: { 56 | before: () => {} 57 | } 58 | } 59 | const res = normalizeOptions(normalizeOptions(opts)) 60 | expect(res).toEqual({ 61 | _isNormalized: true, 62 | adapter: null, 63 | payload: { a: 1, b: 2 }, 64 | meta: {}, 65 | hooks: { 66 | before: [opts.hooks.before], 67 | done: [], 68 | fail: [] 69 | } 70 | }) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /types/adapter.d.ts: -------------------------------------------------------------------------------- 1 | export interface ApiAdapter { 2 | callback: ( 3 | opts: { 4 | payload: T 5 | resolve: Function 6 | reject: Function 7 | } 8 | ) => any 9 | convert?(from: T): T 10 | to?(from: T, to: T): T 11 | } 12 | -------------------------------------------------------------------------------- /types/adapter.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | export type ApiAdapter = { 3 | callback: (opts: { 4 | payload: T, 5 | resolve: any => any, 6 | reject: any => any 7 | }) => any, 8 | convert?: (from: T) => T, 9 | merge?: (from: T, to: T) => T 10 | } 11 | --------------------------------------------------------------------------------