Skip to content

Commit 712ef84

Browse files
committed
Add the possibility to provide axios interceptors for GET requests
1 parent 51e5f18 commit 712ef84

File tree

6 files changed

+107
-16
lines changed

6 files changed

+107
-16
lines changed

src/LoadingResource.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ class LoadingResource implements ResourceInterface {
6464
// Proxy to all other unknown properties: return a function that yields another LoadingResource
6565
const loadProperty = loadResource.then(resource => resource[prop])
6666

67-
const result = templateParams => new LoadingResource(loadProperty.then(property => {
67+
const result = (templateParams, options) => new LoadingResource(loadProperty.then(property => {
6868
try {
69-
return property(templateParams)._meta.load
69+
return property(templateParams, options)._meta.load
7070
} catch (e) {
7171
throw new Error(`Property '${prop.toString()}' on resource '${self}' was used like a relation, but no relation with this name was returned by the API (actual return value: ${JSON.stringify(property)})`)
7272
}

src/Resource.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ class Resource implements ResourceInterface {
4545

4646
// storeData[key] is a reference only (contains only href; no data)
4747
} else if (isEntityReference(value)) {
48-
this[key] = () => this.apiActions.get(value.href)
48+
this[key] = (_, options) => this.apiActions.get(value.href, options)
4949

5050
// storeData[key] is a templated link
5151
} else if (isTemplatedLink(value)) {
52-
this[key] = templateParams => this.apiActions.get(urltemplate.parse(value.href).expand(templateParams || {}))
52+
this[key] = (templateParams, options) => this.apiActions.get(urltemplate.parse(value.href).expand(templateParams || {}), options)
5353

5454
// storeData[key] is a primitive (normal entity property)
5555
} else {

src/index.ts

+20-11
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import LoadingResource from './LoadingResource'
77
import storeModule, { State } from './storeModule'
88
import ServerException from './ServerException'
99
import { ExternalConfig } from './interfaces/Config'
10+
import Options from './interfaces/Options'
1011
import { Store } from 'vuex/types'
11-
import { AxiosInstance, AxiosError } from 'axios'
12+
import AxiosCreator, { AxiosInstance, AxiosError } from 'axios'
13+
import mergeAxiosConfig from 'axios/lib/core/mergeConfig'
1214
import ResourceInterface from './interfaces/ResourceInterface'
1315
import StoreData, { Link, SerializablePromise } from './interfaces/StoreData'
1416
import ApiActions from './interfaces/ApiActions'
@@ -109,11 +111,12 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
109111
* }
110112
*
111113
* @param uriOrEntity URI (or instance) of an entity to load from the store or API. If omitted, the root resource of the API is returned.
114+
* @param options Options for this single request
112115
* @returns entity Entity from the store. Note that when fetching an object for the first time, a reactive
113116
* dummy is returned, which will be replaced with the true data through Vue's reactivity
114117
* system as soon as the API request finishes.
115118
*/
116-
function get (uriOrEntity: string | ResourceInterface = ''): ResourceInterface {
119+
function get (uriOrEntity: string | ResourceInterface = '', options: Options = {}): ResourceInterface {
117120
const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
118121

119122
if (uri === null) {
@@ -125,7 +128,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
125128
throw new Error(`Could not perform GET, "${uriOrEntity}" is not an entity or URI`)
126129
}
127130

128-
setLoadPromiseOnStore(uri, load(uri, false))
131+
setLoadPromiseOnStore(uri, load(uri, false, options))
129132
return resourceCreator.wrap(store.state[opts.apiName][uri])
130133
}
131134

@@ -184,10 +187,11 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
184187
* sets the load promise on the entity in the Vuex store.
185188
* @param uri URI of the entity to load
186189
* @param forceReload If true, the entity will be fetched from the API even if it is already in the Vuex store.
190+
* @param options Options for this single request
187191
* @returns entity the current entity data from the Vuex store. Note: This may be a reactive dummy if the
188192
* API request is still ongoing.
189193
*/
190-
function load (uri: string, forceReload: boolean): Promise<StoreData> {
194+
function load (uri: string, forceReload: boolean, options: Options = {}): Promise<StoreData> {
191195
const existsInStore = !isUnknown(uri)
192196

193197
const isAlreadyLoading = existsInStore && (store.state[opts.apiName][uri]._meta || {}).loading
@@ -204,9 +208,9 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
204208
}
205209

206210
if (!existsInStore) {
207-
return loadFromApi(uri, 'fetch')
211+
return loadFromApi(uri, 'fetch', options)
208212
} else if (forceReload) {
209-
return loadFromApi(uri, 'reload').catch(error => {
213+
return loadFromApi(uri, 'reload', options).catch(error => {
210214
store.commit('reloadingFailed', uri)
211215
throw error
212216
})
@@ -222,11 +226,12 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
222226
* being usable in Vue components).
223227
* @param uri URI of the entity to load from the API
224228
* @param operation description of the operation triggering this load, e.g. fetch or reload, for error reporting
229+
* @param options Options for this single request
225230
* @returns Promise resolves to the raw data stored in the Vuex store after the API request completes, or
226231
* rejects when the API request fails
227232
*/
228-
function loadFromApi (uri: string, operation: string): Promise<StoreData> {
229-
return axios.get(axios.defaults.baseURL + uri).then(({ data }) => {
233+
function loadFromApi (uri: string, operation: string, options: Options = {}): Promise<StoreData> {
234+
return axiosWith(options).get(axios.defaults.baseURL + uri).then(({ data }) => {
230235
if (opts.forceRequestedSelfLink) {
231236
data._links.self.href = uri
232237
}
@@ -237,6 +242,12 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
237242
})
238243
}
239244

245+
function axiosWith (options) {
246+
const instance = AxiosCreator.create(mergeAxiosConfig(axios.defaults, {}))
247+
instance.interceptors.request.use(options.axiosRequestInterceptor)
248+
return instance
249+
}
250+
240251
/**
241252
* Loads the URI of a related entity from the store, or the API in case it is not already fetched.
242253
*
@@ -280,7 +291,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
280291
store.commit('addEmpty', uri)
281292
}
282293

283-
const returnedResource = axios.patch(axios.defaults.baseURL + uri, data).then(({ data }) => {
294+
return axios.patch(axios.defaults.baseURL + uri, data).then(({ data }) => {
284295
if (opts.forceRequestedSelfLink) {
285296
data._links.self.href = uri
286297
}
@@ -289,8 +300,6 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
289300
}, (error) => {
290301
throw handleAxiosError('patch', uri, error)
291302
})
292-
293-
return returnedResource
294303
}
295304

296305
/**

src/interfaces/ApiActions.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import ResourceInterface from './ResourceInterface'
2+
import Options from './Options'
23

34
interface ApiActions {
4-
get: (uriOrEntity: string | ResourceInterface) => ResourceInterface
5+
get: (uriOrEntity: string | ResourceInterface, options?: Options) => ResourceInterface
56
reload: (uriOrEntity: string | ResourceInterface) => Promise<ResourceInterface>
67
post: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise<ResourceInterface | null>
78
patch: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise<ResourceInterface>

src/interfaces/Options.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { AxiosRequestConfig } from 'axios'
2+
3+
interface Options {
4+
axiosRequestInterceptor?: (config: AxiosRequestConfig) => AxiosRequestConfig
5+
}
6+
7+
export default Options

tests/store.spec.js

+74
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,80 @@ describe('API store', () => {
372372
expect(vm.api.get('/camps/1/activities?page_size=2&page=1').items.length).toEqual(1)
373373
})
374374

375+
it('applies request interceptor', async () => {
376+
// given
377+
axiosMock.onGet('http://localhost/camps/1?test=param').reply(200, embeddedSingleEntity.serverResponse)
378+
const interceptor = (config) => {
379+
config.url += '?test=param'
380+
return config
381+
}
382+
383+
// when
384+
vm.api.get('/camps/1', { axiosRequestInterceptor: interceptor })
385+
386+
// then
387+
expect(vm.$store.state.api).toMatchObject({ '/camps/1': { _meta: { self: '/camps/1', loading: true } } })
388+
expect(vm.api.get('/camps/1').campType().name.toString()).toEqual('')
389+
await letNetworkRequestFinish()
390+
expect(vm.$store.state.api).toMatchObject(embeddedSingleEntity.storeState)
391+
expect(vm.api.get('/camps/1')._meta.self).toEqual('/camps/1')
392+
expect(vm.api.get('/camps/1').campType()._meta.self).toEqual('/campTypes/20')
393+
expect(vm.api.get('/campTypes/20')._meta.self).toEqual('/campTypes/20')
394+
expect(vm.api.get('/camps/1').campType().name.toString()).toEqual('camp')
395+
})
396+
397+
it('applies request interceptor when traversing relation', async () => {
398+
// given
399+
const userResponse = {
400+
id: 1,
401+
_links: {
402+
self: {
403+
href: '/users/1'
404+
},
405+
lastReadBook: {
406+
href: '/books/555'
407+
}
408+
}
409+
}
410+
const bookResponse = {
411+
id: 555,
412+
title: 'Moby Dick',
413+
_links: {
414+
self: {
415+
href: '/books/555'
416+
}
417+
}
418+
}
419+
axiosMock.onGet('http://localhost/users/1').replyOnce(200, userResponse)
420+
421+
const user = vm.api.get('/users/1')
422+
await letNetworkRequestFinish()
423+
424+
axiosMock.onGet('http://localhost/books/555?some=param').replyOnce(200, bookResponse)
425+
const interceptor = (config) => {
426+
config.url += '?some=param'
427+
return config
428+
}
429+
430+
// when
431+
const result = user.lastReadBook({}, { axiosRequestInterceptor: interceptor })
432+
433+
// then
434+
await letNetworkRequestFinish()
435+
expect(vm.api.get('/books/555').title).toEqual('Moby Dick')
436+
})
437+
438+
// TODO how to proceed here?
439+
it.skip('treats passed options the same as reload flag', async () => {
440+
// given an entity is already loaded
441+
442+
// when fetching the same URI, but this time around with some options
443+
444+
// then what should happen?
445+
// should we ignore the options and reuse the cached version from the store?
446+
// should we treat options as if the user had used `reload` instead of `get`?
447+
})
448+
375449
it('allows redundantly using get with an object', async () => {
376450
// given
377451
axiosMock.onGet('http://localhost/camps/1').reply(200, embeddedSingleEntity.serverResponse)

0 commit comments

Comments
 (0)