Skip to content

Query parameters #272

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

Draft
wants to merge 2 commits into
base: next
Choose a base branch
from
Draft
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) }))
```
11 changes: 6 additions & 5 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, 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)})`)
}
Expand Down Expand Up @@ -108,8 +109,8 @@ class LoadingResource implements ResourceInterface {
return this._meta.load.then(resource => resource.$del())
}

public $href (relation: string, templateParams = {}): Promise<string | undefined> {
return this._meta.load.then(resource => resource.$href(relation, templateParams))
public $href (relation: string, templateParams = {}, queryParams = {}): Promise<string | undefined> {
return this._meta.load.then(resource => resource.$href(relation, templateParams, queryParams))
}

public toJSON (): string {
Expand Down
11 changes: 7 additions & 4 deletions src/Resource.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -87,8 +90,8 @@ class Resource implements ResourceInterface {
return this.apiActions.del(this._meta.self)
}

$href (relation: string, templateParams: Record<string, string | number | boolean> = {}): Promise<string | undefined> {
return this.apiActions.href(this, relation, templateParams)
$href (relation: string, templateParams: Record<string, string | number | boolean> = {}, queryParams: Record<string, string | number | boolean | Array<string | number | boolean>> = {}): Promise<string | undefined> {
return this.apiActions.href(this, relation, templateParams, queryParams)
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/addQuery.ts
Original file line number Diff line number Diff line change
@@ -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, string | number | boolean | Array<string | number | boolean>>): 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
27 changes: 15 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -31,7 +32,8 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
apiName: 'api',
avoidNPlusOneRequests: true,
forceRequestedSelfLink: false,
nuxtInject: undefined
nuxtInject: undefined,
normalizeUri: (_, normalizedUri) => normalizedUri
}
const opts = { ...defaultOptions, ...options, apiRoot: axios.defaults.baseURL }

Expand Down Expand Up @@ -64,7 +66,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
* in the Vuex store.
*/
function post (uriOrCollection: string | ResourceInterface, data: unknown): Promise<ResourceInterface | null> {
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`))
}
Expand Down Expand Up @@ -114,7 +116,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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) {
Expand Down Expand Up @@ -154,7 +156,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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.
Expand Down Expand Up @@ -243,16 +245,17 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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<string, string | number | boolean> = {}): Promise<string | undefined> {
const selfUri = normalizeEntityUri(await get(uriOrEntity)._meta.load, axios.defaults.baseURL)
async function href (uriOrEntity: string | ResourceInterface, relation: string, templateParams: Record<string, string | number | boolean> = {}, queryParams: Record<string, string | number | boolean | Array<string | number | boolean>> = {}): Promise<string | undefined> {
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) {
return urltemplate.parse(rel.href).expand(templateParams)
return addQuery(urltemplate.parse(rel.href).expand(templateParams), queryParams)
}
return rel.href
return addQuery(rel.href, queryParams)
}

/**
Expand All @@ -263,7 +266,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
* in the Vuex store.
*/
function patch (uriOrEntity: string | ResourceInterface, data: unknown) : Promise<ResourceInterface> {
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`))
}
Expand Down Expand Up @@ -300,7 +303,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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
Expand Down Expand Up @@ -329,7 +332,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance,
* been reloaded from the API, or the failed deletion has been cleaned up.
*/
function del (uriOrEntity: string | ResourceInterface): Promise<void> {
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`))
Expand Down Expand Up @@ -399,7 +402,7 @@ function HalJsonVuex (store: Store<Record<string, State>>, 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
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/ApiActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface ApiActions {
post: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise<ResourceInterface | null>
patch: (uriOrEntity: string | ResourceInterface, data: unknown) => Promise<ResourceInterface>
del: (uriOrEntity: string | ResourceInterface) => Promise<string | void>
href: (uriOrEntity: string | ResourceInterface, relation: string, templateParams) => Promise<string | undefined>
href: (uriOrEntity: string | ResourceInterface, relation: string, templateParams: Record<string, string | number | boolean>, queryParams: Record<string, string | number | boolean | Array<string | number | boolean>>) => Promise<string | undefined>
isUnknown: (uri: string) => boolean
}

Expand Down
1 change: 1 addition & 0 deletions src/interfaces/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface ExternalConfig {
avoidNPlusOneRequests?: boolean
forceRequestedSelfLink?: boolean
nuxtInject?: Inject
normalizeUri?(originalUri: string, normalizedUri: string | null): string | null
}

interface InternalConfig extends ExternalConfig {
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/ResourceInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface ResourceInterface {
$post: (data: unknown) => Promise<ResourceInterface | null>
$patch: (data: unknown) => Promise<ResourceInterface>
$del: () => Promise<string | void>
$href: (relation: string, templateParams: Record<string, string | number | boolean>) => Promise<string | undefined>
$href: (relation: string, templateParams: Record<string, string | number | boolean>, queryParams: Record<string, string | number | boolean | Array<string | number | boolean>>) => Promise<string | undefined>
}

interface VirtualResource extends ResourceInterface {
Expand Down
11 changes: 7 additions & 4 deletions src/normalizeEntityUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -44,7 +47,7 @@ function normalizeEntityUri (uriOrEntity: string | ResourceInterface | null = ''
uri = uriOrEntity?._meta?.self
}

return normalizeUri(uri, baseUrl)
return customNormalizeUri(uri, normalizeUri(uri, baseUrl))
}

/**
Expand Down
72 changes: 72 additions & 0 deletions tests/addQuery.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading