Skip to content

Commit 2e787a8

Browse files
authored
feat: Remove qs dependency (#3659)
1 parent 6c4deab commit 2e787a8

File tree

8 files changed

+7870
-25810
lines changed

8 files changed

+7870
-25810
lines changed

package-lock.json

Lines changed: 7751 additions & 25787 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/feathers/fixtures/client.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ export function clientTests(app: any, name: string) {
2121
it('.get and params passing', async () => {
2222
const query = {
2323
returnquery: 'true',
24-
some: 'thing',
25-
other: ['one', 'two'],
26-
nested: { a: { b: 'object' } }
24+
some: ['thing', '2', 'test']
2725
}
2826

2927
const todo = await getService().get('0', { query })

packages/feathers/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,12 @@
8888
"publishConfig": {
8989
"access": "public"
9090
},
91-
"dependencies": {
92-
"@types/qs": "^6.14.0",
93-
"qs": "^6.14.0"
94-
},
91+
"dependencies": {},
9592
"devDependencies": {
9693
"@types/node": "^24.1.0",
94+
"@types/qs": "^6.14.0",
9795
"@vitest/coverage-v8": "^3.2.4",
96+
"qs": "^6.15.0",
9897
"shx": "^0.4.0",
9998
"typescript": "^5.8.0",
10099
"vitest": "^3.2.4"

packages/feathers/src/client/fetch.test.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeAll, describe, it, expect, vi } from 'vitest'
1+
import { beforeAll, describe, it, expect } from 'vitest'
22
import { feathers } from '../index.js'
33
import { clientTests } from '../../fixtures/client.js'
44
import { NotAcceptable, NotFound, MethodNotAllowed, BadRequest } from '../errors.js'
@@ -54,13 +54,6 @@ describe('fetch REST connector', function () {
5454
await expect(() => service.get('notfound', {})).rejects.toBeInstanceOf(NotFound)
5555
})
5656

57-
it('supports nested arrays in queries', async () => {
58-
const query = { test: { $in: ['0', '1', '2'] }, returnquery: 'true' }
59-
const data = await service.get('dishes', { query })
60-
61-
expect(data.query).toEqual(query)
62-
})
63-
6457
it('can initialize a client instance', async () => {
6558
const init = fetchClient(fetch, {
6659
baseUrl: baseUrl
@@ -245,7 +238,7 @@ describe('FetchClient.handleEventStream', () => {
245238
name: 'test',
246239
baseUrl: 'http://localhost',
247240
connection: fetch,
248-
stringify: (q) => ''
241+
stringify: (_q) => ''
249242
})
250243

251244
const response = createChunkedSSEResponse(chunks)
@@ -267,7 +260,7 @@ describe('FetchClient.handleEventStream', () => {
267260
name: 'test',
268261
baseUrl: 'http://localhost',
269262
connection: fetch,
270-
stringify: (q) => ''
263+
stringify: (_q) => ''
271264
})
272265

273266
const response = createChunkedSSEResponse(chunks)
@@ -291,7 +284,7 @@ describe('FetchClient.handleEventStream', () => {
291284
name: 'test',
292285
baseUrl: 'http://localhost',
293286
connection: fetch,
294-
stringify: (q) => ''
287+
stringify: (_q) => ''
295288
})
296289

297290
const response = createChunkedSSEResponse(chunks)
@@ -330,7 +323,7 @@ describe('FetchClient.handleEventStream', () => {
330323
name: 'test',
331324
baseUrl: 'http://localhost',
332325
connection: fetch,
333-
stringify: (q) => ''
326+
stringify: (_q) => ''
334327
})
335328

336329
const messages: any[] = []

packages/feathers/src/client/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import qs from 'qs'
21
import type { Application, Query } from '../declarations.js'
2+
import { stringify as defaultStringify } from '../query-string.js'
33
import { FetchClient, ProxiedFetchClient } from './fetch.js'
44
import { sseClient, SseClientOptions } from './sse.js'
55
import { defaultServiceEvents } from '../service.js'
@@ -16,7 +16,7 @@ export type ClientOptions = {
1616
}
1717

1818
export function fetchClient(connection: typeof fetch, options: ClientOptions = {}) {
19-
const { stringify = qs.stringify, baseUrl = '', Service = ProxiedFetchClient } = options
19+
const { stringify = defaultStringify, baseUrl = '', Service = ProxiedFetchClient } = options
2020
const events = options.sse ? defaultServiceEvents : undefined
2121
const sseOptions = typeof options.sse === 'string' ? { path: options.sse } : options.sse
2222
const defaultService = function (name: string) {

packages/feathers/src/http/middleware.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Params, Service, Query } from '../index.js'
22
import type { HookContext, NextFunction } from '../hooks/index.js'
33
import { BadRequest, FeathersError } from '../errors.js'
4-
import qs from 'qs'
4+
import { parse } from '../query-string.js'
55

66
interface RouteLookup {
77
service: Service
@@ -60,7 +60,7 @@ export function bodyParser() {
6060
}
6161
}
6262

63-
export function queryParser(parser: (query: string) => Query = qs.parse) {
63+
export function queryParser(parser: (query: string) => Query = parse) {
6464
return async (context: HandlerContext, next: NextFunction) => {
6565
const { request } = context
6666
const url = new URL(request.url)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it } from 'vitest'
2+
import assert from 'assert'
3+
import { stringify, parse } from './query-string.js'
4+
5+
describe('query-string', () => {
6+
describe('stringify', () => {
7+
it('serializes flat string and number values', () => {
8+
assert.strictEqual(stringify({ name: 'Alice', age: 30 }), 'name=Alice&age=30')
9+
})
10+
11+
it('serializes boolean values as strings', () => {
12+
assert.strictEqual(stringify({ active: true, deleted: false }), 'active=true&deleted=false')
13+
})
14+
15+
it('skips null and undefined values', () => {
16+
assert.strictEqual(stringify({ name: 'Alice', removed: null, extra: undefined }), 'name=Alice')
17+
})
18+
19+
it('skips object values', () => {
20+
assert.strictEqual(stringify({ name: 'Alice', age: { $gt: 18 } }), 'name=Alice')
21+
})
22+
23+
it('serializes array values as repeated keys', () => {
24+
assert.strictEqual(stringify({ ids: [1, 2, 3] }), 'ids=1&ids=2&ids=3')
25+
})
26+
27+
it('skips null, undefined and object items in arrays', () => {
28+
assert.strictEqual(stringify({ ids: [1, null, undefined, { $gt: 2 }, 3] }), 'ids=1&ids=3')
29+
})
30+
31+
it('returns empty string for empty query', () => {
32+
assert.strictEqual(stringify({}), '')
33+
})
34+
})
35+
36+
describe('parse', () => {
37+
it('parses flat string values', () => {
38+
assert.deepStrictEqual(parse('name=Alice'), { name: 'Alice' })
39+
})
40+
41+
it('returns values as strings without type coercion', () => {
42+
assert.deepStrictEqual(parse('age=30&active=true&deleted=false'), {
43+
age: '30',
44+
active: 'true',
45+
deleted: 'false'
46+
})
47+
})
48+
49+
it('collects repeated keys into an array', () => {
50+
assert.deepStrictEqual(parse('id=1&id=2&id=3'), { id: ['1', '2', '3'] })
51+
})
52+
53+
it('returns empty object for empty string', () => {
54+
assert.deepStrictEqual(parse(''), {})
55+
})
56+
57+
it('decodes encoded characters', () => {
58+
assert.deepStrictEqual(parse('name=Alice+Smith'), { name: 'Alice Smith' })
59+
})
60+
})
61+
62+
describe('round-trip', () => {
63+
it('round-trips flat values (as strings)', () => {
64+
const query = { name: 'Alice', age: 30, active: true }
65+
assert.deepStrictEqual(parse(stringify(query)), { name: 'Alice', age: '30', active: 'true' })
66+
})
67+
68+
it('round-trips array values', () => {
69+
assert.deepStrictEqual(parse(stringify({ ids: [1, 2, 3] })), { ids: ['1', '2', '3'] })
70+
})
71+
})
72+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Query } from './declarations.js'
2+
3+
const isStringifiable = (value: unknown): value is string | number | boolean =>
4+
value !== undefined && value !== null && typeof value !== 'object'
5+
6+
export function stringify(query: Query): string {
7+
const params = new URLSearchParams()
8+
9+
for (const [key, value] of Object.entries(query)) {
10+
if (Array.isArray(value)) {
11+
for (const item of value) {
12+
if (isStringifiable(item)) {
13+
params.append(key, String(item))
14+
}
15+
}
16+
} else if (isStringifiable(value)) {
17+
params.set(key, String(value))
18+
}
19+
}
20+
21+
return params.toString()
22+
}
23+
24+
export function parse(query: string): Query {
25+
const params = new URLSearchParams(query)
26+
const result: Query = {}
27+
28+
for (const key of params.keys()) {
29+
const values = params.getAll(key)
30+
result[key] = values.length === 1 ? values[0] : values
31+
}
32+
33+
return result
34+
}

0 commit comments

Comments
 (0)