Skip to content

Commit 373912c

Browse files
authored
change querystring format for multi value parameters (#319) (#320)
* change querystring format vor multi value parameters (#319) Signed-off-by: Thomas Altmann <[email protected]> * fix: move query string ternary to util function Signed-off-by: Thomas Altmann <[email protected]> * fix: unify query for internalEvent Signed-off-by: Thomas Altmann <[email protected]> --------- Signed-off-by: Thomas Altmann <[email protected]>
1 parent 848d169 commit 373912c

File tree

8 files changed

+133
-37
lines changed

8 files changed

+133
-37
lines changed

examples/app-router/app/search-query/page.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,26 @@ import { headers } from "next/headers";
33
export default function SearchQuery({
44
searchParams: propsSearchParams,
55
}: {
6-
searchParams: Record<string, string>;
6+
searchParams: Record<string, string | string[]>;
77
}) {
88
const mwSearchParams = headers().get("search-params");
9+
const multiValueParams = propsSearchParams["multi"];
10+
const multiValueArray = Array.isArray(multiValueParams)
11+
? multiValueParams
12+
: [multiValueParams];
913
return (
1014
<>
1115
<h1>Search Query</h1>
1216
<div>Search Params via Props: {propsSearchParams.searchParams}</div>
1317
<div>Search Params via Middleware: {mwSearchParams}</div>
18+
{multiValueParams && (
19+
<>
20+
<div>Multi-value Params (key: multi): {multiValueArray.length}</div>
21+
{multiValueArray.map((value) => (
22+
<div>{value}</div>
23+
))}
24+
</>
25+
)}
1426
</>
1527
);
1628
}

packages/open-next/src/adapters/event-mapper.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from "aws-lambda";
1010

1111
import { debug } from "./logger.js";
12+
import { convertToQuery } from "./routing/util.js";
1213
import { parseCookies } from "./util.js";
1314

1415
export type InternalEvent = {
@@ -123,7 +124,7 @@ function convertFromAPIGatewayProxyEventV2(
123124
body: normalizeAPIGatewayProxyEventV2Body(event),
124125
headers: normalizeAPIGatewayProxyEventV2Headers(event),
125126
remoteAddress: requestContext.http.sourceIp,
126-
query: removeUndefinedFromQuery(event.queryStringParameters ?? {}),
127+
query: removeUndefinedFromQuery(convertToQuery(rawQueryString)),
127128
cookies:
128129
event.cookies?.reduce((acc, cur) => {
129130
const [key, value] = cur.split("=");
@@ -148,13 +149,7 @@ function convertFromCloudFrontRequestEvent(
148149
),
149150
headers: normalizeCloudFrontRequestEventHeaders(headers),
150151
remoteAddress: clientIp,
151-
query: querystring.split("&").reduce(
152-
(acc, cur) => ({
153-
...acc,
154-
[cur.split("=")[0]]: cur.split("=")[1],
155-
}),
156-
{},
157-
),
152+
query: convertToQuery(querystring),
158153
cookies:
159154
headers.cookie?.reduce((acc, cur) => {
160155
const { key, value } = cur;

packages/open-next/src/adapters/routing/matcher.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
RouteHas,
1212
} from "../types/next-types";
1313
import { escapeRegex, unescapeRegex } from "../util";
14-
import { convertQuery, getUrlParts, isExternal } from "./util";
14+
import { convertToQueryString, getUrlParts, isExternal } from "./util";
1515

1616
const routeHasMatcher =
1717
(
@@ -127,7 +127,6 @@ export function handleRewrites<T extends RewriteDefinition>(
127127
checkHas(matcher, route.missing, true),
128128
);
129129

130-
const urlQueryString = new URLSearchParams(convertQuery(query)).toString();
131130
let rewrittenUrl = rawPath;
132131
const isExternalRewrite = isExternal(rewrite?.destination);
133132
debug("isExternalRewrite", isExternalRewrite);
@@ -154,13 +153,11 @@ export function handleRewrites<T extends RewriteDefinition>(
154153
}
155154
}
156155

157-
const queryString = urlQueryString ? `?${urlQueryString}` : "";
158-
159156
return {
160157
internalEvent: {
161158
...event,
162159
rawPath: rewrittenUrl,
163-
url: `${rewrittenUrl}${queryString}`,
160+
url: `${rewrittenUrl}${convertToQueryString(query)}`,
164161
},
165162
__rewrite: rewrite,
166163
isExternalRewrite,
@@ -233,18 +230,12 @@ export function fixDataPage(internalEvent: InternalEvent, buildId: string) {
233230
let newPath = rawPath.replace(dataPattern, "").replace(/\.json$/, "");
234231
newPath = newPath === "/index" ? "/" : newPath;
235232
query.__nextDataReq = "1";
236-
const urlQuery: Record<string, string> = {};
237-
Object.keys(query).forEach((k) => {
238-
const v = query[k];
239-
urlQuery[k] = Array.isArray(v) ? v.join(",") : v;
240-
});
233+
241234
return {
242235
...internalEvent,
243236
rawPath: newPath,
244237
query,
245-
url: `${newPath}${
246-
query ? `?${new URLSearchParams(urlQuery).toString()}` : ""
247-
}`,
238+
url: `${newPath}${convertToQueryString(query)}`,
248239
};
249240
}
250241
return internalEvent;

packages/open-next/src/adapters/routing/middleware.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { IncomingMessage } from "../http/request.js";
66
import { ServerlessResponse } from "../http/response.js";
77
import {
88
convertRes,
9+
convertToQueryString,
910
getMiddlewareMatch,
1011
isExternal,
1112
loadMiddlewareManifest,
@@ -61,17 +62,11 @@ export async function handleMiddleware(
6162
path.join(NEXT_DIR, file),
6263
);
6364

64-
const urlQuery: Record<string, string> = {};
65-
Object.keys(query).forEach((k) => {
66-
const v = query[k];
67-
urlQuery[k] = Array.isArray(v) ? v.join(",") : v;
68-
});
69-
7065
const host = req.headers.host
7166
? `https://${req.headers.host}`
7267
: "http://localhost:3000";
7368
const initialUrl = new URL(rawPath, host);
74-
initialUrl.search = new URLSearchParams(urlQuery).toString();
69+
initialUrl.search = convertToQueryString(query);
7570
const url = initialUrl.toString();
7671

7772
const result: MiddlewareResult = await run({

packages/open-next/src/adapters/routing/util.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,38 @@ export function convertRes(res: ServerlessResponse) {
5454
};
5555
}
5656

57-
export function convertQuery(query: Record<string, string | string[]>) {
58-
const urlQuery: Record<string, string> = {};
59-
Object.keys(query).forEach((k) => {
60-
const v = query[k];
61-
urlQuery[k] = Array.isArray(v) ? v.join(",") : v;
57+
/**
58+
* Make sure that multi-value query parameters are transformed to
59+
* ?key=value1&key=value2&... so that Next converts those parameters
60+
* to an array when reading the query parameters
61+
*/
62+
export function convertToQueryString(query: Record<string, string | string[]>) {
63+
const urlQuery = new URLSearchParams();
64+
Object.entries(query).forEach(([key, value]) => {
65+
if (Array.isArray(value)) {
66+
value.forEach((entry) => urlQuery.append(key, entry));
67+
} else {
68+
urlQuery.append(key, value);
69+
}
6270
});
63-
return urlQuery;
71+
const queryString = urlQuery.toString();
72+
73+
return queryString ? `?${queryString}` : "";
74+
}
75+
76+
/**
77+
* Given a raw query string, returns a record with key value-array pairs
78+
* similar to how multiValueQueryStringParameters are structured
79+
*/
80+
export function convertToQuery(querystring: string) {
81+
const query = new URLSearchParams(querystring);
82+
const queryObject: Record<string, string[]> = {};
83+
84+
for (const key of query.keys()) {
85+
queryObject[key] = query.getAll(key);
86+
}
87+
88+
return queryObject;
6489
}
6590

6691
export function getMiddlewareMatch(middlewareManifest: MiddlewareManifest) {

packages/tests-e2e/tests/appRouter/query.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ import { expect, test } from "@playwright/test";
44
* Tests that query params are available in middleware and RSC
55
*/
66
test("SearchQuery", async ({ page }) => {
7-
await page.goto("/search-query?searchParams=e2etest");
7+
await page.goto("/search-query?searchParams=e2etest&multi=one&multi=two");
88

99
let propsEl = page.getByText(`Search Params via Props: e2etest`);
1010
let mwEl = page.getByText(`Search Params via Middleware: mw/e2etest`);
11+
let multiEl = page.getByText(`Multi-value Params (key: multi): 2`);
12+
let multiOne = page.getByText(`one`);
13+
let multiTwo = page.getByText(`two`);
1114
await expect(propsEl).toBeVisible();
1215
await expect(mwEl).toBeVisible();
16+
await expect(multiEl).toBeVisible();
17+
await expect(multiOne).toBeVisible();
18+
await expect(multiTwo).toBeVisible();
1319
});

packages/tests-unit/tests/adapter.utils.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
convertToQuery,
3+
convertToQueryString,
4+
} from "../../open-next/src/adapters/routing/util";
15
import { parseCookies } from "../../open-next/src/adapters/util";
26

37
describe("adapter utils", () => {
@@ -46,4 +50,68 @@ describe("adapter utils", () => {
4650
]);
4751
});
4852
});
53+
54+
describe("convertToQueryString", () => {
55+
it("returns an empty string for no queries", () => {
56+
const query = {};
57+
expect(convertToQueryString(query)).toBe("");
58+
});
59+
60+
it("converts a single entry to one querystring parameter", () => {
61+
const query = { key: "value" };
62+
expect(convertToQueryString(query)).toBe("?key=value");
63+
});
64+
65+
it("converts multiple distinct entries to a querystring parameter each", () => {
66+
const query = { key: "value", another: "value2" };
67+
expect(convertToQueryString(query)).toBe("?key=value&another=value2");
68+
});
69+
70+
it("converts multi-value parameters to multiple key value pairs", () => {
71+
const query = { key: ["value1", "value2"] };
72+
expect(convertToQueryString(query)).toBe("?key=value1&key=value2");
73+
});
74+
75+
it("converts mixed multi-value and single value parameters", () => {
76+
const query = { key: ["value1", "value2"], another: "value3" };
77+
expect(convertToQueryString(query)).toBe(
78+
"?key=value1&key=value2&another=value3",
79+
);
80+
});
81+
});
82+
83+
describe("convertToQuery", () => {
84+
it("returns an empty object for empty string", () => {
85+
const querystring = "";
86+
expect(convertToQuery(querystring)).toEqual({});
87+
});
88+
89+
it("converts a single querystring parameter to one query entry", () => {
90+
const querystring = "key=value";
91+
expect(convertToQuery(querystring)).toEqual({ key: ["value"] });
92+
});
93+
94+
it("converts multiple distinct entries to an entry in the query", () => {
95+
const querystring = "key=value&another=value2";
96+
expect(convertToQuery(querystring)).toEqual({
97+
key: ["value"],
98+
another: ["value2"],
99+
});
100+
});
101+
102+
it("converts multi-value parameters to an array in the query", () => {
103+
const querystring = "key=value1&key=value2";
104+
expect(convertToQuery(querystring)).toEqual({
105+
key: ["value1", "value2"],
106+
});
107+
});
108+
109+
it("converts mixed multi-value and single value parameters", () => {
110+
const querystring = "key=value1&key=value2&another=value3";
111+
expect(convertToQuery(querystring)).toEqual({
112+
key: ["value1", "value2"],
113+
another: ["value3"],
114+
});
115+
});
116+
});
49117
});

packages/tests-unit/tests/cache.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { hasCacheExtension } from "../../open-next/src/adapters/cache";
22

33
describe("hasCacheExtension", () => {
44
it("Should returns true if has an extension and it is a CacheExtension", () => {
5-
expect(hasCacheExtension("hello.json")).toBeTruthy();
5+
expect(hasCacheExtension("hello.cache")).toBeTruthy();
6+
});
7+
8+
it("Should returns false if has an extension and it is not a CacheExtension", () => {
9+
expect(hasCacheExtension("hello.json")).toBeFalsy();
610
});
711

812
it("Should return false if does not have any extension", () => {

0 commit comments

Comments
 (0)