Skip to content

Commit 2626c7b

Browse files
feat: Add pagination to /connected_accounts/list (#251)
* Add pagination to /connected_accounts/list * Add test * ci: Generate code * ci: Format code * Remove outdated test --------- Co-authored-by: Seam Bot <seambot@getseam.com>
1 parent dd690b9 commit 2626c7b

File tree

8 files changed

+308
-77
lines changed

8 files changed

+308
-77
lines changed

src/lib/api/pagination.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { createHash } from "node:crypto"
2+
3+
import { serializeUrlSearchParams } from "@seamapi/url-search-params-serializer"
4+
import { z } from "zod"
5+
6+
export const getNextPageUrl = (
7+
next_page_cursor: string | null,
8+
{
9+
req,
10+
}: {
11+
req: {
12+
url?: string
13+
commonParams: Record<string, unknown>
14+
baseUrl: string | undefined
15+
}
16+
},
17+
): string | null => {
18+
if (req.url == null || req.baseUrl == null) return null
19+
if (next_page_cursor == null) return null
20+
const { page_cursor, ...params } = req.commonParams
21+
const query = serializeUrlSearchParams(params)
22+
const url = new URL([req.baseUrl, req.url].join(""))
23+
url.search = query
24+
url.searchParams.set("next_page_cursor", next_page_cursor)
25+
url.searchParams.sort()
26+
return url.toString()
27+
}
28+
29+
export const getPageCursorQueryHash = (
30+
params: Record<string, unknown>,
31+
): string => {
32+
const query = serializeUrlSearchParams(params)
33+
return createHash("sha256").update(query).digest("hex")
34+
}
35+
36+
export const createPageCursorSchema = <T extends z.ZodTypeAny>(
37+
internal_page_cursor_schema: T,
38+
) =>
39+
z
40+
.string()
41+
.base64()
42+
.optional()
43+
.nullable()
44+
.transform((page_cursor) => {
45+
if (page_cursor == null) return page_cursor
46+
return internal_page_cursor_schema.parse(
47+
JSON.parse(Buffer.from(page_cursor, "base64").toString("utf8")),
48+
)
49+
})

src/lib/zod/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from "./device.ts"
1515
export * from "./event.ts"
1616
export * from "./health.ts"
1717
export * from "./noise_threshold.ts"
18+
export * from "./pagination.ts"
1819
export * from "./phone.ts"
1920
export * from "./phone-number.ts"
2021
export * from "./schedule.ts"

src/lib/zod/pagination.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { z } from "zod"
2+
3+
import { createPageCursorSchema } from "lib/api/pagination.ts"
4+
5+
export const pagination = z.object({
6+
has_next_page: z.boolean(),
7+
next_page_cursor: z.string().base64().nullable(),
8+
next_page_url: z.string().url().nullable(),
9+
})
10+
11+
export const device_internal_page_cursor = z.tuple([
12+
z.string(),
13+
z.object({
14+
created_at: z.coerce.date(),
15+
device_id: z.string(),
16+
}),
17+
])
18+
19+
export const device_page_cursor = createPageCursorSchema(
20+
device_internal_page_cursor,
21+
)
22+
23+
export const connected_account_internal_page_cursor = z.tuple([
24+
z.string(),
25+
z.object({
26+
created_at: z.coerce.date(),
27+
connected_account_id: z.string(),
28+
}),
29+
])
30+
31+
export const connected_account_page_cursor = createPageCursorSchema(
32+
connected_account_internal_page_cursor,
33+
)
Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
1+
import { sortBy } from "lodash"
2+
import { BadRequestException } from "nextlove"
13
import { z } from "zod"
24

3-
import { connected_account } from "lib/zod/index.ts"
5+
import {
6+
connected_account,
7+
connected_account_internal_page_cursor,
8+
connected_account_page_cursor,
9+
pagination,
10+
} from "lib/zod/index.ts"
411

12+
import { getNextPageUrl, getPageCursorQueryHash } from "lib/api/pagination.ts"
513
import { withRouteSpec } from "lib/middleware/with-route-spec.ts"
614

15+
export const common_params = z.object({
16+
limit: z.coerce.number().int().positive().default(500),
17+
page_cursor: connected_account_page_cursor,
18+
})
19+
720
export default withRouteSpec({
821
auth: [
922
"client_session",
@@ -12,17 +25,69 @@ export default withRouteSpec({
1225
"api_key",
1326
],
1427
methods: ["GET", "POST"],
28+
commonParams: common_params,
1529
jsonResponse: z.object({
1630
connected_accounts: z.array(connected_account),
31+
pagination,
1732
}),
1833
} as const)(async (req, res) => {
34+
const { page_cursor, ...params } = req.commonParams
35+
36+
const query_hash = getPageCursorQueryHash(params)
37+
const page_cursor_query_hash = page_cursor?.[0]
38+
const page_cursor_pointer = page_cursor?.[1]
39+
if (page_cursor_query_hash != null && page_cursor_query_hash !== query_hash) {
40+
throw new BadRequestException({
41+
type: "mismatched_page_parameters",
42+
message:
43+
"When using next_page_cursor, the request must send parameters identical to the initial request.",
44+
})
45+
}
46+
47+
const { workspace_id } = req.auth
48+
49+
let accounts = req.db.connected_accounts
50+
.filter((ca) => ca.workspace_id === workspace_id)
51+
.filter((ca) =>
52+
req.auth.type === "client_session"
53+
? req.auth.connected_account_ids.includes(ca.connected_account_id)
54+
: true,
55+
)
56+
57+
accounts = sortBy(accounts, ["created_at", "connected_account_id"])
58+
59+
const connected_account_id = page_cursor_pointer?.connected_account_id
60+
const startIdx =
61+
connected_account_id == null
62+
? 0
63+
: accounts.findIndex(
64+
(account) => account.connected_account_id === connected_account_id,
65+
)
66+
67+
const endIdx = Math.min(startIdx + params.limit, accounts.length)
68+
const page = accounts.slice(startIdx, endIdx)
69+
const next_account = accounts[endIdx]
70+
const has_next_page = next_account != null
71+
72+
let next_page_cursor = null
73+
if (has_next_page) {
74+
const next_page_cursor_data = connected_account_internal_page_cursor.parse([
75+
query_hash,
76+
{
77+
connected_account_id: next_account.connected_account_id,
78+
created_at: next_account.created_at,
79+
},
80+
])
81+
next_page_cursor = Buffer.from(
82+
JSON.stringify(next_page_cursor_data),
83+
"utf8",
84+
).toString("base64")
85+
}
86+
87+
const next_page_url = getNextPageUrl(next_page_cursor, { req })
88+
1989
res.status(200).json({
20-
connected_accounts: req.db.connected_accounts
21-
.filter((ca) =>
22-
req.auth.type === "client_session"
23-
? req.auth.connected_account_ids.includes(ca.connected_account_id)
24-
: true,
25-
)
26-
.filter((ca) => ca.workspace_id === req.auth.workspace_id),
90+
connected_accounts: page,
91+
pagination: { has_next_page, next_page_cursor, next_page_url },
2792
})
2893
})

src/pages/api/devices/list.ts

Lines changed: 11 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { createHash } from "node:crypto"
2-
3-
import { serializeUrlSearchParams } from "@seamapi/url-search-params-serializer"
41
import { sortBy } from "lodash"
52
import { BadRequestException } from "nextlove"
63
import { z } from "zod"
74

8-
import { device, device_type } from "lib/zod/index.ts"
5+
import {
6+
device,
7+
device_internal_page_cursor,
8+
device_page_cursor,
9+
device_type,
10+
pagination,
11+
} from "lib/zod/index.ts"
912

13+
import { getNextPageUrl, getPageCursorQueryHash } from "lib/api/pagination.ts"
1014
import { withRouteSpec } from "lib/middleware/with-route-spec.ts"
1115
import { getManagedDevicesWithFilter } from "lib/util/devices.ts"
1216

@@ -18,38 +22,16 @@ export const common_params = z.object({
1822
device_types: z.array(device_type).optional(),
1923
manufacturer: z.string().optional(),
2024
limit: z.coerce.number().int().positive().default(500),
21-
page_cursor: z
22-
.string()
23-
.base64()
24-
.optional()
25-
.nullable()
26-
.transform((page_cursor) => {
27-
if (page_cursor == null) return page_cursor
28-
return page_cursor_schema.parse(
29-
JSON.parse(Buffer.from(page_cursor, "base64").toString("utf8")),
30-
)
31-
}),
25+
page_cursor: device_page_cursor,
3226
})
3327

34-
const page_cursor_schema = z.tuple([
35-
z.string(),
36-
z.object({
37-
created_at: z.coerce.date(),
38-
device_id: z.string(),
39-
}),
40-
])
41-
4228
export default withRouteSpec({
4329
auth: ["console_session_with_workspace", "client_session", "api_key"],
4430
methods: ["GET", "POST"],
4531
commonParams: common_params,
4632
jsonResponse: z.object({
4733
devices: z.array(device),
48-
pagination: z.object({
49-
has_next_page: z.boolean(),
50-
next_page_cursor: z.string().base64().nullable(),
51-
next_page_url: z.string().url().nullable(),
52-
}),
34+
pagination,
5335
}),
5436
} as const)(async (req, res) => {
5537
const { page_cursor, ...params } = req.commonParams
@@ -111,7 +93,7 @@ export default withRouteSpec({
11193

11294
let next_page_cursor = null
11395
if (has_next_page) {
114-
const next_page_cursor_data = page_cursor_schema.parse([
96+
const next_page_cursor_data = device_internal_page_cursor.parse([
11597
query_hash,
11698
{
11799
device_id: next_device.device_id,
@@ -131,31 +113,3 @@ export default withRouteSpec({
131113
pagination: { has_next_page, next_page_cursor, next_page_url },
132114
})
133115
})
134-
135-
const getNextPageUrl = (
136-
next_page_cursor: string | null,
137-
{
138-
req,
139-
}: {
140-
req: {
141-
url?: string
142-
commonParams: Record<string, unknown>
143-
baseUrl: string | undefined
144-
}
145-
},
146-
): string | null => {
147-
if (req.url == null || req.baseUrl == null) return null
148-
if (next_page_cursor == null) return null
149-
const { page_cursor, ...params } = req.commonParams
150-
const query = serializeUrlSearchParams(params)
151-
const url = new URL([req.baseUrl, req.url].join(""))
152-
url.search = query
153-
url.searchParams.set("next_page_cursor", next_page_cursor)
154-
url.searchParams.sort()
155-
return url.toString()
156-
}
157-
158-
const getPageCursorQueryHash = (params: Record<string, unknown>): string => {
159-
const query = serializeUrlSearchParams(params)
160-
return createHash("sha256").update(query).digest("hex")
161-
}

src/route-types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1959,7 +1959,10 @@ export type Routes = {
19591959
method: "GET" | "POST"
19601960
queryParams: {}
19611961
jsonBody: {}
1962-
commonParams: {}
1962+
commonParams: {
1963+
limit?: number
1964+
page_cursor?: (string | undefined) | null
1965+
}
19631966
formData: {}
19641967
jsonResponse: {
19651968
connected_accounts: {
@@ -1982,6 +1985,11 @@ export type Routes = {
19821985
assa_abloy_credential_service_id?: string | undefined
19831986
bridge_id: string | null
19841987
}[]
1988+
pagination: {
1989+
has_next_page: boolean
1990+
next_page_cursor: string | null
1991+
next_page_url: string | null
1992+
}
19851993
ok: boolean
19861994
}
19871995
}

0 commit comments

Comments
 (0)