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
49 changes: 48 additions & 1 deletion src/SDK/Language/Node.php
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -79,7 +96,7 @@ public function getReturn(array $method, array $spec): string
return 'Promise<{}>';
}

/**
/**
* @param array $param
* @param string $lang
* @return string
Expand Down Expand Up @@ -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
*/
Expand Down
51 changes: 50 additions & 1 deletion templates/node/src/client.ts.twig
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down Expand Up @@ -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);
}
Expand Down
149 changes: 135 additions & 14 deletions templates/node/src/inputFile.ts.twig
Original file line number Diff line number Diff line change
@@ -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<void>;
}>;
readFile: (path: string) => Promise<Uint8Array>;
};

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<FsPromises> {
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<ArrayBuffer>;
};

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<number> {
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<Uint8Array> {
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<File> {
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<Uint8Array> {
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());
}
}
}
4 changes: 4 additions & 0 deletions templates/node/src/services/template.ts.twig
Original file line number Diff line number Diff line change
@@ -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 %}
Expand Down
5 changes: 4 additions & 1 deletion tests/Node16Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion tests/Node18Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion tests/Node20Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading