diff --git a/ava.config.js b/ava.config.js index 362794f12..c84b05118 100644 --- a/ava.config.js +++ b/ava.config.js @@ -1,6 +1,8 @@ export default { files: ["tests/**/*.test.ts"], - extensions: ["ts"], + // This package is ESM only, so tests must load as ESM to resolve + // its ESM only dependencies. + extensions: { ts: "module" }, nodeArguments: ["--import=tsx"], workerThreads: false, watchMode: { diff --git a/example/lib/middlewares/with-route-spec.ts b/example/lib/middlewares/with-route-spec.ts index ad831d452..817af3703 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 "../zod" @@ -21,6 +17,7 @@ const defaultRouteSpec = { todo: ZT.todo, ok: ZT.ok, }, + useLegacyQueryParamsParser: false, maxDuration: 60, // Default maxDuration of 60 seconds for all routes onMultipleAuthMiddlewareFailures: (errors: unknown[]) => { throw new UnauthorizedException({ @@ -39,14 +36,6 @@ export const withRouteSpecWithGlobalMiddlewareAfterAuth = createWithRouteSpec({ ...defaultRouteSpec, } as const) -export const withRouteSpecSupportedArrayFormats = ( - supportedArrayFormats: QueryArrayFormats -) => - createWithRouteSpec({ - ...defaultRouteSpec, - supportedArrayFormats, - }) - export const withRouteSpecWithoutValidateGetRequestBody = createWithRouteSpec({ authMiddlewareMap: { auth_token: withAuthToken }, globalMiddlewares: [], diff --git a/example/package-lock.json b/example/package-lock.json index 3171ab7e7..f9f71f7c1 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -35,9 +35,10 @@ } }, "..": { - "version": "4.0.3", + "version": "4.0.4", "license": "MIT", "dependencies": { + "@seamapi/url-search-params-parser": "^0.2.2", "@types/js-yaml": "^4.0.9", "chalk": "^5.3.0", "dedent": "^1.5.1", diff --git a/example/pages/api/todo/array-query-brackets.ts b/example/pages/api/todo/array-query-brackets.ts deleted file mode 100644 index 1b06ae6a2..000000000 --- a/example/pages/api/todo/array-query-brackets.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(["brackets"])(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-comma.ts b/example/pages/api/todo/array-query-comma.ts deleted file mode 100644 index 78b54b4a0..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 c25c5a981..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/tests/api/todo/array-query-brackets.test.ts b/example/tests/api/todo/array-query-brackets.test.ts deleted file mode 100644 index 21d8e9827..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 "../../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 9efe5c3ad..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 "../../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 3f2b4afc6..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 "../../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/package-lock.json b/package-lock.json index 8b5733782..a13392170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.0.4", "license": "MIT", "dependencies": { + "@seamapi/url-search-params-parser": "^0.2.2", "@types/js-yaml": "^4.0.9", "chalk": "^5.3.0", "dedent": "^1.5.1", @@ -2142,6 +2143,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@seamapi/url-search-params-parser": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-parser/-/url-search-params-parser-0.2.2.tgz", + "integrity": "sha512-pbghTbqJwdtqwavX8XC0lRwj7oKuxth+XEQsbPjuuRwvmOiJ7+Yu+XkK69b6p3HhgOrljDFmnZ58l1v2ZTlAAw==", + "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", @@ -15471,7 +15485,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 5fe194175..d71dde3e2 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.2", "@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 e701846bd..9f5bfa346 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,6 +45,14 @@ export interface RouteSpec< onMultipleAuthMiddlewareFailures?: (errors: unknown[]) => void + /** + * Whether this route parses query params with the legacy parser + * (true, the default) or with @seamapi/url-search-params-parser (false). + * Overrides the setup param if set. + * The default will change to false in the next major version. + */ + useLegacyQueryParamsParser?: boolean + /** * Route-specific maxDuration (in seconds). * Overrides the global default if set. @@ -97,8 +105,19 @@ export interface SetupParams< securitySchemas?: Record globalSchemas?: Record + /** + * Only applies when using the legacy query params parser. + */ supportedArrayFormats?: QueryArrayFormats + /** + * Whether routes parse query params with the legacy parser + * (true, the default) or with @seamapi/url-search-params-parser (false). + * May be overridden per route in the route spec. + * The default will change to false in the next major version. + */ + 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. * You can inspect the errors and throw a more generic error in this hook if you want. diff --git a/src/with-route-spec/index.ts b/src/with-route-spec/index.ts index e28947cc0..58cf3ef4c 100644 --- a/src/with-route-spec/index.ts +++ b/src/with-route-spec/index.ts @@ -84,6 +84,7 @@ export const createWithRouteSpec: CreateWithRouteSpecFunction = (( } : {}, supportedArrayFormats = DEFAULT_ARRAY_FORMATS, + useLegacyQueryParamsParser = true, onMultipleAuthMiddlewareFailures, maxDuration: globalMaxDuration, } = setupParams @@ -154,6 +155,8 @@ export const createWithRouteSpec: CreateWithRouteSpecFunction = (( shouldValidateResponses, shouldValidateGetRequestBody, supportedArrayFormats, + useLegacyQueryParamsParser: + spec.useLegacyQueryParamsParser ?? useLegacyQueryParamsParser, }), userDefinedRouteFn )(req as any, res) diff --git a/src/with-route-spec/middlewares/parse-query-params-from-url.ts b/src/with-route-spec/middlewares/parse-query-params-from-url.ts new file mode 100644 index 000000000..f8762ca2f --- /dev/null +++ b/src/with-route-spec/middlewares/parse-query-params-from-url.ts @@ -0,0 +1,65 @@ +import { + parseUrlSearchParams, + UnparseableSchemaError, + UnparseableSearchParamError, +} from "@seamapi/url-search-params-parser" +import { z } from "zod" + +import { + BadRequestException, + InternalServerErrorException, +} from "../../nextjs-exception-middleware/index.js" + +/** + * Parses the query string with @seamapi/url-search-params-parser, then + * merges in params the parser has no knowledge of: Next.js dynamic route + * params, which are in req.query but not in the query string, and 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. + */ +export const parseQueryParamsFromUrl = ( + schema: z.ZodTypeAny, + inputUrl: string, + routeQuery: Record +): Record => { + const { searchParams } = new URL(inputUrl, "https://example.com") + + 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 only the params it found in the query string. + const parsed_keys = new Set(Object.keys(parsed)) + + for (const [key, value] of Object.entries(routeQuery)) { + if (parsed_keys.has(key)) continue + + // Alternate forms of a param the parser already handled. + if (key.endsWith("[]") && parsed_keys.has(key.slice(0, -2))) continue + const [root_key] = key.split(".") + if (root_key !== key && parsed_keys.has(root_key)) continue + + parsed[key] = value + } + + return parsed +} diff --git a/src/with-route-spec/middlewares/with-validation.ts b/src/with-route-spec/middlewares/with-validation.ts index e4a9ae864..91fefaf9d 100644 --- a/src/with-route-spec/middlewares/with-validation.ts +++ b/src/with-route-spec/middlewares/with-validation.ts @@ -8,6 +8,7 @@ import { } from "../../nextjs-exception-middleware/index.js" import { QueryArrayFormats } from "../../types/index.js" import { DEFAULT_ARRAY_FORMATS } from "../index.js" +import { parseQueryParamsFromUrl } from "./parse-query-params-from-url.js" import { getTypeName, getEffectsSchema, @@ -197,6 +198,7 @@ export interface RequestInput< shouldValidateResponses?: boolean shouldValidateGetRequestBody?: boolean supportedArrayFormats?: QueryArrayFormats + useLegacyQueryParamsParser?: boolean } const zodIssueToString = (issue: z.ZodIssue) => { @@ -253,7 +255,10 @@ export const withValidation = ) => (next) => async (req: NextApiRequest, res: NextApiResponse) => { - const { supportedArrayFormats = DEFAULT_ARRAY_FORMATS } = input + const { + supportedArrayFormats = DEFAULT_ARRAY_FORMATS, + useLegacyQueryParamsParser = true, + } = input if ( (input.formData && input.jsonBody) || @@ -290,6 +295,8 @@ export const withValidation = try { const original_combined_params = { ...req.query, ...req.body } + const original_query = req.query + const original_body = req.body const willValidateRequestBody = input.shouldValidateGetRequestBody ? true @@ -305,7 +312,7 @@ export const withValidation = req.body = input.jsonBody?.parse(req.body) } - if (input.queryParams) { + if (input.queryParams && useLegacyQueryParamsParser) { if (!req.url) { throw new Error("req.url is undefined") } @@ -319,7 +326,7 @@ export const withValidation = ) as typeof req.query } - if (input.commonParams) { + if (input.commonParams && useLegacyQueryParamsParser) { /** * as commonParams includes query params, we can use the parseQueryParams function */ @@ -329,6 +336,37 @@ export const withValidation = supportedArrayFormats ) } + + if ( + !useLegacyQueryParamsParser && + (input.queryParams || input.commonParams) + ) { + if (!req.url) { + throw new Error("req.url is undefined") + } + + if (input.queryParams) { + req.query = input.queryParams.parse( + parseQueryParamsFromUrl(input.queryParams, req.url, original_query) + ) as typeof req.query + } + + if (input.commonParams) { + /** + * as commonParams includes query params, we can use the + * parseQueryParamsFromUrl function, with body params taking + * precedence over query params as they do above + */ + ;(req as any).commonParams = input.commonParams.parse({ + ...parseQueryParamsFromUrl( + input.commonParams, + req.url, + original_query + ), + ...original_body, + }) + } + } } catch (error: any) { if (error instanceof BadRequestException) { throw error diff --git a/tests/with-route-spec/basic.test.ts b/tests/with-route-spec/basic.test.ts index 4abce4a61..4e10f9436 100644 --- a/tests/with-route-spec/basic.test.ts +++ b/tests/with-route-spec/basic.test.ts @@ -1,4 +1,5 @@ import test from "ava" +import { z } from "zod" import { createWithRouteSpec } from "../../src/with-route-spec/index.js" test("route-level onMultipleAuthMiddlewareFailures spec takes precedent", async (t) => { @@ -37,3 +38,119 @@ test("route-level onMultipleAuthMiddlewareFailures spec takes precedent", async t.false(globalWasCalled) t.true(routeWasCalled) }) + +// Next.js parses repeated query params into an array. +const toNextQuery = (searchParams: URLSearchParams) => { + const query: Record = {} + for (const [key, value] of searchParams) { + const existing = query[key] + if (existing === undefined) { + query[key] = value + } else if (Array.isArray(existing)) { + existing.push(value) + } else { + query[key] = [existing, value] + } + } + return query +} + +const getQueryParams = async ( + routeSpec: { + queryParams: z.ZodTypeAny + useLegacyQueryParamsParser?: boolean + }, + url: string +) => { + const withRouteSpec = createWithRouteSpec({ + apiName: "test", + productionServerUrl: "https://seam.com", + globalMiddlewares: [], + authMiddlewareMap: {}, + }) + const route = withRouteSpec({ + methods: ["GET"], + auth: "none", + ...routeSpec, + } as any) + + let query: any + const req = { + method: "GET", + url, + query: toNextQuery(new URL(url, "https://example.com").searchParams), + headers: {}, + } + await route(async (req: any) => void (query = req.query))( + req as any, + { + status() { + return { json() {} } + }, + } as any + ) + + return query +} + +const idsQueryParams = z.object({ ids: z.array(z.string()) }) +const flagQueryParams = z.object({ flag: z.boolean() }) + +test("query params are parsed with the legacy parser by default", async (t) => { + // The legacy parser coerces any boolean string other than "true" to false. + t.deepEqual( + await getQueryParams( + { queryParams: flagQueryParams }, + "/api/test?flag=yolo" + ), + { flag: false } + ) + + for (const query of ["ids=a,b", "ids[]=a&ids[]=b", "ids=a&ids=b"]) { + t.deepEqual( + await getQueryParams( + { queryParams: idsQueryParams }, + `/api/test?${query}` + ), + { ids: ["a", "b"] }, + query + ) + } +}) + +test("route-level useLegacyQueryParamsParser spec takes precedent", async (t) => { + t.deepEqual( + await getQueryParams( + { queryParams: flagQueryParams, useLegacyQueryParamsParser: false }, + "/api/test?flag=yes" + ), + { flag: true }, + "parses generous booleans with @seamapi/url-search-params-parser" + ) + + for (const query of ["ids=a,b", "ids[]=a&ids[]=b", "ids=a&ids=b"]) { + t.deepEqual( + await getQueryParams( + { queryParams: idsQueryParams, useLegacyQueryParamsParser: false }, + `/api/test?${query}` + ), + { ids: ["a", "b"] }, + query + ) + } +}) + +test("optional query params that are not sent are omitted", async (t) => { + const query = await getQueryParams( + { + queryParams: z.object({ + ids: z.array(z.string()).optional(), + flag: z.boolean().optional(), + }), + useLegacyQueryParamsParser: false, + }, + "/api/test?ids=a" + ) + + t.deepEqual(Object.keys(query), ["ids"], "flag is not a key of req.query") +})