Skip to content

Commit 4ac3ba8

Browse files
authored
Add typesafety for Assembly Status & Index (#229)
* parse result * Use the project's typescript in vscode (or else erasableSyntaxOnly is not recognized) * Update zodParseWithContext.ts * first swing at index * Update assemblyStatus.ts * Don't let unknown meta keys explode on us
1 parent 53c5863 commit 4ac3ba8

File tree

6 files changed

+136
-53
lines changed

6 files changed

+136
-53
lines changed

.vscode/node-sdk.code-workspace

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
"titleBar.activeForeground": "#3e3e3e",
1010
"titleBar.activeBackground": "#ffd100",
1111
},
12+
"typescript.tsdk": "node_modules/typescript/lib",
1213
},
1314
}

src/Transloadit.ts

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@ import { isReadableStream, isStream } from 'is-stream'
1616
import * as assert from 'assert'
1717
import pMap from 'p-map'
1818
import type { Readable } from 'stream'
19+
import { z } from 'zod'
1920
import InconsistentResponseError from './InconsistentResponseError.js'
2021
import PaginationStream from './PaginationStream.js'
2122
import PollingTimeoutError from './PollingTimeoutError.js'
2223
import { TransloaditErrorResponseBody, ApiError } from './ApiError.js'
2324
import packageJson from '../package.json' with { type: 'json' }
2425
import { sendTusRequest, Stream } from './tus.js'
25-
import { AssemblyStatus } from './alphalib/types/assemblyStatus.js'
26+
import {
27+
AssemblyStatus,
28+
assemblyStatusSchema,
29+
assemblyIndexItemSchema,
30+
type AssemblyIndexItem,
31+
} from './alphalib/types/assemblyStatus.js'
2632
import type {
2733
BaseResponse,
2834
BillResponse,
@@ -31,8 +37,6 @@ import type {
3137
CreateTemplateParams,
3238
EditTemplateParams,
3339
ListAssembliesParams,
34-
ListedAssembly,
35-
ListedTemplate,
3640
ListTemplateCredentialsParams,
3741
ListTemplatesParams,
3842
OptionalAuthParams,
@@ -44,7 +48,9 @@ import type {
4448
TemplateCredentialResponse,
4549
TemplateCredentialsResponse,
4650
TemplateResponse,
51+
ListedTemplate,
4752
} from './apiTypes.js'
53+
import { zodParseWithContext } from './alphalib/zodParseWithContext.ts'
4854

4955
export * from './apiTypes.js'
5056

@@ -384,16 +390,19 @@ export class Transloadit {
384390
* @returns after the assembly is deleted
385391
*/
386392
async cancelAssembly(assemblyId: string): Promise<AssemblyStatus> {
387-
// You may wonder why do we need to call getAssembly first:
388-
// If we use the default base URL (instead of the one returned in assembly_url_ssl),
389-
// the delete call will hang in certain cases
390-
// See test "should stop the assembly from reaching completion"
391393
const { assembly_ssl_url: url } = await this.getAssembly(assemblyId)
392-
return this._remoteJson({
394+
const rawResult = await this._remoteJson<Record<string, unknown>, OptionalAuthParams>({
393395
url,
394-
// urlSuffix: `/assemblies/${assemblyId}`, // Cannot simply do this, see above
395396
method: 'delete',
396397
})
398+
399+
const parsedResult = zodParseWithContext(assemblyStatusSchema, rawResult)
400+
if (!parsedResult.success) {
401+
throw new InconsistentResponseError(
402+
`The API responded with data that does not match the expected schema.\n${parsedResult.humanReadable}`
403+
)
404+
}
405+
return parsedResult.safe
397406
}
398407

399408
/**
@@ -442,12 +451,38 @@ export class Transloadit {
442451
*/
443452
async listAssemblies(
444453
params?: ListAssembliesParams
445-
): Promise<PaginationListWithCount<ListedAssembly>> {
446-
return this._remoteJson({
454+
): Promise<PaginationListWithCount<AssemblyIndexItem>> {
455+
const rawResponse = await this._remoteJson<
456+
PaginationListWithCount<Record<string, unknown>>,
457+
ListAssembliesParams
458+
>({
447459
urlSuffix: '/assemblies',
448460
method: 'get',
449461
params: params || {},
450462
})
463+
464+
if (
465+
rawResponse == null ||
466+
typeof rawResponse !== 'object' ||
467+
!Array.isArray(rawResponse.items)
468+
) {
469+
throw new InconsistentResponseError(
470+
'API response for listAssemblies is malformed or missing items array'
471+
)
472+
}
473+
474+
const parsedResult = zodParseWithContext(z.array(assemblyIndexItemSchema), rawResponse.items)
475+
476+
if (!parsedResult.success) {
477+
throw new InconsistentResponseError(
478+
`API response for listAssemblies contained items that do not match the expected schema.\n${parsedResult.humanReadable}`
479+
)
480+
}
481+
482+
return {
483+
items: parsedResult.safe,
484+
count: rawResponse.count,
485+
}
451486
}
452487

453488
streamAssemblies(params: ListAssembliesParams): Readable {
@@ -461,11 +496,20 @@ export class Transloadit {
461496
* @returns the retrieved Assembly
462497
*/
463498
async getAssembly(assemblyId: string): Promise<AssemblyStatus> {
464-
const result: AssemblyStatus = await this._remoteJson({
499+
const rawResult = await this._remoteJson<Record<string, unknown>, OptionalAuthParams>({
465500
urlSuffix: `/assemblies/${assemblyId}`,
466501
})
467-
checkAssemblyUrls(result)
468-
return result
502+
503+
const parsedResult = zodParseWithContext(assemblyStatusSchema, rawResult)
504+
505+
if (!parsedResult.success) {
506+
throw new InconsistentResponseError(
507+
`The API responded with data that does not match the expected schema.\n${parsedResult.humanReadable}`
508+
)
509+
}
510+
511+
checkAssemblyUrls(parsedResult.safe)
512+
return parsedResult.safe
469513
}
470514

471515
/**

src/alphalib/types/assemblyStatus.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { z } from 'zod'
22

33
const assemblyBusyCodeSchema = z.enum(['ASSEMBLY_UPLOADING'])
44

5-
const assemblyStatusOkCodeSchema = z.enum([
5+
export const assemblyStatusOkCodeSchema = z.enum([
66
'ASSEMBLY_COMPLETED',
77
'REQUEST_ABORTED',
88
'ASSEMBLY_CANCELED',
99
'ASSEMBLY_EXECUTING',
1010
])
1111

12-
const assemblyStatusErrCodeSchema = z.enum([
12+
export const assemblyStatusErrCodeSchema = z.enum([
1313
'INVALID_INPUT_ERROR',
1414
'FILE_FILTER_DECLINED_FILE',
1515
'INTERNAL_COMMAND_TIMEOUT',
@@ -202,7 +202,7 @@ const assemblyStatusMetaSchema = z
202202
line_count: z.union([z.number(), z.null()]).optional(),
203203
paragraph_count: z.union([z.number(), z.null()]).optional(),
204204
})
205-
.strict()
205+
.passthrough()
206206
export type AssemblyStatusMeta = z.infer<typeof assemblyStatusMetaSchema>
207207

208208
// --- Define HLS Nested Meta Schema ---
@@ -584,3 +584,25 @@ export function hasOkPartial(
584584
Boolean(assembly.ok)
585585
)
586586
}
587+
588+
// Schema for items returned by the List Assemblies endpoint
589+
export const assemblyIndexItemSchema = z
590+
.object({
591+
id: z.string(), // Likely always present for a list item
592+
parent_id: assemblyStatusBaseSchema.shape.parent_id.optional(), // from base, made optional explicitly
593+
account_id: assemblyStatusBaseSchema.shape.account_id.unwrap().optional(), // from base (it's string().optional() so unwrap then optional)
594+
template_id: assemblyStatusBaseSchema.shape.template_id.optional(), // from base, made optional
595+
instance: assemblyStatusBaseSchema.shape.instance.unwrap().optional(), // from base
596+
notify_url: assemblyStatusBaseSchema.shape.notify_url.optional(), // from base
597+
redirect_url: z.string().nullable().optional(), // Specific to list item, was in old ListedAssembly
598+
files: z.string(), // JSON stringified, specific to list item
599+
warning_count: z.number().optional(), // Specific to list item
600+
execution_duration: assemblyStatusBaseSchema.shape.execution_duration.optional(), // from base
601+
execution_start: assemblyStatusBaseSchema.shape.execution_start.optional(), // from base
602+
ok: assemblyStatusOkCodeSchema.nullable().optional(), // Use exported enum
603+
error: assemblyStatusErrCodeSchema.nullable().optional(), // Use exported enum
604+
created: z.string(), // Specific to list item, mandatory based on old interface
605+
})
606+
.strict()
607+
608+
export type AssemblyIndexItem = z.infer<typeof assemblyIndexItemSchema>

src/alphalib/zodParseWithContext.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,39 @@ export function zodParseWithContext<T extends z.ZodType>(
3636
): ZodParseWithContextResult<T> {
3737
const zodRes = schema.safeParse(obj)
3838
if (!zodRes.success) {
39+
// Check for empty object input causing a general union failure
40+
if (
41+
typeof obj === 'object' &&
42+
obj !== null &&
43+
Object.keys(obj).length === 0 &&
44+
zodRes.error.errors.length > 0
45+
) {
46+
// eslint-disable-next-line no-console
47+
// console.log('[zodParseWithContext] Empty object detected, Zod errors:', JSON.stringify(zodRes.error.errors, null, 2));
48+
49+
const firstError = zodRes.error.errors[0]
50+
if (
51+
zodRes.error.errors.length === 1 &&
52+
firstError &&
53+
firstError.code === 'invalid_union' &&
54+
firstError.path.length === 0 &&
55+
Array.isArray((firstError as z.ZodInvalidUnionIssue).unionErrors) &&
56+
(firstError as z.ZodInvalidUnionIssue).unionErrors.length > 0
57+
) {
58+
const humanReadable =
59+
"Validation failed: Input object is empty or missing key fields required to determine its type, " +
60+
"and does not match any variant of the expected schema. Please provide a valid object."
61+
return {
62+
success: false,
63+
// For this specific summarized error, we might not need to map all detailed ZodIssueWithContext
64+
// or we can provide a simplified single error entry reflecting this summary.
65+
// For now, let's return the original errors but with the new top-level humanReadable.
66+
errors: zodRes.error.errors.map(e => ({...e, parentObj: obj, humanReadable: e.message})),
67+
humanReadable,
68+
}
69+
}
70+
}
71+
3972
const zodIssuesWithContext: ZodIssueWithContext[] = []
4073
const badPaths = new Map<string, string[]>()
4174

src/apiTypes.ts

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { AssemblyInstructions, AssemblyInstructionsInput } from './alphalib/types/template.js'
22

33
export { assemblyInstructionsSchema } from './alphalib/types/template.js'
4-
export { assemblyStatusSchema } from './alphalib/types/assemblyStatus.js'
4+
export {
5+
assemblyStatusSchema,
6+
assemblyIndexItemSchema,
7+
type AssemblyIndexItem,
8+
} from './alphalib/types/assemblyStatus.js'
59

610
export interface OptionalAuthParams {
711
auth?: { key?: string; expires?: string }
@@ -37,25 +41,6 @@ export type ListAssembliesParams = OptionalAuthParams & {
3741
keywords?: string[]
3842
}
3943

40-
// todo this is outdated and possibly wrong
41-
/** See https://transloadit.com/docs/api/assemblies-assembly-id-get/ */
42-
export interface ListedAssembly {
43-
id: string
44-
parent_id?: string | null
45-
account_id: string
46-
template_id?: string | null
47-
instance: string
48-
notify_url?: string | null
49-
redirect_url?: string | null
50-
files: string // json stringified
51-
warning_count: number
52-
execution_duration: number
53-
execution_start: string
54-
ok?: string | null
55-
error?: string | null
56-
created: string
57-
}
58-
5944
export type ReplayAssemblyParams = Pick<
6045
CreateAssemblyParams,
6146
'auth' | 'template_id' | 'notify_url' | 'fields'

test/unit/mock-http.test.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -238,27 +238,25 @@ describe('Mocked API tests', () => {
238238
it('should throw error on missing assembly_url/assembly_ssl_url', async () => {
239239
const client = getLocalClient()
240240

241+
const validOkStatusMissingUrls = {
242+
ok: 'ASSEMBLY_COMPLETED',
243+
assembly_id: 'test-id', // assembly_id is optional, but good to have for a realistic "ok" status
244+
// assembly_url is intentionally missing/null
245+
// assembly_ssl_url is intentionally missing/null
246+
}
247+
241248
const scope = nock('http://localhost')
242249
.get('/assemblies/1')
243250
.query(() => true)
244-
.reply(200, {
245-
assembly_url: 'https://transloadit.com/',
246-
assembly_ssl_url: 'https://transloadit.com/',
247-
})
248-
.get('/assemblies/1')
249-
.query(() => true)
250-
.reply(200, {})
251+
.reply(200, validOkStatusMissingUrls)
251252

252-
// Success
253-
await client.getAssembly('1')
254-
255-
// Failure
253+
// This call should pass Zod validation but fail at checkAssemblyUrls
256254
const promise = client.getAssembly('1')
257-
await expect(promise).rejects.toThrow(InconsistentResponseError)
258-
await expect(promise).rejects.toThrow(
259-
expect.objectContaining({
260-
message: 'Server returned an incomplete assembly response (no URL)',
261-
})
255+
256+
await expect(promise).rejects.toBeInstanceOf(InconsistentResponseError)
257+
await expect(promise).rejects.toHaveProperty(
258+
'message',
259+
'Server returned an incomplete assembly response (no URL)'
262260
)
263261
scope.done()
264262
})

0 commit comments

Comments
 (0)