Skip to content

Commit

Permalink
feat(kitsu): Configurable modern query serializer
Browse files Browse the repository at this point in the history
  • Loading branch information
pedep authored and wopian committed Feb 28, 2023
1 parent 240a00e commit ef94ae0
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 12 deletions.
31 changes: 26 additions & 5 deletions packages/kitsu-core/src/query/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,40 @@
*
* @param {string} value Right-hand side of the query
* @param {string} key Left-hand side of the query
* @param {boolean} traditional use traditional array key serializer
*
* @returns {string} URL query string
* @private
*/
function queryFormat (value, key) {
if (value !== null && Array.isArray(value)) return value.map(v => queryFormat(v, key)).join('&')
else if (value !== null && typeof value === 'object') return query(value, key)
function queryFormat (value, key, traditional) {
if (traditional && value !== null && Array.isArray(value)) return value.map(v => queryFormat(v, key, traditional)).join('&')
if (!traditional && value !== null && Array.isArray(value)) return value.map(v => queryFormat(v, `${key}[]`, traditional)).join('&')
else if (value !== null && typeof value === 'object') return query(value, key, traditional)
else return encodeURIComponent(key) + '=' + encodeURIComponent(value)
}

/**
* Formats key names to correct array syntax
*
* @param {string} [param] Parameter name to parse
*
* @returns {string} Key name in nested query-param format with optional array style suffix
* @private
*/
export function paramKeyName (param) {
if ([ '[]', '][' ].includes(param.slice(-2))) {
return `[${param.slice(0, -2)}][]`
}

return `[${param}]`
}

/**
* Constructs a URL query string for JSON:API parameters
*
* @param {Object} [params] Parameters to parse
* @param {string} [prefix] Prefix for nested parameters - used internally
* @param {boolean} [traditional=true] Use the traditional (default) or modern param serializer. Set to false if your server is running Ruby on Rails or other modern web frameworks
* @returns {string} URL query string
*
* @example
Expand All @@ -31,12 +51,13 @@ function queryFormat (value, key) {
* })
* // filter%5Bslug%5D=cowboy-bebop&filter%5Btitle%5D%5Bvalue%5D=foo&sort=-id
*/
export function query (params, prefix = null) {

export function query (params, prefix = null, traditional = true) {
const str = []

for (const param in params) {
str.push(
queryFormat(params[param], prefix ? `${prefix}[${param}]` : param)
queryFormat(params[param], prefix ? `${prefix}${paramKeyName(param)}` : param, traditional)
)
}
return str.join('&')
Expand Down
41 changes: 39 additions & 2 deletions packages/kitsu-core/src/query/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('kitsu-core', () => {
})).toEqual('fields%5Babc%5D%5Bdef%5D%5Bghi%5D%5Bjkl%5D=mno')
})

it('builds list parameters', () => {
it('builds list parameters in traditional mode', () => {
expect.assertions(1)
expect(query({
filter: {
Expand All @@ -68,13 +68,50 @@ describe('kitsu-core', () => {
})).toEqual('filter%5Bid_in%5D=1&filter%5Bid_in%5D=2&filter%5Bid_in%5D=3')
})

it('builds nested list parameters', () => {
it('builds nested list parameters in traditional mode', () => {
expect.assertions(1)
expect(query({
filter: {
users: [ { id: 1, type: 'users' }, { id: 2, type: 'users' } ]
}
})).toEqual('filter%5Busers%5D%5Bid%5D=1&filter%5Busers%5D%5Btype%5D=users&filter%5Busers%5D%5Bid%5D=2&filter%5Busers%5D%5Btype%5D=users')
})

it('builds list parameters in modern mode', () => {
expect.assertions(1)
expect(query({
filter: {
id_in: [ 1, 2, 3 ]
}
}, null, false)).toEqual('filter%5Bid_in%5D%5B%5D=1&filter%5Bid_in%5D%5B%5D=2&filter%5Bid_in%5D%5B%5D=3')
})

it('builds nested list parameters in modern mode', () => {
expect.assertions(1)
expect(query({
filter: {
users: [ { id: 1, type: 'users' }, { id: 2, type: 'users' } ]
}
}, null, false)).toEqual('filter%5Busers%5D%5B%5D%5Bid%5D=1&filter%5Busers%5D%5B%5D%5Btype%5D=users&filter%5Busers%5D%5B%5D%5Bid%5D=2&filter%5Busers%5D%5B%5D%5Btype%5D=users')
})

it('parses list-style keys', () => {
expect.assertions(1)
expect(query({
filter: {
'id_in[]': [ 1, 2 ],
'parent_id_in][': [ 3, 4 ]
}
})).toEqual('filter%5Bid_in%5D%5B%5D=1&filter%5Bid_in%5D%5B%5D=2&filter%5Bparent_id_in%5D%5B%5D=3&filter%5Bparent_id_in%5D%5B%5D=4')
})

it('preserves square-brackets in key names in modern mode', () => {
expect.assertions(1)
expect(query({
filter: {
'id_in[]': [ 1, 2 ]
}
}, null, false)).toEqual('filter%5Bid_in%5D%5B%5D%5B%5D=1&filter%5Bid_in%5D%5B%5D%5B%5D=2')
})
})
})
16 changes: 11 additions & 5 deletions packages/kitsu/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import pluralise from 'pluralize'
* @param {Object} [options] Options
* @param {string} [options.baseURL=https://kitsu.io/api/edge] Set the API endpoint
* @param {Object} [options.headers] Additional headers to send with the requests
* @param {'traditional'|'modern'|Function} [options.query=traditional] Query serializer function to use. This will impact the say keys are serialized when passing arrays as query parameters. 'modern' is recommended for new projects.
* @param {boolean} [options.camelCaseTypes=true] If enabled, `type` will be converted to camelCase from kebab-casae or snake_case
* @param {'kebab'|'snake'|'none'} [options.resourceCase=kebab] Case to convert camelCase to. `kebab` - `/library-entries`; `snake` - /library_entries`; `none` - `/libraryEntries`
* @param {boolean} [options.pluralize=true] If enabled, `/user` will become `/users` in the URL request and `type` will be pluralized in POST, PATCH and DELETE requests
Expand All @@ -29,6 +30,11 @@ import pluralise from 'pluralize'
*/
export default class Kitsu {
constructor (options = {}) {
const traditional = typeof options.query === 'string' ? options.query === 'traditional' : true
this.query = typeof options.query === 'function'
? options.query
: obj => query(obj, null, traditional)

if (options.camelCaseTypes === false) this.camel = s => s
else this.camel = camel

Expand Down Expand Up @@ -229,7 +235,7 @@ export default class Kitsu {
const { data, headers: responseHeaders } = await this.axios.get(url, {
headers,
params,
paramsSerializer: /* istanbul ignore next */ p => query(p),
paramsSerializer: /* istanbul ignore next */ p => this.query(p),
...config.axiosOptions
})

Expand Down Expand Up @@ -292,7 +298,7 @@ export default class Kitsu {
{
headers,
params,
paramsSerializer: /* istanbul ignore next */ p => query(p),
paramsSerializer: /* istanbul ignore next */ p => this.query(p),
...config.axiosOptions
}
)
Expand Down Expand Up @@ -352,7 +358,7 @@ export default class Kitsu {
{
headers,
params,
paramsSerializer: /* istanbul ignore next */ p => query(p),
paramsSerializer: /* istanbul ignore next */ p => this.query(p),
...config.axiosOptions
}
)
Expand Down Expand Up @@ -407,7 +413,7 @@ export default class Kitsu {
}),
headers,
params,
paramsSerializer: /* istanbul ignore next */ p => query(p),
paramsSerializer: /* istanbul ignore next */ p => this.query(p),
...config.axiosOptions
})

Expand Down Expand Up @@ -517,7 +523,7 @@ export default class Kitsu {
}),
headers: { ...this.headers, ...headers },
params,
paramsSerializer: /* istanbul ignore next */ p => query(p),
paramsSerializer: /* istanbul ignore next */ p => this.query(p),
...axiosOptions
})

Expand Down
22 changes: 22 additions & 0 deletions packages/kitsu/src/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,28 @@ describe('kitsu', () => {
expect(api.axios.defaults.baseURL).toBe('https://example.api')
})

it('uses the query serializer provided in constructor', () => {
expect.assertions(1)
const stringsOnlyQuery = obj => Object.keys(obj).reduce((acc, key) =>
typeof obj[key] === 'string' ? [ ...acc, `${key}=${obj[key]}` ] : acc, []
).join('&')

const api = new Kitsu({ query: stringsOnlyQuery })
expect(api.query({ a: 1, b: 'str', c: 3, d: 'ing' })).toBe('b=str&d=ing')
})

it('uses the traditional query serializer by default', () => {
expect.assertions(1)
const api = new Kitsu({})
expect(api.query({ id_in: [ 1, 2, 3 ] })).toBe('id_in=1&id_in=2&id_in=3')
})

it('uses the modern query serializer if query option is "modern"', () => {
expect.assertions(1)
const api = new Kitsu({ query: 'modern' })
expect(api.query({ id_in: [ 1, 2, 3 ] })).toBe('id_in%5B%5D=1&id_in%5B%5D=2&id_in%5B%5D=3')
})

it('uses provided axios options', () => {
expect.assertions(1)
const api = new Kitsu({ axiosOptions: { withCredentials: true } })
Expand Down

0 comments on commit ef94ae0

Please sign in to comment.