Last active
June 12, 2018 07:06
-
-
Save avioli/a850ff986575af6f54c52e9a72856009 to your computer and use it in GitHub Desktop.
A Fetch controller (plus Vue support)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* A Fetch controller (plus Vue support) | |
* | |
* Copyright 2018 Ionata Digital | |
* | |
* Author: Evo Stamatov <evo@ionata.com.au> | |
* License: BSD 2-clause | |
*/ | |
import uuidV4 from 'uuid/v4' | |
/** | |
* Injects two fetch APIs: | |
* | |
* - global: `Vue.$fetch(url, args)` | |
* - local: `this.$fetch(url, args)` | |
* | |
* To auto-abort a fetch, supply a `key`. Subsequent call with the same key | |
* will abort the previous one. | |
* | |
* ``` | |
* this.$fetch('https://httpbin.org/delay/1', { key: 'status' }).then(...) | |
* ``` | |
* | |
* Local fetch will auto-abort fetches before destroying the component. | |
* | |
* To be able to manually abort the fetch: | |
* | |
* 1) Create a new signal via Vue.$abortSignal() and supply it as `signal`. | |
* 2) Call `abort()` on the signal. | |
* | |
* An abort controller can be reused - it will be auto-aborted, if `key` is set. | |
* If reused, the `abort()` call will abort only the latest fetch call. | |
* | |
* ``` | |
* const signal = Vue.$abortSignal() | |
* Vue.$fetch('https://httpbin.org/delay/1', { signal }).catch((err) => { | |
* if (err.isAbort) { | |
* console.log('Aborted') | |
* return | |
* } | |
* console.error(err) | |
* }) | |
* signal.abort() | |
* ``` | |
* | |
* If you want to "silently" abort - set `silentAbort` to true and handle it in | |
* `then()`: | |
* | |
* ``` | |
* const signal = Vue.$abortSignal() | |
* Vue.$fetch('https://httpbin.org/delay/1', { signal, silentAbort: true }) | |
* .then((result) => { | |
* if (result === void 0) return | |
* ... | |
* }) | |
* signal.abort() | |
* // Won't throw | |
* ``` | |
* | |
* Local fetch that is aborted before a component is destroyed will be | |
* auto-silenced, unless `silentAbort` is explicitly set to false. | |
*/ | |
class FetchController { | |
install(Vue, options = {}) { | |
const { fetchService } = options | |
if (!fetchService) throw new Error('no fetchService') | |
const globalStore = new Map() | |
Vue.$fetch = function $fetch(url, args) { | |
return fetch(globalStore, fetchService, url, args) | |
} | |
Vue.$abortSignal = function $abortSignal() { | |
return new FetchAbortController() | |
} | |
Vue.prototype.$abortSignal = Vue.$abortSignal | |
Vue.mixin({ | |
created: function FetchControllerMixin_created() { | |
const localStore = new Map() | |
this._getLocalFetchStore = () => localStore | |
this.$fetch = function $fetch(url, args) { | |
return fetch(localStore, fetchService, url, args) | |
} | |
}, | |
beforeDestroy: function FetchControllerMixin_beforeDestroy() { | |
const store = this._getLocalFetchStore() | |
// Object.defineProperty(store, 'silentAbort', { | |
// value: true, | |
// writable: false | |
// }) | |
store.forEach((signal) => signal.abort()) | |
store.clear() | |
} | |
}) | |
} | |
} | |
/** | |
* Returns a Promise, similar to window.fetch, but if a `key` is set | |
* in the args, any previous (ongoing) fetches will be aborted. | |
* | |
* The store can be any object, that implements `get`, `set`, `delete` and | |
* `clear` methods. | |
* | |
* The fetch service could be either a Function that accepts a url and args, | |
* or an object with `fetch` method that does the same. | |
* | |
* ``` | |
* args = { | |
* key: String, | |
* silentAbort: Boolean, | |
* ... | |
* } | |
* ``` | |
* | |
* NOTE: The args object will be passed to the fetchService as-is, it is the | |
* fetchService's job to remove the key and silentAbort, if needed. | |
* | |
* Usage: | |
* | |
* ``` | |
* const store = new Map() | |
* fetch(store, window, 'https://httpbin.org/delay/10', { key: 'status' }) | |
* .then(...).catch(...) | |
* fetch(store, window, 'https://httpbin.org/delay/1', { key: 'status' }) | |
* .then(...) | |
* // first fetch will be aborted (rejected) | |
* ``` | |
* | |
* Manual abort: | |
* | |
* ``` | |
* const signal = new FetchAbortController() | |
* fetch(store, window, 'https://httpbin.org/delay/1', { signal }) | |
* .catch((err) => { | |
* if (err.isAbort) { | |
* console.log('Aborted') | |
* return | |
* } | |
* console.error(err) | |
* }) | |
* // Abort the fetch at some point: | |
* signal.abort() | |
* ``` | |
* | |
* Silent abort - abort by resolving with undefined: | |
* | |
* ``` | |
* const signal = new FetchAbortController() | |
* fetch(store, window, 'https://httpbin.org/delay/1', { | |
* signal, | |
* silentAbort: true | |
* }).then((result) => { | |
* if (result === void 0) return | |
* // ... | |
* }) | |
* signal.abort() | |
* // Won't throw | |
* ``` | |
* | |
* @param {Map} store A store for signals | |
* @param {Function} fetchService | |
* @param {String} url | |
* @param {Object} args | |
*/ | |
export async function fetch(store, fetchService, url, args) { | |
if (!store) throw new Error('no store') | |
if (!fetchService) throw new Error('no fetchService') | |
const key = (args || {}).key || uuidV4() | |
const prevSignal = store.get(key) | |
if (prevSignal) prevSignal.abort() | |
const signal = (args || {}).signal || new FetchAbortController() | |
store.set(key, signal) | |
const deferred = {} | |
const promise = new Promise((resolve, reject) => { | |
deferred.resolve = resolve | |
deferred.reject = reject | |
}) | |
let aborted = false | |
function onabort() { | |
aborted = true | |
if (store.get(key) === signal) store.delete(key) | |
let silentAbort = (args || {}).silentAbort | |
if (silentAbort === void 0 || silentAbort === null) { | |
silentAbort = store.silentAbort | |
} | |
setTimeout(() => { | |
if (silentAbort) return deferred.resolve() | |
const abortError = new Error('Fetch aborted') | |
abortError.isAbort = true | |
deferred.reject(abortError) | |
}, 0) | |
} | |
if (signal._preAborted) { | |
onabort() | |
return promise | |
} | |
signal._cb.onabort = onabort | |
let _fetcher = fetchService | |
if (fetchService.fetch) { | |
_fetcher = function _fetcher() { | |
return fetchService.fetch.apply(fetchService, arguments) | |
} | |
} | |
Promise.resolve(_fetcher(url, args)) | |
.then((result) => { | |
if (aborted) return | |
if (store.get(key) === signal) store.delete(key) | |
deferred.resolve(result) | |
}) | |
.catch((err) => { | |
if (store.get(key) === signal) store.delete(key) | |
deferred.reject(err) | |
}) | |
return promise | |
} | |
/** | |
* Generates a "signal" object that can be used to abort a fetch | |
*/ | |
export class FetchAbortController { | |
constructor() { | |
this._preAborted = false | |
const onabort = () => { | |
this._preAborted = true | |
} | |
this._cb = { onabort } | |
this.abort = () => this._cb.onabort() | |
} | |
} | |
/** | |
* A simple class to more easily manage window.fetch requests by adding | |
* default headers | |
*/ | |
export class FetchService { | |
constructor(options = {}) { | |
const { apiBase, getAuthHeader } = options | |
if (typeof apiBase !== 'string') throw new Error('no apiBase') | |
if (typeof getAuthHeader !== 'function') throw new Error('no getAuthHeader') | |
this.apiBase = apiBase | |
this.getAuthHeader = getAuthHeader | |
} | |
/** | |
* Returns a Headers object with default Content-Type and Accept keys set | |
* to support JSON payloads | |
* | |
* @param {Object} headers headers to convert and set the defaults to | |
*/ | |
static getHeaders(headers) { | |
headers = { | |
'Content-Type': 'application/json', | |
Accept: 'application/json', | |
...headers | |
} | |
return new window.Headers(headers) | |
} | |
/** | |
* Perform a fetch request and return the JSON on promise resolve | |
* | |
* ``` | |
* args = { | |
* method: 'GET', | |
* headers: {}, // see getHeaders for default ones | |
* data: {}, // do not set for method === 'GET' | |
* auth: true, // true? = get token from AuthService | |
* filters: {} // see fetchPages for assumed defaults | |
* } | |
* ``` | |
* | |
* @param {String} url | |
* @param {Object} args | |
* @return {Promise} | |
*/ | |
fetch = async (url, args) => { | |
if (!url) throw new Error('no url') | |
args = { | |
auth: true, | |
...args | |
} | |
if (args.auth) { | |
const authHeader = this.getAuthHeader() | |
if (authHeader) { | |
args.headers = { | |
...args.headers, | |
...authHeader | |
} | |
} | |
} | |
if (!args.method) { | |
if (args.data) args.method = 'POST' | |
else args.method = 'GET' | |
} | |
const opts = { | |
method: args.method.toUpperCase(), | |
headers: FetchService.getHeaders(args.headers), | |
body: args.data ? JSON.stringify(args.data) : args.body | |
} | |
const uri = new URL(`${this.apiBase}${url}`) | |
const filters = args.filters || {} | |
for (let param of Object.entries(filters)) { | |
uri.searchParams.append(param[0], param[1]) | |
} | |
if (__DEV__) console.info('fetch: url', uri.href) | |
const response = await window.fetch(uri.href, opts) | |
if (response.status === 204) return true | |
if (((response.status / 100) | 0) === 2) return response.json() // 200-299 | |
throw new Error(response.status + ': ' + response.statusText) | |
} | |
/** | |
* Perform a fetch request for paginated results, | |
* until an empty response comes back | |
* | |
* @param {String} url | |
* @param {Number} limit | |
* @param {Object} args | |
* @return {Promise} | |
*/ | |
fetchPages = async (url, limit = 1000, args = {}) => { | |
let results = [] | |
let offset = 0 | |
args.filters = args.filters || {} | |
const inner = (offset, results) => { | |
args.filters = Object.assign(args.filters, { limit, offset }) | |
return this.fetch(url, args).then((response) => { | |
results.push(response) | |
offset += limit | |
return offset >= response.count ? results : inner(offset, results) | |
}) | |
} | |
return inner(offset, results) | |
} | |
} | |
export default new FetchController() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Jest unit tests | |
import { createLocalVue } from '@vue/test-utils' | |
import FetchController, { | |
fetch, | |
FetchAbortController | |
} from '@/plugins/FetchController' | |
function mockFetchService(url, args = {}) { | |
const request = args._request || {} | |
return new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve({ | |
status: request.status, | |
statusText: request.statusText, | |
json: () => Promise.resolve(request.responseText).then(JSON.parse) | |
}) | |
}, args._delay || 0) | |
}) | |
} | |
describe('fetch', () => { | |
let store | |
let signal | |
beforeEach(() => { | |
store = new Map() | |
signal = new FetchAbortController() | |
}) | |
it('should fail with no store or no fetchService', async () => { | |
expect.assertions(2) | |
try { | |
await fetch() | |
} catch (e) { | |
expect(e.message).toBe('no store') | |
} | |
try { | |
await fetch(store) | |
} catch (e) { | |
expect(e.message).toBe('no fetchService') | |
} | |
}) | |
it("should call fetchService's fetch method if available", async () => { | |
expect.assertions(2) | |
const mockFetchService = jest.fn() | |
mockFetchService.fetch = jest.fn() | |
await fetch(store, mockFetchService) | |
expect(mockFetchService.fetch).toHaveBeenCalledTimes(1) | |
expect(mockFetchService).not.toHaveBeenCalled() | |
}) | |
it('should call fetchService if no fetch method is set on it', async () => { | |
expect.assertions(1) | |
const mockFetchService = jest.fn() | |
await fetch(store, mockFetchService) | |
expect(mockFetchService).toHaveBeenCalledTimes(1) | |
}) | |
it('should store a fetch signal', async () => { | |
expect.assertions(2) | |
const promise = fetch(store, () => {}) | |
expect(store.size).toBe(1) | |
await promise | |
expect(store.size).toBe(0) | |
}) | |
describe('without key', () => { | |
it('should generate unique keys for subsequent calls', async () => { | |
expect.assertions(2) | |
const promise1 = fetch(store, () => {}) | |
const promise2 = fetch(store, () => {}) | |
expect(store.size).toBe(2) | |
await Promise.all([promise1, promise2]) | |
expect(store.size).toBe(0) | |
}) | |
it("should call signal's abort", () => { | |
expect.assertions(1) | |
const abortMock = jest.fn() | |
signal.abort = abortMock | |
const promise = fetch(store, () => {}, 'https://example.com', { signal }) | |
signal.abort() | |
expect(abortMock).toHaveBeenCalledTimes(1) | |
}) | |
it('should abort fetch on signal abort and return no result', async () => { | |
expect.assertions(1) | |
const mockFetchService = jest.fn() | |
const promise = fetch(store, mockFetchService, 'https://example.com', { | |
signal | |
}) | |
signal.abort() | |
try { | |
await promise | |
} catch (e) { | |
expect(e.isAbort).toBe(true) | |
} | |
}) | |
it('should abort fetch if signal calls its abort(), prior fetch attaches callback', async () => { | |
expect.assertions(1) | |
signal.abort() | |
const mockFetchService = jest.fn() | |
const promise = fetch(store, mockFetchService, 'https://example.com', { | |
signal | |
}) | |
try { | |
await promise | |
} catch (e) { | |
expect(e.isAbort).toBe(true) | |
} | |
}) | |
it('should not throw if silentAbort is true', async () => { | |
expect.assertions(1) | |
const fetchService = () => ({ json: true }) | |
const promise = fetch(store, fetchService, 'https://example.com', { | |
signal, | |
silentAbort: true | |
}) | |
signal.abort() | |
await expect(promise).resolves.toBeUndefined() | |
}) | |
it('should return correct result', async () => { | |
expect.assertions(1) | |
const fetchService = jest.fn().mockReturnValue({ data: 123 }) | |
const promise = fetch(store, fetchService, 'https://example.com') | |
await expect(promise).resolves.toEqual({ data: 123 }) | |
}) | |
it('should abort subsequent calls', async () => { | |
expect.assertions(2) | |
const fetchService1 = () => | |
new Promise((resolve) => setTimeout(() => resolve({ data: 1 }), 10)) | |
const fetchService2 = () => | |
new Promise((resolve) => setTimeout(() => resolve({ data: 'a' }), 10)) | |
setTimeout(() => { | |
signal.abort() | |
}, 15) | |
try { | |
const result1 = await fetch( | |
store, | |
fetchService1, | |
'https://example.com', | |
{ | |
signal | |
} | |
) | |
expect(result1).toEqual({ data: 1 }) | |
const result2 = await fetch( | |
store, | |
fetchService2, | |
'https://example.com', | |
{ | |
signal | |
} | |
) | |
} catch (e) { | |
expect(e.isAbort).toBe(true) | |
} | |
}) | |
}) | |
describe('with key', () => { | |
it('should reuse store entry on subsequent calls', async () => { | |
expect.assertions(2) | |
const promise1 = fetch(store, () => {}, 'https://example.com', { | |
key: 'aKey' | |
}) | |
const promise2 = fetch(store, () => {}, 'https://example.com', { | |
key: 'aKey' | |
}) | |
expect(store.size).toBe(1) | |
try { | |
await Promise.all([promise1, promise2]) | |
} catch (e) { | |
if (!e.isAbort) throw e | |
} | |
expect(store.size).toBe(0) | |
}) | |
it('should use provided signal', () => { | |
expect.assertions(1) | |
const promise = fetch(store, () => {}, 'https://example.com', { | |
key: 'aKey', | |
signal | |
}) | |
expect(store.get('aKey')).toBe(signal) | |
}) | |
it('should abort previous fetch on subsequent calls', async () => { | |
expect.assertions(2) | |
const abortMock = jest.fn() | |
signal.abort = abortMock | |
const promise1 = fetch(store, () => {}, 'https://example.com', { | |
key: 'aKey', | |
signal | |
}) | |
const promise2 = fetch(store, () => {}, 'https://example.com', { | |
key: 'aKey', | |
signal | |
}) | |
const promise3 = fetch(store, () => {}, 'https://example.com', { | |
key: 'aKey', | |
signal | |
}) | |
expect(store.get('aKey')).toBe(signal) | |
expect(abortMock).toHaveBeenCalledTimes(2) | |
try { | |
await Promise.all([promise1, promise2, promise3]) | |
} catch (e) { | |
if (!e.isAbort) throw e | |
} | |
}) | |
}) | |
}) | |
describe('FetchController', () => { | |
let Vue | |
let options | |
beforeEach(() => { | |
Vue = createLocalVue() | |
options = { | |
fetchService: jest.fn() | |
} | |
}) | |
it('should get installed', () => { | |
expect(() => { | |
Vue.use(FetchController, options) | |
}).not.toThrow() | |
}) | |
it('should inject global Vue.$fetch', () => { | |
Vue.use(FetchController, options) | |
expect(Vue.$fetch).toBeInstanceOf(Function) | |
}) | |
it('should inject local this.$fetch', () => { | |
Vue.use(FetchController, options) | |
const vm = new Vue({ | |
render: (h) => h('div') | |
}).$mount() | |
expect(vm.$fetch).toBeInstanceOf(Function) | |
}) | |
it('should call fetchService', async () => { | |
Vue.use(FetchController, options) | |
await Vue.$fetch('https://example.com') | |
expect(options.fetchService.mock.calls.length).toBe(1) | |
expect(options.fetchService).toHaveBeenCalledWith( | |
'https://example.com', | |
undefined | |
) | |
}) | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment