Skip to content

Commit 20fb87f

Browse files
authored
feat: ez.paginated() (#3245)
- featuring proprietary schema `ez.paginated()` to simplify common tasks on paginating data - composable and configurable - supports both offset and cursor pagination - along with `Client::hasMore()` helper <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added offset-style paginated users list endpoint with role filtering at /v2/users/list * Introduced unified pagination support (offset and cursor), exported ez.paginated, a public Pagination type, and Client.hasMore helper * **Documentation** * Added OpenAPI docs and README pagination usage and examples for the new endpoint and pagination features * **Tests** * Added comprehensive tests for paginated inputs, outputs, limits, and composability <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent dc22650 commit 20fb87f

17 files changed

Lines changed: 1134 additions & 16 deletions

CHANGELOG.md

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

33
## Version 27
44

5+
### v27.1.0
6+
7+
- Introducing `ez.paginated()` helper for creating paginated endpoints:
8+
- The configurable helper returns `input` and `output` schemas for your Endpoint;
9+
- Use the `style` option to choose between `offset` and `cursor` pagination;
10+
- The `itemsName` option configures the name of the property containing the items array;
11+
- The `Integration` generator now equips the `Client` with a static `hasMore()` method.
12+
13+
```ts
14+
import { z } from "zod";
15+
import { ez, defaultEndpointsFactory } from "express-zod-api";
16+
17+
const pagination = ez.paginated({
18+
style: "offset", // or "cursor"
19+
itemSchema: z.object({ id: z.number(), name: z.string() }),
20+
itemsName: "users", // defines the output name of the items array
21+
maxLimit: 100,
22+
defaultLimit: 20,
23+
});
24+
25+
const listUsers = defaultEndpointsFactory.build({
26+
input: pagination.input,
27+
output: pagination.output,
28+
handler: async ({ input: { limit, offset } }) => {
29+
const { users, total } = await db.getUsers(limit, offset);
30+
return { users, total, limit, offset }; // or { users, nextCursor, limit } for cursor pagination
31+
},
32+
});
33+
```
34+
535
### v27.0.1
636

737
- Removed debug-level comments from the declaration files in the distribution.

README.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ Start your API server with I/O schema validation and custom middlewares in minut
2626
7. [Transformations](#transformations)
2727
8. [Top level transformations and mapping](#top-level-transformations-and-mapping)
2828
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)
29+
10. [Pagination](#pagination)
30+
11. [Cross-Origin Resource Sharing](#cross-origin-resource-sharing) (CORS)
31+
12. [Enabling HTTPS](#enabling-https)
32+
13. [Enabling compression](#enabling-compression)
33+
14. [Customizing logger](#customizing-logger)
34+
15. [Child logger](#child-logger)
3435
5. [Advanced features](#advanced-features)
3536
1. [Customizing input sources](#customizing-input-sources)
3637
2. [Headers as input source](#headers-as-input-source)
@@ -612,6 +613,34 @@ const updateUserEndpoint = defaultEndpointsFactory.build({
612613
});
613614
```
614615

616+
## Pagination
617+
618+
Consider using `ez.paginated()` to get reusable `input` and `output` schemas for offset or cursor-based pagination.
619+
Attach schemas to your endpoint and compose with other params (e.g. `.and(z.object({ ... }))`).
620+
To check if more pages are available, use [Client::hasMore()](#generating-a-frontend-client).
621+
622+
```ts
623+
import { z } from "zod";
624+
import { ez, defaultEndpointsFactory } from "express-zod-api";
625+
626+
const pagination = ez.paginated({
627+
style: "offset", // or "cursor"
628+
itemSchema: z.object({ id: z.number(), name: z.string() }),
629+
itemsName: "users", // defines the output name of the items array
630+
maxLimit: 100,
631+
defaultLimit: 20,
632+
});
633+
634+
const listUsers = defaultEndpointsFactory.build({
635+
input: pagination.input,
636+
output: pagination.output,
637+
handler: async ({ input: { limit, offset } }) => {
638+
const { users, total } = await db.getUsers(limit, offset);
639+
return { users, total, limit, offset }; // or { users, nextCursor, limit } for cursor pagination
640+
},
641+
});
642+
```
643+
615644
## Cross-Origin Resource Sharing
616645

617646
You can enable your API for other domains using the corresponding configuration option `cors`. The value is required to

example/__snapshots__/index.spec.ts.snap

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,51 @@ exports[`Example > Positive > Should respond with array (legacy API ResultHandle
173173
]
174174
`;
175175

176+
exports[`Example > Positive > Should respond with paginated list (ez.paginated) 1`] = `
177+
{
178+
"data": {
179+
"limit": 20,
180+
"offset": 0,
181+
"total": 8,
182+
"users": [
183+
{
184+
"name": "Maria Merian",
185+
"role": "manager",
186+
},
187+
{
188+
"name": "Mary Anning",
189+
"role": "operator",
190+
},
191+
{
192+
"name": "Marie Skłodowska Curie",
193+
"role": "admin",
194+
},
195+
{
196+
"name": "Henrietta Leavitt",
197+
"role": "manager",
198+
},
199+
{
200+
"name": "Lise Meitner",
201+
"role": "operator",
202+
},
203+
{
204+
"name": "Alice Ball",
205+
"role": "admin",
206+
},
207+
{
208+
"name": "Gerty Cori",
209+
"role": "manager",
210+
},
211+
{
212+
"name": "Helen Taussig",
213+
"role": "operator",
214+
},
215+
],
216+
},
217+
"status": "success",
218+
}
219+
`;
220+
176221
exports[`Example > Positive > Should send an image with a correct header 1`] = `"f39beeff92379dc935586d726211c2620be6f879"`;
177222

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

example/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import qs from "qs";
66

77
export const config = createConfig({
88
http: { listen: givePort("example") },
9-
queryParser: (query) => qs.parse(query, { comma: true }), // affects listUsersEndpoint
9+
// required for listUsersPaginatedEndpoint and listUsersEndpoint (legacy API demo)
10+
queryParser: (query) => qs.parse(query, { comma: true }),
1011
upload: {
1112
limits: { fileSize: 51200 },
1213
limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { z } from "zod";
2+
import { defaultEndpointsFactory, ez } from "express-zod-api";
3+
4+
const roleSchema = z.enum(["manager", "operator", "admin"]);
5+
6+
const userSchema = z.object({
7+
name: z.string(),
8+
role: roleSchema,
9+
});
10+
11+
const paginatedUsers = ez.paginated({
12+
style: "offset",
13+
itemSchema: userSchema,
14+
itemsName: "users",
15+
maxLimit: 100,
16+
defaultLimit: 20,
17+
});
18+
19+
const users: z.output<typeof userSchema>[] = [
20+
{ name: "Maria Merian", role: "manager" },
21+
{ name: "Mary Anning", role: "operator" },
22+
{ name: "Marie Skłodowska Curie", role: "admin" },
23+
{ name: "Henrietta Leavitt", role: "manager" },
24+
{ name: "Lise Meitner", role: "operator" },
25+
{ name: "Alice Ball", role: "admin" },
26+
{ name: "Gerty Cori", role: "manager" },
27+
{ name: "Helen Taussig", role: "operator" },
28+
];
29+
30+
/**
31+
* Lists users with offset pagination and optional role filter.
32+
* Uses ez.paginated() for input params (limit, offset) and output shape (users, total, limit, offset).
33+
*/
34+
export const listUsersPaginatedEndpoint = defaultEndpointsFactory.build({
35+
tag: "users",
36+
shortDescription: "Lists users with pagination.",
37+
description:
38+
"Returns a page of users. Optionally filter by roles. Uses offset-based pagination (limit and offset).",
39+
input: paginatedUsers.input.and(
40+
z.object({
41+
roles: z
42+
.array(roleSchema)
43+
.optional()
44+
.describe("Filter by roles; omit for all"),
45+
}),
46+
),
47+
output: paginatedUsers.output,
48+
handler: async ({ input: { limit, offset, roles } }) => {
49+
const filtered = roles
50+
? users.filter(({ role }) => roles.includes(role))
51+
: users;
52+
const total = filtered.length;
53+
const page = filtered.slice(offset, offset + limit);
54+
return { users: page, total, limit, offset };
55+
},
56+
});

example/example.client.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,78 @@ interface PostV1FormsFeedbackNegativeResponseVariants {
435435
400: PostV1FormsFeedbackNegativeVariant1;
436436
}
437437

438+
/** get /v2/users/list */
439+
type GetV2UsersListInput = {
440+
/** Page size (number of users per page) */
441+
limit?: number | undefined;
442+
/** Number of users to skip */
443+
offset?: number | undefined;
444+
/** Filter by roles; omit for all */
445+
roles?: ("manager" | "operator" | "admin")[] | undefined;
446+
};
447+
448+
/** get /v2/users/list */
449+
type GetV2UsersListPositiveVariant1 = {
450+
status: "success";
451+
data: {
452+
/** Page of users */
453+
users: {
454+
name: string;
455+
role: "manager" | "operator" | "admin";
456+
}[];
457+
/** Total number of users */
458+
total: number;
459+
/** Page size used */
460+
limit: number;
461+
/** Offset used */
462+
offset: number;
463+
};
464+
};
465+
466+
/** get /v2/users/list */
467+
interface GetV2UsersListPositiveResponseVariants {
468+
200: GetV2UsersListPositiveVariant1;
469+
}
470+
471+
/** get /v2/users/list */
472+
type GetV2UsersListNegativeVariant1 = {
473+
status: "error";
474+
error: {
475+
message: string;
476+
};
477+
};
478+
479+
/** get /v2/users/list */
480+
interface GetV2UsersListNegativeResponseVariants {
481+
400: GetV2UsersListNegativeVariant1;
482+
}
483+
484+
/** head /v2/users/list */
485+
type HeadV2UsersListInput = {
486+
/** Page size (number of users per page) */
487+
limit?: number | undefined;
488+
/** Number of users to skip */
489+
offset?: number | undefined;
490+
/** Filter by roles; omit for all */
491+
roles?: ("manager" | "operator" | "admin")[] | undefined;
492+
};
493+
494+
/** head /v2/users/list */
495+
type HeadV2UsersListPositiveVariant1 = undefined;
496+
497+
/** head /v2/users/list */
498+
interface HeadV2UsersListPositiveResponseVariants {
499+
200: HeadV2UsersListPositiveVariant1;
500+
}
501+
502+
/** head /v2/users/list */
503+
type HeadV2UsersListNegativeVariant1 = undefined;
504+
505+
/** head /v2/users/list */
506+
interface HeadV2UsersListNegativeResponseVariants {
507+
400: HeadV2UsersListNegativeVariant1;
508+
}
509+
438510
export type Path =
439511
| "/v1/user/retrieve"
440512
| "/v1/user/:id/remove"
@@ -446,7 +518,8 @@ export type Path =
446518
| "/v1/avatar/upload"
447519
| "/v1/avatar/raw"
448520
| "/v1/events/stream"
449-
| "/v1/forms/feedback";
521+
| "/v1/forms/feedback"
522+
| "/v2/users/list";
450523

451524
export type Method = "get" | "post" | "put" | "delete" | "patch" | "head";
452525

@@ -469,6 +542,8 @@ export interface Input {
469542
"get /v1/events/stream": GetV1EventsStreamInput;
470543
"head /v1/events/stream": HeadV1EventsStreamInput;
471544
"post /v1/forms/feedback": PostV1FormsFeedbackInput;
545+
"get /v2/users/list": GetV2UsersListInput;
546+
"head /v2/users/list": HeadV2UsersListInput;
472547
}
473548

474549
export interface PositiveResponse {
@@ -490,6 +565,8 @@ export interface PositiveResponse {
490565
"get /v1/events/stream": SomeOf<GetV1EventsStreamPositiveResponseVariants>;
491566
"head /v1/events/stream": SomeOf<HeadV1EventsStreamPositiveResponseVariants>;
492567
"post /v1/forms/feedback": SomeOf<PostV1FormsFeedbackPositiveResponseVariants>;
568+
"get /v2/users/list": SomeOf<GetV2UsersListPositiveResponseVariants>;
569+
"head /v2/users/list": SomeOf<HeadV2UsersListPositiveResponseVariants>;
493570
}
494571

495572
export interface NegativeResponse {
@@ -511,6 +588,8 @@ export interface NegativeResponse {
511588
"get /v1/events/stream": SomeOf<GetV1EventsStreamNegativeResponseVariants>;
512589
"head /v1/events/stream": SomeOf<HeadV1EventsStreamNegativeResponseVariants>;
513590
"post /v1/forms/feedback": SomeOf<PostV1FormsFeedbackNegativeResponseVariants>;
591+
"get /v2/users/list": SomeOf<GetV2UsersListNegativeResponseVariants>;
592+
"head /v2/users/list": SomeOf<HeadV2UsersListNegativeResponseVariants>;
514593
}
515594

516595
export interface EncodedResponse {
@@ -548,6 +627,10 @@ export interface EncodedResponse {
548627
HeadV1EventsStreamNegativeResponseVariants;
549628
"post /v1/forms/feedback": PostV1FormsFeedbackPositiveResponseVariants &
550629
PostV1FormsFeedbackNegativeResponseVariants;
630+
"get /v2/users/list": GetV2UsersListPositiveResponseVariants &
631+
GetV2UsersListNegativeResponseVariants;
632+
"head /v2/users/list": HeadV2UsersListPositiveResponseVariants &
633+
HeadV2UsersListNegativeResponseVariants;
551634
}
552635

553636
export interface Response {
@@ -601,6 +684,12 @@ export interface Response {
601684
"post /v1/forms/feedback":
602685
| PositiveResponse["post /v1/forms/feedback"]
603686
| NegativeResponse["post /v1/forms/feedback"];
687+
"get /v2/users/list":
688+
| PositiveResponse["get /v2/users/list"]
689+
| NegativeResponse["get /v2/users/list"];
690+
"head /v2/users/list":
691+
| PositiveResponse["head /v2/users/list"]
692+
| NegativeResponse["head /v2/users/list"];
604693
}
605694

606695
export type Request = keyof Input;
@@ -622,6 +711,8 @@ export const endpointTags = {
622711
"get /v1/events/stream": ["subscriptions"],
623712
"head /v1/events/stream": ["subscriptions"],
624713
"post /v1/forms/feedback": ["forms"],
714+
"get /v2/users/list": ["users"],
715+
"head /v2/users/list": ["users"],
625716
};
626717

627718
const parseRequest = (request: string) =>
@@ -645,6 +736,16 @@ export type Implementation<T = unknown> = (
645736
ctx?: T,
646737
) => Promise<any>;
647738

739+
type Pagination =
740+
| {
741+
nextCursor: string | null;
742+
}
743+
| {
744+
total: number;
745+
limit: number;
746+
offset: number;
747+
};
748+
648749
const defaultImplementation: Implementation = async (method, path, params) => {
649750
const hasBody = !["get", "head", "delete"].includes(method);
650751
const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`;
@@ -674,6 +775,10 @@ export class Client<T> {
674775
const [method, path] = parseRequest(request);
675776
return this.implementation(method, ...substitute(path, params), ctx);
676777
}
778+
public static hasMore(response: Pagination): boolean {
779+
if ("nextCursor" in response) return response.nextCursor !== null;
780+
return response.offset + response.limit < response.total;
781+
}
677782
}
678783

679784
export class Subscription<

0 commit comments

Comments
 (0)