diff --git a/docs/api/Dispatcher.md b/docs/api/Dispatcher.md index 56b34275209..68ed316a6c6 100644 --- a/docs/api/Dispatcher.md +++ b/docs/api/Dispatcher.md @@ -194,6 +194,7 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo * **method** `string` * **body** `string | Buffer | Uint8Array | stream.Readable | Iterable | AsyncIterable | null` (optional) - Default: `null` * **headers** `UndiciHeaders | string[]` (optional) - Default: `null`. +* **query** `Record | 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. * **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. * **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. * **upgrade** `string | null` (optional) - Default: `null` - Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`. diff --git a/lib/core/request.js b/lib/core/request.js index f04fe4fab21..89a1f3ef442 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -4,8 +4,8 @@ const { InvalidArgumentError, NotSupportedError } = require('./errors') -const util = require('./util') const assert = require('assert') +const util = require('./util') const kHandler = Symbol('handler') @@ -38,6 +38,7 @@ class Request { method, body, headers, + query, idempotent, blocking, upgrade, @@ -97,7 +98,7 @@ class Request { this.upgrade = upgrade || null - this.path = path + this.path = query ? util.buildURL(path, query) : path this.origin = origin diff --git a/lib/core/util.js b/lib/core/util.js index 0cb60b0d2a4..635ef2e15f2 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -26,6 +26,51 @@ function isBlobLike (object) { ) } +function isObject (val) { + return val !== null && typeof val === 'object' +} + +// this escapes all non-uri friendly characters +function encode (val) { + return encodeURIComponent(val) +} + +// based on https://github.com/axios/axios/blob/63e559fa609c40a0a460ae5d5a18c3470ffc6c9e/lib/helpers/buildURL.js (MIT license) +function buildURL (url, queryParams) { + if (url.includes('?') || url.includes('#')) { + throw new Error('Query params cannot be passed when url already contains "?" or "#".') + } + if (!isObject(queryParams)) { + throw new Error('Query params must be an object') + } + + const parts = [] + for (let [key, val] of Object.entries(queryParams)) { + if (val === null || typeof val === 'undefined') { + continue + } + + if (!Array.isArray(val)) { + val = [val] + } + + for (const v of val) { + if (isObject(v)) { + throw new Error('Passing object as a query param is not supported, please serialize to string up-front') + } + parts.push(encode(key) + '=' + encode(v)) + } + } + + const serializedParams = parts.join('&') + + if (serializedParams) { + url += '?' + serializedParams + } + + return url +} + function parseURL (url) { if (typeof url === 'string') { url = new URL(url) @@ -357,5 +402,6 @@ module.exports = { isBuffer, validateHandler, getSocketInfo, - isFormDataLike + isFormDataLike, + buildURL } diff --git a/test/client.js b/test/client.js index 6469ce65165..61b4bf97f7d 100644 --- a/test/client.js +++ b/test/client.js @@ -1,10 +1,10 @@ 'use strict' -const { test } = require('tap') -const { Client, errors } = require('..') -const { createServer } = require('http') const { readFileSync, createReadStream } = require('fs') +const { createServer } = require('http') const { Readable } = require('stream') +const { test } = require('tap') +const { Client, errors } = require('..') const { kSocket } = require('../lib/core/symbols') const { wrapWithAsyncIterable } = require('./utils/async-iterators') const EE = require('events') @@ -80,6 +80,241 @@ test('basic get', (t) => { }) }) +test('basic get with query params', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + const searchParamsObject = buildParams(req.url) + t.strictSame(searchParamsObject, { + bool: 'true', + foo: '1', + bar: 'bar', + '%60~%3A%24%2C%2B%5B%5D%40%5E*()-': '%60~%3A%24%2C%2B%5B%5D%40%5E*()-', + multi: ['1', '2'] + }) + + res.statusCode = 200 + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const query = { + bool: true, + foo: 1, + bar: 'bar', + nullVal: null, + undefinedVal: undefined, + '`~:$,+[]@^*()-': '`~:$,+[]@^*()-', + multi: [1, 2] + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.error(err) + const { statusCode } = data + t.equal(statusCode, 200) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('basic get with query params with object throws an error', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + const query = { + obj: { id: 1 } + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.equal(err.message, 'Passing object as a query param is not supported, please serialize to string up-front') + }) + }) +}) + +test('basic get with non-object query params throws an error', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + const query = '{ obj: { id: 1 } }' + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.equal(err.message, 'Query params must be an object') + }) + }) +}) + +test('basic get with query params with date throws an error', (t) => { + t.plan(1) + + const date = new Date() + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + const query = { + dateObj: date + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.equal(err.message, 'Passing object as a query param is not supported, please serialize to string up-front') + }) + }) +}) + +test('basic get with query params fails if url includes hashmark', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + const query = { + foo: 1, + bar: 'bar', + multi: [1, 2] + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/#', + method: 'GET', + query + }, (err, data) => { + t.equal(err.message, 'Query params cannot be passed when url already contains "?" or "#".') + }) + }) +}) + +test('basic get with empty query params', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + const searchParamsObject = buildParams(req.url) + t.strictSame(searchParamsObject, {}) + + res.statusCode = 200 + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const query = {} + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.error(err) + const { statusCode } = data + t.equal(statusCode, 200) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('basic get with query params partially in path', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + const query = { + foo: 1 + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/?bar=2', + method: 'GET', + query + }, (err, data) => { + t.equal(err.message, 'Query params cannot be passed when url already contains "?" or "#".') + }) + }) +}) + test('basic head', (t) => { t.plan(14) @@ -1589,3 +1824,26 @@ test('async iterator yield object error', (t) => { }) }) }) + +function buildParams (path) { + const cleanPath = path.replace('/?', '').replace('/', '').split('&') + const builtParams = cleanPath.reduce((acc, entry) => { + const [key, value] = entry.split('=') + if (key.length === 0) { + return acc + } + + if (acc[key]) { + if (Array.isArray(acc[key])) { + acc[key].push(value) + } else { + acc[key] = [acc[key], value] + } + } else { + acc[key] = value + } + return acc + }, {}) + + return builtParams +} diff --git a/types/dispatcher.d.ts b/types/dispatcher.d.ts index 9b2af26e6d2..7a48a5a3366 100644 --- a/types/dispatcher.d.ts +++ b/types/dispatcher.d.ts @@ -47,6 +47,8 @@ declare namespace Dispatcher { body?: string | Buffer | Uint8Array | Readable | null | FormData; /** Default: `null` */ headers?: IncomingHttpHeaders | string[] | null; + /** Query string params to be embedded in the request URL. Default: `null` */ + query?: Record; /** 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`. */ idempotent?: boolean; /** Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`. Default: `method === 'CONNECT' || null`. */