diff --git a/src/LoadingResource.ts b/src/LoadingResource.ts index f0805be3..5121354b 100644 --- a/src/LoadingResource.ts +++ b/src/LoadingResource.ts @@ -26,10 +26,11 @@ class LoadingResource implements ResourceInterface { private loadResource: Promise /** - * @param entityLoaded a Promise that resolves to a Resource when the entity has finished + * @param loadResource a Promise that resolves to a Resource when the entity has finished * loading from the API * @param self optional URI of the entity being loaded, if available. If passed, the * returned LoadingResource will return it in calls to .self and ._meta.self + * @param config configuration of this instance of hal-json-vuex */ constructor (loadResource: Promise, self: string | null = null, config: InternalConfig | null = null) { this._meta = { @@ -63,9 +64,9 @@ class LoadingResource implements ResourceInterface { // Proxy to all other unknown properties: return a function that yields another LoadingResource const loadProperty = loadResource.then(resource => resource[prop]) - const result = templateParams => new LoadingResource(loadProperty.then(property => { + const result = (templateParams, options) => new LoadingResource(loadProperty.then(property => { try { - return property(templateParams)._meta.load + return property(templateParams, options)._meta.load } catch (e) { 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)})`) } diff --git a/src/Resource.ts b/src/Resource.ts index b4b07366..27da8427 100644 --- a/src/Resource.ts +++ b/src/Resource.ts @@ -45,11 +45,11 @@ class Resource implements ResourceInterface { // storeData[key] is a reference only (contains only href; no data) } else if (isEntityReference(value)) { - this[key] = () => this.apiActions.get(value.href) + this[key] = (_, options) => this.apiActions.get(value.href, options) // storeData[key] is a templated link } else if (isTemplatedLink(value)) { - this[key] = templateParams => this.apiActions.get(urltemplate.parse(value.href).expand(templateParams || {})) + this[key] = (templateParams, options) => this.apiActions.get(urltemplate.parse(value.href).expand(templateParams || {}), options) // storeData[key] is a primitive (normal entity property) } else { diff --git a/src/index.ts b/src/index.ts index 2d886f23..daf6ed91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,10 @@ import LoadingResource from './LoadingResource' import storeModule, { State } from './storeModule' import ServerException from './ServerException' import { ExternalConfig } from './interfaces/Config' +import Options from './interfaces/Options' import { Store } from 'vuex/types' -import { AxiosInstance, AxiosError } from 'axios' +import AxiosCreator, { AxiosInstance, AxiosError } from 'axios' +import mergeAxiosConfig from 'axios/lib/core/mergeConfig' import ResourceInterface from './interfaces/ResourceInterface' import StoreData, { Link, SerializablePromise } from './interfaces/StoreData' import ApiActions from './interfaces/ApiActions' @@ -109,11 +111,12 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * } * * @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. + * @param options Options for this single request * @returns entity Entity from the store. Note that when fetching an object for the first time, a reactive * dummy is returned, which will be replaced with the true data through Vue's reactivity * system as soon as the API request finishes. */ - function get (uriOrEntity: string | ResourceInterface = ''): ResourceInterface { + function get (uriOrEntity: string | ResourceInterface = '', options: Options = {}): ResourceInterface { const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL) if (uri === null) { @@ -125,7 +128,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, throw new Error(`Could not perform GET, "${uriOrEntity}" is not an entity or URI`) } - setLoadPromiseOnStore(uri, load(uri, false)) + setLoadPromiseOnStore(uri, load(uri, false, options)) return resourceCreator.wrap(store.state[opts.apiName][uri]) } @@ -184,10 +187,11 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * sets the load promise on the entity in the Vuex store. * @param uri URI of the entity to load * @param forceReload If true, the entity will be fetched from the API even if it is already in the Vuex store. + * @param options Options for this single request * @returns entity the current entity data from the Vuex store. Note: This may be a reactive dummy if the * API request is still ongoing. */ - function load (uri: string, forceReload: boolean): Promise { + function load (uri: string, forceReload: boolean, options: Options = {}): Promise { const existsInStore = !isUnknown(uri) const isAlreadyLoading = existsInStore && (store.state[opts.apiName][uri]._meta || {}).loading @@ -204,9 +208,9 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, } if (!existsInStore) { - return loadFromApi(uri, 'fetch') + return loadFromApi(uri, 'fetch', options) } else if (forceReload) { - return loadFromApi(uri, 'reload').catch(error => { + return loadFromApi(uri, 'reload', options).catch(error => { store.commit('reloadingFailed', uri) throw error }) @@ -222,11 +226,12 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * being usable in Vue components). * @param uri URI of the entity to load from the API * @param operation description of the operation triggering this load, e.g. fetch or reload, for error reporting + * @param options Options for this single request * @returns Promise resolves to the raw data stored in the Vuex store after the API request completes, or * rejects when the API request fails */ - function loadFromApi (uri: string, operation: string): Promise { - return axios.get(axios.defaults.baseURL + uri).then(({ data }) => { + function loadFromApi (uri: string, operation: string, options: Options = {}): Promise { + return axiosWith(options).get(axios.defaults.baseURL + uri).then(({ data }) => { if (opts.forceRequestedSelfLink) { data._links.self.href = uri } @@ -237,6 +242,12 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, }) } + function axiosWith (options) { + const instance = AxiosCreator.create(mergeAxiosConfig(axios.defaults, {})) + instance.interceptors.request.use(options.axiosRequestInterceptor) + return instance + } + /** * Loads the URI of a related entity from the store, or the API in case it is not already fetched. * @@ -280,7 +291,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, store.commit('addEmpty', uri) } - const returnedResource = axios.patch(axios.defaults.baseURL + uri, data).then(({ data }) => { + return axios.patch(axios.defaults.baseURL + uri, data).then(({ data }) => { if (opts.forceRequestedSelfLink) { data._links.self.href = uri } @@ -289,8 +300,6 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, }, (error) => { throw handleAxiosError('patch', uri, error) }) - - return returnedResource } /** diff --git a/src/interfaces/ApiActions.ts b/src/interfaces/ApiActions.ts index 3f08f5f4..21b17180 100644 --- a/src/interfaces/ApiActions.ts +++ b/src/interfaces/ApiActions.ts @@ -1,7 +1,8 @@ import ResourceInterface from './ResourceInterface' +import Options from './Options' interface ApiActions { - get: (uriOrEntity: string | ResourceInterface) => ResourceInterface + get: (uriOrEntity: string | ResourceInterface, options?: Options) => ResourceInterface reload: (uriOrEntity: string | ResourceInterface) => Promise post: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise patch: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise diff --git a/src/interfaces/Options.ts b/src/interfaces/Options.ts new file mode 100644 index 00000000..43e436f4 --- /dev/null +++ b/src/interfaces/Options.ts @@ -0,0 +1,7 @@ +import { AxiosRequestConfig } from 'axios' + +interface Options { + axiosRequestInterceptor?: (config: AxiosRequestConfig) => AxiosRequestConfig +} + +export default Options diff --git a/tests/store.spec.js b/tests/store.spec.js index faf63644..881917cd 100644 --- a/tests/store.spec.js +++ b/tests/store.spec.js @@ -372,6 +372,80 @@ describe('API store', () => { expect(vm.api.get('/camps/1/activities?page_size=2&page=1').items.length).toEqual(1) }) + it('applies request interceptor', async () => { + // given + axiosMock.onGet('http://localhost/camps/1?test=param').reply(200, embeddedSingleEntity.serverResponse) + const interceptor = (config) => { + config.url += '?test=param' + return config + } + + // when + vm.api.get('/camps/1', { axiosRequestInterceptor: interceptor }) + + // then + expect(vm.$store.state.api).toMatchObject({ '/camps/1': { _meta: { self: '/camps/1', loading: true } } }) + expect(vm.api.get('/camps/1').campType().name.toString()).toEqual('') + await letNetworkRequestFinish() + expect(vm.$store.state.api).toMatchObject(embeddedSingleEntity.storeState) + expect(vm.api.get('/camps/1')._meta.self).toEqual('/camps/1') + expect(vm.api.get('/camps/1').campType()._meta.self).toEqual('/campTypes/20') + expect(vm.api.get('/campTypes/20')._meta.self).toEqual('/campTypes/20') + expect(vm.api.get('/camps/1').campType().name.toString()).toEqual('camp') + }) + + it('applies request interceptor when traversing relation', async () => { + // given + const userResponse = { + id: 1, + _links: { + self: { + href: '/users/1' + }, + lastReadBook: { + href: '/books/555' + } + } + } + const bookResponse = { + id: 555, + title: 'Moby Dick', + _links: { + self: { + href: '/books/555' + } + } + } + axiosMock.onGet('http://localhost/users/1').replyOnce(200, userResponse) + + const user = vm.api.get('/users/1') + await letNetworkRequestFinish() + + axiosMock.onGet('http://localhost/books/555?some=param').replyOnce(200, bookResponse) + const interceptor = (config) => { + config.url += '?some=param' + return config + } + + // when + const result = user.lastReadBook({}, { axiosRequestInterceptor: interceptor }) + + // then + await letNetworkRequestFinish() + expect(vm.api.get('/books/555').title).toEqual('Moby Dick') + }) + + // TODO how to proceed here? + it.skip('treats passed options the same as reload flag', async () => { + // given an entity is already loaded + + // when fetching the same URI, but this time around with some options + + // then what should happen? + // should we ignore the options and reuse the cached version from the store? + // should we treat options as if the user had used `reload` instead of `get`? + }) + it('allows redundantly using get with an object', async () => { // given axiosMock.onGet('http://localhost/camps/1').reply(200, embeddedSingleEntity.serverResponse)