From d55e3040c837f3a19b97815e1c9bc15f45cf15cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:07:05 +0000 Subject: [PATCH] feat!: Introduce @seamapi/url-search-params-parser behind useLegacyQueryParamsParser Add query string parsing with @seamapi/url-search-params-parser (in generous mode) behind a new useLegacyQueryParamsParser option, settable on createWithRouteSpec and per route via the route spec. The legacy hand-rolled coercion remains the default in v4, so upgrading changes no query parsing behavior; set useLegacyQueryParamsParser: false to opt into the new parser. The default will swap in v5. The parser module is loaded lazily, so routes on the legacy default never load it and CommonJS-transformed consumers do not need to resolve the ESM-only dependency. BREAKING CHANGE: The supportedArrayFormats setup option, the QueryArrayFormat and QueryArrayFormats types, and the DEFAULT_ARRAY_FORMATS export are removed. All routes now accept the repeated, bracket, and comma array formats, which was already the default behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015jWzL9LC7Q6bTGaXDoKq4z --- example/lib/middlewares/with-route-spec.ts | 17 +- example/package-lock.json | 3 +- example/pages/api/todo/array-query-comma.ts | 23 -- example/pages/api/todo/array-query-repeat.ts | 23 -- .../api/todo/legacy-query-params-parser.ts | 27 ++ .../todo/new-query-params-parser-per-route.ts | 28 ++ ...brackets.ts => new-query-params-parser.ts} | 10 +- .../api/todo/array-query-brackets.test.ts | 68 ----- .../tests/api/todo/array-query-comma.test.ts | 67 ----- .../tests/api/todo/array-query-repeat.test.ts | 65 ----- .../todo/legacy-query-params-parser.test.ts | 70 +++++ .../new-query-params-parser-per-route.test.ts | 25 ++ .../api/todo/new-query-params-parser.test.ts | 59 +++++ package-lock.json | 15 +- package.json | 1 + src/types/index.ts | 23 +- src/with-route-spec/index.ts | 17 +- .../middlewares/with-validation.ts | 240 +++++++++++------- 18 files changed, 411 insertions(+), 370 deletions(-) delete mode 100644 example/pages/api/todo/array-query-comma.ts delete mode 100644 example/pages/api/todo/array-query-repeat.ts create mode 100644 example/pages/api/todo/legacy-query-params-parser.ts create mode 100644 example/pages/api/todo/new-query-params-parser-per-route.ts rename example/pages/api/todo/{array-query-brackets.ts => new-query-params-parser.ts} (53%) delete mode 100644 example/tests/api/todo/array-query-brackets.test.ts delete mode 100644 example/tests/api/todo/array-query-comma.test.ts delete mode 100644 example/tests/api/todo/array-query-repeat.test.ts create mode 100644 example/tests/api/todo/legacy-query-params-parser.test.ts create mode 100644 example/tests/api/todo/new-query-params-parser-per-route.test.ts create mode 100644 example/tests/api/todo/new-query-params-parser.test.ts diff --git a/example/lib/middlewares/with-route-spec.ts b/example/lib/middlewares/with-route-spec.ts index 85b1a79a8..d30be1844 100644 --- a/example/lib/middlewares/with-route-spec.ts +++ b/example/lib/middlewares/with-route-spec.ts @@ -1,8 +1,4 @@ -import { - createWithRouteSpec, - QueryArrayFormats, - UnauthorizedException, -} from "nextlove" +import { createWithRouteSpec, UnauthorizedException } from "nextlove" import { withAuthToken } from "./with-auth-token" import { withUserSession } from "./with-user-session" import * as ZT from "lib/zod" @@ -39,13 +35,10 @@ export const withRouteSpecWithGlobalMiddlewareAfterAuth = createWithRouteSpec({ ...defaultRouteSpec, } as const) -export const withRouteSpecSupportedArrayFormats = ( - supportedArrayFormats: QueryArrayFormats -) => - createWithRouteSpec({ - ...defaultRouteSpec, - supportedArrayFormats, - }) +export const withRouteSpecNewQueryParamsParser = createWithRouteSpec({ + ...defaultRouteSpec, + useLegacyQueryParamsParser: false, +} as const) export const withRouteSpecWithoutValidateGetRequestBody = createWithRouteSpec({ authMiddlewareMap: { auth_token: withAuthToken }, diff --git a/example/package-lock.json b/example/package-lock.json index ce2a11e4a..d76d4b506 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -50,9 +50,10 @@ } }, "..": { - "version": "3.3.0", + "version": "4.0.0-beta.1", "license": "MIT", "dependencies": { + "@seamapi/url-search-params-parser": "^0.2.1", "@types/js-yaml": "^4.0.9", "chalk": "^5.3.0", "dedent": "^1.5.1", diff --git a/example/pages/api/todo/array-query-comma.ts b/example/pages/api/todo/array-query-comma.ts deleted file mode 100644 index 2cc5fa547..000000000 --- a/example/pages/api/todo/array-query-comma.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { withRouteSpecSupportedArrayFormats } from "lib/middlewares" -import { checkRouteSpec } from "nextlove" -import { z } from "zod" - -export const queryParams = z.object({ - ids: z.array(z.string()), -}) - -export const route_spec = checkRouteSpec({ - methods: ["GET"], - auth: "none", - jsonResponse: z.object({ - ok: z.boolean(), - ids: z.array(z.string()), - }), - queryParams, -}) - -export default withRouteSpecSupportedArrayFormats(["comma"])(route_spec)( - async (req, res) => { - return res.status(200).json({ ok: true, ids: req.query.ids }) - } -) diff --git a/example/pages/api/todo/array-query-repeat.ts b/example/pages/api/todo/array-query-repeat.ts deleted file mode 100644 index d59b4db01..000000000 --- a/example/pages/api/todo/array-query-repeat.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { checkRouteSpec } from "nextlove" -import { withRouteSpecSupportedArrayFormats } from "lib/middlewares" -import { z } from "zod" - -export const queryParams = z.object({ - ids: z.array(z.string()), -}) - -export const route_spec = checkRouteSpec({ - methods: ["GET"], - auth: "none", - jsonResponse: z.object({ - ok: z.boolean(), - ids: z.array(z.string()), - }), - queryParams, -}) - -export default withRouteSpecSupportedArrayFormats(["repeat"])(route_spec)( - async (req, res) => { - return res.status(200).json({ ok: true, ids: req.query.ids }) - } -) diff --git a/example/pages/api/todo/legacy-query-params-parser.ts b/example/pages/api/todo/legacy-query-params-parser.ts new file mode 100644 index 000000000..5118c5c67 --- /dev/null +++ b/example/pages/api/todo/legacy-query-params-parser.ts @@ -0,0 +1,27 @@ +import { withRouteSpec } from "lib/middlewares" +import { checkRouteSpec } from "nextlove" +import { z } from "zod" + +export const queryParams = z.object({ + ids: z.array(z.string()), + flag: z.boolean().optional(), +}) + +// Uses the default factory: the legacy query param parser is the +// default in v4, so no option is set. +export const route_spec = checkRouteSpec({ + methods: ["GET"], + auth: "none", + jsonResponse: z.object({ + ok: z.boolean(), + ids: z.array(z.string()), + flag: z.boolean().optional(), + }), + queryParams, +}) + +export default withRouteSpec(route_spec)(async (req, res) => { + return res + .status(200) + .json({ ok: true, ids: req.query.ids, flag: req.query.flag }) +}) diff --git a/example/pages/api/todo/new-query-params-parser-per-route.ts b/example/pages/api/todo/new-query-params-parser-per-route.ts new file mode 100644 index 000000000..674467b49 --- /dev/null +++ b/example/pages/api/todo/new-query-params-parser-per-route.ts @@ -0,0 +1,28 @@ +import { withRouteSpec } from "lib/middlewares" +import { checkRouteSpec } from "nextlove" +import { z } from "zod" + +export const queryParams = z.object({ + ids: z.array(z.string()), + flag: z.boolean().optional(), +}) + +export const route_spec = checkRouteSpec({ + methods: ["GET"], + auth: "none", + // The route-level option overrides the factory default (legacy in v4), + // so a shared createWithRouteSpec factory can migrate route by route. + useLegacyQueryParamsParser: false, + jsonResponse: z.object({ + ok: z.boolean(), + ids: z.array(z.string()), + flag: z.boolean().optional(), + }), + queryParams, +}) + +export default withRouteSpec(route_spec)(async (req, res) => { + return res + .status(200) + .json({ ok: true, ids: req.query.ids, flag: req.query.flag }) +}) diff --git a/example/pages/api/todo/array-query-brackets.ts b/example/pages/api/todo/new-query-params-parser.ts similarity index 53% rename from example/pages/api/todo/array-query-brackets.ts rename to example/pages/api/todo/new-query-params-parser.ts index a4b8ee8b3..90097bf86 100644 --- a/example/pages/api/todo/array-query-brackets.ts +++ b/example/pages/api/todo/new-query-params-parser.ts @@ -1,9 +1,10 @@ -import { withRouteSpecSupportedArrayFormats } from "lib/middlewares" +import { withRouteSpecNewQueryParamsParser } from "lib/middlewares" import { checkRouteSpec } from "nextlove" import { z } from "zod" export const queryParams = z.object({ ids: z.array(z.string()), + flag: z.boolean().optional(), }) export const route_spec = checkRouteSpec({ @@ -12,12 +13,15 @@ export const route_spec = checkRouteSpec({ jsonResponse: z.object({ ok: z.boolean(), ids: z.array(z.string()), + flag: z.boolean().optional(), }), queryParams, }) -export default withRouteSpecSupportedArrayFormats(["brackets"])(route_spec)( +export default withRouteSpecNewQueryParamsParser(route_spec)( async (req, res) => { - return res.status(200).json({ ok: true, ids: req.query.ids }) + return res + .status(200) + .json({ ok: true, ids: req.query.ids, flag: req.query.flag }) } ) diff --git a/example/tests/api/todo/array-query-brackets.test.ts b/example/tests/api/todo/array-query-brackets.test.ts deleted file mode 100644 index b20d37546..000000000 --- a/example/tests/api/todo/array-query-brackets.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import qs from "qs" -import test from "ava" -import getTestServer from "tests/fixtures/get-test-server" - -test("GET /todo/array-query-brackets (comma-separated array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - response: { error }, - status, - } = await axios - .get("/todo/array-query-brackets", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "comma" }) - }, - }) - .catch((r) => r) - - t.is(status, 400) - // Zod 4 prefixes with "Invalid input: " so we check for the core message - t.true(error.message.includes('expected array, received string for "ids"')) -}) - -test("GET /todo/array-query-brackets (bracket array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - data: { ids }, - status, - } = await axios.get("/todo/array-query-brackets", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "brackets" }) - }, - }) - - t.is(status, 200) - t.deepEqual(ids, ["1", "2", "3"]) -}) - -test("GET /todo/array-query-brackets (repeated array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - response: { error }, - status, - } = await axios - .get("/todo/array-query-brackets", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "repeat" }) - }, - }) - .catch((r) => r) - - t.is(status, 400) - t.is( - error.message, - `Repeated parameters not supported for duplicate query param "ids"` - ) -}) diff --git a/example/tests/api/todo/array-query-comma.test.ts b/example/tests/api/todo/array-query-comma.test.ts deleted file mode 100644 index 97e66ab9b..000000000 --- a/example/tests/api/todo/array-query-comma.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import qs from "qs" -import test from "ava" -import getTestServer from "tests/fixtures/get-test-server" - -test("GET /todo/array-query-comma (comma-separated array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - data: { ids }, - status, - } = await axios.get("/todo/array-query-comma", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "comma" }) - }, - }) - - t.is(status, 200) - t.deepEqual(ids, ["1", "2", "3"]) -}) - -test("GET /todo/array-query-comma (bracket array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - response: { error }, - status, - } = await axios - .get("/todo/array-query-comma", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "brackets" }) - }, - }) - .catch((r) => r) - - t.is(status, 400) - t.is(error.message, `Bracket syntax not supported for query param "ids"`) -}) - -test("GET /todo/array-query-comma (repeated array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - response: { error }, - status, - } = await axios - .get("/todo/array-query-comma", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "repeat" }) - }, - }) - .catch((r) => r) - - t.is(status, 400) - t.is( - error.message, - `Repeated parameters not supported for duplicate query param "ids"` - ) -}) diff --git a/example/tests/api/todo/array-query-repeat.test.ts b/example/tests/api/todo/array-query-repeat.test.ts deleted file mode 100644 index 163e1708e..000000000 --- a/example/tests/api/todo/array-query-repeat.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import qs from "qs" -import test from "ava" -import getTestServer from "tests/fixtures/get-test-server" - -test("GET /todo/array-query-repeat (comma-separated array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - response: { error }, - status, - } = await axios - .get("/todo/array-query-repeat", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "comma" }) - }, - }) - .catch((r) => r) - - t.is(status, 400) - // Zod 4 prefixes with "Invalid input: " so we check for the core message - t.true(error.message.includes('expected array, received string for "ids"')) -}) - -test("GET /todo/array-query-repeat (bracket array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - response: { error }, - status, - } = await axios - .get("/todo/array-query-repeat", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "brackets" }) - }, - }) - .catch((r) => r) - - t.is(status, 400) - t.is(error.message, `Bracket syntax not supported for query param "ids"`) -}) - -test("GET /todo/array-query-repeat (repeated array values)", async (t) => { - const { axios } = await getTestServer(t) - - const { - data: { ids }, - status, - } = await axios.get("/todo/array-query-repeat", { - params: { - ids: ["1", "2", "3"], - }, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: "repeat" }) - }, - }) - - t.is(status, 200) - t.deepEqual(ids, ["1", "2", "3"]) -}) diff --git a/example/tests/api/todo/legacy-query-params-parser.test.ts b/example/tests/api/todo/legacy-query-params-parser.test.ts new file mode 100644 index 000000000..d74727416 --- /dev/null +++ b/example/tests/api/todo/legacy-query-params-parser.test.ts @@ -0,0 +1,70 @@ +import test from "ava" +import getTestServer from "tests/fixtures/get-test-server" + +// The legacy query param parser is the default in v4: +// these tests cover its behavior on a route with no option set. + +test("GET /todo/legacy-query-params-parser (comma array format)", async (t) => { + const { axios } = await getTestServer(t) + + const { + data: { ids }, + status, + } = await axios.get("/todo/legacy-query-params-parser?ids=1,2,3") + + t.is(status, 200) + t.deepEqual(ids, ["1", "2", "3"]) +}) + +test("GET /todo/legacy-query-params-parser (bracket array format)", async (t) => { + const { axios } = await getTestServer(t) + + const { + data: { ids }, + status, + } = await axios.get("/todo/legacy-query-params-parser?ids[]=1&ids[]=2") + + t.is(status, 200) + t.deepEqual(ids, ["1", "2"]) +}) + +test("GET /todo/legacy-query-params-parser (repeated array format)", async (t) => { + const { axios } = await getTestServer(t) + + const { + data: { ids }, + status, + } = await axios.get("/todo/legacy-query-params-parser?ids=1&ids=2") + + t.is(status, 200) + t.deepEqual(ids, ["1", "2"]) +}) + +test("GET /todo/legacy-query-params-parser (legacy boolean coercion)", async (t) => { + const { axios } = await getTestServer(t) + + const trueRes = await axios.get( + "/todo/legacy-query-params-parser?ids=1&flag=true" + ) + t.is(trueRes.status, 200) + t.is(trueRes.data.flag, true) + + // The legacy parser coerces any boolean string other than "true" to false. + const junkRes = await axios.get( + "/todo/legacy-query-params-parser?ids=1&flag=yolo" + ) + t.is(junkRes.status, 200) + t.is(junkRes.data.flag, false) +}) + +test("GET /todo/legacy-query-params-parser (array values keep commas when repeated)", async (t) => { + const { axios } = await getTestServer(t) + + const { + data: { ids }, + status, + } = await axios.get("/todo/legacy-query-params-parser?ids=a,b&ids=c,d") + + t.is(status, 200) + t.deepEqual(ids, ["a,b", "c,d"]) +}) diff --git a/example/tests/api/todo/new-query-params-parser-per-route.test.ts b/example/tests/api/todo/new-query-params-parser-per-route.test.ts new file mode 100644 index 000000000..0fde972e4 --- /dev/null +++ b/example/tests/api/todo/new-query-params-parser-per-route.test.ts @@ -0,0 +1,25 @@ +import test from "ava" +import getTestServer from "tests/fixtures/get-test-server" + +test("GET /todo/new-query-params-parser-per-route (route-level override)", async (t) => { + const { axios } = await getTestServer(t) + + // Mixed array formats are rejected, proving the new parser is active + // for this route even though the factory default is the legacy parser. + const { + response: { error }, + status, + } = await axios + .get("/todo/new-query-params-parser-per-route?ids=1&ids[]=2") + .catch((r) => r) + + t.is(status, 400) + t.is(error.type, "invalid_query_params") + + const okRes = await axios.get( + "/todo/new-query-params-parser-per-route?ids=1,2&flag=yes" + ) + t.is(okRes.status, 200) + t.deepEqual(okRes.data.ids, ["1", "2"]) + t.is(okRes.data.flag, true) +}) diff --git a/example/tests/api/todo/new-query-params-parser.test.ts b/example/tests/api/todo/new-query-params-parser.test.ts new file mode 100644 index 000000000..5a2b2ad3a --- /dev/null +++ b/example/tests/api/todo/new-query-params-parser.test.ts @@ -0,0 +1,59 @@ +import test from "ava" +import getTestServer from "tests/fixtures/get-test-server" + +// Covers @seamapi/url-search-params-parser, opted into with +// useLegacyQueryParamsParser: false on the createWithRouteSpec factory. + +test("GET /todo/new-query-params-parser (all array formats)", async (t) => { + const { axios } = await getTestServer(t) + + for (const query of ["ids=1,2", "ids[]=1&ids[]=2", "ids=1&ids=2"]) { + const { + data: { ids }, + status, + } = await axios.get(`/todo/new-query-params-parser?${query}`) + + t.is(status, 200) + t.deepEqual(ids, ["1", "2"], query) + } +}) + +test("GET /todo/new-query-params-parser (mixed array formats are rejected)", async (t) => { + const { axios } = await getTestServer(t) + + const { + response: { error }, + status, + } = await axios + .get("/todo/new-query-params-parser?ids=1&ids[]=2") + .catch((r) => r) + + t.is(status, 400) + t.is(error.type, "invalid_query_params") +}) + +test("GET /todo/new-query-params-parser (junk boolean values are rejected)", async (t) => { + const { axios } = await getTestServer(t) + + const { + response: { error }, + status, + } = await axios + .get("/todo/new-query-params-parser?ids=1&flag=yolo") + .catch((r) => r) + + t.is(status, 400) + t.is(error.type, "invalid_input") +}) + +test("GET /todo/new-query-params-parser (generous booleans)", async (t) => { + const { axios } = await getTestServer(t) + + const { + data: { flag }, + status, + } = await axios.get("/todo/new-query-params-parser?ids=1&flag=yes") + + t.is(status, 200) + t.is(flag, true) +}) diff --git a/package-lock.json b/package-lock.json index 1e14cd02f..e4fc2ec68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.0.0-beta.1", "license": "MIT", "dependencies": { + "@seamapi/url-search-params-parser": "^0.2.1", "@types/js-yaml": "^4.0.9", "chalk": "^5.3.0", "dedent": "^1.5.1", @@ -2514,6 +2515,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@seamapi/url-search-params-parser": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-parser/-/url-search-params-parser-0.2.1.tgz", + "integrity": "sha512-CniJGmns0tgOmhaKnn+1QIDfIgz1wsBqdexdBAzuBDc+e/cRa8lC7l4K+Rs4zfjZ0tK84ZR374hQiZZ4GuwhUw==", + "license": "MIT", + "engines": { + "node": ">=22.11.0", + "npm": ">=10.0.0" + }, + "peerDependencies": { + "zod": "^3.0.0 || ^4.0.0" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -17130,7 +17144,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 3ed18bbbb..5b1f853af 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "zod": "^3.0.0 || ^4.0.0" }, "dependencies": { + "@seamapi/url-search-params-parser": "^0.2.1", "@types/js-yaml": "^4.0.9", "chalk": "^5.3.0", "dedent": "^1.5.1", diff --git a/src/types/index.ts b/src/types/index.ts index 979f6010d..34be62b19 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,6 +45,16 @@ export interface RouteSpec< onMultipleAuthMiddlewareFailures?: (errors: unknown[]) => void + /** + * Whether this route uses the legacy hand-rolled query param + * coercion (true, the v4 default) or + * @seamapi/url-search-params-parser (false). + * Overrides the createWithRouteSpec setup option if set. + * The default will swap to the new parser in v5, and this option + * will be removed in a later release. + */ + useLegacyQueryParamsParser?: boolean + /** * Route-specific maxDuration (in seconds). * Overrides the global default if set. @@ -70,10 +80,6 @@ export type AuthMiddlewares = { [auth_type: string]: Middleware } -export type QueryArrayFormat = "brackets" | "comma" | "repeat" - -export type QueryArrayFormats = readonly QueryArrayFormat[] - export interface SetupParams< AuthMW extends AuthMiddlewares = AuthMiddlewares, GlobalMW extends Middleware[] = any[], @@ -97,7 +103,14 @@ export interface SetupParams< securitySchemas?: Record globalSchemas?: Record - supportedArrayFormats?: QueryArrayFormats + /** + * Whether routes use the legacy hand-rolled query param coercion + * (true, the v4 default) or @seamapi/url-search-params-parser + * (false). Can be overridden per route via the route spec. + * The default will swap to the new parser in v5, and this option + * will be removed in a later release. + */ + useLegacyQueryParamsParser?: boolean /** * If an endpoint accepts multiple auth methods and they all fail, this hook will be called with the errors thrown by the middlewares. diff --git a/src/with-route-spec/index.ts b/src/with-route-spec/index.ts index 3dccffeb3..f1612fa9f 100644 --- a/src/with-route-spec/index.ts +++ b/src/with-route-spec/index.ts @@ -1,11 +1,7 @@ import { NextApiResponse, NextApiRequest } from "next" import { withExceptionHandling } from "../nextjs-exception-middleware" import wrappers, { Middleware } from "../wrappers" -import { - CreateWithRouteSpecFunction, - QueryArrayFormats, - RouteSpec, -} from "../types" +import { CreateWithRouteSpecFunction, RouteSpec } from "../types" import withMethods, { HTTPMethods } from "./middlewares/with-methods" import withValidation from "./middlewares/with-validation" import { z } from "zod" @@ -51,12 +47,6 @@ export const checkRouteSpec = < ? `your route spec is underspecified, add "as const"` : Spec => spec as any -export const DEFAULT_ARRAY_FORMATS: QueryArrayFormats = [ - "brackets", - "comma", - "repeat", -] - export const createWithRouteSpec: CreateWithRouteSpecFunction = (( setupParams ) => { @@ -83,7 +73,7 @@ export const createWithRouteSpec: CreateWithRouteSpecFunction = (( ok: z.boolean(), } : {}, - supportedArrayFormats = DEFAULT_ARRAY_FORMATS, + useLegacyQueryParamsParser = true, onMultipleAuthMiddlewareFailures, maxDuration: globalMaxDuration, } = setupParams @@ -153,7 +143,8 @@ export const createWithRouteSpec: CreateWithRouteSpecFunction = (( jsonResponse: spec.jsonResponse, shouldValidateResponses, shouldValidateGetRequestBody, - supportedArrayFormats, + useLegacyQueryParamsParser: + spec.useLegacyQueryParamsParser ?? useLegacyQueryParamsParser, }), userDefinedRouteFn )(req as any, res) diff --git a/src/with-route-spec/middlewares/with-validation.ts b/src/with-route-spec/middlewares/with-validation.ts index 0b15e14e0..11a20d1fc 100644 --- a/src/with-route-spec/middlewares/with-validation.ts +++ b/src/with-route-spec/middlewares/with-validation.ts @@ -6,14 +6,90 @@ import { BadRequestException, InternalServerErrorException, } from "../../nextjs-exception-middleware" -import { QueryArrayFormats } from "../../types" -import { DEFAULT_ARRAY_FORMATS } from ".." import { getTypeName, getEffectsSchema, getInnerType, } from "../../lib/zod-compat" +type UrlSearchParamsParserModule = + typeof import("@seamapi/url-search-params-parser") + +let parserModulePromise: Promise | undefined + +/** + * The parser is loaded lazily so it is only required when a route opts + * into it with useLegacyQueryParamsParser: false, and so consumers + * loading nextlove through a CommonJS transform do not need to resolve + * this ESM-only dependency. + */ +const importParserModule = async (): Promise => + (parserModulePromise ??= import("@seamapi/url-search-params-parser")) + +/** + * Parses the request's search params with @seamapi/url-search-params-parser, + * then merges in params the search param parser has no knowledge of: + * Next.js dynamic route params (present in req.query but not in the query + * string) and search params not present in the schema (so strict and + * passthrough object schemas keep rejecting or forwarding unknown params). + * + * The returned object is not validated: pass it to schema.parse. + */ +const parseQueryParams = async ( + schema: z.ZodTypeAny, + searchParams: URLSearchParams, + routeQuery: Record +): Promise> => { + const { + parseUrlSearchParams, + UnparseableSchemaError, + UnparseableSearchParamError, + } = await importParserModule() + + let parsed: Record + try { + parsed = parseUrlSearchParams(searchParams, schema, { strict: false }) + } catch (error: unknown) { + if (error instanceof UnparseableSearchParamError) { + throw new BadRequestException({ + type: "invalid_query_params", + message: error.message, + }) + } + if (error instanceof UnparseableSchemaError) { + throw new InternalServerErrorException({ + type: "unparseable_schema", + message: error.message, + }) + } + throw error + } + + // The parser returns every schema key, absent params as undefined. + const schema_keys = new Set(Object.keys(parsed)) + for (const key of schema_keys) { + if (parsed[key] === undefined) delete parsed[key] + } + + for (const [key, value] of Object.entries(routeQuery)) { + if (schema_keys.has(key)) { + if (!searchParams.has(key) && !searchParams.has(`${key}[]`)) { + // A dynamic route param matching a schema key. + parsed[key] = value + } + continue + } + // Alternate forms of a schema key already handled by the parser. + if (key.endsWith("[]") && schema_keys.has(key.slice(0, -2))) continue + const [root_key] = key.split(".") + if (root_key !== key && schema_keys.has(root_key)) continue + + parsed[key] = value + } + + return parsed +} + const getZodObjectSchemaFromZodEffectSchema = ( isZodEffect: boolean, schema: z.ZodTypeAny @@ -79,11 +155,21 @@ const isZodSchemaBoolean = (schema: z.ZodTypeAny) => { return getTypeName(unwrapped) === "ZodBoolean" } -const parseQueryParams = ( +/** + * The legacy hand-rolled query param coercion, the default in v4 to + * allow a gradual migration to @seamapi/url-search-params-parser + * (set useLegacyQueryParamsParser: false to opt in; the default will + * swap in v5). It coerces array params (repeated, bracket, and comma + * formats) and boolean params ("true" is true, any other string is + * false) keyed on the schema shape, and leaves all other values + * untouched. + * + * The returned object is not validated: pass it to schema.parse. + */ +const legacyParseQueryParams = ( schema: z.ZodTypeAny, - input: Record, - supportedArrayFormats: QueryArrayFormats -) => { + input: Record +): Record => { const parsed_input = Object.assign({}, input) const obj_schema = tryGetZodSchemaAsObject(schema) @@ -96,27 +182,17 @@ const parseQueryParams = ( parsed_input[key] = [] } - if ( - typeof array_input === "string" && - array_input.length > 0 && - supportedArrayFormats.includes("comma") - ) { + if (typeof array_input === "string" && array_input.length > 0) { parsed_input[key] = array_input.split(",") } const bracket_syntax_array_input = input[`${key}[]`] - if ( - typeof bracket_syntax_array_input === "string" && - supportedArrayFormats.includes("brackets") - ) { + if (typeof bracket_syntax_array_input === "string") { const pre_split_array = bracket_syntax_array_input parsed_input[key] = pre_split_array.split(",") } - if ( - Array.isArray(bracket_syntax_array_input) && - supportedArrayFormats.includes("brackets") - ) { + if (Array.isArray(bracket_syntax_array_input)) { parsed_input[key] = bracket_syntax_array_input } @@ -133,53 +209,7 @@ const parseQueryParams = ( } } - return schema.parse(parsed_input) -} - -const validateQueryParams = ( - inputUrl: string, - schema: z.ZodTypeAny, - supportedArrayFormats: QueryArrayFormats -) => { - const url = new URL(inputUrl, "http://dummy.com") - - const seenKeys = new Set() - - const obj_schema = tryGetZodSchemaAsObject(schema) - if (!obj_schema) { - return - } - - for (const key of url.searchParams.keys()) { - for (const [schemaKey, value] of Object.entries(obj_schema.shape)) { - if (isZodSchemaArray(value as z.ZodTypeAny)) { - if ( - key === `${schemaKey}[]` && - !supportedArrayFormats.includes("brackets") - ) { - throw new BadRequestException({ - type: "invalid_query_params", - message: `Bracket syntax not supported for query param "${schemaKey}"`, - }) - } - } - } - - const key_schema = obj_schema.shape[key] - - if (key_schema) { - if (isZodSchemaArray(key_schema)) { - if (seenKeys.has(key) && !supportedArrayFormats.includes("repeat")) { - throw new BadRequestException({ - type: "invalid_query_params", - message: `Repeated parameters not supported for duplicate query param "${key}"`, - }) - } - } - } - - seenKeys.add(key) - } + return parsed_input } export interface RequestInput< @@ -196,7 +226,7 @@ export interface RequestInput< jsonResponse?: JsonResponse shouldValidateResponses?: boolean shouldValidateGetRequestBody?: boolean - supportedArrayFormats?: QueryArrayFormats + useLegacyQueryParamsParser?: boolean } const zodIssueToString = (issue: z.ZodIssue) => { @@ -253,8 +283,6 @@ export const withValidation = ) => (next) => async (req: NextApiRequest, res: NextApiResponse) => { - const { supportedArrayFormats = DEFAULT_ARRAY_FORMATS } = input - if ( (input.formData && input.jsonBody) || (input.formData && input.commonParams) @@ -289,7 +317,8 @@ export const withValidation = } try { - const original_combined_params = { ...req.query, ...req.body } + const original_body = req.body + const original_query = req.query const willValidateRequestBody = input.shouldValidateGetRequestBody ? true @@ -305,29 +334,62 @@ export const withValidation = req.body = input.jsonBody?.parse(req.body) } - if (input.queryParams) { - if (!req.url) { - throw new Error("req.url is undefined") + // The legacy parser is the default in v4. + // The default will swap to @seamapi/url-search-params-parser in v5. + if (input.useLegacyQueryParamsParser ?? true) { + if (input.queryParams) { + req.query = input.queryParams.parse( + legacyParseQueryParams(input.queryParams, original_query) + ) as typeof req.query } - validateQueryParams(req.url, input.queryParams, supportedArrayFormats) + if (input.commonParams) { + /** + * as commonParams includes query params, we can use the + * legacyParseQueryParams function + */ + ;(req as any).commonParams = input.commonParams.parse( + legacyParseQueryParams(input.commonParams, { + ...original_query, + ...original_body, + }) + ) + } + } else { + if (input.queryParams || input.commonParams) { + if (!req.url) { + throw new Error("req.url is undefined") + } + } - req.query = parseQueryParams( - input.queryParams, - req.query, - supportedArrayFormats - ) as typeof req.query - } + const searchParams = req.url + ? new URL(req.url, "http://dummy.com").searchParams + : new URLSearchParams() + + if (input.queryParams) { + req.query = input.queryParams.parse( + await parseQueryParams( + input.queryParams, + searchParams, + original_query + ) + ) as typeof req.query + } - if (input.commonParams) { - /** - * as commonParams includes query params, we can use the parseQueryParams function - */ - ;(req as any).commonParams = parseQueryParams( - input.commonParams, - original_combined_params, - supportedArrayFormats - ) + if (input.commonParams) { + /** + * commonParams includes query params and body params: + * parse the query portion, then let body params take precedence. + */ + ;(req as any).commonParams = input.commonParams.parse({ + ...(await parseQueryParams( + input.commonParams, + searchParams, + original_query + )), + ...original_body, + }) + } } } catch (error: any) { if (error instanceof BadRequestException) {