From 3983f9824846780dfe93e9fad839659c4b551722 Mon Sep 17 00:00:00 2001 From: Carlo Beltrame Date: Sun, 20 Mar 2022 03:23:07 +0100 Subject: [PATCH 1/2] Allow to append additional query parameters to urls and relations --- src/LoadingResource.ts | 11 +- src/Resource.ts | 11 +- src/addQuery.ts | 34 ++++++ src/index.ts | 8 +- src/interfaces/ApiActions.ts | 2 +- src/interfaces/ResourceInterface.ts | 2 +- tests/addQuery.spec.js | 72 +++++++++++ tests/apiOperations.spec.js | 57 ++++++++- tests/resources/non-templated-link.json | 51 ++++++++ tests/store.spec.js | 154 ++++++++++++++++++++++++ 10 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 src/addQuery.ts create mode 100644 tests/addQuery.spec.js create mode 100644 tests/resources/non-templated-link.json diff --git a/src/LoadingResource.ts b/src/LoadingResource.ts index f0805be3..2b09dfba 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, queryParams) => new LoadingResource(loadProperty.then(property => { try { - return property(templateParams)._meta.load + return property(templateParams, queryParams)._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)})`) } @@ -108,8 +109,8 @@ class LoadingResource implements ResourceInterface { return this._meta.load.then(resource => resource.$del()) } - public $href (relation: string, templateParams = {}): Promise { - return this._meta.load.then(resource => resource.$href(relation, templateParams)) + public $href (relation: string, templateParams = {}, queryParams = {}): Promise { + return this._meta.load.then(resource => resource.$href(relation, templateParams, queryParams)) } public toJSON (): string { diff --git a/src/Resource.ts b/src/Resource.ts index b4b07366..4360bda2 100644 --- a/src/Resource.ts +++ b/src/Resource.ts @@ -1,5 +1,6 @@ import urltemplate from 'url-template' import { isTemplatedLink, isVirtualLink, isEntityReference } from './halHelpers' +import addQuery from './addQuery' import ResourceInterface from './interfaces/ResourceInterface' import ApiActions from './interfaces/ApiActions' import { StoreData } from './interfaces/StoreData' @@ -45,11 +46,13 @@ 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] = (_, queryParams) => this.apiActions.get(addQuery(value.href, queryParams)) // 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, queryParams) => this.apiActions.get( + addQuery(urltemplate.parse(value.href).expand(templateParams || {}), queryParams) + ) // storeData[key] is a primitive (normal entity property) } else { @@ -87,8 +90,8 @@ class Resource implements ResourceInterface { return this.apiActions.del(this._meta.self) } - $href (relation: string, templateParams: Record = {}): Promise { - return this.apiActions.href(this, relation, templateParams) + $href (relation: string, templateParams: Record = {}, queryParams: Record> = {}): Promise { + return this.apiActions.href(this, relation, templateParams, queryParams) } /** diff --git a/src/addQuery.ts b/src/addQuery.ts new file mode 100644 index 00000000..8d86ca7f --- /dev/null +++ b/src/addQuery.ts @@ -0,0 +1,34 @@ +/** + * Adds the passed query parameters to the end of the passed URI. + * @param uri to be processed + * @returns string URI with sorted query parameters + */ +function addQuery (uri: string, queryParams: Record>): string { + if (isEmpty(queryParams)) return uri + if (typeof uri !== 'string') return uri + + const queryStart = uri.indexOf('?') + const prefix = queryStart === -1 ? uri : uri.substring(0, queryStart) + const query = new URLSearchParams(queryStart === -1 ? '' : uri.substring(queryStart + 1)) + + Object.keys(queryParams).forEach(key => { + const paramValue = queryParams[key] + if (Array.isArray(paramValue)) { + paramValue.forEach(value => query.append(key, value.toString())) + } else { + query.append(key, paramValue.toString()) + } + }) + + if ([...query.keys()].length) { + return `${prefix}?${query.toString()}` + } + + return uri +} + +function isEmpty (obj) { + return obj === null || undefined === obj || (Object.keys(obj).length === 0 && Object.getPrototypeOf(obj) === Object.prototype) +} + +export default addQuery diff --git a/src/index.ts b/src/index.ts index 2d886f23..c3728d2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import normalize from 'hal-json-normalizer' import urltemplate from 'url-template' import normalizeEntityUri from './normalizeEntityUri' +import addQuery from './addQuery' import ResourceCreator from './ResourceCreator' import Resource from './Resource' import LoadingResource from './LoadingResource' @@ -243,16 +244,17 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * @param uriOrEntity URI (or instance) of an entity from the API * @param relation the name of the relation for which the URI should be retrieved * @param templateParams in case the relation is a templated link, the template parameters that should be filled in + * @param queryParams query parameters to add to the url * @returns Promise resolves to the URI of the related entity. */ - async function href (uriOrEntity: string | ResourceInterface, relation: string, templateParams:Record = {}): Promise { + async function href (uriOrEntity: string | ResourceInterface, relation: string, templateParams: Record = {}, queryParams: Record> = {}): Promise { const selfUri = normalizeEntityUri(await get(uriOrEntity)._meta.load, axios.defaults.baseURL) const rel = selfUri != null ? store.state[opts.apiName][selfUri][relation] : null if (!rel || !rel.href) return undefined if (rel.templated) { - return urltemplate.parse(rel.href).expand(templateParams) + return addQuery(urltemplate.parse(rel.href).expand(templateParams), queryParams) } - return rel.href + return addQuery(rel.href, queryParams) } /** diff --git a/src/interfaces/ApiActions.ts b/src/interfaces/ApiActions.ts index 3f08f5f4..80a11353 100644 --- a/src/interfaces/ApiActions.ts +++ b/src/interfaces/ApiActions.ts @@ -6,7 +6,7 @@ interface ApiActions { post: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise patch: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise del: (uriOrEntity: string | ResourceInterface) => Promise - href: (uriOrEntity: string | ResourceInterface, relation: string, templateParams) => Promise + href: (uriOrEntity: string | ResourceInterface, relation: string, templateParams: Record, queryParams: Record>) => Promise isUnknown: (uri: string) => boolean } diff --git a/src/interfaces/ResourceInterface.ts b/src/interfaces/ResourceInterface.ts index 13ca9305..6ff4507b 100644 --- a/src/interfaces/ResourceInterface.ts +++ b/src/interfaces/ResourceInterface.ts @@ -19,7 +19,7 @@ interface ResourceInterface { $post: (data: unknown) => Promise $patch: (data: unknown) => Promise $del: () => Promise - $href: (relation: string, templateParams: Record) => Promise + $href: (relation: string, templateParams: Record, queryParams: Record>) => Promise } interface VirtualResource extends ResourceInterface { diff --git a/tests/addQuery.spec.js b/tests/addQuery.spec.js new file mode 100644 index 00000000..82880277 --- /dev/null +++ b/tests/addQuery.spec.js @@ -0,0 +1,72 @@ +import addQuery from '../src/addQuery.ts' + +describe('appending query parameters', () => { + it('appends the query parameters', () => { + // given + const examples = [ + ['', {}, ''], + ['http://localhost:3000/index.html', {}, 'http://localhost:3000/index.html'], + ['http://localhost:3000/index.html?query=param', {}, 'http://localhost:3000/index.html?query=param'], + ['http://localhost:3000/index.html?query=param&query=again', {}, 'http://localhost:3000/index.html?query=param&query=again'], + ['http://localhost:3000/index.html?', {}, 'http://localhost:3000/index.html?'], + ['http://localhost:3000/index.html?multi[]=test', {}, 'http://localhost:3000/index.html?multi[]=test'], + ['', { single: 'param' }, '?single=param'], + ['', { double: ['param', 'fun'] }, '?double=param&double=fun'], + ['', { one: 1, two: true, three: ['hello', 'world'] }, '?one=1&two=true&three=hello&three=world'], + ['', { 'three[]': ['hello', 'world'] }, '?three%5B%5D=hello&three%5B%5D=world'], + ['http://localhost:3000/index.html', { one: 1, two: true, 'three[]': ['hello', 'world'] }, 'http://localhost:3000/index.html?one=1&two=true&three%5B%5D=hello&three%5B%5D=world'], + ['http://localhost:3000/index.html?one=zero', { one: 1, two: true, 'three[]': ['hello', 'world'] }, 'http://localhost:3000/index.html?one=zero&one=1&two=true&three%5B%5D=hello&three%5B%5D=world'], + ['http://localhost:3000/index.html?two=none', { one: 1, two: true, 'three[]': ['hello', 'world'] }, 'http://localhost:3000/index.html?two=none&one=1&two=true&three%5B%5D=hello&three%5B%5D=world'], + ['http://localhost:3000/index.html?', { one: 1, two: true, 'three[]': ['hello', 'world'] }, 'http://localhost:3000/index.html?one=1&two=true&three%5B%5D=hello&three%5B%5D=world'], + ['http://localhost:3000/index.html?multi[]=test', { 'multi[]': ['hello', 'world'] }, 'http://localhost:3000/index.html?multi%5B%5D=test&multi%5B%5D=hello&multi%5B%5D=world'], + ] + + examples.forEach(([url, params, expected]) => { + // when + const result = addQuery(url, params) + + // then + expect(result).toEqual(expected) + }) + }) + + it('handles null params', () => { + // given + + // when + const result = addQuery('', null) + + // then + expect(result).toEqual('') + }) + + it('handles undefined params', () => { + // given + + // when + const result = addQuery('', undefined) + + // then + expect(result).toEqual('') + }) + + it('handles null uri', () => { + // given + + // when + const result = addQuery(null, { test: '1' }) + + // then + expect(result).toEqual(null) + }) + + it('handles undefined uri', () => { + // given + + // when + const result = addQuery(undefined, { test: '1' }) + + // then + expect(result).toEqual(undefined) + }) +}) diff --git a/tests/apiOperations.spec.js b/tests/apiOperations.spec.js index a9cb4d62..3fe99e9e 100644 --- a/tests/apiOperations.spec.js +++ b/tests/apiOperations.spec.js @@ -331,7 +331,24 @@ describe('Using dollar methods', () => { // then await letNetworkRequestFinish() - expect(hrefPromise).resolves.toEqual('/camps/1/activities') + await expect(hrefPromise).resolves.toEqual('/camps/1/activities') + }) + + it('$href adds query parameters to a relation URI', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, linkedCollection.serverResponse) + + vm.api.get('/camps/1') + await letNetworkRequestFinish() + const camp = vm.api.get('/camps/1') + expect(camp).toBeInstanceOf(Resource) + + // when + const hrefPromise = camp.$href('activities', {}, { test: 'param' }) + + // then + await letNetworkRequestFinish() + await expect(hrefPromise).resolves.toEqual('/camps/1/activities?test=param') }) it('$href returns a relation URI filled in with template parameters', async () => { @@ -348,7 +365,24 @@ describe('Using dollar methods', () => { // then await letNetworkRequestFinish() - expect(hrefPromise).resolves.toEqual('/camps/1/users/999') + return await expect(hrefPromise).resolves.toEqual('/camps/1/users/999') + }) + + it('$href adds query parameters to a relation URI with template parameters', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, templatedLink.linkingServerResponse) + + vm.api.get('/camps/1') + await letNetworkRequestFinish() + const camp = vm.api.get('/camps/1') + expect(camp).toBeInstanceOf(Resource) + + // when + const hrefPromise = camp.$href('users', { id: 999 }, { some: 'thing' }) + + // then + await letNetworkRequestFinish() + return expect(hrefPromise).resolves.toEqual('/camps/1/users/999?some=thing') }) it('$href also works on the root API endpoint', async () => { @@ -365,7 +399,24 @@ describe('Using dollar methods', () => { // then await letNetworkRequestFinish() - expect(hrefPromise).resolves.toEqual('/books') + await expect(hrefPromise).resolves.toEqual('/books') + }) + + it('$href also works with query parameters on the root API endpoint', async () => { + // given + axiosMock.onGet('http://localhost/').reply(200, rootWithLink.serverResponse) + + vm.api.get() + await letNetworkRequestFinish() + const root = vm.api.get() + expect(root).toBeInstanceOf(Resource) + + // when + const hrefPromise = root.$href('books', {}, { must_be: true }) + + // then + await letNetworkRequestFinish() + await expect(hrefPromise).resolves.toEqual('/books?must_be=true') }) it('$deletes loading entity and removes it from the store', async () => { diff --git a/tests/resources/non-templated-link.json b/tests/resources/non-templated-link.json new file mode 100644 index 00000000..b2ac5e52 --- /dev/null +++ b/tests/resources/non-templated-link.json @@ -0,0 +1,51 @@ +{ + "linkingServerResponse": { + "id": 1, + "_links": { + "self": { + "href": "/camps/1" + }, + "user": { + "href": "/camps/1/users/83" + } + } + }, + "linkedServerResponse": { + "id": 83, + "name": "Pflock", + "_links": { + "self": { + "href": "/camps/1/users/83" + } + } + }, + "storeStateBeforeLinkedLoaded": { + "/camps/1": { + "id": 1, + "user": { + "href": "/camps/1/users/83" + }, + "_meta": { + "self": "/camps/1" + } + } + }, + "storeStateAfterLinkedLoaded": { + "/camps/1": { + "id": 1, + "user": { + "href": "/camps/1/users/83" + }, + "_meta": { + "self": "/camps/1" + } + }, + "/camps/1/users/83": { + "id": 83, + "name": "Pflock", + "_meta": { + "self": "/camps/1/users/83" + } + } + } +} \ No newline at end of file diff --git a/tests/store.spec.js b/tests/store.spec.js index faf63644..8a761d0f 100644 --- a/tests/store.spec.js +++ b/tests/store.spec.js @@ -19,6 +19,7 @@ import collectionPage1 from './resources/collection-page1' import circularReference from './resources/circular-reference' import multipleReferencesToUser from './resources/multiple-references-to-user' import templatedLink from './resources/templated-link' +import nonTemplatedLink from './resources/non-templated-link' import objectProperty from './resources/object-property' import arrayProperty from './resources/array-property' import root from './resources/root' @@ -1380,6 +1381,19 @@ describe('API store', () => { expect(await load).toEqual('/users/83') }) + it('can add query parameters to the href of a linked entity', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').replyOnce(200, linkedSingleEntity.serverResponse) + axiosMock.onGet('http://localhost/users/83').networkError() + + // when + const load = vm.api.href('/camps/1', 'main_leader', {}, { test: 'param' }) + + // then + await letNetworkRequestFinish() + expect(await load).toEqual('/users/83?test=param') + }) + it('gets the href of a templated linked entity without fetching the entity itself', async () => { // given axiosMock.onGet('http://localhost/camps/1').replyOnce(200, templatedLink.linkingServerResponse) @@ -1394,6 +1408,58 @@ describe('API store', () => { expect(vm.$store.state.api).toMatchObject(templatedLink.storeStateBeforeLinkedLoaded) }) + it('can add query parameters to the templated href of a linked entity', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').replyOnce(200, templatedLink.linkingServerResponse) + axiosMock.onGet('http://localhost/users/83').networkError() + + // when + const load = vm.api.href('/camps/1', 'users', { id: 83 }, { query: 'param' }) + + // then + await letNetworkRequestFinish() + expect(await load).toEqual('/camps/1/users/83?query=param') + expect(vm.$store.state.api).toMatchObject(templatedLink.storeStateBeforeLinkedLoaded) + }) + + it('imports normal link to single entity when linking entity is still loading', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, nonTemplatedLink.linkingServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83').reply(200, nonTemplatedLink.linkedServerResponse) + const loadingCamp = vm.api.get('/camps/1') + + // when + const load = loadingCamp.user()._meta.load + + // then + await letNetworkRequestFinish() + expect(vm.$store.state.api).toMatchObject(nonTemplatedLink.storeStateAfterLinkedLoaded) + expect(await load).toMatchObject({ + id: 83, + name: 'Pflock', + _meta: { self: '/camps/1/users/83' } + }) + }) + + it('can add query params to normal link when linking entity is still loading', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, nonTemplatedLink.linkingServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83?query=param').reply(200, nonTemplatedLink.linkedServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83').networkError() + const loadingCamp = vm.api.get('/camps/1') + + // when + const load = loadingCamp.user({}, { query: 'param' })._meta.load + + // then + await letNetworkRequestFinish() + expect(await load).toMatchObject({ + id: 83, + name: 'Pflock', + _meta: { self: '/camps/1/users/83?query=param' } + }) + }) + it('imports templated link to single entity when linking entity is still loading', async () => { // given axiosMock.onGet('http://localhost/camps/1').reply(200, templatedLink.linkingServerResponse) @@ -1413,6 +1479,71 @@ describe('API store', () => { }) }) + it('can add query parameters to templated linked entity when linking entity is still loading', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, templatedLink.linkingServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83?query=param').reply(200, templatedLink.linkedServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83').networkError() + const loadingCamp = vm.api.get('/camps/1') + + // when + const load = loadingCamp.users({ id: 83 }, { query: 'param' })._meta.load + + // then + await letNetworkRequestFinish() + expect(await load).toMatchObject({ + id: 83, + name: 'Pflock', + _meta: { self: '/camps/1/users/83?query=param' } + }) + }) + + it('imports normal link to single entity when linking entity is already loaded', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, nonTemplatedLink.linkingServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83').reply(200, nonTemplatedLink.linkedServerResponse) + vm.api.get('/camps/1') + await letNetworkRequestFinish() + const camp = vm.api.get('/camps/1') + + // when + const load = camp.user()._meta.load + + // then + expect(vm.$store.state.api).toMatchObject(nonTemplatedLink.storeStateBeforeLinkedLoaded) + expect(vm.$store.state.api).not.toMatchObject(nonTemplatedLink.storeStateAfterLinkedLoaded) + await letNetworkRequestFinish() + expect(vm.$store.state.api).toMatchObject(nonTemplatedLink.storeStateAfterLinkedLoaded) + expect(await load).toMatchObject({ + id: 83, + name: 'Pflock', + _meta: { self: '/camps/1/users/83' } + }) + }) + + it('can add query parameters to normal link when linking entity is already loaded', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, nonTemplatedLink.linkingServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83?query=param').reply(200, nonTemplatedLink.linkedServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83').networkError() + vm.api.get('/camps/1') + await letNetworkRequestFinish() + const camp = vm.api.get('/camps/1') + + // when + const load = camp.user({}, { query: 'param' })._meta.load + + // then + expect(vm.$store.state.api).toMatchObject(nonTemplatedLink.storeStateBeforeLinkedLoaded) + expect(vm.$store.state.api).not.toMatchObject(nonTemplatedLink.storeStateAfterLinkedLoaded) + await letNetworkRequestFinish() + expect(await load).toMatchObject({ + id: 83, + name: 'Pflock', + _meta: { self: '/camps/1/users/83?query=param' } + }) + }) + it('imports templated link to single entity when linking entity is already loaded', async () => { // given axiosMock.onGet('http://localhost/camps/1').reply(200, templatedLink.linkingServerResponse) @@ -1436,6 +1567,29 @@ describe('API store', () => { }) }) + it('can add query params to linked entity when linking entity is already loaded', async () => { + // given + axiosMock.onGet('http://localhost/camps/1').reply(200, templatedLink.linkingServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83?query=param').reply(200, templatedLink.linkedServerResponse) + axiosMock.onGet('http://localhost/camps/1/users/83').networkError() + vm.api.get('/camps/1') + await letNetworkRequestFinish() + const camp = vm.api.get('/camps/1') + + // when + const load = camp.users({ id: 83 }, { query: 'param' })._meta.load + + // then + expect(vm.$store.state.api).toMatchObject(templatedLink.storeStateBeforeLinkedLoaded) + expect(vm.$store.state.api).not.toMatchObject(templatedLink.storeStateAfterLinkedLoaded) + await letNetworkRequestFinish() + expect(await load).toMatchObject({ + id: 83, + name: 'Pflock', + _meta: { self: '/camps/1/users/83?query=param' } + }) + }) + it('sets property loading on LoadingResource to true', () => { // given axiosMock.onGet('http://localhost/camps/1').reply(200, embeddedSingleEntity.serverResponse) From 0af257a0c64c487b4defe243c206cacaf158c92d Mon Sep 17 00:00:00 2001 From: Carlo Beltrame Date: Sun, 20 Mar 2022 03:45:19 +0100 Subject: [PATCH 2/2] Allow to specify a custom URI normalization function --- README.md | 9 +++++++ src/index.ts | 19 ++++++------- src/interfaces/Config.ts | 1 + src/normalizeEntityUri.ts | 11 +++++--- ...Uri.spec.js => normalizeEntityUri.spec.js} | 27 +++++++++++++++++++ 5 files changed, 54 insertions(+), 13 deletions(-) rename tests/{normalizeUri.spec.js => normalizeEntityUri.spec.js} (71%) diff --git a/README.md b/README.md index e0da28ef..2ce28acb 100644 --- a/README.md +++ b/README.md @@ -148,3 +148,12 @@ In case your API does this, you can set the `forceRequestedSelfLink` option to t ```js Vue.use(HalJsonVuex(store, axios, { forceRequestedSelfLink: true })) ``` + +### normalizeUri +For defining custom logic to influence the self links in the application (e.g. ignoring the presence of some query parameters), you can specify a `normalizeUri` function. +This function will be called whenever hal-json-vuex normalizes an URI, and will be passed the original, unnormalized URI, and the default-normalized URI as arguments. +The function is expected to return a final normalized URI string or null. + +```js +Vue.use(HalJsonVuex(store, axios, { normalizeUri: (originalUri, normalizedUri) => removeUnknownQueryParameters(normalizedUri) })) +``` diff --git a/src/index.ts b/src/index.ts index c3728d2e..1a4c73d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,8 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, apiName: 'api', avoidNPlusOneRequests: true, forceRequestedSelfLink: false, - nuxtInject: undefined + nuxtInject: undefined, + normalizeUri: (_, normalizedUri) => normalizedUri } const opts = { ...defaultOptions, ...options, apiRoot: axios.defaults.baseURL } @@ -65,7 +66,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * in the Vuex store. */ function post (uriOrCollection: string | ResourceInterface, data: unknown): Promise { - const uri = normalizeEntityUri(uriOrCollection, axios.defaults.baseURL) + const uri = normalizeEntityUri(uriOrCollection, axios.defaults.baseURL, opts.normalizeUri) if (uri === null) { return Promise.reject(new Error(`Could not perform POST, "${uriOrCollection}" is not an entity or URI`)) } @@ -115,7 +116,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * system as soon as the API request finishes. */ function get (uriOrEntity: string | ResourceInterface = ''): ResourceInterface { - const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL) + const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL, opts.normalizeUri) if (uri === null) { if (uriOrEntity instanceof LoadingResource) { @@ -155,7 +156,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, return reload(owningResource).then(owner => owner[owningRelation]()) } - const uri = normalizeEntityUri(resource, axios.defaults.baseURL) + const uri = normalizeEntityUri(resource, axios.defaults.baseURL, opts.normalizeUri) if (uri === null) { // We don't know anything about the requested object, something is wrong. @@ -248,7 +249,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * @returns Promise resolves to the URI of the related entity. */ async function href (uriOrEntity: string | ResourceInterface, relation: string, templateParams: Record = {}, queryParams: Record> = {}): Promise { - const selfUri = normalizeEntityUri(await get(uriOrEntity)._meta.load, axios.defaults.baseURL) + const selfUri = normalizeEntityUri(await get(uriOrEntity)._meta.load, axios.defaults.baseURL, opts.normalizeUri) const rel = selfUri != null ? store.state[opts.apiName][selfUri][relation] : null if (!rel || !rel.href) return undefined if (rel.templated) { @@ -265,7 +266,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * in the Vuex store. */ function patch (uriOrEntity: string | ResourceInterface, data: unknown) : Promise { - const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL) + const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL, opts.normalizeUri) if (uri === null) { return Promise.reject(new Error(`Could not perform PATCH, "${uriOrEntity}" is not an entity or URI`)) } @@ -302,7 +303,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * @param uriOrEntity URI (or instance) of an entity which should be removed from the Vuex store */ function purge (uriOrEntity: string | ResourceInterface): void { - const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL) + const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL, opts.normalizeUri) if (uri === null) { // Can't purge an unknown URI, do nothing return @@ -331,7 +332,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, * been reloaded from the API, or the failed deletion has been cleaned up. */ function del (uriOrEntity: string | ResourceInterface): Promise { - const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL) + const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL, opts.normalizeUri) if (uri === null) { // Can't delete an unknown URI, do nothing return Promise.reject(new Error(`Could not perform DELETE, "${uriOrEntity}" is not an entity or URI`)) @@ -401,7 +402,7 @@ function HalJsonVuex (store: Store>, axios: AxiosInstance, const normalizedData = normalize(data, { camelizeKeys: false, metaKey: '_meta', - normalizeUri: (uri: string) => normalizeEntityUri(uri, axios.defaults.baseURL), + normalizeUri: (uri: string) => normalizeEntityUri(uri, axios.defaults.baseURL, opts.normalizeUri), filterReferences: true, embeddedStandaloneListKey: 'items', virtualSelfLinks: true diff --git a/src/interfaces/Config.ts b/src/interfaces/Config.ts index 8de868a4..7eb6a649 100644 --- a/src/interfaces/Config.ts +++ b/src/interfaces/Config.ts @@ -5,6 +5,7 @@ interface ExternalConfig { avoidNPlusOneRequests?: boolean forceRequestedSelfLink?: boolean nuxtInject?: Inject + normalizeUri?(originalUri: string, normalizedUri: string | null): string | null } interface InternalConfig extends ExternalConfig { diff --git a/src/normalizeEntityUri.ts b/src/normalizeEntityUri.ts index c6ebea7a..f356b3b8 100644 --- a/src/normalizeEntityUri.ts +++ b/src/normalizeEntityUri.ts @@ -31,11 +31,14 @@ function sortQueryParams (uri: string): string { /** * Extracts the URI from an entity (or uses the passed URI if it is a string) and normalizes it for use in * the Vuex store. - * @param uriOrEntity entity or literal URI string - * @param baseUrl common URI prefix to remove during normalization + * @param uriOrEntity entity or literal URI string + * @param baseUrl common URI prefix to remove during normalization + * @param customNormalizeUri a custom function to normalize URIs according to application specific logic. + * Gets passed the original as well as the default-normalized URI as arguments, + * and should return the final normalized URI. * @returns {null|string} normalized URI, or null if the uriOrEntity argument was not understood */ -function normalizeEntityUri (uriOrEntity: string | ResourceInterface | null = '', baseUrl = ''): string | null { +function normalizeEntityUri (uriOrEntity: string | ResourceInterface | null = '', baseUrl = '', customNormalizeUri: (originalUri: string, normalizedUri: string | null) => string | null = (_, normalizedUri) => normalizedUri): string | null { let uri if (typeof uriOrEntity === 'string') { @@ -44,7 +47,7 @@ function normalizeEntityUri (uriOrEntity: string | ResourceInterface | null = '' uri = uriOrEntity?._meta?.self } - return normalizeUri(uri, baseUrl) + return customNormalizeUri(uri, normalizeUri(uri, baseUrl)) } /** diff --git a/tests/normalizeUri.spec.js b/tests/normalizeEntityUri.spec.js similarity index 71% rename from tests/normalizeUri.spec.js rename to tests/normalizeEntityUri.spec.js index 702dd292..2049023a 100644 --- a/tests/normalizeUri.spec.js +++ b/tests/normalizeEntityUri.spec.js @@ -54,4 +54,31 @@ describe('URI normalizing', () => { // then expect(result).toEqual('') }) + + it('allows to specify a custom normalization function', () => { + // given + const reverse = (string) => string.split('').reverse().join('') + + const examples = { + '': '', + '/': '/', + '/?': '/', + '?': '', + 'http://localhost': 'tsohlacol//:ptth', + } + + Object.entries(examples).forEach(([example, expected]) => { + // when + const result = normalizeEntityUri(example, '', (_, normalized) => reverse(normalized)) + + // then + expect(result).toEqual(expected) + + // when + const result2 = normalizeEntityUri(example, '', (original, _) => original) + + // then + expect(result2).toEqual(example) + }) + }) })