diff --git a/src/explodeQueryFormStyle.ts b/src/explodeQueryFormStyle.ts new file mode 100644 index 00000000..8c23486a --- /dev/null +++ b/src/explodeQueryFormStyle.ts @@ -0,0 +1,32 @@ +export default function explodeQueryFormStyle( + acc: string[], + prefix: string, + params: unknown, +): string[] { + if (params == null) { + return acc + } + + if (Array.isArray(params)) { + return acc.concat( + params.flatMap((param, i) => + explodeQueryFormStyle(acc, prefix ? `${prefix}[${i}]` : '', param), + ), + ) + } + + if (typeof params === 'object') { + return Object.entries(params || {}).flatMap(([key, value]) => { + return explodeQueryFormStyle( + acc, + prefix + ? `${prefix}[${encodeURIComponent(key)}]` + : encodeURIComponent(key), + value, + ) + }) + } + + const encodedTerminalValue = encodeURIComponent(String(params)) + return prefix ? [`${prefix}=${encodedTerminalValue}`] : [encodedTerminalValue] +} diff --git a/src/fetcher.ts b/src/fetcher.ts index 1eb1d5f9..0d6eb53c 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -1,3 +1,4 @@ +import explodeQueryFormStyle from './explodeQueryFormStyle' import { ApiError, ApiResponse, @@ -21,28 +22,12 @@ const sendBody = (method: Method) => method === 'patch' || method === 'delete' -function queryString(params: Record): string { - const qs: string[] = [] +function queryString>( + params: TParams, +): string { + const encoded = explodeQueryFormStyle([], '', params).join('&') - const encode = (key: string, value: unknown) => - `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}` - - Object.keys(params).forEach((key) => { - const value = params[key] - if (value != null) { - if (Array.isArray(value)) { - value.forEach((value) => qs.push(encode(key, value))) - } else { - qs.push(encode(key, value)) - } - } - }) - - if (qs.length > 0) { - return `?${qs.join('&')}` - } - - return '' + return encoded.length ? `?${encoded}` : '' } function getPath(path: string, payload: Record) { @@ -53,15 +38,12 @@ function getPath(path: string, payload: Record) { }) } -function getQuery( - method: Method, - payload: Record, - query: string[], -) { +function getQuery(request: Request, payload: Record) { + const { method, queryParams } = request let queryObj = {} as any if (sendBody(method)) { - query.forEach((key) => { + queryParams.forEach((key) => { queryObj[key] = payload[key] delete payload[key] }) @@ -119,7 +101,7 @@ function getFetchParams(request: Request) { ) const path = getPath(request.path, payload) - const query = getQuery(request.method, payload, request.queryParams) + const query = getQuery(request, payload) const body = getBody(request.method, payload) const headers = getHeaders(body, request.init?.headers) const url = request.baseUrl + path + query diff --git a/test/explodeQueryFormStyle.test.ts b/test/explodeQueryFormStyle.test.ts new file mode 100644 index 00000000..8e80ab2b --- /dev/null +++ b/test/explodeQueryFormStyle.test.ts @@ -0,0 +1,83 @@ +import explodeQueryFormStyle from '../src/explodeQueryFormStyle' + +describe('explodeQueryFormStyle', () => { + it.each([ + [null, []], + [undefined, []], + ['someString', ['someString']], + [{ query: 'queryValue' }, ['query=queryValue']], + [{ query: 9 }, ['query=9']], + [ + { + level1: { + level1a: 'a', + level1b: 'b', + }, + }, + ['level1[level1a]=a', 'level1[level1b]=b'], + ], + [ + { + level1: { + level1a: 'a', + level1b: 'b', + }, + level2: { + level2a: { + level2sigma: 'off limits', + }, + level2b: 'b', + }, + }, + [ + 'level1[level1a]=a', + 'level1[level1b]=b', + 'level2[level2a][level2sigma]=off%20limits', + 'level2[level2b]=b', + ], + ], + [ + { + options: ['staySignedIn', 'darkMode'], + }, + ['options[0]=staySignedIn', 'options[1]=darkMode'], + ], + [ + { + user1: { + options: ['staySignedIn', 'darkMode'], + }, + }, + ['user1[options][0]=staySignedIn', 'user1[options][1]=darkMode'], + ], + [ + ['isFirstView', 'isRedirect'], + ['isFirstView', 'isRedirect'], + ], + [ + { + list: [{ name: 'Turtle' }, { name: 'Mouse' }], + }, + ['list[0][name]=Turtle', 'list[1][name]=Mouse'], + ], + [ + { + parts: [ + ['red', 200], + ['green', 25], + ['blue', 170], + ], + }, + [ + 'parts[0][0]=red', + 'parts[0][1]=200', + 'parts[1][0]=green', + 'parts[1][1]=25', + 'parts[2][0]=blue', + 'parts[2][1]=170', + ], + ], + ])('should explode %p to %p', (input, expectedOutput) => { + expect(explodeQueryFormStyle([], '', input)).toEqual(expectedOutput) + }) +}) diff --git a/test/fetch.test.ts b/test/fetch.test.ts index 974dbdda..38881e05 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -270,4 +270,38 @@ describe('fetch', () => { expect(captured.url).toEqual('https://api.backend.dev/bodyquery/1?scalar=a') expect(captured.body).toEqual('{"list":["b","c"]}') }) + + describe('stringify params', () => { + it.only('should use form-style stringifier', async () => { + fetcher.configure({ + baseUrl: 'https://api.backend.dev', + }) + + const captured = { url: '' } + + fetcher.use(async (url, init, next) => { + captured.url = url + return next(url, init) + }) + + const fun = fetcher.path('/query/{a}/{b}').method('get').create() + + await fun({ + a: 1, + b: '/', + scalar: 'a', + list: ['b', 'c'], + object: { + nestedObject: { + nestedKey: 'd', + }, + nestedList: ['e', 'f'], + }, + }) + + expect(captured.url).toBe( + 'https://api.backend.dev/query/1/%2F?scalar=a&list[0]=b&list[1]=c&object[nestedObject][nestedKey]=d&object[nestedList][0]=e&object[nestedList][1]=f', + ) + }) + }) }) diff --git a/test/paths.ts b/test/paths.ts index afc3831a..7351390e 100644 --- a/test/paths.ts +++ b/test/paths.ts @@ -9,7 +9,16 @@ export type Data = { type Query = { parameters: { path: { a: number; b: string } - query: { scalar: string; list: string[] } + query: { + scalar: string + list: string[] + object?: { + nestedObject: { + nestedKey: string + } + nestedList: string[] + } + } } responses: { 200: { schema: Data } } }