Skip to content

Commit c8de36a

Browse files
authored
feat: Add pagination to /devices/list (#236)
* Add pagination to /devices/list * Fix schema * Run codegen * Allow page_cursor nullable * Reuse query from previous page_cursor * Validate params when using next_page_cursor * Validate query for page_cursor * Fix type error * Mark failing test
1 parent cfee45e commit c8de36a

File tree

5 files changed

+286
-4
lines changed

5 files changed

+286
-4
lines changed

src/lib/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export async function startServer(
4242
middlewares: [
4343
(next) => (req: ApiRequest, res) => {
4444
req.db = database
45-
req.baseUrl = baseUrl
45+
req.baseUrl = baseUrl.replace(/\/+$/, "")
4646
return next(req, res)
4747
},
4848
],

src/pages/api/devices/list.ts

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import { createHash } from "node:crypto"
2+
3+
import { serializeUrlSearchParams } from "@seamapi/url-search-params-serializer"
4+
import { sortBy } from "lodash"
5+
import { BadRequestException } from "nextlove"
16
import { z } from "zod"
27

38
import { device, device_type } from "lib/zod/index.ts"
@@ -12,6 +17,23 @@ export const common_params = z.object({
1217
device_type: device_type.optional(),
1318
device_types: z.array(device_type).optional(),
1419
manufacturer: z.string().optional(),
20+
limit: z.coerce.number().int().positive().default(500),
21+
page_cursor: z
22+
.string()
23+
.optional()
24+
.nullable()
25+
.transform((page_cursor) => {
26+
if (page_cursor == null) return page_cursor
27+
return page_cursor_schema.parse(
28+
JSON.parse(Buffer.from(page_cursor, "base64").toString("utf8")),
29+
)
30+
}),
31+
})
32+
33+
const page_cursor_schema = z.object({
34+
created_at: z.coerce.date(),
35+
device_id: z.string(),
36+
query_hash: z.string(),
1537
})
1638

1739
export default withRouteSpec({
@@ -20,16 +42,24 @@ export default withRouteSpec({
2042
commonParams: common_params,
2143
jsonResponse: z.object({
2244
devices: z.array(device),
45+
pagination: z.object({
46+
has_next_page: z.boolean(),
47+
next_page_cursor: z.string().nullable(),
48+
next_page_url: z.string().url().nullable(),
49+
}),
2350
}),
2451
} as const)(async (req, res) => {
52+
const { page_cursor, ...params } = req.commonParams
53+
2554
const {
2655
device_ids,
2756
connected_account_id,
2857
connected_account_ids,
2958
device_type,
3059
device_types,
3160
manufacturer,
32-
} = req.commonParams
61+
limit,
62+
} = params
3363

3464
const { workspace_id } = req.auth
3565

@@ -52,7 +82,74 @@ export default withRouteSpec({
5282
)
5383
}
5484

85+
devices = sortBy(devices, ["created_at", "device_id"])
86+
87+
const device_id = page_cursor?.device_id
88+
const startIdx =
89+
device_id == null
90+
? 0
91+
: devices.findIndex((device) => device.device_id === device_id)
92+
93+
const endIdx = Math.min(startIdx + limit, devices.length)
94+
const page = devices.slice(startIdx, endIdx)
95+
const next_device = devices[endIdx]
96+
const has_next_page = next_device != null
97+
98+
const query_hash = getPageCursorQueryHash(params)
99+
if (
100+
page_cursor?.query_hash != null &&
101+
page_cursor.query_hash !== query_hash
102+
) {
103+
throw new BadRequestException({
104+
type: "mismatched_page_parameters",
105+
message:
106+
"When using next_page_cursor, the request send parameters identical to the initial request.",
107+
})
108+
}
109+
110+
const next_page_cursor = has_next_page
111+
? Buffer.from(
112+
JSON.stringify({
113+
device_id: next_device.device_id,
114+
created_at: next_device.created_at,
115+
query_hash,
116+
}),
117+
"utf8",
118+
).toString("base64")
119+
: null
120+
121+
const next_page_url = getNextPageUrl(next_page_cursor, { req })
122+
55123
res.status(200).json({
56-
devices,
124+
devices: page,
125+
pagination: { has_next_page, next_page_cursor, next_page_url },
57126
})
58127
})
128+
129+
const getNextPageUrl = (
130+
next_page_cursor: string | null,
131+
{
132+
req,
133+
}: {
134+
req: {
135+
url?: string
136+
commonParams: Record<string, unknown>
137+
baseUrl: string | undefined
138+
}
139+
},
140+
): string | null => {
141+
if (req.url == null || req.baseUrl == null) return null
142+
if (next_page_cursor == null) return null
143+
const { page_cursor, ...params } = req.commonParams
144+
const query = serializeUrlSearchParams(params)
145+
const url = new URL([req.baseUrl, req.url].join(""))
146+
url.search = query
147+
url.searchParams.set("next_page_cursor", next_page_cursor)
148+
url.searchParams.sort()
149+
return url.toString()
150+
}
151+
152+
const getPageCursorQueryHash = (params: Record<string, unknown>): string => {
153+
const query = serializeUrlSearchParams(params)
154+
return createHash("sha256").update(query).digest("hex")
155+
}

src/route-types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2283,6 +2283,8 @@ export type Routes = {
22832283
)[]
22842284
| undefined
22852285
manufacturer?: string | undefined
2286+
limit?: number
2287+
page_cursor?: (string | undefined) | null
22862288
}
22872289
formData: {}
22882290
jsonResponse: {
@@ -2512,6 +2514,11 @@ export type Routes = {
25122514
can_remotely_unlock?: boolean | undefined
25132515
can_program_online_access_codes?: boolean | undefined
25142516
}[]
2517+
pagination: {
2518+
has_next_page: boolean
2519+
next_page_cursor: string | null
2520+
next_page_url: string | null
2521+
}
25152522
ok: boolean
25162523
}
25172524
}
@@ -2612,6 +2619,8 @@ export type Routes = {
26122619
)[]
26132620
| undefined
26142621
manufacturer?: string | undefined
2622+
limit?: number
2623+
page_cursor?: (string | undefined) | null
26152624
}
26162625
formData: {}
26172626
jsonResponse: {
@@ -3691,6 +3700,8 @@ export type Routes = {
36913700
)[]
36923701
| undefined
36933702
manufacturer?: string | undefined
3703+
limit?: number
3704+
page_cursor?: (string | undefined) | null
36943705
}
36953706
formData: {}
36963707
jsonResponse: {

test/api/devices/list.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,179 @@ test("GET /devices/list with api key", async (t: ExecutionContext) => {
5151
}
5252
})
5353

54+
test("GET /devices/list with limit", async (t: ExecutionContext) => {
55+
const { axios, db } = await getTestServer(t, { seed: false })
56+
const seed_result = seedDatabase(db)
57+
58+
axios.defaults.headers.common.Authorization = `Bearer ${seed_result.seam_apikey1_token}`
59+
60+
const {
61+
data: { devices },
62+
} = await axios.get("/devices/list", { params: { limit: 2 } })
63+
64+
t.is(devices.length, 2)
65+
})
66+
67+
test("GET /devices/list with pages", async (t: ExecutionContext) => {
68+
const { axios, db } = await getTestServer(t, { seed: false })
69+
const seed_result = seedDatabase(db)
70+
71+
const params = { limit: 2 }
72+
73+
axios.defaults.headers.common.Authorization = `Bearer ${seed_result.seam_apikey1_token}`
74+
75+
const {
76+
data: {
77+
devices,
78+
pagination: { has_next_page, next_page_cursor, next_page_url },
79+
},
80+
} = await axios.get("/devices/list")
81+
t.false(has_next_page)
82+
t.is(next_page_cursor, null)
83+
t.is(next_page_url, null)
84+
t.is(devices.length, 5)
85+
86+
const {
87+
data: {
88+
devices: page1,
89+
pagination: {
90+
has_next_page: has_page_2,
91+
next_page_cursor: page2_cursor,
92+
next_page_url: page2_url,
93+
},
94+
},
95+
} = await axios.get("/devices/list", { params })
96+
97+
t.is(page1.length, 2)
98+
t.true(has_page_2)
99+
t.truthy(page2_cursor)
100+
101+
if (page2_url == null) {
102+
t.fail("Null next_page_url")
103+
return
104+
}
105+
106+
const url = new URL(page2_url)
107+
t.is(url.pathname, "/devices/list")
108+
t.deepEqual(url.searchParams.getAll("limit"), ["2"])
109+
110+
t.deepEqual(page1, [devices[0], devices[1]])
111+
112+
const {
113+
data: {
114+
devices: page2,
115+
pagination: { has_next_page: has_page_3, next_page_cursor: page3_cursor },
116+
},
117+
} = await axios.get("/devices/list", {
118+
params: { ...params, page_cursor: page2_cursor },
119+
})
120+
121+
t.is(page2.length, 2)
122+
t.true(has_page_3)
123+
t.truthy(page3_cursor)
124+
125+
t.deepEqual(page2, [devices[2], devices[3]])
126+
127+
const {
128+
data: {
129+
devices: page3,
130+
pagination: { has_next_page: has_page_4, next_page_cursor: page4_cursor },
131+
},
132+
} = await axios.get("/devices/list", {
133+
params: { ...params, page_cursor: page3_cursor },
134+
})
135+
136+
t.is(page3.length, 1)
137+
t.false(has_page_4)
138+
t.is(page4_cursor, null)
139+
140+
t.deepEqual(page3, [devices[4]])
141+
})
142+
143+
test("GET /devices/list validates query hash", async (t: ExecutionContext) => {
144+
const { axios, db } = await getTestServer(t, { seed: false })
145+
const seed_result = seedDatabase(db)
146+
147+
axios.defaults.headers.common.Authorization = `Bearer ${seed_result.seam_apikey1_token}`
148+
149+
const {
150+
data: {
151+
pagination: { has_next_page, next_page_cursor },
152+
},
153+
} = await axios.get("/devices/list", { params: { limit: 2 } })
154+
155+
t.true(has_next_page)
156+
157+
const err = await t.throwsAsync<SimpleAxiosError>(
158+
async () =>
159+
await axios.get("/devices/list", {
160+
params: { limit: 3, page_cursor: next_page_cursor },
161+
}),
162+
)
163+
t.is(err?.status, 400)
164+
t.regex(
165+
(err?.response?.error?.message as string) ?? "",
166+
/parameters identical/,
167+
)
168+
169+
const err_empty = await t.throwsAsync<SimpleAxiosError>(
170+
async () =>
171+
await axios.get("/devices/list", {
172+
params: { page_cursor: next_page_cursor },
173+
}),
174+
)
175+
t.is(err_empty?.status, 400)
176+
t.regex(
177+
(err_empty?.response?.error?.message as string) ?? "",
178+
/parameters identical/,
179+
)
180+
181+
const err_post = await t.throwsAsync<SimpleAxiosError>(
182+
async () =>
183+
await axios.post("/devices/list", {
184+
limit: 3,
185+
device_types: ["august_lock"],
186+
page_cursor: next_page_cursor,
187+
}),
188+
)
189+
t.is(err_post?.status, 400)
190+
t.regex(
191+
(err_post?.response?.error?.message as string) ?? "",
192+
/parameters identical/,
193+
)
194+
})
195+
196+
test("GET /devices/list handles array params", async (t: ExecutionContext) => {
197+
const { axios, db } = await getTestServer(t, { seed: false })
198+
const seed_result = seedDatabase(db)
199+
200+
axios.defaults.headers.common.Authorization = `Bearer ${seed_result.seam_apikey1_token}`
201+
202+
const {
203+
data: {
204+
pagination: { has_next_page, next_page_cursor, next_page_url },
205+
},
206+
} = await axios.get("/devices/list", {
207+
params: { limit: 1, device_types: ["august_lock", "schlage_lock"] },
208+
})
209+
210+
t.true(has_next_page)
211+
t.truthy(next_page_cursor)
212+
213+
if (next_page_url == null) {
214+
t.fail("Null next_page_url")
215+
return
216+
}
217+
218+
const url = new URL(next_page_url)
219+
t.is(url.pathname, "/devices/list")
220+
t.deepEqual(url.searchParams.getAll("limit"), ["1"])
221+
t.deepEqual(url.searchParams.getAll("device_types"), [
222+
"august_lock",
223+
"schlage_lock",
224+
])
225+
})
226+
54227
test("GET /devices/list with filters", async (t: ExecutionContext) => {
55228
const { axios, db } = await getTestServer(t, { seed: false })
56229
const seed_result = seedDatabase(db)

test/array-parsing.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ test.failing("index array param", async (t: ExecutionContext) => {
5959
t.is(devices.length, 2)
6060
})
6161

62-
test("empty array param", async (t: ExecutionContext) => {
62+
// UPSTREAM: nextlove will parse device_ids= to [''] but it should parse to []
63+
test.failing("empty array param", async (t: ExecutionContext) => {
6364
const { axios, db } = await getTestServer(t, { seed: false })
6465
const seed_result = seedDatabase(db)
6566

0 commit comments

Comments
 (0)