Skip to content

Commit ea56750

Browse files
committed
Merge branch 'main' into claude/js-sdk-audit-6x115l-l8a-params-serializer
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2
2 parents f8723a8 + 31b4a93 commit ea56750

12 files changed

Lines changed: 642 additions & 37 deletions

.c8rc.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"exclude": [
3-
"**/index.ts",
43
"package/**/*.ts",
54
"examples/**/*.ts",
65
"**/*.test.ts",

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ const pages = seam.createPaginator(
350350
)
351351

352352
for await (const device of pages.flatten()) {
353-
console.log(devices.name)
353+
console.log(device.display_name)
354354
}
355355
```
356356

@@ -474,7 +474,8 @@ default.
474474

475475
The Axios client and retry behavior may be configured with custom initiation options
476476
via [`axiosOptions`][axiosOptions] and [`axiosRetryOptions`][axiosRetryOptions].
477-
Options are deep merged with the default options.
477+
Options are shallow merged with the default options:
478+
each provided top-level option replaces the default value.
478479

479480
By default, the SDK makes up to three attempts: the initial request and two
480481
retries. Retries are limited to `GET`, `HEAD`, `OPTIONS`, `PUT`, and `DELETE`
@@ -537,6 +538,12 @@ console.log(`${request.method} ${request.url}`, JSON.stringify(request.body))
537538
const devices = await request.execute()
538539
```
539540

541+
A `SeamHttpRequest` is sent at most once.
542+
Awaiting the same request again,
543+
or calling `execute`, `then`, `catch`, or `finally` more than once,
544+
always returns the result of the first execution
545+
and never repeats the HTTP request.
546+
540547
#### Serializing URL search params
541548

542549
The Seam API parses URL search params as complex types.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
22
"name": "@seamapi/http",
3-
"version": "2.23.0",
3+
"version": "2.23.5",
44
"description": "JavaScript HTTP client for the Seam API written in TypeScript.",
55
"type": "module",
66
"main": "index.js",
77
"types": "index.d.ts",
88
"exports": {
99
".": {
1010
"types": "./index.d.ts",
11-
"import": "./index.js"
11+
"default": "./index.js"
1212
},
1313
"./connect": {
1414
"types": "./index.d.ts",

src/lib/seam-http-error.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,38 @@ export const isSeamHttpInvalidInputError = (
103103
): error is SeamHttpInvalidInputError => {
104104
return error instanceof SeamHttpInvalidInputError
105105
}
106+
107+
/**
108+
* Error thrown when the Seam API returns a success response
109+
* with an unexpected shape,
110+
* e.g., a response missing the expected response key.
111+
*/
112+
export class SeamHttpInvalidResponseError extends Error {
113+
/**
114+
* Path of the endpoint that returned the invalid response.
115+
*/
116+
path: string
117+
118+
/**
119+
* Key expected to contain the response data.
120+
*/
121+
responseKey: string
122+
123+
constructor(path: string, responseKey: string, reason: string) {
124+
super(
125+
`Seam returned an invalid response for ${path}: expected "${responseKey}", ${reason}`,
126+
)
127+
this.name = this.constructor.name
128+
this.path = path
129+
this.responseKey = responseKey
130+
}
131+
}
132+
133+
/**
134+
* Returns true if the error is a {@link SeamHttpInvalidResponseError}.
135+
*/
136+
export const isSeamHttpInvalidResponseError = (
137+
error: unknown,
138+
): error is SeamHttpInvalidResponseError => {
139+
return error instanceof SeamHttpInvalidResponseError
140+
}

src/lib/seam-http-request.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
resolveActionAttempt,
99
} from './resolve-action-attempt.js'
1010
import type { ActionAttempt } from './resources/action-attempt.js'
11+
import { SeamHttpInvalidResponseError } from './seam-http-error.js'
1112
import { serializeUrlSearchParams } from './url-search-params-serializer.js'
1213

1314
interface SeamHttpRequestParent {
@@ -36,6 +37,11 @@ interface SeamHttpRequestConfig<TResponseKey> {
3637
* The request is sent once `execute` is called,
3738
* or when the request is awaited like a Promise,
3839
* e.g., with `await`, `then`, `catch`, or `finally`.
40+
* The request is sent at most once:
41+
* awaiting the same SeamHttpRequest again,
42+
* or calling `execute`, `then`, `catch`, or `finally` more than once,
43+
* always returns the result of the first execution
44+
* and never repeats the HTTP request.
3945
* When the response contains an action attempt,
4046
* awaiting the request also waits for the action attempt to resolve
4147
* according to the `waitForActionAttempt` option.
@@ -54,6 +60,12 @@ export class SeamHttpRequest<
5460
readonly #parent: SeamHttpRequestParent
5561
readonly #config: SeamHttpRequestConfig<TResponseKey>
5662

63+
#executePromise: Promise<
64+
TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined
65+
> | null = null
66+
67+
#fetchResponsePromise: Promise<TResponse> | null = null
68+
5769
constructor(
5870
parent: SeamHttpRequestParent,
5971
config: SeamHttpRequestConfig<TResponseKey>,
@@ -115,9 +127,19 @@ export class SeamHttpRequest<
115127
* If the response contains an action attempt,
116128
* waits for the action attempt to resolve
117129
* according to the `waitForActionAttempt` option.
130+
* The request is sent at most once:
131+
* calling this method again returns the result of the first call
132+
* and never repeats the HTTP request.
118133
*/
119134
async execute(): Promise<
120135
TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined
136+
> {
137+
this.#executePromise ??= this.#execute()
138+
return await this.#executePromise
139+
}
140+
141+
async #execute(): Promise<
142+
TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined
121143
> {
122144
const response = await this.fetchResponse()
123145

@@ -129,7 +151,11 @@ export class SeamHttpRequest<
129151
return undefined as Response
130152
}
131153

132-
const data = response[this.responseKey] as unknown as Response
154+
const data = readResponseData(
155+
response,
156+
this.responseKey,
157+
this.pathname,
158+
) as Response
133159

134160
if (this.responseKey === 'action_attempt') {
135161
const waitForActionAttempt =
@@ -157,8 +183,16 @@ export class SeamHttpRequest<
157183
/**
158184
* Sends the request and returns the entire response body
159185
* without waiting for any action attempt to resolve.
186+
* The request is sent at most once:
187+
* calling this method again returns the result of the first call
188+
* and never repeats the HTTP request.
160189
*/
161190
async fetchResponse(): Promise<TResponse> {
191+
this.#fetchResponsePromise ??= this.#fetchResponse()
192+
return await this.#fetchResponsePromise
193+
}
194+
195+
async #fetchResponse(): Promise<TResponse> {
162196
assertValidRequestParameters(
163197
this.#config.parameters,
164198
this.pathname,
@@ -229,8 +263,40 @@ const getParamsSerializer = (
229263
return serializeUrlSearchParams
230264
}
231265

266+
/**
267+
* Reads the response data at the response key,
268+
* throwing a {@link SeamHttpInvalidResponseError} for a success response
269+
* that is not an object or does not contain the response key.
270+
*/
271+
export const readResponseData = <
272+
TResponse,
273+
TResponseKey extends keyof TResponse,
274+
>(
275+
response: TResponse,
276+
responseKey: TResponseKey,
277+
path: string,
278+
): TResponse[TResponseKey] => {
279+
if (response == null || typeof response !== 'object') {
280+
throw new SeamHttpInvalidResponseError(
281+
path,
282+
String(responseKey),
283+
`got ${response === null ? 'null' : typeof response} instead of a response object`,
284+
)
285+
}
286+
287+
if (!(responseKey in response)) {
288+
throw new SeamHttpInvalidResponseError(
289+
path,
290+
String(responseKey),
291+
'which the response does not contain',
292+
)
293+
}
294+
295+
return response[responseKey]
296+
}
297+
232298
const getUrlPrefix = (input: string): string => {
233-
if (canParseUrl(input)) {
299+
if (isAbsoluteHttpUrl(input)) {
234300
const url = new URL(input).toString()
235301
if (url.endsWith('/')) return url.slice(0, -1)
236302
return url
@@ -246,11 +312,13 @@ const getUrlPrefix = (input: string): string => {
246312
)
247313
}
248314

249-
// UPSTREAM: Prefer URL.canParse when it has wider support.
250-
// https://caniuse.com/mdn-api_url_canparse_static
251-
const canParseUrl = (input: string): boolean => {
315+
// An input without an http or https scheme, e.g., localhost:3000,
316+
// may still parse as a URL with an unintended scheme, e.g., localhost:,
317+
// and must not be treated as an absolute URL.
318+
const isAbsoluteHttpUrl = (input: string): boolean => {
252319
try {
253-
return new URL(input) != null
320+
const { protocol } = new URL(input)
321+
return protocol === 'http:' || protocol === 'https:'
254322
} catch {
255323
return false
256324
}

src/lib/seam-paginator.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Client } from './client.js'
22
import type { SeamHttpRequestOptions } from './options.js'
3-
import { SeamHttpRequest } from './seam-http-request.js'
3+
import { SeamHttpInvalidResponseError } from './seam-http-error.js'
4+
import { readResponseData, SeamHttpRequest } from './seam-http-request.js'
45

56
interface SeamPaginatorParent {
67
readonly client: Client
@@ -96,27 +97,38 @@ export class SeamPaginator<
9697
})
9798

9899
const response = await request.fetchResponse()
99-
const data = response[responseKey]
100+
const data = readResponseData(response, responseKey, request.pathname)
100101

101-
const paginationData =
102-
response != null &&
103-
typeof response === 'object' &&
104-
'pagination' in response
105-
? (response.pagination as PaginationData)
106-
: null
107-
108-
const pagination: Pagination = {
109-
hasNextPage: paginationData?.has_next_page ?? false,
110-
nextPageCursor: paginationData?.next_page_cursor ?? null,
111-
nextPageUrl: paginationData?.next_page_url ?? null,
102+
if (!Array.isArray(data)) {
103+
throw new SeamHttpInvalidResponseError(
104+
request.pathname,
105+
String(responseKey),
106+
`got ${data === null ? 'null' : typeof data} instead of a list`,
107+
)
112108
}
113109

114-
if (!Array.isArray(data)) {
115-
throw new Error(
116-
`Expected an array response for ${String(responseKey)} but got ${String(typeof data)}`,
110+
const paginationData = readResponseData(
111+
response as { pagination: unknown },
112+
'pagination',
113+
request.pathname,
114+
)
115+
116+
if (paginationData === null || typeof paginationData !== 'object') {
117+
throw new SeamHttpInvalidResponseError(
118+
request.pathname,
119+
'pagination',
120+
`got ${paginationData === null ? 'null' : typeof paginationData} instead of a pagination object`,
117121
)
118122
}
119123

124+
const paginationResponse = paginationData as PaginationData
125+
126+
const pagination: Pagination = {
127+
hasNextPage: paginationResponse.has_next_page ?? false,
128+
nextPageCursor: paginationResponse.next_page_cursor ?? null,
129+
nextPageUrl: paginationResponse.next_page_url ?? null,
130+
}
131+
120132
return [
121133
data as EnsureReadonlyArray<TResponse[TResponseKey]>,
122134
pagination,
@@ -142,9 +154,7 @@ export class SeamPaginator<
142154
/**
143155
* Yields each item across all pages, fetching the next page as needed.
144156
*/
145-
async *flatten(): AsyncGenerator<
146-
EnsureReadonlyArray<TResponse[TResponseKey]>
147-
> {
157+
async *flatten(): AsyncGenerator<ElementOfArray<TResponse[TResponseKey]>> {
148158
let [current, pagination] = await this.firstPage()
149159
for (const item of current) {
150160
yield item
@@ -174,6 +184,8 @@ export class SeamPaginator<
174184

175185
type EnsureReadonlyArray<T> = T extends readonly any[] ? T : never
176186
type EnsureMutableArray<T> = T extends any[] ? T : never
187+
type ElementOfArray<T> =
188+
T extends ReadonlyArray<infer TElement> ? TElement : never
177189

178190
interface PaginationData {
179191
has_next_page: boolean

0 commit comments

Comments
 (0)