diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d1e9ea..d1b1296c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,7 +229,10 @@ jobs: env: COVERAGE_REPO_SSH_PRIVATE_KEY: ${{ secrets.COVERAGE_REPO_SSH_PRIVATE_KEY }} run: | - if [ -n "$COVERAGE_REPO_SSH_PRIVATE_KEY" ]; then + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Coverage publish skipped for pull request runs." + elif [ -n "$COVERAGE_REPO_SSH_PRIVATE_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" diff --git a/packages/node/examples/api2-devdock-template-lifecycle/main.ts b/packages/node/examples/api2-devdock-template-lifecycle/main.ts new file mode 100644 index 00000000..116bb020 --- /dev/null +++ b/packages/node/examples/api2-devdock-template-lifecycle/main.ts @@ -0,0 +1,255 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { ApiError, Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface ScenarioContent { + additionalProperties: JsonRecord + steps: JsonRecord +} + +interface TemplateConfig { + content: ScenarioContent + namePrefix: string + requireSignatureAuth: boolean +} + +interface UpdateConfig { + content: ScenarioContent + nameSuffix: string + requireSignatureAuth: boolean +} + +interface TemplateLifecycleScenario { + delete: { + errorCodeIncludes: string + } + list: { + minimumCount: number + pageSize: number + } + scenarioId: string + template: TemplateConfig + update: UpdateConfig +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function requireBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') { + fail(`${label} must be a boolean`) + } + + return value +} + +function scenarioContent(value: unknown, label: string): ScenarioContent { + const content = requireRecord(value, label) + + return { + additionalProperties: requireRecord( + content.additionalProperties, + `${label}.additionalProperties`, + ), + steps: requireRecord(content.steps, `${label}.steps`), + } +} + +function templateConfig(value: unknown, label: string): TemplateConfig { + const config = requireRecord(value, label) + + return { + content: scenarioContent(config.content, `${label}.content`), + namePrefix: requireString(config.namePrefix, `${label}.namePrefix`), + requireSignatureAuth: requireBoolean( + config.requireSignatureAuth, + `${label}.requireSignatureAuth`, + ), + } +} + +function updateConfig(value: unknown, label: string): UpdateConfig { + const config = requireRecord(value, label) + + return { + content: scenarioContent(config.content, `${label}.content`), + nameSuffix: requireString(config.nameSuffix, `${label}.nameSuffix`), + requireSignatureAuth: requireBoolean( + config.requireSignatureAuth, + `${label}.requireSignatureAuth`, + ), + } +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const scenario = requireRecord(JSON.parse(await readFile(scenarioPath, 'utf8')), 'scenario') + const list = requireRecord(scenario.list, 'scenario.list') + const deleteConfig = requireRecord(scenario.delete, 'scenario.delete') + + return { + delete: { + errorCodeIncludes: requireString( + deleteConfig.errorCodeIncludes, + 'scenario.delete.errorCodeIncludes', + ), + }, + list: { + minimumCount: requireNumber(list.minimumCount, 'scenario.list.minimumCount'), + pageSize: requireNumber(list.pageSize, 'scenario.list.pageSize'), + }, + scenarioId: requireString(scenario.scenarioId, 'scenario.scenarioId'), + template: templateConfig(scenario.template, 'scenario.template'), + update: updateConfig(scenario.update, 'scenario.update'), + } +} + +function templatePayload(name: string, config: TemplateConfig | UpdateConfig): JsonRecord { + return { + name, + require_signature_auth: config.requireSignatureAuth ? 1 : 0, + template: { + ...config.content.additionalProperties, + steps: config.content.steps, + }, + } +} + +function requireTemplateId(value: unknown, label: string): string { + const template = requireRecord(value, label) + + return requireString(template.id, `${label}.id`) +} + +function templateResult(value: unknown): JsonRecord { + const template = requireRecord(value, 'template response') + const content = requireRecord(template.content, 'template response.content') + const requireSignatureAuth = requireNumber( + template.require_signature_auth, + 'template response.require_signature_auth', + ) + + return { + content, + id: requireString(template.id, 'template response.id'), + name: requireString(template.name, 'template response.name'), + requireSignatureAuth: requireSignatureAuth !== 0, + } +} + +async function deletedGetResult(client: Transloadit, templateId: string): Promise { + try { + await client.getTemplate(templateId) + return { + deletedErrorCode: '', + deletedGetSucceeded: true, + } + } catch (err) { + if (!(err instanceof ApiError)) { + throw err + } + + return { + deletedErrorCode: err.code ?? '', + deletedGetSucceeded: false, + } + } +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const templateName = `${scenario.template.namePrefix}-${Date.now()}` + const created = await client.createTemplate(templatePayload(templateName, scenario.template)) + const templateId = requireTemplateId(created, 'createTemplate response') + let deleteTemplate = true + + try { + const fetched = await client.getTemplate(templateId) + const listed = await client.listTemplates({ pagesize: scenario.list.pageSize }) + const updatedTemplateName = `${templateName}${scenario.update.nameSuffix}` + + await client.editTemplate(templateId, templatePayload(updatedTemplateName, scenario.update)) + const updated = await client.getTemplate(templateId) + + await client.deleteTemplate(templateId) + deleteTemplate = false + + await writeResult({ + ...(await deletedGetResult(client, templateId)), + fetched: templateResult(fetched), + listCount: listed.count, + templateId, + templateName, + updated: templateResult(updated), + updatedTemplateName, + }) + } finally { + if (deleteTemplate) { + await client.deleteTemplate(templateId) + } + } + + console.log(`Node SDK devdock scenario ${scenario.scenarioId} passed for ${templateId}`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts new file mode 100644 index 00000000..25c7d90e --- /dev/null +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -0,0 +1,174 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface ExampleInput { + scenarioId: string + sdkFeatureInputs: { + uploadTusAssembly: UploadTusAssemblyInput + } +} + +interface TusAssemblyScenario { + exampleInput: ExampleInput +} + +interface UploadConfig { + content: string + fieldname: string + filename: string + user_meta: Record +} + +interface UploadTusAssemblyInput { + file_count: number + upload: UploadConfig +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function stringRecord(value: unknown, label: string): Record { + const record = requireRecord(value, label) + return Object.fromEntries( + Object.entries(record).map(([key, entryValue]) => [key, String(entryValue)]), + ) +} + +function optionalStringRecord(value: unknown, label: string): Record { + if (value == null) { + return {} + } + + return stringRecord(value, label) +} + +function uploadConfig(value: unknown, label: string): UploadConfig { + const config = requireRecord(value, label) + + return { + content: requireString(config.content, `${label}.content`), + fieldname: requireString(config.fieldname, `${label}.fieldname`), + filename: requireString(config.filename, `${label}.filename`), + user_meta: optionalStringRecord(config.user_meta, `${label}.user_meta`), + } +} + +function uploadTusAssemblyInput(value: unknown, label: string): UploadTusAssemblyInput { + const input = requireRecord(value, label) + + return { + file_count: requireNumber(input.file_count, `${label}.file_count`), + upload: uploadConfig(input.upload, `${label}.upload`), + } +} + +function exampleInput(value: unknown, label: string): ExampleInput { + const input = requireRecord(value, label) + const sdkFeatureInputs = requireRecord(input.sdkFeatureInputs, `${label}.sdkFeatureInputs`) + + return { + scenarioId: requireString(input.scenarioId, `${label}.scenarioId`), + sdkFeatureInputs: { + uploadTusAssembly: uploadTusAssemblyInput( + sdkFeatureInputs.uploadTusAssembly, + `${label}.sdkFeatureInputs.uploadTusAssembly`, + ), + }, + } +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const parsed: unknown = JSON.parse(await readFile(scenarioPath, 'utf8')) + const scenario = requireRecord(parsed, 'scenario') + + return { + exampleInput: exampleInput(scenario.exampleInput, 'scenario.exampleInput'), + } +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const input = scenario.exampleInput.sdkFeatureInputs.uploadTusAssembly + const upload = input.upload + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const result = await client.uploadTusAssembly( + input.file_count, + Buffer.from(upload.content, 'utf8'), + upload.fieldname, + upload.filename, + upload.user_meta, + ) + + await writeResult({ + createResponse: result.assembly, + uploadUrl: result.uploadUrl, + waitOk: result.assembly.ok, + }) + + console.log( + `Node SDK devdock scenario ${scenario.exampleInput.scenarioId} uploaded to ${result.uploadUrl}`, + ) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 18ad3ef8..0693f3e9 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -108,6 +108,19 @@ export type AssemblyStatusWithUploadUrls = AssemblyStatus & { upload_urls?: Record } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +export interface UploadTusAssemblyResult { + assembly: AssemblyStatus + uploadUrl: string +} + +// + const { version } = packageJson export type AssemblyProgress = (assembly: AssemblyStatus) => void @@ -575,6 +588,179 @@ export class Transloadit { return result.data } + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Creates a TUS-ready Assembly that waits for the requested number of resumable uploads before execution continues. + */ + async createTusAssembly(fileCount: number): Promise { + return await this._remoteJson< + AssemblyStatusWithUploadUrls, + CreateAssemblyParams & Record + >({ + urlSuffix: '/assemblies', + method: 'post', + params: { + await: false, + steps: { + ':original': { + output_meta: true, + result: 'debug', + robot: '/upload/handle', + }, + }, + }, + fields: { + num_expected_upload_files: fileCount, + }, + }) + } + + // + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Waits for an Assembly to finish uploading and executing. + * Use the returned assembly_ssl_url as the assembly URL. + */ + async waitForAssembly(assemblyUrl: string): Promise { + while (true) { + const result = await this._remoteJson({ + url: assemblyUrl, + isTrustedUrl: true, + method: 'get', + }) + + // Abort polling if the assembly has entered an error state + if (result.error) { + return result + } + + // The polling is done if the assembly is not uploading or executing anymore. + if (result.ok !== 'ASSEMBLY_UPLOADING' && result.ok !== 'ASSEMBLY_EXECUTING') { + return result + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + // + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Creates a TUS-ready Assembly, uploads one file with the TUS protocol, and waits for the Assembly to finish. + */ + async uploadTusAssembly( + fileCount: number, + content: Buffer | Uint8Array | string, + fieldname: string, + filename: string, + userMeta: Record, + ): Promise { + const createdAssembly = await this.createTusAssembly(fileCount) + + const endpointUrl = createdAssembly.tus_url + if (!endpointUrl) { + throw new Error('TUS singleUploadLifecycle needs input.endpointUrl') + } + + const contentBytes = Buffer.isBuffer(content) ? content : Buffer.from(content) + + const metadataMap = new Map() + for (const [key, value] of Object.entries(userMeta)) { + metadataMap.set(String(key), String(value)) + } + metadataMap.set('assembly_url', String(createdAssembly.assembly_url)) + metadataMap.set('fieldname', String(fieldname)) + metadataMap.set('filename', String(filename)) + + const createHeaders: Record = {} + createHeaders['Tus-Resumable'] = '1.0.0' + createHeaders['Upload-Length'] = String(contentBytes.length) + const createMetadataParts: string[] = [] + for (const [key, value] of metadataMap) { + const encodedValue = Buffer.from(String(value), 'utf8').toString('base64') + createMetadataParts.push(`${key} ${encodedValue}`) + } + createHeaders['Upload-Metadata'] = createMetadataParts.join(',') + const createResponse = await got(endpointUrl, { + method: 'POST', + body: Buffer.alloc(0), + headers: createHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (createResponse.statusCode !== 201) { + throw new Error(`TUS create returned HTTP ${createResponse.statusCode}, expected 201`) + } + const uploadUrlLocation = createResponse.headers.location + const uploadUrlLocationText = Array.isArray(uploadUrlLocation) + ? uploadUrlLocation[0] + : uploadUrlLocation + if (!uploadUrlLocationText) { + throw new Error('TUS create did not return a Location header') + } + const uploadUrlText = new URL(uploadUrlLocationText, endpointUrl).toString() + + const uploadHeaders: Record = {} + uploadHeaders['Tus-Resumable'] = '1.0.0' + uploadHeaders['Upload-Offset'] = '0' + uploadHeaders['Content-Type'] = 'application/offset+octet-stream' + const uploadResponse = await got(uploadUrlText, { + method: 'PATCH', + body: contentBytes, + headers: uploadHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (uploadResponse.statusCode !== 204) { + throw new Error(`TUS upload returned HTTP ${uploadResponse.statusCode}, expected 204`) + } + const uploadOffsetHeader = uploadResponse.headers['upload-offset'] + const uploadOffsetText = Array.isArray(uploadOffsetHeader) + ? uploadOffsetHeader[0] + : uploadOffsetHeader + if (!uploadOffsetText) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + const uploadOffset = Number(uploadOffsetText) + if (!Number.isInteger(uploadOffset)) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + if (uploadOffset !== contentBytes.length) { + throw new Error(`TUS upload offset ${uploadOffset}, expected ${contentBytes.length}`) + } + + const createdAssemblyAssemblySslUrl = createdAssembly.assembly_ssl_url + if (!createdAssemblyAssemblySslUrl) { + throw new Error('uploadTusAssembly needs createdAssembly.assembly_ssl_url') + } + const completedAssembly = await this.waitForAssembly(createdAssemblyAssemblySslUrl) + + return { assembly: completedAssembly, uploadUrl: uploadUrlText } + } + + // + async resumeAssemblyUploads( opts: ResumeAssemblyUploadsOptions, ): Promise { @@ -968,6 +1154,12 @@ export class Transloadit { * @param params optional request options * @returns when the Credential is created */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async createTemplateCredential( params: CreateTemplateCredentialParams, ): Promise { @@ -978,6 +1170,8 @@ export class Transloadit { }) } + // + /** * Edit a Credential * @@ -985,6 +1179,12 @@ export class Transloadit { * @param params optional request options * @returns when the Credential is edited */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async editTemplateCredential( credentialId: string, params: CreateTemplateCredentialParams, @@ -996,12 +1196,20 @@ export class Transloadit { }) } + // + /** * Delete a Credential * * @param credentialId the Credential ID * @returns when the Credential is deleted */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async deleteTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ urlSuffix: `/template_credentials/${credentialId}`, @@ -1009,12 +1217,20 @@ export class Transloadit { }) } + // + /** * Get a Credential * * @param credentialId the Credential ID * @returns when the Credential is retrieved */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ urlSuffix: `/template_credentials/${credentialId}`, @@ -1022,12 +1238,20 @@ export class Transloadit { }) } + // + /** * List all TemplateCredentials * * @param params optional request options * @returns the list of templates */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async listTemplateCredentials( params?: ListTemplateCredentialsParams, ): Promise { @@ -1038,6 +1262,8 @@ export class Transloadit { }) } + // + streamTemplateCredentials(params: ListTemplateCredentialsParams) { return new PaginationStream(async (page) => ({ items: (await this.listTemplateCredentials({ ...params, page })).credentials, @@ -1050,6 +1276,12 @@ export class Transloadit { * @param params optional request options * @returns when the template is created */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async createTemplate(params: CreateTemplateParams): Promise { return await this._remoteJson({ urlSuffix: '/templates', @@ -1058,6 +1290,8 @@ export class Transloadit { }) } + // + /** * Edit an Assembly Template * @@ -1065,6 +1299,12 @@ export class Transloadit { * @param params optional request options * @returns when the template is edited */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async editTemplate(templateId: string, params: EditTemplateParams): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1073,12 +1313,20 @@ export class Transloadit { }) } + // + /** * Delete an Assembly Template * * @param templateId the template ID * @returns when the template is deleted */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async deleteTemplate(templateId: string): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1086,12 +1334,20 @@ export class Transloadit { }) } + // + /** * Get an Assembly Template * * @param templateId the template ID * @returns when the template is retrieved */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getTemplate(templateId: string): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1099,12 +1355,20 @@ export class Transloadit { }) } + // + /** * List all Assembly Templates * * @param params optional request options * @returns the list of templates */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async listTemplates( params?: ListTemplatesParams, ): Promise> { @@ -1115,6 +1379,8 @@ export class Transloadit { }) } + // + streamTemplates(params?: ListTemplatesParams): PaginationStream { return new PaginationStream(async (page) => this.listTemplates({ ...params, page })) } @@ -1126,6 +1392,12 @@ export class Transloadit { * @returns with billing data * @see https://transloadit.com/docs/api/bill-date-get/ */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getBill(month: string): Promise { assert.ok(month, 'month is required') return await this._remoteJson({ @@ -1134,6 +1406,8 @@ export class Transloadit { }) } + // + calcSignature( params: OptionalAuthParams, algorithm?: string,