Skip to content

Commit 15d5875

Browse files
authored
feat: Query string params (nodejs#1449)
1 parent 0d8877a commit 15d5875

File tree

5 files changed

+314
-6
lines changed

5 files changed

+314
-6
lines changed

docs/api/Dispatcher.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo
194194
* **method** `string`
195195
* **body** `string | Buffer | Uint8Array | stream.Readable | Iterable | AsyncIterable | null` (optional) - Default: `null`
196196
* **headers** `UndiciHeaders | string[]` (optional) - Default: `null`.
197+
* **query** `Record<string, any> | null` (optional) - Default: `null` - Query string params to be embedded in the request URL. Note that both keys and values of query are encoded using `encodeURIComponent`. If for some reason you need to send them unencoded, embed query params into path directly instead.
197198
* **idempotent** `boolean` (optional) - Default: `true` if `method` is `'HEAD'` or `'GET'` - Whether the requests can be safely retried or not. If `false` the request won't be sent until all preceding requests in the pipeline has completed.
198199
* **blocking** `boolean` (optional) - Default: `false` - Whether the response is expected to take a long time and would end up blocking the pipeline. When this is set to `true` further pipelining will be avoided on the same connection until headers have been received.
199200
* **upgrade** `string | null` (optional) - Default: `null` - Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`.

lib/core/request.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ const {
44
InvalidArgumentError,
55
NotSupportedError
66
} = require('./errors')
7-
const util = require('./util')
87
const assert = require('assert')
8+
const util = require('./util')
99

1010
const kHandler = Symbol('handler')
1111

@@ -38,6 +38,7 @@ class Request {
3838
method,
3939
body,
4040
headers,
41+
query,
4142
idempotent,
4243
blocking,
4344
upgrade,
@@ -97,7 +98,7 @@ class Request {
9798

9899
this.upgrade = upgrade || null
99100

100-
this.path = path
101+
this.path = query ? util.buildURL(path, query) : path
101102

102103
this.origin = origin
103104

lib/core/util.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,51 @@ function isBlobLike (object) {
2626
)
2727
}
2828

29+
function isObject (val) {
30+
return val !== null && typeof val === 'object'
31+
}
32+
33+
// this escapes all non-uri friendly characters
34+
function encode (val) {
35+
return encodeURIComponent(val)
36+
}
37+
38+
// based on https://github.com/axios/axios/blob/63e559fa609c40a0a460ae5d5a18c3470ffc6c9e/lib/helpers/buildURL.js (MIT license)
39+
function buildURL (url, queryParams) {
40+
if (url.includes('?') || url.includes('#')) {
41+
throw new Error('Query params cannot be passed when url already contains "?" or "#".')
42+
}
43+
if (!isObject(queryParams)) {
44+
throw new Error('Query params must be an object')
45+
}
46+
47+
const parts = []
48+
for (let [key, val] of Object.entries(queryParams)) {
49+
if (val === null || typeof val === 'undefined') {
50+
continue
51+
}
52+
53+
if (!Array.isArray(val)) {
54+
val = [val]
55+
}
56+
57+
for (const v of val) {
58+
if (isObject(v)) {
59+
throw new Error('Passing object as a query param is not supported, please serialize to string up-front')
60+
}
61+
parts.push(encode(key) + '=' + encode(v))
62+
}
63+
}
64+
65+
const serializedParams = parts.join('&')
66+
67+
if (serializedParams) {
68+
url += '?' + serializedParams
69+
}
70+
71+
return url
72+
}
73+
2974
function parseURL (url) {
3075
if (typeof url === 'string') {
3176
url = new URL(url)
@@ -357,5 +402,6 @@ module.exports = {
357402
isBuffer,
358403
validateHandler,
359404
getSocketInfo,
360-
isFormDataLike
405+
isFormDataLike,
406+
buildURL
361407
}

test/client.js

Lines changed: 261 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
'use strict'
22

3-
const { test } = require('tap')
4-
const { Client, errors } = require('..')
5-
const { createServer } = require('http')
63
const { readFileSync, createReadStream } = require('fs')
4+
const { createServer } = require('http')
75
const { Readable } = require('stream')
6+
const { test } = require('tap')
7+
const { Client, errors } = require('..')
88
const { kSocket } = require('../lib/core/symbols')
99
const { wrapWithAsyncIterable } = require('./utils/async-iterators')
1010
const EE = require('events')
@@ -80,6 +80,241 @@ test('basic get', (t) => {
8080
})
8181
})
8282

83+
test('basic get with query params', (t) => {
84+
t.plan(4)
85+
86+
const server = createServer((req, res) => {
87+
const searchParamsObject = buildParams(req.url)
88+
t.strictSame(searchParamsObject, {
89+
bool: 'true',
90+
foo: '1',
91+
bar: 'bar',
92+
'%60~%3A%24%2C%2B%5B%5D%40%5E*()-': '%60~%3A%24%2C%2B%5B%5D%40%5E*()-',
93+
multi: ['1', '2']
94+
})
95+
96+
res.statusCode = 200
97+
res.end('hello')
98+
})
99+
t.teardown(server.close.bind(server))
100+
101+
const query = {
102+
bool: true,
103+
foo: 1,
104+
bar: 'bar',
105+
nullVal: null,
106+
undefinedVal: undefined,
107+
'`~:$,+[]@^*()-': '`~:$,+[]@^*()-',
108+
multi: [1, 2]
109+
}
110+
111+
server.listen(0, () => {
112+
const client = new Client(`http://localhost:${server.address().port}`, {
113+
keepAliveTimeout: 300e3
114+
})
115+
t.teardown(client.close.bind(client))
116+
117+
const signal = new EE()
118+
client.request({
119+
signal,
120+
path: '/',
121+
method: 'GET',
122+
query
123+
}, (err, data) => {
124+
t.error(err)
125+
const { statusCode } = data
126+
t.equal(statusCode, 200)
127+
})
128+
t.equal(signal.listenerCount('abort'), 1)
129+
})
130+
})
131+
132+
test('basic get with query params with object throws an error', (t) => {
133+
t.plan(1)
134+
135+
const server = createServer((req, res) => {
136+
t.fail()
137+
})
138+
t.teardown(server.close.bind(server))
139+
140+
const query = {
141+
obj: { id: 1 }
142+
}
143+
144+
server.listen(0, () => {
145+
const client = new Client(`http://localhost:${server.address().port}`, {
146+
keepAliveTimeout: 300e3
147+
})
148+
t.teardown(client.close.bind(client))
149+
150+
const signal = new EE()
151+
client.request({
152+
signal,
153+
path: '/',
154+
method: 'GET',
155+
query
156+
}, (err, data) => {
157+
t.equal(err.message, 'Passing object as a query param is not supported, please serialize to string up-front')
158+
})
159+
})
160+
})
161+
162+
test('basic get with non-object query params throws an error', (t) => {
163+
t.plan(1)
164+
165+
const server = createServer((req, res) => {
166+
t.fail()
167+
})
168+
t.teardown(server.close.bind(server))
169+
170+
const query = '{ obj: { id: 1 } }'
171+
172+
server.listen(0, () => {
173+
const client = new Client(`http://localhost:${server.address().port}`, {
174+
keepAliveTimeout: 300e3
175+
})
176+
t.teardown(client.close.bind(client))
177+
178+
const signal = new EE()
179+
client.request({
180+
signal,
181+
path: '/',
182+
method: 'GET',
183+
query
184+
}, (err, data) => {
185+
t.equal(err.message, 'Query params must be an object')
186+
})
187+
})
188+
})
189+
190+
test('basic get with query params with date throws an error', (t) => {
191+
t.plan(1)
192+
193+
const date = new Date()
194+
const server = createServer((req, res) => {
195+
t.fail()
196+
})
197+
t.teardown(server.close.bind(server))
198+
199+
const query = {
200+
dateObj: date
201+
}
202+
203+
server.listen(0, () => {
204+
const client = new Client(`http://localhost:${server.address().port}`, {
205+
keepAliveTimeout: 300e3
206+
})
207+
t.teardown(client.close.bind(client))
208+
209+
const signal = new EE()
210+
client.request({
211+
signal,
212+
path: '/',
213+
method: 'GET',
214+
query
215+
}, (err, data) => {
216+
t.equal(err.message, 'Passing object as a query param is not supported, please serialize to string up-front')
217+
})
218+
})
219+
})
220+
221+
test('basic get with query params fails if url includes hashmark', (t) => {
222+
t.plan(1)
223+
224+
const server = createServer((req, res) => {
225+
t.fail()
226+
})
227+
t.teardown(server.close.bind(server))
228+
229+
const query = {
230+
foo: 1,
231+
bar: 'bar',
232+
multi: [1, 2]
233+
}
234+
235+
server.listen(0, () => {
236+
const client = new Client(`http://localhost:${server.address().port}`, {
237+
keepAliveTimeout: 300e3
238+
})
239+
t.teardown(client.close.bind(client))
240+
241+
const signal = new EE()
242+
client.request({
243+
signal,
244+
path: '/#',
245+
method: 'GET',
246+
query
247+
}, (err, data) => {
248+
t.equal(err.message, 'Query params cannot be passed when url already contains "?" or "#".')
249+
})
250+
})
251+
})
252+
253+
test('basic get with empty query params', (t) => {
254+
t.plan(4)
255+
256+
const server = createServer((req, res) => {
257+
const searchParamsObject = buildParams(req.url)
258+
t.strictSame(searchParamsObject, {})
259+
260+
res.statusCode = 200
261+
res.end('hello')
262+
})
263+
t.teardown(server.close.bind(server))
264+
265+
const query = {}
266+
267+
server.listen(0, () => {
268+
const client = new Client(`http://localhost:${server.address().port}`, {
269+
keepAliveTimeout: 300e3
270+
})
271+
t.teardown(client.close.bind(client))
272+
273+
const signal = new EE()
274+
client.request({
275+
signal,
276+
path: '/',
277+
method: 'GET',
278+
query
279+
}, (err, data) => {
280+
t.error(err)
281+
const { statusCode } = data
282+
t.equal(statusCode, 200)
283+
})
284+
t.equal(signal.listenerCount('abort'), 1)
285+
})
286+
})
287+
288+
test('basic get with query params partially in path', (t) => {
289+
t.plan(1)
290+
291+
const server = createServer((req, res) => {
292+
t.fail()
293+
})
294+
t.teardown(server.close.bind(server))
295+
296+
const query = {
297+
foo: 1
298+
}
299+
300+
server.listen(0, () => {
301+
const client = new Client(`http://localhost:${server.address().port}`, {
302+
keepAliveTimeout: 300e3
303+
})
304+
t.teardown(client.close.bind(client))
305+
306+
const signal = new EE()
307+
client.request({
308+
signal,
309+
path: '/?bar=2',
310+
method: 'GET',
311+
query
312+
}, (err, data) => {
313+
t.equal(err.message, 'Query params cannot be passed when url already contains "?" or "#".')
314+
})
315+
})
316+
})
317+
83318
test('basic head', (t) => {
84319
t.plan(14)
85320

@@ -1589,3 +1824,26 @@ test('async iterator yield object error', (t) => {
15891824
})
15901825
})
15911826
})
1827+
1828+
function buildParams (path) {
1829+
const cleanPath = path.replace('/?', '').replace('/', '').split('&')
1830+
const builtParams = cleanPath.reduce((acc, entry) => {
1831+
const [key, value] = entry.split('=')
1832+
if (key.length === 0) {
1833+
return acc
1834+
}
1835+
1836+
if (acc[key]) {
1837+
if (Array.isArray(acc[key])) {
1838+
acc[key].push(value)
1839+
} else {
1840+
acc[key] = [acc[key], value]
1841+
}
1842+
} else {
1843+
acc[key] = value
1844+
}
1845+
return acc
1846+
}, {})
1847+
1848+
return builtParams
1849+
}

types/dispatcher.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ declare namespace Dispatcher {
4747
body?: string | Buffer | Uint8Array | Readable | null | FormData;
4848
/** Default: `null` */
4949
headers?: IncomingHttpHeaders | string[] | null;
50+
/** Query string params to be embedded in the request URL. Default: `null` */
51+
query?: Record<string, any>;
5052
/** Whether the requests can be safely retried or not. If `false` the request won't be sent until all preceding requests in the pipeline have completed. Default: `true` if `method` is `HEAD` or `GET`. */
5153
idempotent?: boolean;
5254
/** Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`. Default: `method === 'CONNECT' || null`. */

0 commit comments

Comments
 (0)