diff --git a/package.json b/package.json index f550d89..6ddd05d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "node-appwrite", + "name": "node-appwrite-1", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API", "version": "13.0.0", @@ -42,12 +42,13 @@ }, "devDependencies": { "@types/node": "20.11.25", - "tsup": "7.2.0", "esbuild-plugin-file-path-extensions": "^2.0.0", "tslib": "2.6.2", + "tsup": "7.2.0", "typescript": "5.4.2" }, "dependencies": { - "node-fetch-native-with-agent": "1.7.2" + "node-fetch-native-with-agent": "1.7.2", + "parse-multipart-data": "^1.5.0" } } diff --git a/src/BodyMultipart.ts b/src/BodyMultipart.ts new file mode 100644 index 0000000..7c0b4f7 --- /dev/null +++ b/src/BodyMultipart.ts @@ -0,0 +1,156 @@ +export type Part = { + contentDispositionHeader: string; + contentTypeHeader: string; + part: number[]; +}; + +type Input = { + filename?: string; + name?: string; + type: string; + data: Buffer; +}; + +enum ParsingState { + INIT, + READING_HEADERS, + READING_DATA, + READING_PART_SEPARATOR, +} + +export function parse(multipartBodyBuffer: Buffer, boundary: string): Input[] { + let lastline = ""; + let contentDispositionHeader = ""; + let contentTypeHeader = ""; + let state: ParsingState = ParsingState.INIT; + let buffer: number[] = []; + const allParts: Input[] = []; + + let currentPartHeaders: string[] = []; + + for (let i = 0; i < multipartBodyBuffer.length; i++) { + const oneByte: number = multipartBodyBuffer[i]; + const prevByte: number | null = i > 0 ? multipartBodyBuffer[i - 1] : null; + // 0x0a => \n + // 0x0d => \r + const newLineDetected: boolean = oneByte === 0x0a && prevByte === 0x0d; + const newLineChar: boolean = oneByte === 0x0a || oneByte === 0x0d; + + if (!newLineChar) lastline += String.fromCharCode(oneByte); + if (ParsingState.INIT === state && newLineDetected) { + // searching for boundary + if ("--" + boundary === lastline) { + state = ParsingState.READING_HEADERS; // found boundary. start reading headers + } + lastline = ""; + } else if (ParsingState.READING_HEADERS === state && newLineDetected) { + // parsing headers. Headers are separated by an empty line from the content. Stop reading headers when the line is empty + if (lastline.length) { + currentPartHeaders.push(lastline); + } else { + // found empty line. search for the headers we want and set the values + for (const h of currentPartHeaders) { + if (h.toLowerCase().startsWith("content-disposition:")) { + contentDispositionHeader = h; + } else if (h.toLowerCase().startsWith("content-type:")) { + contentTypeHeader = h; + } + } + state = ParsingState.READING_DATA; + buffer = []; + } + lastline = ""; + } else if (ParsingState.READING_DATA === state) { + // parsing data + if (lastline.length > boundary.length + 4) { + lastline = ""; // mem save + } + if ("--" + boundary === lastline) { + const j = buffer.length - lastline.length; + const part = buffer.slice(0, j - 1); + + allParts.push( + process({ contentDispositionHeader, contentTypeHeader, part }) + ); + buffer = []; + currentPartHeaders = []; + lastline = ""; + state = ParsingState.READING_PART_SEPARATOR; + contentDispositionHeader = ""; + contentTypeHeader = ""; + } else { + buffer.push(oneByte); + } + if (newLineDetected) { + lastline = ""; + } + } else if (ParsingState.READING_PART_SEPARATOR === state) { + if (newLineDetected) { + state = ParsingState.READING_HEADERS; + } + } + } + return allParts; +} + +export function getBoundary(str: string): string { + const lines = str.replaceAll("\r\n", "\n").split("\n").reverse(); + for (const line of lines) { + if (line !== "") { + return line.slice(0, -2).slice(2); + } + } + return ""; +} + +function process(part: Part): Input { + // will transform this object: + // { header: 'Content-Disposition: form-data; name="uploads[]"; filename="A.txt"', + // info: 'Content-Type: text/plain', + // part: 'AAAABBBB' } + // into this one: + // { filename: 'A.txt', type: 'text/plain', data: } + const obj = function (str: string) { + const k = str.split("="); + const a = k[0].trim(); + + const b = JSON.parse(k[1].trim()); + const o = {}; + Object.defineProperty(o, a, { + value: b, + writable: true, + enumerable: true, + configurable: true, + }); + return o; + }; + const header = part.contentDispositionHeader.split(";"); + + const filenameData = header[2]; + let input = {}; + if (filenameData) { + input = obj(filenameData); + const contentType = part.contentTypeHeader.split(":")[1].trim(); + Object.defineProperty(input, "type", { + value: contentType, + writable: true, + enumerable: true, + configurable: true, + }); + } + // always process the name field + Object.defineProperty(input, "name", { + value: header[1].split("=")[1].replace(/"/g, ""), + writable: true, + enumerable: true, + configurable: true, + }); + + Object.defineProperty(input, "data", { + value: Buffer.from(part.part), + writable: true, + enumerable: true, + configurable: true, + }); + return input as Input; +} diff --git a/src/NewPayload.ts b/src/NewPayload.ts new file mode 100644 index 0000000..a2e7c2a --- /dev/null +++ b/src/NewPayload.ts @@ -0,0 +1,50 @@ +import { realpathSync, readFileSync } from "fs"; +export class NewPayload { + private data: Buffer; + + constructor(data: Buffer) { + this.data = data; + } + + public getData(): Buffer { + return this.data; + } + + public static fromBuffer(buffer: Buffer): Buffer { + return Buffer.from(buffer); + } + + public toBuffer(): Buffer { + return this.data; + } + + public static fromString(string: string): Buffer { + return Buffer.from(string); + } + + public toString(encoding: BufferEncoding = "utf8"): string { + return this.data.toString(encoding); + } + + public static fromJson(json: object): Buffer { + return Buffer.from(JSON.stringify(json)); + } + + public toJson(): object { + return JSON.parse(this.data.toString()); + } + + public static fromPath(path: string): Buffer { + const realPath = realpathSync(path); + const contents = readFileSync(realPath); + return Buffer.from(contents); + } + + public toFile(fileName: string): File { + if (!fileName) { + fileName = "code.tar.gz"; + } + const blob = new Blob([this.data]); + return new File([blob], fileName); + } +} diff --git a/src/client.ts b/src/client.ts index 770edb4..c8db18c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,9 @@ import { fetch, FormData, File } from 'node-fetch-native-with-agent'; import { createAgent } from 'node-fetch-native-with-agent/agent'; import { Models } from './models'; +import { NewPayload } from './NewPayload'; +import * as multipart from 'parse-multipart-data'; +const { buffer } = require('node:stream/consumers'); type Payload = { [key: string]: any; @@ -331,7 +334,28 @@ class Client { data = await response.json(); } else if (responseType === 'arrayBuffer') { data = await response.arrayBuffer(); - } else { + } else if (response.headers.get('content-type')?.includes('multipart/form-data')) { + const body = await buffer(response.body); + const boundary = multipart.getBoundary( + response.headers.get("content-type") || "" + ); + const parts = multipart.parse(body, boundary); + const partsObject: { [key: string]: Buffer | string } = {}; + for (const part of parts) { + if (part.name) { + if (part.name === "responseBody") { + partsObject[part.name] = part.data; + } else { + partsObject[part.name] = part.data.toString(); + } + } + } + data = { + ...partsObject, + responseBody: new NewPayload(partsObject.responseBody as Buffer), + }; + } + else { data = { message: await response.text() }; diff --git a/src/index.ts b/src/index.ts index 38ceb0d..cdc2c96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,3 +33,5 @@ export { ImageGravity } from './enums/image-gravity'; export { ImageFormat } from './enums/image-format'; export { PasswordHash } from './enums/password-hash'; export { MessagingProviderType } from './enums/messaging-provider-type'; +export { NewPayload } from './NewPayload'; +export { InputFile } from './inputFile'; diff --git a/src/services/functions.ts b/src/services/functions.ts index afe10e3..34fb834 100644 --- a/src/services/functions.ts +++ b/src/services/functions.ts @@ -2,6 +2,7 @@ import { AppwriteException, Client, type Payload, UploadProgress } from '../clie import type { Models } from '../models'; import { Runtime } from '../enums/runtime'; import { ExecutionMethod } from '../enums/execution-method'; +import { NewPayload } from '../NewPayload'; export class Functions { client: Client; @@ -624,7 +625,7 @@ Use the "command" param to set the entrypoint used to execute your cod * Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously. * * @param {string} functionId - * @param {string} body + * @param {NewPayload} body * @param {boolean} async * @param {string} xpath * @param {ExecutionMethod} method @@ -632,14 +633,14 @@ Use the "command" param to set the entrypoint used to execute your cod * @throws {AppwriteException} * @returns {Promise} */ - async createExecution(functionId: string, body?: string, async?: boolean, xpath?: string, method?: ExecutionMethod, headers?: object): Promise { + async createExecution(functionId: string, body?: NewPayload, async?: boolean, xpath?: string, method?: ExecutionMethod, headers?: object): Promise { if (typeof functionId === 'undefined') { throw new AppwriteException('Missing required parameter: "functionId"'); } const apiPath = '/functions/{functionId}/executions'.replace('{functionId}', functionId); const payload: Payload = {}; if (typeof body !== 'undefined') { - payload['body'] = body; + payload['body'] = body ? body.getData() : body; } if (typeof async !== 'undefined') { payload['async'] = async; @@ -656,7 +657,8 @@ Use the "command" param to set the entrypoint used to execute your cod const uri = new URL(this.client.config.endpoint + apiPath); const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json', + 'content-type': 'multipart/form-data', + 'accept': 'multipart/form-data', } return await this.client.call(