Skip to content

Instantly share code, notes, and snippets.

@avioli
Last active June 12, 2018 07:06
Show Gist options
  • Save avioli/a850ff986575af6f54c52e9a72856009 to your computer and use it in GitHub Desktop.
Save avioli/a850ff986575af6f54c52e9a72856009 to your computer and use it in GitHub Desktop.
A Fetch controller (plus Vue support)
/**
* 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()
// 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