Skip to content

Commit

Permalink
feat: Query string params (nodejs#1449)
Browse files Browse the repository at this point in the history
  • Loading branch information
kibertoad authored and KhafraDev committed Jun 23, 2022
1 parent fbafc6b commit 1f8ad2c
Show file tree
Hide file tree
Showing 5 changed files with 314 additions and 6 deletions.
1 change: 1 addition & 0 deletions docs/api/Dispatcher.md
Expand Up @@ -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<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.
* **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'`.
Expand Down
5 changes: 3 additions & 2 deletions lib/core/request.js
Expand Up @@ -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')

Expand Down Expand Up @@ -38,6 +38,7 @@ class Request {
method,
body,
headers,
query,
idempotent,
blocking,
upgrade,
Expand Down Expand Up @@ -97,7 +98,7 @@ class Request {

this.upgrade = upgrade || null

this.path = path
this.path = query ? util.buildURL(path, query) : path

this.origin = origin

Expand Down
48 changes: 47 additions & 1 deletion lib/core/util.js
Expand Up @@ -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)
Expand Down Expand Up @@ -357,5 +402,6 @@ module.exports = {
isBuffer,
validateHandler,
getSocketInfo,
isFormDataLike
isFormDataLike,
buildURL
}
264 changes: 261 additions & 3 deletions 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')
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions types/dispatcher.d.ts
Expand Up @@ -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<string, any>;
/** 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`. */
Expand Down

0 comments on commit 1f8ad2c

Please sign in to comment.