Skip to content

Commit

Permalink
Merge 115ae8b into 0e348e2
Browse files Browse the repository at this point in the history
  • Loading branch information
carlobeltrame committed Jun 20, 2022
2 parents 0e348e2 + 115ae8b commit 34b37be
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 17 deletions.
7 changes: 4 additions & 3 deletions src/LoadingResource.ts
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
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
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
@@ -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
@@ -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
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

0 comments on commit 34b37be

Please sign in to comment.