diff --git a/src/SDK/Language/Node.php b/src/SDK/Language/Node.php index b53ea74b39..7062e85cb0 100644 --- a/src/SDK/Language/Node.php +++ b/src/SDK/Language/Node.php @@ -32,6 +32,23 @@ protected function getPermissionPrefix(): string return 'sdk.'; } + public function getTypeName(array $parameter, array $method = []): string + { + if (($parameter['type'] ?? null) === self::TYPE_ARRAY) { + $arrayType = $parameter['array']['type'] ?? $parameter['items']['type'] ?? null; + + if ($arrayType === self::TYPE_FILE) { + return '(File | InputFile)[]'; + } + } + + if (($parameter['type'] ?? null) === self::TYPE_FILE) { + return 'File | InputFile'; + } + + return parent::getTypeName($parameter, $method); + } + public function getReturn(array $method, array $spec): string { if ($method['type'] === 'webAuth') { @@ -79,7 +96,7 @@ public function getReturn(array $method, array $spec): string return 'Promise<{}>'; } - /** + /** * @param array $param * @param string $lang * @return string @@ -114,6 +131,36 @@ public function getParamExample(array $param, string $lang = ''): string }; } + /** + * Check if service has any file parameters + * + * @param array $service + * @return bool + */ + public function hasFileParam(array $service): bool + { + foreach ($service['methods'] as $method) { + foreach ($method['parameters']['all'] as $parameter) { + if ($parameter['type'] === self::TYPE_FILE) { + return true; + } + } + } + return false; + } + + /** + * @return array + */ + public function getFilters(): array + { + return \array_merge(parent::getFilters(), [ + new \Twig\TwigFilter('hasFileParam', function ($service) { + return $this->hasFileParam($service); + }), + ]); + } + /** * @return array */ diff --git a/templates/node/src/client.ts.twig b/templates/node/src/client.ts.twig index 6f9ee40f93..8a3e3ba684 100644 --- a/templates/node/src/client.ts.twig +++ b/templates/node/src/client.ts.twig @@ -1,6 +1,7 @@ import { fetch, FormData, File } from 'node-fetch-native-with-agent'; import { createAgent } from 'node-fetch-native-with-agent/agent'; import { Models } from './models'; +import { InputFile } from './inputFile'; import JSONbigModule from 'json-bigint'; const JSONbigParser = JSONbigModule({ storeAsString: false }); const JSONbigSerializer = JSONbigModule({ useNativeBigInt: true }); @@ -257,12 +258,60 @@ class Client { } async chunkedUpload(method: string, url: URL, headers: Headers = {}, originalPayload: Payload = {}, onProgress: (progress: UploadProgress) => void) { - const [fileParam, file] = Object.entries(originalPayload).find(([_, value]) => value instanceof File) ?? []; + const [fileParam, file] = Object.entries(originalPayload).find( + ([_, value]) => value instanceof File || value instanceof InputFile + ) ?? []; if (!file || !fileParam) { throw new Error('File not found in payload'); } + if (file instanceof InputFile) { + const size = await file.size(); + + if (size <= Client.CHUNK_SIZE) { + const payload = { ...originalPayload }; + payload[fileParam] = await file.toFile(); + return await this.call(method, url, headers, payload); + } + + let start = 0; + let response = null; + + while (start < size) { + let end = start + Client.CHUNK_SIZE; + if (end >= size) { + end = size; + } + + headers['content-range'] = `bytes ${start}-${end - 1}/${size}`; + const chunk = await file.slice(start, end); + + const payload = { ...originalPayload }; + payload[fileParam] = new File([chunk], file.filename); + + response = await this.call(method, url, headers, payload); + + if (onProgress && typeof onProgress === 'function') { + onProgress({ + $id: response.$id, + progress: Math.round((end / size) * 100), + sizeUploaded: end, + chunksTotal: Math.ceil(size / Client.CHUNK_SIZE), + chunksUploaded: Math.ceil(end / Client.CHUNK_SIZE) + }); + } + + if (response && response.$id) { + headers['x-{{spec.title | caseLower }}-id'] = response.$id; + } + + start = end; + } + + return response; + } + if (file.size <= Client.CHUNK_SIZE) { return await this.call(method, url, headers, originalPayload); } diff --git a/templates/node/src/inputFile.ts.twig b/templates/node/src/inputFile.ts.twig index a30ea55d2c..94afbe2371 100644 --- a/templates/node/src/inputFile.ts.twig +++ b/templates/node/src/inputFile.ts.twig @@ -1,23 +1,144 @@ import { File } from "node-fetch-native-with-agent"; -import { realpathSync, readFileSync } from "fs"; -import type { BinaryLike } from "crypto"; + +type FsPromises = { + stat: (path: string) => Promise<{ size: number }>; + open: (path: string, flags: string) => Promise<{ + read: (buffer: Uint8Array, offset: number, length: number, position: number) => Promise<{ bytesRead: number }>; + close: () => Promise; + }>; + readFile: (path: string) => Promise; +}; + +function isEdgeRuntime(): boolean { + return typeof globalThis !== 'undefined' && typeof (globalThis as { EdgeRuntime?: unknown }).EdgeRuntime !== 'undefined'; +} + +function assertFileSystemAvailable(): void { + if (isEdgeRuntime()) { + throw new Error('File system operations are not supported in edge runtimes. Please use InputFile.fromBuffer instead.'); + } +} + +async function getFs(): Promise { + assertFileSystemAvailable(); + + try { + const fs = await import('fs'); + return fs.promises; + } catch { + throw new Error('File system operations are not available in this runtime. Please use InputFile.fromBuffer instead.'); + } +} + +function getFilename(path: string): string { + const segments = path.replace(/\\/g, '/').split('/').filter(Boolean); + return segments.pop() ?? 'file'; +} + +type BlobLike = { + size: number; + slice: (start: number, end: number) => BlobLike; + arrayBuffer: () => Promise; +}; + +type InputFileSource = + | { type: 'path'; path: string } + | { type: 'buffer'; data: Uint8Array } + | { type: 'blob'; data: BlobLike }; export class InputFile { - static fromBuffer( - parts: Blob | BinaryLike, - name: string - ): File { - return new File([parts], name); + private source: InputFileSource; + filename: string; + + private constructor(source: InputFileSource, filename: string) { + this.source = source; + this.filename = filename; + } + + static fromBuffer(parts: BlobLike | Uint8Array | ArrayBuffer | string, name: string): InputFile { + if (parts && !ArrayBuffer.isView(parts) && typeof (parts as BlobLike).arrayBuffer === 'function') { + return new InputFile({ type: 'blob', data: parts as BlobLike }, name); + } + + if (typeof parts === 'string') { + return new InputFile({ type: 'buffer', data: new TextEncoder().encode(parts) }, name); + } + + if (parts instanceof ArrayBuffer) { + return new InputFile({ type: 'buffer', data: new Uint8Array(parts) }, name); + } + + if (ArrayBuffer.isView(parts)) { + return new InputFile({ + type: 'buffer', + data: new Uint8Array(parts.buffer, parts.byteOffset, parts.byteLength), + }, name); + } + + throw new Error('Unsupported input type for InputFile.fromBuffer'); + } + + static fromPath(path: string, name?: string): InputFile { + assertFileSystemAvailable(); + return new InputFile({ type: 'path', path }, name ?? getFilename(path)); + } + + static fromPlainText(content: string, name: string): InputFile { + return new InputFile({ type: 'buffer', data: new TextEncoder().encode(content) }, name); + } + + async size(): Promise { + switch (this.source.type) { + case 'path': { + const fs = await getFs(); + return (await fs.stat(this.source.path)).size; + } + case 'buffer': + return this.source.data.length; + case 'blob': + return this.source.data.size; + } + } + + async slice(start: number, end: number): Promise { + const length = end - start; + + switch (this.source.type) { + case 'path': { + const fs = await getFs(); + const handle = await fs.open(this.source.path, 'r'); + try { + const buffer = new Uint8Array(length); + const result = await handle.read(buffer, 0, length, start); + return result.bytesRead === buffer.length ? buffer : buffer.subarray(0, result.bytesRead); + } finally { + await handle.close(); + } + } + case 'buffer': + return this.source.data.subarray(start, end); + case 'blob': { + const arrayBuffer = await this.source.data.slice(start, end).arrayBuffer(); + return new Uint8Array(arrayBuffer); + } + } } - static fromPath(path: string, name: string): File { - const realPath = realpathSync(path); - const contents = readFileSync(realPath); - return this.fromBuffer(contents, name); + async toFile(): Promise { + const data = await this.toUint8Array(); + return new File([data], this.filename); } - static fromPlainText(content: string, name: string): File { - const arrayBytes = new TextEncoder().encode(content); - return this.fromBuffer(arrayBytes, name); + private async toUint8Array(): Promise { + switch (this.source.type) { + case 'path': { + const fs = await getFs(); + return await fs.readFile(this.source.path); + } + case 'buffer': + return this.source.data; + case 'blob': + return new Uint8Array(await this.source.data.arrayBuffer()); + } } } diff --git a/templates/node/src/services/template.ts.twig b/templates/node/src/services/template.ts.twig index d4b9560aa1..f395f0d664 100644 --- a/templates/node/src/services/template.ts.twig +++ b/templates/node/src/services/template.ts.twig @@ -1,6 +1,10 @@ import { {{ spec.title | caseUcfirst}}Exception, Client, type Payload, UploadProgress } from '../client'; import type { Models } from '../models'; +{% if service | hasFileParam %} +import { InputFile } from '../inputFile'; +{% endif %} + {% set added = [] %} {% for method in service.methods %} {% for parameter in method.parameters.all %} diff --git a/tests/Node16Test.php b/tests/Node16Test.php index eea2928632..d5ab3f3674 100644 --- a/tests/Node16Test.php +++ b/tests/Node16Test.php @@ -27,7 +27,10 @@ class Node16Test extends Base ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, ...Base::UPLOAD_RESPONSES, - ...Base::UPLOAD_RESPONSES, // Object params + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, // Large file uploads ...Base::ENUM_RESPONSES, ...Base::MODEL_RESPONSES, ...Base::EXCEPTION_RESPONSES, diff --git a/tests/Node18Test.php b/tests/Node18Test.php index 830575c23e..138411d733 100644 --- a/tests/Node18Test.php +++ b/tests/Node18Test.php @@ -27,7 +27,10 @@ class Node18Test extends Base ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, ...Base::UPLOAD_RESPONSES, - ...Base::UPLOAD_RESPONSES, // Object params + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, // Large file uploads ...Base::ENUM_RESPONSES, ...Base::MODEL_RESPONSES, ...Base::EXCEPTION_RESPONSES, diff --git a/tests/Node20Test.php b/tests/Node20Test.php index f69ca34040..280c34ed3e 100644 --- a/tests/Node20Test.php +++ b/tests/Node20Test.php @@ -27,7 +27,10 @@ class Node20Test extends Base ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, ...Base::UPLOAD_RESPONSES, - ...Base::UPLOAD_RESPONSES, // Object params + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, // Large file uploads ...Base::ENUM_RESPONSES, ...Base::MODEL_RESPONSES, ...Base::EXCEPTION_RESPONSES,