Skip to content

Commit daa6639

Browse files
committed
feat: Explode query string
OpenAPI 3 accepts parameters whose schema is an object When specified, these parameters can be `in: query` Such parameters can specify a style used to express them in the URL The default style for `in: query` parameters is `form` `form`-style parameters are expressed with a parameter-per-terminal value in the query object This change extends the existing query params encoder. It can now express nested objects and arrays in addition to scalar values
1 parent 2e0d66e commit daa6639

File tree

5 files changed

+173
-29
lines changed

5 files changed

+173
-29
lines changed

src/explodeQueryFormStyle.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export default function explodeQueryFormStyle(
2+
acc: string[],
3+
prefix: string,
4+
params: unknown,
5+
): string[] {
6+
if (params == null) {
7+
return acc
8+
}
9+
10+
if (Array.isArray(params)) {
11+
return acc.concat(
12+
params.flatMap((param, i) =>
13+
explodeQueryFormStyle(
14+
acc,
15+
typeof param === 'object' ? `${prefix}[${i}]` : prefix,
16+
param,
17+
),
18+
),
19+
)
20+
}
21+
22+
if (typeof params === 'object') {
23+
return Object.entries(params || {}).flatMap(([key, value]) => {
24+
return explodeQueryFormStyle(
25+
acc,
26+
prefix
27+
? `${prefix}[${encodeURIComponent(key)}]`
28+
: encodeURIComponent(key),
29+
value,
30+
)
31+
})
32+
}
33+
34+
const encodedTerminalValue = encodeURIComponent(String(params))
35+
return prefix ? [`${prefix}=${encodedTerminalValue}`] : [encodedTerminalValue]
36+
}

src/fetcher.ts

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import explodeQueryFormStyle from './explodeQueryFormStyle'
12
import {
23
ApiError,
34
ApiResponse,
@@ -21,28 +22,12 @@ const sendBody = (method: Method) =>
2122
method === 'patch' ||
2223
method === 'delete'
2324

24-
function queryString(params: Record<string, unknown>): string {
25-
const qs: string[] = []
25+
function queryString<TParams extends Record<string, unknown>>(
26+
params: TParams,
27+
): string {
28+
const encoded = explodeQueryFormStyle([], '', params).join('&')
2629

27-
const encode = (key: string, value: unknown) =>
28-
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
29-
30-
Object.keys(params).forEach((key) => {
31-
const value = params[key]
32-
if (value != null) {
33-
if (Array.isArray(value)) {
34-
value.forEach((value) => qs.push(encode(key, value)))
35-
} else {
36-
qs.push(encode(key, value))
37-
}
38-
}
39-
})
40-
41-
if (qs.length > 0) {
42-
return `?${qs.join('&')}`
43-
}
44-
45-
return ''
30+
return encoded.length ? `?${encoded}` : ''
4631
}
4732

4833
function getPath(path: string, payload: Record<string, any>) {
@@ -53,15 +38,12 @@ function getPath(path: string, payload: Record<string, any>) {
5338
})
5439
}
5540

56-
function getQuery(
57-
method: Method,
58-
payload: Record<string, any>,
59-
query: string[],
60-
) {
41+
function getQuery(request: Request, payload: Record<string, any>) {
42+
const { method, queryParams } = request
6143
let queryObj = {} as any
6244

6345
if (sendBody(method)) {
64-
query.forEach((key) => {
46+
queryParams.forEach((key) => {
6547
queryObj[key] = payload[key]
6648
delete payload[key]
6749
})
@@ -119,7 +101,7 @@ function getFetchParams(request: Request) {
119101
)
120102

121103
const path = getPath(request.path, payload)
122-
const query = getQuery(request.method, payload, request.queryParams)
104+
const query = getQuery(request, payload)
123105
const body = getBody(request.method, payload)
124106
const headers = getHeaders(body, request.init?.headers)
125107
const url = request.baseUrl + path + query

test/explodeQueryFormStyle.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import explodeQueryFormStyle from '../src/explodeQueryFormStyle'
2+
3+
describe('explodeQueryFormStyle', () => {
4+
it.each([
5+
[null, []],
6+
[undefined, []],
7+
['someString', ['someString']],
8+
[{ query: 'queryValue' }, ['query=queryValue']],
9+
[{ query: 9 }, ['query=9']],
10+
[
11+
{
12+
level1: {
13+
level1a: 'a',
14+
level1b: 'b',
15+
},
16+
},
17+
['level1[level1a]=a', 'level1[level1b]=b'],
18+
],
19+
[
20+
{
21+
level1: {
22+
level1a: 'a',
23+
level1b: 'b',
24+
},
25+
level2: {
26+
level2a: {
27+
level2sigma: 'off limits',
28+
},
29+
level2b: 'b',
30+
},
31+
},
32+
[
33+
'level1[level1a]=a',
34+
'level1[level1b]=b',
35+
'level2[level2a][level2sigma]=off%20limits',
36+
'level2[level2b]=b',
37+
],
38+
],
39+
[
40+
{
41+
options: ['staySignedIn', 'darkMode'],
42+
},
43+
['options=staySignedIn', 'options=darkMode'],
44+
],
45+
[
46+
{
47+
user1: {
48+
options: ['staySignedIn', 'darkMode'],
49+
},
50+
},
51+
['user1[options]=staySignedIn', 'user1[options]=darkMode'],
52+
],
53+
[
54+
['isFirstView', 'isRedirect'],
55+
['isFirstView', 'isRedirect'],
56+
],
57+
[
58+
{
59+
list: [{ name: 'Turtle' }, { name: 'Mouse' }],
60+
},
61+
['list[0][name]=Turtle', 'list[1][name]=Mouse'],
62+
],
63+
[
64+
{
65+
parts: [
66+
['red', 200],
67+
['green', 25],
68+
['blue', 170],
69+
],
70+
},
71+
[
72+
'parts[0]=red',
73+
'parts[0]=200',
74+
'parts[1]=green',
75+
'parts[1]=25',
76+
'parts[2]=blue',
77+
'parts[2]=170',
78+
],
79+
],
80+
])('should explode %p to %p', (input, expectedOutput) => {
81+
expect(explodeQueryFormStyle([], '', input)).toEqual(expectedOutput)
82+
})
83+
})

test/fetch.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,38 @@ describe('fetch', () => {
270270
expect(captured.url).toEqual('https://api.backend.dev/bodyquery/1?scalar=a')
271271
expect(captured.body).toEqual('{"list":["b","c"]}')
272272
})
273+
274+
describe('stringify params', () => {
275+
it('should use form-style stringifier', async () => {
276+
fetcher.configure({
277+
baseUrl: 'https://api.backend.dev',
278+
})
279+
280+
const captured = { url: '' }
281+
282+
fetcher.use(async (url, init, next) => {
283+
captured.url = url
284+
return next(url, init)
285+
})
286+
287+
const fun = fetcher.path('/query/{a}/{b}').method('get').create()
288+
289+
await fun({
290+
a: 1,
291+
b: '/',
292+
scalar: 'a',
293+
list: ['b', 'c'],
294+
object: {
295+
nestedObject: {
296+
nestedKey: 'd',
297+
},
298+
nestedList: ['e', 'f'],
299+
},
300+
})
301+
302+
expect(captured.url).toBe(
303+
'https://api.backend.dev/query/1/%2F?scalar=a&list=b&list=c&object[nestedObject][nestedKey]=d&object[nestedList]=e&object[nestedList]=f',
304+
)
305+
})
306+
})
273307
})

test/paths.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ export type Data = {
99
type Query = {
1010
parameters: {
1111
path: { a: number; b: string }
12-
query: { scalar: string; list: string[] }
12+
query: {
13+
scalar: string
14+
list: string[]
15+
object?: {
16+
nestedObject: {
17+
nestedKey: string
18+
}
19+
nestedList: string[]
20+
}
21+
}
1322
}
1423
responses: { 200: { schema: Data } }
1524
}

0 commit comments

Comments
 (0)