Skip to content

Commit 10ca657

Browse files
razor-xseambot
andauthored
feat: Add waitForActionAttempt option (#22)
Co-authored-by: Seam Bot <[email protected]>
1 parent e9660ef commit 10ca657

30 files changed

+868
-2
lines changed

.gitattributes

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
src/lib/seam/connect/routes/** linguist-generated

README.md

+68-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Seam HTTP client.
1+
# Seam HTTP Client
22

33
[![npm](https://img.shields.io/npm/v/@seamapi/http.svg)](https://www.npmjs.com/package/@seamapi/http)
44
[![GitHub Actions](https://github.com/seamapi/javascript-http/actions/workflows/check.yml/badge.svg)](https://github.com/seamapi/javascript-http/actions/workflows/check.yml)
@@ -145,6 +145,73 @@ const seam = SeamHttp.fromConsoleSessionToken(
145145
)
146146
```
147147

148+
### Action Attempts
149+
150+
Some asynchronous operations, e.g., unlocking a door, return an [action attempt].
151+
Seam tracks the progress of requested operation and updates the action attempt.
152+
153+
To make working with action attempts more convenient for applications,
154+
this library provides the `waitForActionAttempt` option:
155+
156+
```ts
157+
await seam.locks.unlockDoor(
158+
{ device_id },
159+
{
160+
waitForActionAttempt: true,
161+
},
162+
)
163+
```
164+
165+
Using the `waitForActionAttempt` option:
166+
167+
- Polls the action attempt up to the `timeout`
168+
at the `pollingInterval` (both in milliseconds).
169+
- Resolves with a fresh copy of the successful action attempt.
170+
- Rejects with a `SeamActionAttemptFailedError` if the action attempt is unsuccessful.
171+
- Rejects with a `SeamActionAttemptTimeoutError` if the action attempt is still pending when the `timeout` is reached.
172+
- Both errors expose an `actionAttempt` property.
173+
174+
```ts
175+
import {
176+
SeamHttp,
177+
isSeamActionAttemptFailedError,
178+
isSeamActionAttemptTimeoutError,
179+
} from '@seamapi/http/connect'
180+
181+
const seam = new SeamHttp('your-api-key')
182+
183+
const [lock] = await seam.locks.list()
184+
185+
if (lock == null) throw new Error('No locks in this workspace')
186+
187+
try {
188+
await seam.locks.unlockDoor(
189+
{ device_id: lock.device_id },
190+
{
191+
waitForActionAttempt: {
192+
pollingInterval: 1000,
193+
timeout: 5000,
194+
},
195+
},
196+
)
197+
console.log('Door unlocked')
198+
} catch (err: unknown) {
199+
if (isSeamActionAttemptFailedError(err)) {
200+
console.log('Could not unlock the door')
201+
return
202+
}
203+
204+
if (isSeamActionAttemptTimeoutError(err)) {
205+
console.log('Door took too long to unlock')
206+
return
207+
}
208+
209+
throw err
210+
}
211+
```
212+
213+
[action attempt]: https://docs.seam.co/latest/core-concepts/action-attempts
214+
148215
### Advanced Usage
149216

150217
In addition the various authentication options,

examples/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import landlubber, {
1212

1313
import { SeamHttp } from '@seamapi/http/connect'
1414

15+
import * as locks from './locks.js'
16+
import * as unlock from './unlock.js'
1517
import * as workspace from './workspace.js'
1618

1719
export type Handler<Options = EmptyOptions> = DefaultHandler<Options, Context>
@@ -22,7 +24,7 @@ interface ClientContext {
2224
seam: SeamHttp
2325
}
2426

25-
const commands = [workspace]
27+
const commands = [locks, unlock, workspace]
2628

2729
const createAppContext: MiddlewareFunction = async (argv) => {
2830
const apiKey = argv['api-key']

examples/locks.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Builder, Command, Describe } from 'landlubber'
2+
3+
import type { Handler } from './index.js'
4+
5+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
6+
interface Options {}
7+
8+
export const command: Command = 'locks'
9+
10+
export const describe: Describe = 'List locks'
11+
12+
export const builder: Builder = {}
13+
14+
export const handler: Handler<Options> = async ({ seam, logger }) => {
15+
const devices = await seam.locks.list()
16+
logger.info({ devices }, 'locks')
17+
}

examples/unlock.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Builder, Command, Describe } from 'landlubber'
2+
3+
import {
4+
isSeamActionAttemptFailedError,
5+
isSeamActionAttemptTimeoutError,
6+
type LocksUnlockDoorResponse,
7+
} from '@seamapi/http/connect'
8+
9+
import type { Handler } from './index.js'
10+
11+
interface Options {
12+
deviceId: string
13+
}
14+
15+
export const command: Command = 'unlock deviceId'
16+
17+
export const describe: Describe = 'Unlock a door'
18+
19+
export const builder: Builder = {
20+
deviceId: {
21+
type: 'string',
22+
describe: 'Device id of lock to unlock',
23+
},
24+
}
25+
26+
export const handler: Handler<Options> = async ({ deviceId, seam, logger }) => {
27+
try {
28+
const actionAttempt = await seam.locks.unlockDoor(
29+
{
30+
device_id: deviceId,
31+
},
32+
{ waitForActionAttempt: true },
33+
)
34+
logger.info({ actionAttempt }, 'unlocked')
35+
} catch (err: unknown) {
36+
if (isSeamActionAttemptFailedError<UnlockDoorActionAttempt>(err)) {
37+
logger.info({ err }, 'Could not unlock the door')
38+
return
39+
}
40+
41+
if (isSeamActionAttemptTimeoutError<UnlockDoorActionAttempt>(err)) {
42+
logger.info({ err }, 'Door took too long to unlock')
43+
return
44+
}
45+
46+
throw err
47+
}
48+
}
49+
50+
type UnlockDoorActionAttempt = LocksUnlockDoorResponse['action_attempt']

generate-routes.ts

+57
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,21 @@ import {
256256
type SeamHttpOptionsWithPersonalAccessToken,
257257
} from 'lib/seam/connect/options.js'
258258
import { parseOptions } from 'lib/seam/connect/parse-options.js'
259+
import {
260+
resolveActionAttempt,
261+
type ResolveActionAttemptOptions,
262+
} from 'lib/seam/connect/resolve-action-attempt.js'
259263
260264
${
261265
namespace === 'client_sessions'
262266
? ''
263267
: "import { SeamHttpClientSessions } from './client-sessions.js'"
264268
}
269+
${
270+
namespace === 'action_attempts'
271+
? ''
272+
: "import { SeamHttpActionAttempts } from './action-attempts.js'"
273+
}
265274
${subresources
266275
.map((subresource) => renderSubresourceImport(subresource, namespace))
267276
.join('\n')}
@@ -314,6 +323,7 @@ const renderClassMethod = ({
314323
name,
315324
namespace,
316325
})},
326+
${renderClassMethodOptions({ resource })}
317327
): Promise<${
318328
resource === null
319329
? 'void'
@@ -330,10 +340,51 @@ const renderClassMethod = ({
330340
requestFormat === 'params' ? 'params,' : ''
331341
} ${requestFormat === 'body' ? 'data: body,' : ''}
332342
})
343+
${
344+
resource === 'action_attempt'
345+
? `if (waitForActionAttempt != null && waitForActionAttempt !== false) {
346+
return resolveActionAttempt(
347+
data.${resource},
348+
SeamHttpActionAttempts.fromClient(this.client),
349+
typeof waitForActionAttempt === 'boolean' ? {} : waitForActionAttempt,
350+
)
351+
}`
352+
: ''
353+
}
333354
${resource === null ? '' : `return data.${resource}`}
334355
}
335356
`
336357

358+
const renderClassMethodOptions = ({
359+
resource,
360+
}: Pick<Endpoint, 'resource'>): string => {
361+
if (resource === 'action_attempt') {
362+
return `{ waitForActionAttempt = false }: ${renderClassMethodOptionsTypeDef(
363+
{ resource },
364+
)} = {},`
365+
}
366+
return ''
367+
}
368+
369+
const renderClassMethodOptionsType = ({
370+
name,
371+
namespace,
372+
}: Pick<Endpoint, 'name' | 'namespace'>): string =>
373+
[pascalCase(namespace), pascalCase(name), 'Options'].join('')
374+
375+
const renderClassMethodOptionsTypeDef = ({
376+
resource,
377+
}: Pick<Endpoint, 'resource'>): string => {
378+
if (resource === 'action_attempt') {
379+
return `
380+
{
381+
waitForActionAttempt?: boolean | Partial<ResolveActionAttemptOptions>
382+
}
383+
`
384+
}
385+
return 'never'
386+
}
387+
337388
const renderSubresourceMethod = (
338389
subresource: string,
339390
namespace: string,
@@ -354,6 +405,7 @@ const renderEndpointExports = ({
354405
name,
355406
path,
356407
namespace,
408+
resource,
357409
requestFormat,
358410
}: Endpoint): string => `
359411
export type ${renderRequestType({
@@ -364,6 +416,11 @@ export type ${renderRequestType({
364416
export type ${renderResponseType({ name, namespace })}= SetNonNullable<
365417
Required<RouteResponse<'${path}'>>
366418
>
419+
420+
export type ${renderClassMethodOptionsType({
421+
name,
422+
namespace,
423+
})} = ${renderClassMethodOptionsTypeDef({ resource })}
367424
`
368425

369426
const renderRequestType = ({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// UPSTREAM: These types should be provided by @seamapi/types/connect.
2+
3+
export interface ActionAttempt {
4+
action_attempt_id: string
5+
status: 'pending' | 'error' | 'success'
6+
}
7+
8+
export type SuccessfulActionAttempt<T extends ActionAttempt> = T & {
9+
status: 'success'
10+
}
11+
12+
export type PendingActionAttempt<T extends ActionAttempt> = T & {
13+
status: 'pending'
14+
}
15+
16+
export type FailedActionAttempt<T extends ActionAttempt> = T & {
17+
status: 'error'
18+
error: {
19+
type: string
20+
message: string
21+
}
22+
}

src/lib/seam/connect/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
export { SeamHttpInvalidTokenError } from './auth.js'
22
export * from './error-interceptor.js'
33
export * from './options.js'
4+
export {
5+
isSeamActionAttemptError,
6+
isSeamActionAttemptFailedError,
7+
isSeamActionAttemptTimeoutError,
8+
SeamActionAttemptError,
9+
SeamActionAttemptFailedError,
10+
SeamActionAttemptTimeoutError,
11+
} from './resolve-action-attempt.js'
412
export * from './routes/index.js'
513
export * from './seam-http.js'
614
export * from './seam-http-error.js'

0 commit comments

Comments
 (0)