Skip to content

Commit 2a368c1

Browse files
committed
fix: Validate every paginated request like the original request
The paginator rebuilt each page request with only the pathname, method, response key, and data, dropping the request parameters and validation configuration, so required parameter checks were skipped for every paginated fetch including the first page. A cursor was also dropped entirely when the original request had no params or body. Build each page through a new SeamHttpRequest.withPageCursor, which copies the entire request configuration and merges the page cursor into the params or body chosen by the request method. New configuration fields now travel to page requests automatically instead of being hand-copied in the paginator. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2
1 parent 4c71913 commit 2a368c1

3 files changed

Lines changed: 65 additions & 17 deletions

File tree

src/lib/seam-http-request.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,32 @@ export class SeamHttpRequest<
113113
return this.#config.body
114114
}
115115

116+
/**
117+
* Returns a copy of this request with the page_cursor parameter set,
118+
* keeping the entire request configuration,
119+
* so every page is built and validated exactly like the original request.
120+
* Used by SeamPaginator to fetch pages.
121+
*/
122+
withPageCursor(
123+
pageCursor?: string,
124+
): SeamHttpRequest<TResponse, TResponseKey> {
125+
const usesParams = ['GET', 'DELETE'].includes(this.method.toUpperCase())
126+
127+
const requestData = {
128+
...(usesParams
129+
? (this.#config.params ?? {})
130+
: ((this.#config.body as Record<string, unknown> | null) ?? {})),
131+
page_cursor: pageCursor,
132+
}
133+
134+
return new SeamHttpRequest(this.#parent, {
135+
...this.#config,
136+
parameters: requestData,
137+
params: usesParams ? requestData : undefined,
138+
body: usesParams ? undefined : requestData,
139+
})
140+
}
141+
116142
/**
117143
* Sends the request and returns the response data.
118144
* If the response contains an action attempt,

src/lib/seam-paginator.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Client } from './client.js'
22
import type { SeamHttpRequestOptions } from './options.js'
3-
import { SeamHttpRequest } from './seam-http-request.js'
3+
import type { SeamHttpRequest } from './seam-http-request.js'
44

55
interface SeamPaginatorParent {
66
readonly client: Client
@@ -34,18 +34,16 @@ export class SeamPaginator<
3434
const TResponseKey extends keyof TResponse,
3535
> implements AsyncIterable<EnsureReadonlyArray<TResponse[TResponseKey]>> {
3636
readonly #request: SeamHttpRequest<TResponse, TResponseKey>
37-
readonly #parent: SeamPaginatorParent
3837

3938
constructor(
40-
parent: SeamPaginatorParent,
39+
_parent: SeamPaginatorParent,
4140
request: SeamHttpRequest<TResponse, TResponseKey>,
4241
) {
4342
if (!request.hasPagination) {
4443
throw new Error(
4544
`The ${request.pathname} endpoint does not support pagination`,
4645
)
4746
}
48-
this.#parent = parent
4947
this.#request = request
5048
}
5149

@@ -81,19 +79,7 @@ export class SeamPaginator<
8179
throw new Error('Cannot paginate a response without a responseKey')
8280
}
8381

84-
const request = new SeamHttpRequest<TResponse, TResponseKey>(this.#parent, {
85-
pathname: this.#request.pathname,
86-
method: this.#request.method,
87-
responseKey,
88-
params:
89-
this.#request.params != null
90-
? { ...this.#request.params, page_cursor: nextPageCursor }
91-
: undefined,
92-
body:
93-
this.#request.body != null
94-
? { ...this.#request.body, page_cursor: nextPageCursor }
95-
: undefined,
96-
})
82+
const request = this.#request.withPageCursor(nextPageCursor ?? undefined)
9783

9884
const response = await request.fetchResponse()
9985
const data = response[responseKey]

test/seam/connect/seam-paginator.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,42 @@ test('SeamPaginator: flatten allows iteration over all devices', async (t) => {
8888
t.is(devices.length, allDevices.length)
8989
})
9090

91+
test('SeamPaginator: validates request parameters before fetching a page', async (t) => {
92+
const { seed, endpoint } = await getTestServer(t)
93+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })
94+
95+
let requestCount = 0
96+
seam.client.interceptors.request.use((config) => {
97+
if (config.url === '/access_codes/list') requestCount++
98+
return config
99+
})
100+
101+
const pages = seam.createPaginator(
102+
// @ts-expect-error Verify an invalid request is rejected when paginated.
103+
seam.accessCodes.list({}),
104+
)
105+
106+
await t.throwsAsync(async () => await pages.firstPage(), {
107+
instanceOf: TypeError,
108+
message: 'At least one parameter is required for /access_codes/list',
109+
})
110+
111+
t.is(requestCount, 0)
112+
})
113+
114+
test('SeamPaginator: fetches pages for a request with valid parameters', async (t) => {
115+
const { seed, endpoint } = await getTestServer(t)
116+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })
117+
118+
const pages = seam.createPaginator(
119+
seam.accessCodes.list({ device_id: seed.august_device_1 }),
120+
)
121+
const [accessCodes, pagination] = await pages.firstPage()
122+
123+
t.true(Array.isArray(accessCodes))
124+
t.false(pagination.hasNextPage)
125+
})
126+
91127
test('SeamPaginator: instance allows iteration over all pages', async (t) => {
92128
const { seed, endpoint } = await getTestServer(t)
93129
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

0 commit comments

Comments
 (0)