Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request interceptors #276

Open
wants to merge 2 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/LoadingResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ class LoadingResource implements ResourceInterface {
private loadResource: Promise<ResourceInterface>

/**
* @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<ResourceInterface>, self: string | null = null, config: InternalConfig | null = null) {
this._meta = {
Expand Down Expand Up @@ -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)})`)
}
Expand Down
4 changes: 2 additions & 2 deletions src/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 20 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -109,11 +111,12 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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) {
Expand All @@ -125,7 +128,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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])
}

Expand Down Expand Up @@ -184,10 +187,11 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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<StoreData> {
function load (uri: string, forceReload: boolean, options: Options = {}): Promise<StoreData> {
const existsInStore = !isUnknown(uri)

const isAlreadyLoading = existsInStore && (store.state[opts.apiName][uri]._meta || {}).loading
Expand All @@ -204,9 +208,9 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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
})
Expand All @@ -222,11 +226,12 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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<StoreData> {
return axios.get(axios.defaults.baseURL + uri).then(({ data }) => {
function loadFromApi (uri: string, operation: string, options: Options = {}): Promise<StoreData> {
return axiosWith(options).get(axios.defaults.baseURL + uri).then(({ data }) => {
if (opts.forceRequestedSelfLink) {
data._links.self.href = uri
}
Expand All @@ -237,6 +242,12 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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.
*
Expand Down Expand Up @@ -280,7 +291,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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
}
Expand All @@ -289,8 +300,6 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
}, (error) => {
throw handleAxiosError('patch', uri, error)
})

return returnedResource
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/interfaces/ApiActions.ts
Original file line number Diff line number Diff line change
@@ -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<ResourceInterface>
post: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise<ResourceInterface | null>
patch: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise<ResourceInterface>
Expand Down
7 changes: 7 additions & 0 deletions src/interfaces/Options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { AxiosRequestConfig } from 'axios'

interface Options {
axiosRequestInterceptor?: (config: AxiosRequestConfig) => AxiosRequestConfig
}

export default Options
74 changes: 74 additions & 0 deletions tests/store.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down