diff --git a/.vscode/node-sdk.code-workspace b/.vscode/node-sdk.code-workspace index 75322f69..7b518b52 100644 --- a/.vscode/node-sdk.code-workspace +++ b/.vscode/node-sdk.code-workspace @@ -9,5 +9,6 @@ "titleBar.activeForeground": "#3e3e3e", "titleBar.activeBackground": "#ffd100", }, + "typescript.tsdk": "node_modules/typescript/lib", }, } diff --git a/src/Transloadit.ts b/src/Transloadit.ts index 3d62915d..68ed8775 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -16,13 +16,19 @@ import { isReadableStream, isStream } from 'is-stream' import * as assert from 'assert' import pMap from 'p-map' import type { Readable } from 'stream' +import { z } from 'zod' import InconsistentResponseError from './InconsistentResponseError.js' import PaginationStream from './PaginationStream.js' import PollingTimeoutError from './PollingTimeoutError.js' import { TransloaditErrorResponseBody, ApiError } from './ApiError.js' import packageJson from '../package.json' with { type: 'json' } import { sendTusRequest, Stream } from './tus.js' -import { AssemblyStatus } from './alphalib/types/assemblyStatus.js' +import { + AssemblyStatus, + assemblyStatusSchema, + assemblyIndexItemSchema, + type AssemblyIndexItem, +} from './alphalib/types/assemblyStatus.js' import type { BaseResponse, BillResponse, @@ -31,8 +37,6 @@ import type { CreateTemplateParams, EditTemplateParams, ListAssembliesParams, - ListedAssembly, - ListedTemplate, ListTemplateCredentialsParams, ListTemplatesParams, OptionalAuthParams, @@ -44,7 +48,9 @@ import type { TemplateCredentialResponse, TemplateCredentialsResponse, TemplateResponse, + ListedTemplate, } from './apiTypes.js' +import { zodParseWithContext } from './alphalib/zodParseWithContext.ts' export * from './apiTypes.js' @@ -384,16 +390,19 @@ export class Transloadit { * @returns after the assembly is deleted */ async cancelAssembly(assemblyId: string): Promise { - // You may wonder why do we need to call getAssembly first: - // If we use the default base URL (instead of the one returned in assembly_url_ssl), - // the delete call will hang in certain cases - // See test "should stop the assembly from reaching completion" const { assembly_ssl_url: url } = await this.getAssembly(assemblyId) - return this._remoteJson({ + const rawResult = await this._remoteJson, OptionalAuthParams>({ url, - // urlSuffix: `/assemblies/${assemblyId}`, // Cannot simply do this, see above method: 'delete', }) + + const parsedResult = zodParseWithContext(assemblyStatusSchema, rawResult) + if (!parsedResult.success) { + throw new InconsistentResponseError( + `The API responded with data that does not match the expected schema.\n${parsedResult.humanReadable}` + ) + } + return parsedResult.safe } /** @@ -442,12 +451,38 @@ export class Transloadit { */ async listAssemblies( params?: ListAssembliesParams - ): Promise> { - return this._remoteJson({ + ): Promise> { + const rawResponse = await this._remoteJson< + PaginationListWithCount>, + ListAssembliesParams + >({ urlSuffix: '/assemblies', method: 'get', params: params || {}, }) + + if ( + rawResponse == null || + typeof rawResponse !== 'object' || + !Array.isArray(rawResponse.items) + ) { + throw new InconsistentResponseError( + 'API response for listAssemblies is malformed or missing items array' + ) + } + + const parsedResult = zodParseWithContext(z.array(assemblyIndexItemSchema), rawResponse.items) + + if (!parsedResult.success) { + throw new InconsistentResponseError( + `API response for listAssemblies contained items that do not match the expected schema.\n${parsedResult.humanReadable}` + ) + } + + return { + items: parsedResult.safe, + count: rawResponse.count, + } } streamAssemblies(params: ListAssembliesParams): Readable { @@ -461,11 +496,20 @@ export class Transloadit { * @returns the retrieved Assembly */ async getAssembly(assemblyId: string): Promise { - const result: AssemblyStatus = await this._remoteJson({ + const rawResult = await this._remoteJson, OptionalAuthParams>({ urlSuffix: `/assemblies/${assemblyId}`, }) - checkAssemblyUrls(result) - return result + + const parsedResult = zodParseWithContext(assemblyStatusSchema, rawResult) + + if (!parsedResult.success) { + throw new InconsistentResponseError( + `The API responded with data that does not match the expected schema.\n${parsedResult.humanReadable}` + ) + } + + checkAssemblyUrls(parsedResult.safe) + return parsedResult.safe } /** diff --git a/src/alphalib/types/assemblyStatus.ts b/src/alphalib/types/assemblyStatus.ts index 30a08ed7..e47c6614 100644 --- a/src/alphalib/types/assemblyStatus.ts +++ b/src/alphalib/types/assemblyStatus.ts @@ -2,14 +2,14 @@ import { z } from 'zod' const assemblyBusyCodeSchema = z.enum(['ASSEMBLY_UPLOADING']) -const assemblyStatusOkCodeSchema = z.enum([ +export const assemblyStatusOkCodeSchema = z.enum([ 'ASSEMBLY_COMPLETED', 'REQUEST_ABORTED', 'ASSEMBLY_CANCELED', 'ASSEMBLY_EXECUTING', ]) -const assemblyStatusErrCodeSchema = z.enum([ +export const assemblyStatusErrCodeSchema = z.enum([ 'INVALID_INPUT_ERROR', 'FILE_FILTER_DECLINED_FILE', 'INTERNAL_COMMAND_TIMEOUT', @@ -202,7 +202,7 @@ const assemblyStatusMetaSchema = z line_count: z.union([z.number(), z.null()]).optional(), paragraph_count: z.union([z.number(), z.null()]).optional(), }) - .strict() + .passthrough() export type AssemblyStatusMeta = z.infer // --- Define HLS Nested Meta Schema --- @@ -584,3 +584,25 @@ export function hasOkPartial( Boolean(assembly.ok) ) } + +// Schema for items returned by the List Assemblies endpoint +export const assemblyIndexItemSchema = z + .object({ + id: z.string(), // Likely always present for a list item + parent_id: assemblyStatusBaseSchema.shape.parent_id.optional(), // from base, made optional explicitly + account_id: assemblyStatusBaseSchema.shape.account_id.unwrap().optional(), // from base (it's string().optional() so unwrap then optional) + template_id: assemblyStatusBaseSchema.shape.template_id.optional(), // from base, made optional + instance: assemblyStatusBaseSchema.shape.instance.unwrap().optional(), // from base + notify_url: assemblyStatusBaseSchema.shape.notify_url.optional(), // from base + redirect_url: z.string().nullable().optional(), // Specific to list item, was in old ListedAssembly + files: z.string(), // JSON stringified, specific to list item + warning_count: z.number().optional(), // Specific to list item + execution_duration: assemblyStatusBaseSchema.shape.execution_duration.optional(), // from base + execution_start: assemblyStatusBaseSchema.shape.execution_start.optional(), // from base + ok: assemblyStatusOkCodeSchema.nullable().optional(), // Use exported enum + error: assemblyStatusErrCodeSchema.nullable().optional(), // Use exported enum + created: z.string(), // Specific to list item, mandatory based on old interface + }) + .strict() + +export type AssemblyIndexItem = z.infer diff --git a/src/alphalib/zodParseWithContext.ts b/src/alphalib/zodParseWithContext.ts index dca2af55..8a7ea660 100644 --- a/src/alphalib/zodParseWithContext.ts +++ b/src/alphalib/zodParseWithContext.ts @@ -36,6 +36,39 @@ export function zodParseWithContext( ): ZodParseWithContextResult { const zodRes = schema.safeParse(obj) if (!zodRes.success) { + // Check for empty object input causing a general union failure + if ( + typeof obj === 'object' && + obj !== null && + Object.keys(obj).length === 0 && + zodRes.error.errors.length > 0 + ) { + // eslint-disable-next-line no-console + // console.log('[zodParseWithContext] Empty object detected, Zod errors:', JSON.stringify(zodRes.error.errors, null, 2)); + + const firstError = zodRes.error.errors[0] + if ( + zodRes.error.errors.length === 1 && + firstError && + firstError.code === 'invalid_union' && + firstError.path.length === 0 && + Array.isArray((firstError as z.ZodInvalidUnionIssue).unionErrors) && + (firstError as z.ZodInvalidUnionIssue).unionErrors.length > 0 + ) { + const humanReadable = + "Validation failed: Input object is empty or missing key fields required to determine its type, " + + "and does not match any variant of the expected schema. Please provide a valid object." + return { + success: false, + // For this specific summarized error, we might not need to map all detailed ZodIssueWithContext + // or we can provide a simplified single error entry reflecting this summary. + // For now, let's return the original errors but with the new top-level humanReadable. + errors: zodRes.error.errors.map(e => ({...e, parentObj: obj, humanReadable: e.message})), + humanReadable, + } + } + } + const zodIssuesWithContext: ZodIssueWithContext[] = [] const badPaths = new Map() diff --git a/src/apiTypes.ts b/src/apiTypes.ts index 1a0a962e..d68b8609 100644 --- a/src/apiTypes.ts +++ b/src/apiTypes.ts @@ -1,7 +1,11 @@ import { AssemblyInstructions, AssemblyInstructionsInput } from './alphalib/types/template.js' export { assemblyInstructionsSchema } from './alphalib/types/template.js' -export { assemblyStatusSchema } from './alphalib/types/assemblyStatus.js' +export { + assemblyStatusSchema, + assemblyIndexItemSchema, + type AssemblyIndexItem, +} from './alphalib/types/assemblyStatus.js' export interface OptionalAuthParams { auth?: { key?: string; expires?: string } @@ -37,25 +41,6 @@ export type ListAssembliesParams = OptionalAuthParams & { keywords?: string[] } -// todo this is outdated and possibly wrong -/** See https://transloadit.com/docs/api/assemblies-assembly-id-get/ */ -export interface ListedAssembly { - id: string - parent_id?: string | null - account_id: string - template_id?: string | null - instance: string - notify_url?: string | null - redirect_url?: string | null - files: string // json stringified - warning_count: number - execution_duration: number - execution_start: string - ok?: string | null - error?: string | null - created: string -} - export type ReplayAssemblyParams = Pick< CreateAssemblyParams, 'auth' | 'template_id' | 'notify_url' | 'fields' diff --git a/test/unit/mock-http.test.ts b/test/unit/mock-http.test.ts index f7a715a2..9fedb3f9 100644 --- a/test/unit/mock-http.test.ts +++ b/test/unit/mock-http.test.ts @@ -238,27 +238,25 @@ describe('Mocked API tests', () => { it('should throw error on missing assembly_url/assembly_ssl_url', async () => { const client = getLocalClient() + const validOkStatusMissingUrls = { + ok: 'ASSEMBLY_COMPLETED', + assembly_id: 'test-id', // assembly_id is optional, but good to have for a realistic "ok" status + // assembly_url is intentionally missing/null + // assembly_ssl_url is intentionally missing/null + } + const scope = nock('http://localhost') .get('/assemblies/1') .query(() => true) - .reply(200, { - assembly_url: 'https://transloadit.com/', - assembly_ssl_url: 'https://transloadit.com/', - }) - .get('/assemblies/1') - .query(() => true) - .reply(200, {}) + .reply(200, validOkStatusMissingUrls) - // Success - await client.getAssembly('1') - - // Failure + // This call should pass Zod validation but fail at checkAssemblyUrls const promise = client.getAssembly('1') - await expect(promise).rejects.toThrow(InconsistentResponseError) - await expect(promise).rejects.toThrow( - expect.objectContaining({ - message: 'Server returned an incomplete assembly response (no URL)', - }) + + await expect(promise).rejects.toBeInstanceOf(InconsistentResponseError) + await expect(promise).rejects.toHaveProperty( + 'message', + 'Server returned an incomplete assembly response (no URL)' ) scope.done() })