Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/node-sdk.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
"titleBar.activeForeground": "#3e3e3e",
"titleBar.activeBackground": "#ffd100",
},
"typescript.tsdk": "node_modules/typescript/lib",
},
}
72 changes: 58 additions & 14 deletions src/Transloadit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,8 +37,6 @@ import type {
CreateTemplateParams,
EditTemplateParams,
ListAssembliesParams,
ListedAssembly,
ListedTemplate,
ListTemplateCredentialsParams,
ListTemplatesParams,
OptionalAuthParams,
Expand All @@ -44,7 +48,9 @@ import type {
TemplateCredentialResponse,
TemplateCredentialsResponse,
TemplateResponse,
ListedTemplate,
} from './apiTypes.js'
import { zodParseWithContext } from './alphalib/zodParseWithContext.ts'

export * from './apiTypes.js'

Expand Down Expand Up @@ -384,16 +390,19 @@ export class Transloadit {
* @returns after the assembly is deleted
*/
async cancelAssembly(assemblyId: string): Promise<AssemblyStatus> {
// 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<Record<string, unknown>, 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
}

/**
Expand Down Expand Up @@ -442,12 +451,38 @@ export class Transloadit {
*/
async listAssemblies(
params?: ListAssembliesParams
): Promise<PaginationListWithCount<ListedAssembly>> {
return this._remoteJson({
): Promise<PaginationListWithCount<AssemblyIndexItem>> {
const rawResponse = await this._remoteJson<
PaginationListWithCount<Record<string, unknown>>,
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 {
Expand All @@ -461,11 +496,20 @@ export class Transloadit {
* @returns the retrieved Assembly
*/
async getAssembly(assemblyId: string): Promise<AssemblyStatus> {
const result: AssemblyStatus = await this._remoteJson({
const rawResult = await this._remoteJson<Record<string, unknown>, 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
}

/**
Expand Down
28 changes: 25 additions & 3 deletions src/alphalib/types/assemblyStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<typeof assemblyStatusMetaSchema>

// --- Define HLS Nested Meta Schema ---
Expand Down Expand Up @@ -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<typeof assemblyIndexItemSchema>
33 changes: 33 additions & 0 deletions src/alphalib/zodParseWithContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,39 @@ export function zodParseWithContext<T extends z.ZodType>(
): ZodParseWithContextResult<T> {
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<string, string[]>()

Expand Down
25 changes: 5 additions & 20 deletions src/apiTypes.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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'
Expand Down
30 changes: 14 additions & 16 deletions test/unit/mock-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down