From d5a4ed545be51432bbecb2dac9ca51670c7a3619 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 14 May 2025 22:15:49 +0200 Subject: [PATCH 1/6] parse result --- src/Transloadit.ts | 33 +++++++++++++++++++++++---------- test/unit/mock-http.test.ts | 30 ++++++++++++++---------------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index 3d62915d..ec390eef 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -22,7 +22,7 @@ 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 } from './alphalib/types/assemblyStatus.js' import type { BaseResponse, BillResponse, @@ -45,6 +45,7 @@ import type { TemplateCredentialsResponse, TemplateResponse, } from './apiTypes.js' +import { zodParseWithContext } from './alphalib/zodParseWithContext.ts' export * from './apiTypes.js' @@ -384,16 +385,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 } /** @@ -461,11 +465,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/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() }) From e19a751700b996ed1b4135fe6bda953512d600d0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 15 May 2025 09:07:58 +0200 Subject: [PATCH 2/6] Use the project's typescript in vscode (or else erasableSyntaxOnly is not recognized) --- .vscode/node-sdk.code-workspace | 1 + 1 file changed, 1 insertion(+) 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", }, } From e6f9490dc31516ded557688ab8d35e67da71ed7b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 15 May 2025 09:30:45 +0200 Subject: [PATCH 3/6] Update zodParseWithContext.ts --- src/alphalib/zodParseWithContext.ts | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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() From 499eedd288b9283563c368bccc56c7552bc61983 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 15 May 2025 09:39:42 +0200 Subject: [PATCH 4/6] first swing at index --- src/Transloadit.ts | 41 ++++++++++++++++++++++++---- src/alphalib/types/assemblyStatus.ts | 24 ++++++++++++++++ src/apiTypes.ts | 25 ++++------------- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index ec390eef..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, assemblyStatusSchema } 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,6 +48,7 @@ import type { TemplateCredentialResponse, TemplateCredentialsResponse, TemplateResponse, + ListedTemplate, } from './apiTypes.js' import { zodParseWithContext } from './alphalib/zodParseWithContext.ts' @@ -446,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 { diff --git a/src/alphalib/types/assemblyStatus.ts b/src/alphalib/types/assemblyStatus.ts index 30a08ed7..be642c5e 100644 --- a/src/alphalib/types/assemblyStatus.ts +++ b/src/alphalib/types/assemblyStatus.ts @@ -584,3 +584,27 @@ export function hasOkPartial( Boolean(assembly.ok) ) } + +// Schema for items returned by the List Assemblies endpoint +export const assemblyIndexItemSchema = z + .object({ + id: z.string(), + parent_id: z.string().nullable().optional(), + account_id: z.string(), + template_id: z.string().nullable().optional(), + instance: z.string(), + notify_url: z.string().nullable().optional(), + redirect_url: z.string().nullable().optional(), // Field from old ListedAssembly + files: z.string(), // JSON stringified, as per old ListedAssembly + warning_count: z.number().optional(), // Field from old ListedAssembly + execution_duration: z.number().optional(), + execution_start: z.string().optional(), + ok: z.string().nullable().optional(), // Based on old ListedAssembly + error: z.string().nullable().optional(), // Based on old ListedAssembly + created: z.string(), // Field from old ListedAssembly + // Consider if other common fields from assemblyStatusBaseSchema should be here if consistently returned by list endpoint + // For now, keeping it aligned with the old ListedAssembly interface + making some fields optional for safety. + }) + .strict() + +export type AssemblyIndexItem = z.infer 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' From 93d10e32745b5f4d332725d4565294879ef936c9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 15 May 2025 09:42:13 +0200 Subject: [PATCH 5/6] Update assemblyStatus.ts --- src/alphalib/types/assemblyStatus.ts | 34 +++++++++++++--------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/alphalib/types/assemblyStatus.ts b/src/alphalib/types/assemblyStatus.ts index be642c5e..e45093c6 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', @@ -588,22 +588,20 @@ export function hasOkPartial( // Schema for items returned by the List Assemblies endpoint export const assemblyIndexItemSchema = z .object({ - id: z.string(), - parent_id: z.string().nullable().optional(), - account_id: z.string(), - template_id: z.string().nullable().optional(), - instance: z.string(), - notify_url: z.string().nullable().optional(), - redirect_url: z.string().nullable().optional(), // Field from old ListedAssembly - files: z.string(), // JSON stringified, as per old ListedAssembly - warning_count: z.number().optional(), // Field from old ListedAssembly - execution_duration: z.number().optional(), - execution_start: z.string().optional(), - ok: z.string().nullable().optional(), // Based on old ListedAssembly - error: z.string().nullable().optional(), // Based on old ListedAssembly - created: z.string(), // Field from old ListedAssembly - // Consider if other common fields from assemblyStatusBaseSchema should be here if consistently returned by list endpoint - // For now, keeping it aligned with the old ListedAssembly interface + making some fields optional for safety. + 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() From fc9e78a6bde7decb3ef596ce8b720812d339a6e7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 15 May 2025 10:01:52 +0200 Subject: [PATCH 6/6] Don't let unknown meta keys explode on us --- src/alphalib/types/assemblyStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alphalib/types/assemblyStatus.ts b/src/alphalib/types/assemblyStatus.ts index e45093c6..e47c6614 100644 --- a/src/alphalib/types/assemblyStatus.ts +++ b/src/alphalib/types/assemblyStatus.ts @@ -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 ---