Skip to content

Commit a90fa55

Browse files
authored
Merge branch 'master' into org-plugin
2 parents a745cab + ee3cf1b commit a90fa55

17 files changed

Lines changed: 432 additions & 275 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
## Version 25
44

5+
### v25.4.0
6+
7+
- Feat: configurable query parser:
8+
- In Express 5 the default query parser was changed from "extended" to "simple";
9+
- The "extended" parser is the `qs` module, while the "simple" parser is the `node:querystring` module;
10+
- This version introduces the new config option `queryParser` having the default value "simple" for compatibility;
11+
- The "extended" parser supports nested objects and arrays with optional indexes in square brackets;
12+
- You can now choose between "simple" and "extended" parsers as well as configure a custom implementation.
13+
14+
```ts
15+
import { createConfig } from "express-zod-api";
16+
import qs from "qs";
17+
18+
const config = createConfig({
19+
// for comma-separated arrays: ?values=1,2,3
20+
queryParser: (query) => qs.parse(query, { comma: true }),
21+
});
22+
```
23+
524
### v25.3.1
625

726
- Small optimization for running diagnostics (non-production mode);

README.md

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@ Start your API server with I/O schema validation and custom middlewares in minut
2222
3. [Options](#options)
2323
4. [Using native express middlewares](#using-native-express-middlewares)
2424
5. [Refinements](#refinements)
25-
6. [Transformations](#transformations)
26-
7. [Top level transformations and mapping](#top-level-transformations-and-mapping)
27-
8. [Dealing with dates](#dealing-with-dates)
28-
9. [Cross-Origin Resource Sharing](#cross-origin-resource-sharing) (CORS)
29-
10. [Enabling HTTPS](#enabling-https)
30-
11. [Enabling compression](#enabling-compression)
31-
12. [Customizing logger](#customizing-logger)
32-
13. [Child logger](#child-logger)
25+
6. [Query string parser](#query-string-parser)
26+
7. [Transformations](#transformations)
27+
8. [Top level transformations and mapping](#top-level-transformations-and-mapping)
28+
9. [Dealing with dates](#dealing-with-dates)
29+
10. [Cross-Origin Resource Sharing](#cross-origin-resource-sharing) (CORS)
30+
11. [Enabling HTTPS](#enabling-https)
31+
12. [Enabling compression](#enabling-compression)
32+
13. [Customizing logger](#customizing-logger)
33+
14. [Child logger](#child-logger)
3334
5. [Advanced features](#advanced-features)
3435
1. [Customizing input sources](#customizing-input-sources)
3536
2. [Headers as input source](#headers-as-input-source)
@@ -491,24 +492,31 @@ const endpoint = endpointsFactory.build({
491492
});
492493
```
493494

495+
## Query string parser
496+
497+
In Express 5 the default query string parser was changed from "extended" (which is the `qs` module) to "simple" (which
498+
is the `node:querystring` module). The "extended" parser supports nested objects and arrays with optional indexes in
499+
square brackets. You can choose between those parsers as well as configure a custom implementation:
500+
501+
| `queryParser` value | Query string example for arrays |
502+
| -------------------------------------- | ------------------------------------------------ |
503+
| simple | `?values=1&values=2&values=3` |
504+
| extended | as simple or `?values[]=1&values[]=2&values[]=3` |
505+
| `(str) => qs.parse(str, {comma:true})` | as extended or `?values=1,2,3` |
506+
494507
## Transformations
495508

496-
Since parameters of GET requests come in the form of strings, there is often a need to transform them into numbers or
497-
arrays of numbers.
509+
Since parameters of GET requests come in the form of strings, there is often a need to transform them into numbers.
498510

499511
```typescript
500512
import { z } from "zod";
501513

502-
const getUserEndpoint = endpointsFactory.build({
514+
const getUserEndpoint = endpointsFactory.buildVoid({
503515
input: z.object({
504516
id: z.string().transform((id) => parseInt(id, 10)),
505-
ids: z
506-
.string()
507-
.transform((ids) => ids.split(",").map((id) => parseInt(id, 10))),
508517
}),
509-
handler: async ({ input: { id, ids }, logger }) => {
510-
logger.debug("id", id); // type: number
511-
logger.debug("ids", ids); // type: number[]
518+
handler: async ({ input: { id }, logger }) => {
519+
logger.debug("id", typeof id); // number
512520
},
513521
});
514522
```

example/__snapshots__/index.spec.ts.snap

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,43 @@ exports[`Example > Positive > Should handle valid GET request 1`] = `
136136
}
137137
`;
138138

139+
exports[`Example > Positive > Should respond with array (legacy API ResultHandler) 1`] = `
140+
[
141+
{
142+
"name": "Maria Merian",
143+
"role": "manager",
144+
},
145+
{
146+
"name": "Mary Anning",
147+
"role": "operator",
148+
},
149+
{
150+
"name": "Marie Skłodowska Curie",
151+
"role": "admin",
152+
},
153+
{
154+
"name": "Henrietta Leavitt",
155+
"role": "manager",
156+
},
157+
{
158+
"name": "Lise Meitner",
159+
"role": "operator",
160+
},
161+
{
162+
"name": "Alice Ball",
163+
"role": "admin",
164+
},
165+
{
166+
"name": "Gerty Cori",
167+
"role": "manager",
168+
},
169+
{
170+
"name": "Helen Taussig",
171+
"role": "operator",
172+
},
173+
]
174+
`;
175+
139176
exports[`Example > Positive > Should send an image with a correct header 1`] = `"f39beeff92379dc935586d726211c2620be6f879"`;
140177

141178
exports[`Example > Positive > Should serve static files 1`] = `"f39beeff92379dc935586d726211c2620be6f879"`;

example/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { BuiltinLogger, createConfig } from "express-zod-api";
22
import ui from "swagger-ui-express";
33
import createHttpError from "http-errors";
44
import { givePort } from "../tools/ports";
5+
import qs from "qs";
56

67
export const config = createConfig({
78
http: { listen: givePort("example") },
9+
queryParser: (query) => qs.parse(query, { comma: true }), // affects listUsersEndpoint
810
upload: {
911
limits: { fileSize: 51200 },
1012
limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint

example/endpoints/list-users.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,37 @@
11
import { z } from "zod";
22
import { arrayRespondingFactory } from "../factories";
33

4+
const roleSchema = z.enum(["manager", "operator", "admin"]);
5+
6+
const users = [
7+
{ name: "Maria Merian", role: "manager" },
8+
{ name: "Mary Anning", role: "operator" },
9+
{ name: "Marie Skłodowska Curie", role: "admin" },
10+
{ name: "Henrietta Leavitt", role: "manager" },
11+
{ name: "Lise Meitner", role: "operator" },
12+
{ name: "Alice Ball", role: "admin" },
13+
{ name: "Gerty Cori", role: "manager" },
14+
{ name: "Helen Taussig", role: "operator" },
15+
] as const;
16+
417
/**
518
* This endpoint demonstrates the ability to respond with array.
619
* Avoid doing this in new projects. This feature is only for easier migration of legacy APIs.
720
* */
821
export const listUsersEndpoint = arrayRespondingFactory.build({
922
tag: "users",
23+
input: z.object({
24+
roles: z.array(roleSchema).optional(),
25+
}),
1026
output: z.object({
1127
// the arrayResultHandler will take the "items" prop as the response
12-
items: z
13-
.array(z.object({ name: z.string() }))
14-
.example([
15-
{ name: "Hunter Schafer" },
16-
{ name: "Laverne Cox" },
17-
{ name: "Patti Harrison" },
18-
]),
28+
items: z.array(z.object({ name: z.string(), role: roleSchema })).example([
29+
{ name: "Hunter Schafer", role: "manager" },
30+
{ name: "Laverne Cox", role: "operator" },
31+
{ name: "Patti Harrison", role: "admin" },
32+
]),
1933
}),
20-
handler: async () => ({
21-
items: [
22-
{ name: "Maria Merian" },
23-
{ name: "Mary Anning" },
24-
{ name: "Marie Skłodowska Curie" },
25-
{ name: "Henrietta Leavitt" },
26-
{ name: "Lise Meitner" },
27-
{ name: "Alice Ball" },
28-
{ name: "Gerty Cori" },
29-
{ name: "Helen Taussig" },
30-
],
34+
handler: async ({ input: { roles } }) => ({
35+
items: users.filter(({ role }) => roles?.includes(role) ?? true),
3136
}),
3237
});

example/example.client.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,14 @@ interface PostV1UserCreateNegativeResponseVariants {
161161
}
162162

163163
/** get /v1/user/list */
164-
type GetV1UserListInput = {};
164+
type GetV1UserListInput = {
165+
roles?: ("manager" | "operator" | "admin")[] | undefined;
166+
};
165167

166168
/** get /v1/user/list */
167169
type GetV1UserListPositiveVariant1 = {
168170
name: string;
171+
role: "manager" | "operator" | "admin";
169172
}[];
170173

171174
/** get /v1/user/list */
@@ -182,7 +185,9 @@ interface GetV1UserListNegativeResponseVariants {
182185
}
183186

184187
/** head /v1/user/list */
185-
type HeadV1UserListInput = {};
188+
type HeadV1UserListInput = {
189+
roles?: ("manager" | "operator" | "admin")[] | undefined;
190+
};
186191

187192
/** head /v1/user/list */
188193
type HeadV1UserListPositiveVariant1 = undefined;

example/example.documentation.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,19 @@ paths:
391391
operationId: GetV1UserList
392392
tags:
393393
- users
394+
parameters:
395+
- name: roles
396+
in: query
397+
required: false
398+
description: GET /v1/user/list Parameter
399+
schema:
400+
type: array
401+
items:
402+
type: string
403+
enum:
404+
- manager
405+
- operator
406+
- admin
394407
responses:
395408
"200":
396409
description: GET /v1/user/list Positive response
@@ -403,15 +416,25 @@ paths:
403416
properties:
404417
name:
405418
type: string
419+
role:
420+
type: string
421+
enum:
422+
- manager
423+
- operator
424+
- admin
406425
required:
407426
- name
427+
- role
408428
additionalProperties: false
409429
examples:
410430
example1:
411431
value:
412432
- name: Hunter Schafer
433+
role: manager
413434
- name: Laverne Cox
435+
role: operator
414436
- name: Patti Harrison
437+
role: admin
415438
"400":
416439
description: GET /v1/user/list Negative response
417440
content:
@@ -425,6 +448,19 @@ paths:
425448
operationId: HeadV1UserList
426449
tags:
427450
- users
451+
parameters:
452+
- name: roles
453+
in: query
454+
required: false
455+
description: HEAD /v1/user/list Parameter
456+
schema:
457+
type: array
458+
items:
459+
type: string
460+
enum:
461+
- manager
462+
- operator
463+
- admin
428464
responses:
429465
"200":
430466
description: HEAD /v1/user/list Positive response

example/index.spec.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Client, Subscription } from "./example.client";
55
import { givePort } from "../tools/ports";
66
import { createHash } from "node:crypto";
77
import { readFile } from "node:fs/promises";
8+
import { fail } from "node:assert";
89

910
describe("Example", async () => {
1011
let out = "";
@@ -107,16 +108,23 @@ describe("Example", async () => {
107108
const response = await fetch(`http://localhost:${port}/v1/user/list`);
108109
expect(response.status).toBe(200);
109110
const json = await response.json();
110-
expect(json).toEqual([
111-
{ name: "Maria Merian" },
112-
{ name: "Mary Anning" },
113-
{ name: "Marie Skłodowska Curie" },
114-
{ name: "Henrietta Leavitt" },
115-
{ name: "Lise Meitner" },
116-
{ name: "Alice Ball" },
117-
{ name: "Gerty Cori" },
118-
{ name: "Helen Taussig" },
119-
]);
111+
expect(json).toMatchSnapshot();
112+
});
113+
114+
test.each([
115+
"roles=admin,operator",
116+
"roles[]=admin&roles[]=operator",
117+
"roles=admin&roles=operator",
118+
])("Should support arrays in query %#", async (query) => {
119+
const response = await fetch(
120+
`http://localhost:${port}/v1/user/list?${query}`,
121+
);
122+
expect(response.status).toBe(200);
123+
const json = await response.json();
124+
if (!Array.isArray(json)) fail("should be an array");
125+
expect(
126+
json.every((one) => ["admin", "operator"].includes(one.role)),
127+
).toBeTruthy();
120128
});
121129

122130
test("Should send an image with a correct header", async () => {

example/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@types/swagger-ui-express": "^4.1.8",
1717
"express-zod-api": "workspace:*",
1818
"http-errors": "catalog:dev",
19+
"qs": "^6.14.0",
1920
"swagger-ui-express": "^5.0.0",
2021
"typescript": "catalog:dev",
2122
"zod": "catalog:dev"

express-zod-api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "express-zod-api",
3-
"version": "25.3.1",
3+
"version": "25.4.0",
44
"description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.",
55
"license": "MIT",
66
"repository": {

0 commit comments

Comments
 (0)