From bfc8fa8c85a209511624cc92703201b5c8f60ba5 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 23:22:14 +0000 Subject: [PATCH] SDK regeneration --- package.json | 2 +- src/Client.ts | 4 +- .../documentCatalog/client/Client.ts | 112 ++++ .../client/requests/UploadFileRequest.ts | 30 ++ .../documentCatalog/client/requests/index.ts | 1 + src/core/exports.ts | 1 + src/core/file/exports.ts | 1 + src/core/file/file.ts | 217 ++++++++ src/core/file/index.ts | 2 + src/core/file/types.ts | 81 +++ src/core/form-data-utils/FormDataWrapper.ts | 140 +++++ .../form-data-utils/encodeAsFormParameter.ts | 12 + src/core/form-data-utils/index.ts | 2 + src/core/index.ts | 2 + src/exports.ts | 1 + src/index.ts | 1 + src/version.ts | 2 +- tests/unit/file/file.test.ts | 498 ++++++++++++++++++ .../encodeAsFormParameter.test.ts | 344 ++++++++++++ .../form-data-utils/formDataWrapper.test.ts | 346 ++++++++++++ tests/unit/test-file.txt | 1 + 21 files changed, 1796 insertions(+), 4 deletions(-) create mode 100644 src/api/resources/documentCatalog/client/requests/UploadFileRequest.ts create mode 100644 src/core/exports.ts create mode 100644 src/core/file/exports.ts create mode 100644 src/core/file/file.ts create mode 100644 src/core/file/index.ts create mode 100644 src/core/file/types.ts create mode 100644 src/core/form-data-utils/FormDataWrapper.ts create mode 100644 src/core/form-data-utils/encodeAsFormParameter.ts create mode 100644 src/core/form-data-utils/index.ts create mode 100644 src/exports.ts create mode 100644 tests/unit/file/file.test.ts create mode 100644 tests/unit/form-data-utils/encodeAsFormParameter.test.ts create mode 100644 tests/unit/form-data-utils/formDataWrapper.test.ts create mode 100644 tests/unit/test-file.txt diff --git a/package.json b/package.json index 57dc925..8b3a8aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@credal/sdk", - "version": "0.1.9", + "version": "0.1.10", "private": false, "repository": "github:credal-ai/credal-typescript-sdk", "type": "commonjs", diff --git a/src/Client.ts b/src/Client.ts index 593db23..4145e1d 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -30,8 +30,8 @@ export class CredalClient { { "X-Fern-Language": "JavaScript", "X-Fern-SDK-Name": "@credal/sdk", - "X-Fern-SDK-Version": "0.1.9", - "User-Agent": "@credal/sdk/0.1.9", + "X-Fern-SDK-Version": "0.1.10", + "User-Agent": "@credal/sdk/0.1.10", "X-Fern-Runtime": core.RUNTIME.type, "X-Fern-Runtime-Version": core.RUNTIME.version, }, diff --git a/src/api/resources/documentCatalog/client/Client.ts b/src/api/resources/documentCatalog/client/Client.ts index f829cb3..278497c 100644 --- a/src/api/resources/documentCatalog/client/Client.ts +++ b/src/api/resources/documentCatalog/client/Client.ts @@ -97,6 +97,118 @@ export class DocumentCatalog { } } + /** + * Upload a file (PDF, Word, Excel, CSV, PowerPoint) to Credal. Unlike uploadDocumentContents which requires pre-parsed text, this endpoint accepts actual file uploads and automatically parses them using Credal's parsing service. + * + * @param {Credal.UploadFileRequest} request + * @param {DocumentCatalog.RequestOptions} requestOptions - Request-specific configuration. + */ + public uploadFile( + request: Credal.UploadFileRequest, + requestOptions?: DocumentCatalog.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__uploadFile(request, requestOptions)); + } + + private async __uploadFile( + request: Credal.UploadFileRequest, + requestOptions?: DocumentCatalog.RequestOptions, + ): Promise> { + const _request = await core.newFormData(); + await _request.appendFile("file", request.file); + if (request.documentName != null) { + _request.append("documentName", request.documentName); + } + + _request.append("uploadAsUserEmail", request.uploadAsUserEmail); + _request.append("documentExternalId", request.documentExternalId); + if (request.allowedUsersEmailAddresses != null) { + _request.append("allowedUsersEmailAddresses", request.allowedUsersEmailAddresses); + } + + if (request.documentExternalUrl != null) { + _request.append("documentExternalUrl", request.documentExternalUrl); + } + + if (request.customMetadata != null) { + _request.append("customMetadata", request.customMetadata); + } + + if (request.collectionId != null) { + _request.append("collectionId", request.collectionId); + } + + if (request.forceUpdate != null) { + _request.append("forceUpdate", request.forceUpdate); + } + + if (request.internalPublic != null) { + _request.append("internalPublic", request.internalPublic); + } + + if (request.sourceSystemUpdated != null) { + _request.append("sourceSystemUpdated", request.sourceSystemUpdated); + } + + if (request.awaitVectorStoreSync != null) { + _request.append("awaitVectorStoreSync", request.awaitVectorStoreSync); + } + + const _maybeEncodedRequest = await _request.getRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + this._options?.headers, + mergeOnlyDefinedHeaders({ + Authorization: await this._getAuthorizationHeader(), + ..._maybeEncodedRequest.headers, + }), + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.CredalEnvironment.Production, + "/v0/catalog/uploadFile", + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + requestType: "file", + duplex: _maybeEncodedRequest.duplex, + body: _maybeEncodedRequest.body, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return { data: _response.body as Credal.UploadDocumentResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.CredalError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.CredalError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + rawResponse: _response.rawResponse, + }); + case "timeout": + throw new errors.CredalTimeoutError("Timeout exceeded when calling POST /v0/catalog/uploadFile."); + case "unknown": + throw new errors.CredalError({ + message: _response.error.errorMessage, + rawResponse: _response.rawResponse, + }); + } + } + /** * Sync a document from a source URL. Does not support recursive web search. Reach out to a Credal representative for access. * diff --git a/src/api/resources/documentCatalog/client/requests/UploadFileRequest.ts b/src/api/resources/documentCatalog/client/requests/UploadFileRequest.ts new file mode 100644 index 0000000..e22bd09 --- /dev/null +++ b/src/api/resources/documentCatalog/client/requests/UploadFileRequest.ts @@ -0,0 +1,30 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as core from "../../../../../core/index.js"; + +export interface UploadFileRequest { + /** The file to upload. Supported formats: PDF (.pdf), Word (.docx), Excel (.xlsx, .xls), PowerPoint (.pptx, .ppt), CSV (.csv), and text files. */ + file: core.file.Uploadable; + /** The name of the document you want to upload. If not provided, the original filename will be used. */ + documentName?: string; + /** [Legacy] The user on behalf of whom the document should be uploaded. In most cases, this can simply be the email of the developer making the API call. This field will be removed in the future in favor of purely specifying permissions via allowedUsersEmailAddresses. */ + uploadAsUserEmail: string; + /** The external ID of the document. This is typically the ID as it exists in its original external system. Uploads to the same external ID will update the document in Credal. */ + documentExternalId: string; + /** Users allowed to access the document. Can be provided as a JSON array string (e.g., ["user1@example.com","user2@example.com"]) or comma-separated list (e.g., "user1@example.com,user2@example.com"). Unlike Credal's out of the box connectors which reconcile various permissions models from 3rd party software, for custom uploads the caller is responsible for specifying who can access the document and currently flattening groups if applicable. Documents can also be marked as internal public. */ + allowedUsersEmailAddresses?: string; + /** The external URL of the document you want to upload. If provided Credal will link to this URL. */ + documentExternalUrl?: string; + /** Optional JSON string representing any custom metadata for this document (e.g., '{"key1":"value1","key2":"value2"}'). */ + customMetadata?: string; + /** If specified, the document will also be added to the provided document collection. This operation is eventually consistent, meaning the document does not immediately start appearing in searches of that collection due to an asynchronous embedding process. To achieve strong consistency use the `awaitVectorStoreSync` parameter. */ + collectionId?: string; + /** If set to "true", document contents will be re-uploaded and re-embedded even if the document already exists in Credal. */ + forceUpdate?: string; + /** If set to "true", document will be accessible to everyone within the organization of the uploader. */ + internalPublic?: string; + /** ISO 8601 date string indicating when the document was last updated in the source system (e.g., "2025-11-03T21:15:00Z"). */ + sourceSystemUpdated?: string; + /** Document uploads are eventually consistent by default. If set to "true" the API will wait for the vector store to be updated before returning. This is useful if you want to ensure that the document is immediately searchable after this call returns. */ + awaitVectorStoreSync?: string; +} diff --git a/src/api/resources/documentCatalog/client/requests/index.ts b/src/api/resources/documentCatalog/client/requests/index.ts index 59392b9..40ee5f4 100644 --- a/src/api/resources/documentCatalog/client/requests/index.ts +++ b/src/api/resources/documentCatalog/client/requests/index.ts @@ -1 +1,2 @@ export type { UploadDocumentContentsRequest } from "./UploadDocumentContentsRequest.js"; +export type { UploadFileRequest } from "./UploadFileRequest.js"; diff --git a/src/core/exports.ts b/src/core/exports.ts new file mode 100644 index 0000000..e415a8f --- /dev/null +++ b/src/core/exports.ts @@ -0,0 +1 @@ +export * from "./file/exports.js"; diff --git a/src/core/file/exports.ts b/src/core/file/exports.ts new file mode 100644 index 0000000..3b0b396 --- /dev/null +++ b/src/core/file/exports.ts @@ -0,0 +1 @@ +export type { Uploadable } from "./types.js"; diff --git a/src/core/file/file.ts b/src/core/file/file.ts new file mode 100644 index 0000000..0bacc48 --- /dev/null +++ b/src/core/file/file.ts @@ -0,0 +1,217 @@ +import type { Uploadable } from "./types.js"; + +export async function toBinaryUploadRequest( + file: Uploadable, +): Promise<{ body: Uploadable.FileLike; headers?: Record }> { + const { data, filename, contentLength, contentType } = await getFileWithMetadata(file); + const request = { + body: data, + headers: {} as Record, + }; + if (filename) { + request.headers["Content-Disposition"] = `attachment; filename="${filename}"`; + } + if (contentType) { + request.headers["Content-Type"] = contentType; + } + if (contentLength != null) { + request.headers["Content-Length"] = contentLength.toString(); + } + return request; +} + +export async function toMultipartDataPart( + file: Uploadable, +): Promise<{ data: Uploadable.FileLike; filename?: string; contentType?: string }> { + const { data, filename, contentType } = await getFileWithMetadata(file, { + noSniffFileSize: true, + }); + return { + data, + filename, + contentType, + }; +} + +async function getFileWithMetadata( + file: Uploadable, + { noSniffFileSize }: { noSniffFileSize?: boolean } = {}, +): Promise { + if (isFileLike(file)) { + return getFileWithMetadata( + { + data: file, + }, + { noSniffFileSize }, + ); + } + + if ("path" in file) { + const fs = await import("fs"); + if (!fs || !fs.createReadStream) { + throw new Error("File path uploads are not supported in this environment."); + } + const data = fs.createReadStream(file.path); + const contentLength = + file.contentLength ?? (noSniffFileSize === true ? undefined : await tryGetFileSizeFromPath(file.path)); + const filename = file.filename ?? getNameFromPath(file.path); + return { + data, + filename, + contentType: file.contentType, + contentLength, + }; + } + if ("data" in file) { + const data = file.data; + const contentLength = + file.contentLength ?? + (await tryGetContentLengthFromFileLike(data, { + noSniffFileSize, + })); + const filename = file.filename ?? tryGetNameFromFileLike(data); + return { + data, + filename, + contentType: file.contentType ?? tryGetContentTypeFromFileLike(data), + contentLength, + }; + } + + throw new Error(`Invalid FileUpload of type ${typeof file}: ${JSON.stringify(file)}`); +} + +function isFileLike(value: unknown): value is Uploadable.FileLike { + return ( + isBuffer(value) || + isArrayBufferView(value) || + isArrayBuffer(value) || + isUint8Array(value) || + isBlob(value) || + isFile(value) || + isStreamLike(value) || + isReadableStream(value) + ); +} + +async function tryGetFileSizeFromPath(path: string): Promise { + try { + const fs = await import("fs"); + if (!fs || !fs.promises || !fs.promises.stat) { + return undefined; + } + const fileStat = await fs.promises.stat(path); + return fileStat.size; + } catch (_fallbackError) { + return undefined; + } +} + +function tryGetNameFromFileLike(data: Uploadable.FileLike): string | undefined { + if (isNamedValue(data)) { + return data.name; + } + if (isPathedValue(data)) { + return getNameFromPath(data.path.toString()); + } + return undefined; +} + +async function tryGetContentLengthFromFileLike( + data: Uploadable.FileLike, + { noSniffFileSize }: { noSniffFileSize?: boolean } = {}, +): Promise { + if (isBuffer(data)) { + return data.length; + } + if (isArrayBufferView(data)) { + return data.byteLength; + } + if (isArrayBuffer(data)) { + return data.byteLength; + } + if (isBlob(data)) { + return data.size; + } + if (isFile(data)) { + return data.size; + } + if (noSniffFileSize === true) { + return undefined; + } + if (isPathedValue(data)) { + return await tryGetFileSizeFromPath(data.path.toString()); + } + return undefined; +} + +function tryGetContentTypeFromFileLike(data: Uploadable.FileLike): string | undefined { + if (isBlob(data)) { + return data.type; + } + if (isFile(data)) { + return data.type; + } + + return undefined; +} + +function getNameFromPath(path: string): string | undefined { + const lastForwardSlash = path.lastIndexOf("/"); + const lastBackSlash = path.lastIndexOf("\\"); + const lastSlashIndex = Math.max(lastForwardSlash, lastBackSlash); + return lastSlashIndex >= 0 ? path.substring(lastSlashIndex + 1) : path; +} + +type NamedValue = { + name: string; +} & unknown; + +type PathedValue = { + path: string | { toString(): string }; +} & unknown; + +type StreamLike = { + read?: () => unknown; + pipe?: (dest: unknown) => unknown; +} & unknown; + +function isNamedValue(value: unknown): value is NamedValue { + return typeof value === "object" && value != null && "name" in value; +} + +function isPathedValue(value: unknown): value is PathedValue { + return typeof value === "object" && value != null && "path" in value; +} + +function isStreamLike(value: unknown): value is StreamLike { + return typeof value === "object" && value != null && ("read" in value || "pipe" in value); +} + +function isReadableStream(value: unknown): value is ReadableStream { + return typeof value === "object" && value != null && "getReader" in value; +} + +function isBuffer(value: unknown): value is Buffer { + return typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(value); +} + +function isArrayBufferView(value: unknown): value is ArrayBufferView { + return typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value); +} + +function isArrayBuffer(value: unknown): value is ArrayBuffer { + return typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer; +} + +function isUint8Array(value: unknown): value is Uint8Array { + return typeof Uint8Array !== "undefined" && value instanceof Uint8Array; +} + +function isBlob(value: unknown): value is Blob { + return typeof Blob !== "undefined" && value instanceof Blob; +} + +function isFile(value: unknown): value is File { + return typeof File !== "undefined" && value instanceof File; +} diff --git a/src/core/file/index.ts b/src/core/file/index.ts new file mode 100644 index 0000000..fc16dd5 --- /dev/null +++ b/src/core/file/index.ts @@ -0,0 +1,2 @@ +export * from "./file.js"; +export * from "./types.js"; diff --git a/src/core/file/types.ts b/src/core/file/types.ts new file mode 100644 index 0000000..531b692 --- /dev/null +++ b/src/core/file/types.ts @@ -0,0 +1,81 @@ +/** + * A file that can be uploaded. Can be a file-like object (stream, buffer, blob, etc.), + * a path to a file, or an object with a file-like object and metadata. + */ +export type Uploadable = Uploadable.FileLike | Uploadable.FromPath | Uploadable.WithMetadata; + +export namespace Uploadable { + /** + * Various file-like objects that can be used to upload a file. + */ + export type FileLike = + | ArrayBuffer + | ArrayBufferLike + | ArrayBufferView + | Uint8Array + | import("buffer").Buffer + | import("buffer").Blob + | import("buffer").File + | import("stream").Readable + | import("stream/web").ReadableStream + | globalThis.Blob + | globalThis.File + | ReadableStream; + + /** + * A file path with optional metadata, used for uploading a file from the file system. + */ + export type FromPath = { + /** The path to the file to upload */ + path: string; + /** + * Optional override for the file name (defaults to basename of path). + * This is used to set the `Content-Disposition` header in upload requests. + */ + filename?: string; + /** + * Optional MIME type of the file (e.g., 'image/jpeg', 'text/plain'). + * This is used to set the `Content-Type` header in upload requests. + */ + contentType?: string; + /** + * Optional file size in bytes. + * If not provided, the file size will be determined from the file system. + * The content length is used to set the `Content-Length` header in upload requests. + */ + contentLength?: number; + }; + + /** + * A file-like object with metadata, used for uploading files. + */ + export type WithMetadata = { + /** The file data */ + data: FileLike; + /** + * Optional override for the file name (defaults to basename of path). + * This is used to set the `Content-Disposition` header in upload requests. + */ + filename?: string; + /** + * Optional MIME type of the file (e.g., 'image/jpeg', 'text/plain'). + * This is used to set the `Content-Type` header in upload requests. + * + * If not provided, the content type may be determined from the data itself. + * * If the data is a `File`, `Blob`, or similar, the content type will be determined from the file itself, if the type is set. + * * Any other data type will not have a content type set, and the upload request will use `Content-Type: application/octet-stream` instead. + */ + contentType?: string; + /** + * Optional file size in bytes. + * The content length is used to set the `Content-Length` header in upload requests. + * If the content length is not provided and cannot be determined, the upload request will not include the `Content-Length` header, but will use `Transfer-Encoding: chunked` instead. + * + * If not provided, the file size will be determined depending on the data type. + * * If the data is of type `fs.ReadStream` (`createReadStream`), the size will be determined from the file system. + * * If the data is a `Buffer`, `ArrayBuffer`, `Uint8Array`, `Blob`, `File`, or similar, the size will be determined from the data itself. + * * If the data is a `Readable` or `ReadableStream`, the size will not be determined. + */ + contentLength?: number; + }; +} diff --git a/src/core/form-data-utils/FormDataWrapper.ts b/src/core/form-data-utils/FormDataWrapper.ts new file mode 100644 index 0000000..bea0cf8 --- /dev/null +++ b/src/core/form-data-utils/FormDataWrapper.ts @@ -0,0 +1,140 @@ +import { toMultipartDataPart, type Uploadable } from "../../core/file/index.js"; +import { toJson } from "../../core/json.js"; +import { RUNTIME } from "../runtime/index.js"; + +interface FormDataRequest { + body: Body; + headers: Record; + duplex?: "half"; +} + +export async function newFormData(): Promise { + return new FormDataWrapper(); +} + +export class FormDataWrapper { + private fd: FormData = new FormData(); + + public async setup(): Promise { + // noop + } + + public append(key: string, value: unknown): void { + this.fd.append(key, String(value)); + } + + public async appendFile(key: string, value: Uploadable): Promise { + const { data, filename, contentType } = await toMultipartDataPart(value); + const blob = await convertToBlob(data, contentType); + if (filename) { + this.fd.append(key, blob, filename); + } else { + this.fd.append(key, blob); + } + } + + public getRequest(): FormDataRequest { + return { + body: this.fd, + headers: {}, + duplex: "half" as const, + }; + } +} + +type StreamLike = { + read?: () => unknown; + pipe?: (dest: unknown) => unknown; +} & unknown; + +function isStreamLike(value: unknown): value is StreamLike { + return typeof value === "object" && value != null && ("read" in value || "pipe" in value); +} + +function isReadableStream(value: unknown): value is ReadableStream { + return typeof value === "object" && value != null && "getReader" in value; +} + +function isBuffer(value: unknown): value is Buffer { + return typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(value); +} + +function isArrayBufferView(value: unknown): value is ArrayBufferView { + return ArrayBuffer.isView(value); +} + +async function streamToBuffer(stream: unknown): Promise { + if (RUNTIME.type === "node") { + const { Readable } = await import("stream"); + + if (stream instanceof Readable) { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); + } + } + + if (isReadableStream(stream)) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + + return Buffer.from(result); + } + + throw new Error( + `Unsupported stream type: ${typeof stream}. Expected Node.js Readable stream or Web ReadableStream.`, + ); +} + +async function convertToBlob(value: unknown, contentType?: string): Promise { + if (isStreamLike(value) || isReadableStream(value)) { + const buffer = await streamToBuffer(value); + return new Blob([buffer], { type: contentType }); + } + + if (value instanceof Blob) { + return value; + } + + if (isBuffer(value)) { + return new Blob([value], { type: contentType }); + } + + if (value instanceof ArrayBuffer) { + return new Blob([value], { type: contentType }); + } + + if (isArrayBufferView(value)) { + return new Blob([value], { type: contentType }); + } + + if (typeof value === "string") { + return new Blob([value], { type: contentType }); + } + + if (typeof value === "object" && value !== null) { + return new Blob([toJson(value)], { type: contentType ?? "application/json" }); + } + + return new Blob([String(value)], { type: contentType }); +} diff --git a/src/core/form-data-utils/encodeAsFormParameter.ts b/src/core/form-data-utils/encodeAsFormParameter.ts new file mode 100644 index 0000000..cfc6741 --- /dev/null +++ b/src/core/form-data-utils/encodeAsFormParameter.ts @@ -0,0 +1,12 @@ +import { toQueryString } from "../url/qs.js"; + +export function encodeAsFormParameter(value: unknown): Record { + const stringified = toQueryString(value, { encode: false }); + + const keyValuePairs = stringified.split("&").map((pair) => { + const [key, value] = pair.split("="); + return [key, value] as const; + }); + + return Object.fromEntries(keyValuePairs); +} diff --git a/src/core/form-data-utils/index.ts b/src/core/form-data-utils/index.ts new file mode 100644 index 0000000..1188f80 --- /dev/null +++ b/src/core/form-data-utils/index.ts @@ -0,0 +1,2 @@ +export { encodeAsFormParameter } from "./encodeAsFormParameter.js"; +export * from "./FormDataWrapper.js"; diff --git a/src/core/index.ts b/src/core/index.ts index 31bf19d..fcfaa65 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,8 @@ export * from "./auth/index.js"; export * from "./base64.js"; export * from "./fetcher/index.js"; +export * as file from "./file/index.js"; +export * from "./form-data-utils/index.js"; export * from "./runtime/index.js"; export * from "./stream/index.js"; export * as url from "./url/index.js"; diff --git a/src/exports.ts b/src/exports.ts new file mode 100644 index 0000000..7b70ee1 --- /dev/null +++ b/src/exports.ts @@ -0,0 +1 @@ +export * from "./core/exports.js"; diff --git a/src/index.ts b/src/index.ts index 211751d..36eec97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,4 @@ export type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js"; export { CredalClient } from "./Client.js"; export { CredalEnvironment } from "./environments.js"; export { CredalError, CredalTimeoutError } from "./errors/index.js"; +export * from "./exports.js"; diff --git a/src/version.ts b/src/version.ts index cf8a01b..7e84756 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const SDK_VERSION = "0.1.9"; +export const SDK_VERSION = "0.1.10"; diff --git a/tests/unit/file/file.test.ts b/tests/unit/file/file.test.ts new file mode 100644 index 0000000..d7c4570 --- /dev/null +++ b/tests/unit/file/file.test.ts @@ -0,0 +1,498 @@ +import fs from "fs"; +import { join } from "path"; +import { Readable } from "stream"; +import { toBinaryUploadRequest, type Uploadable } from "../../../src/core/file/index"; + +describe("toBinaryUploadRequest", () => { + const TEST_FILE_PATH = join(__dirname, "..", "test-file.txt"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Buffer input", () => { + it("should handle Buffer with all metadata", async () => { + const buffer = Buffer.from("test data"); + const input: Uploadable.WithMetadata = { + data: buffer, + filename: "test.txt", + contentType: "text/plain", + contentLength: 42, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(buffer); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="test.txt"', + "Content-Type": "text/plain", + "Content-Length": "42", + }); + }); + + it("should handle Buffer without metadata", async () => { + const buffer = Buffer.from("test data"); + const input: Uploadable.WithMetadata = { + data: buffer, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(buffer); + expect(result.headers).toEqual({ + "Content-Length": "9", // buffer.length + }); + }); + + it("should handle Buffer passed directly", async () => { + const buffer = Buffer.from("test data"); + + const result = await toBinaryUploadRequest(buffer); + + expect(result.body).toBe(buffer); + expect(result.headers).toEqual({ + "Content-Length": "9", // buffer.length + }); + }); + }); + + describe("ArrayBuffer input", () => { + it("should handle ArrayBuffer with metadata", async () => { + const arrayBuffer = new ArrayBuffer(10); + const input: Uploadable.WithMetadata = { + data: arrayBuffer, + filename: "data.bin", + contentType: "application/octet-stream", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(arrayBuffer); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="data.bin"', + "Content-Type": "application/octet-stream", + "Content-Length": "10", // arrayBuffer.byteLength + }); + }); + + it("should handle ArrayBuffer passed directly", async () => { + const arrayBuffer = new ArrayBuffer(10); + + const result = await toBinaryUploadRequest(arrayBuffer); + + expect(result.body).toBe(arrayBuffer); + expect(result.headers).toEqual({ + "Content-Length": "10", // arrayBuffer.byteLength + }); + }); + }); + + describe("Uint8Array input", () => { + it("should handle Uint8Array with metadata", async () => { + const uint8Array = new Uint8Array([1, 2, 3, 4, 5]); + const input: Uploadable.WithMetadata = { + data: uint8Array, + filename: "bytes.bin", + contentType: "application/octet-stream", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(uint8Array); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="bytes.bin"', + "Content-Type": "application/octet-stream", + "Content-Length": "5", // uint8Array.byteLength + }); + }); + + it("should handle Uint8Array passed directly", async () => { + const uint8Array = new Uint8Array([1, 2, 3, 4, 5]); + + const result = await toBinaryUploadRequest(uint8Array); + + expect(result.body).toBe(uint8Array); + expect(result.headers).toEqual({ + "Content-Length": "5", // uint8Array.byteLength + }); + }); + }); + + describe("Blob input", () => { + it("should handle Blob with metadata", async () => { + const blob = new Blob(["test content"], { type: "text/plain" }); + const input: Uploadable.WithMetadata = { + data: blob, + filename: "override.txt", + contentType: "text/html", // Override blob's type + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(blob); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="override.txt"', + "Content-Type": "text/html", // Should use provided contentType + "Content-Length": "12", // blob.size + }); + }); + + it("should handle Blob with intrinsic type", async () => { + const blob = new Blob(["test content"], { type: "application/json" }); + const input: Uploadable.WithMetadata = { + data: blob, + filename: "data.json", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(blob); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="data.json"', + "Content-Type": "application/json", // Should use blob's type + "Content-Length": "12", // blob.size + }); + }); + + it("should handle Blob passed directly", async () => { + const blob = new Blob(["test content"], { type: "text/plain" }); + + const result = await toBinaryUploadRequest(blob); + + expect(result.body).toBe(blob); + expect(result.headers).toEqual({ + "Content-Type": "text/plain", // Should use blob's type + "Content-Length": "12", // blob.size + }); + }); + }); + + describe("File input", () => { + it("should handle File with metadata", async () => { + const file = new File(["file content"], "original.txt", { type: "text/plain" }); + const input: Uploadable.WithMetadata = { + data: file, + filename: "renamed.txt", + contentType: "text/html", // Override file's type + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(file); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="renamed.txt"', + "Content-Type": "text/html", // Should use provided contentType + "Content-Length": "12", // file.size + }); + }); + + it("should handle File with intrinsic properties", async () => { + const file = new File(["file content"], "test.json", { type: "application/json" }); + const input: Uploadable.WithMetadata = { + data: file, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(file); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="test.json"', // Should use file's name + "Content-Type": "application/json", // Should use file's type + "Content-Length": "12", // file.size + }); + }); + + it("should handle File passed directly", async () => { + const file = new File(["file content"], "direct.txt", { type: "text/plain" }); + + const result = await toBinaryUploadRequest(file); + + expect(result.body).toBe(file); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="direct.txt"', + "Content-Type": "text/plain", + "Content-Length": "12", // file.size + }); + }); + }); + + describe("ReadableStream input", () => { + it("should handle ReadableStream with metadata", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("stream data")); + controller.close(); + }, + }); + const input: Uploadable.WithMetadata = { + data: stream, + filename: "stream.txt", + contentType: "text/plain", + contentLength: 100, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(stream); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="stream.txt"', + "Content-Type": "text/plain", + "Content-Length": "100", // Should use provided contentLength + }); + }); + + it("should handle ReadableStream without size", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("stream data")); + controller.close(); + }, + }); + const input: Uploadable.WithMetadata = { + data: stream, + filename: "stream.txt", + contentType: "text/plain", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(stream); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="stream.txt"', + "Content-Type": "text/plain", + // No Content-Length header since it cannot be determined from ReadableStream + }); + }); + + it("should handle ReadableStream passed directly", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("stream data")); + controller.close(); + }, + }); + + const result = await toBinaryUploadRequest(stream); + + expect(result.body).toBe(stream); + expect(result.headers).toEqual({ + // No headers since no metadata provided and cannot be determined + }); + }); + }); + + describe("Node.js Readable stream input", () => { + it("should handle Readable stream with metadata", async () => { + const readable = new Readable({ + read() { + this.push("readable data"); + this.push(null); + }, + }); + const input: Uploadable.WithMetadata = { + data: readable, + filename: "readable.txt", + contentType: "text/plain", + contentLength: 50, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(readable); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="readable.txt"', + "Content-Type": "text/plain", + "Content-Length": "50", // Should use provided contentLength + }); + }); + + it("should handle Readable stream without size", async () => { + const readable = new Readable({ + read() { + this.push("readable data"); + this.push(null); + }, + }); + const input: Uploadable.WithMetadata = { + data: readable, + filename: "readable.txt", + contentType: "text/plain", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(readable); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="readable.txt"', + "Content-Type": "text/plain", + // No Content-Length header since it cannot be determined from Readable + }); + }); + + it("should handle Readable stream passed directly", async () => { + const readable = new Readable({ + read() { + this.push("readable data"); + this.push(null); + }, + }); + + const result = await toBinaryUploadRequest(readable); + + expect(result.body).toBe(readable); + expect(result.headers).toEqual({ + // No headers since no metadata provided and cannot be determined + }); + }); + }); + + describe("File path input (FromPath type)", () => { + it("should handle file path with all metadata", async () => { + const input: Uploadable.FromPath = { + path: TEST_FILE_PATH, + filename: "custom.txt", + contentType: "text/html", + contentLength: 42, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBeInstanceOf(fs.ReadStream); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="custom.txt"', + "Content-Type": "text/html", + "Content-Length": "42", // Should use provided contentLength + }); + }); + + it("should handle file path with minimal metadata", async () => { + const input: Uploadable.FromPath = { + path: TEST_FILE_PATH, + contentType: "text/plain", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBeInstanceOf(fs.ReadStream); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="test-file.txt"', // Should extract from path + "Content-Type": "text/plain", + "Content-Length": "21", // Should determine from file system (test file is 21 bytes) + }); + }); + + it("should handle file path with no metadata", async () => { + const input: Uploadable.FromPath = { + path: TEST_FILE_PATH, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBeInstanceOf(fs.ReadStream); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="test-file.txt"', // Should extract from path + "Content-Length": "21", // Should determine from file system (test file is 21 bytes) + }); + }); + }); + + describe("ArrayBufferView input", () => { + it("should handle ArrayBufferView with metadata", async () => { + const arrayBuffer = new ArrayBuffer(10); + const arrayBufferView = new Int8Array(arrayBuffer); + const input: Uploadable.WithMetadata = { + data: arrayBufferView, + filename: "view.bin", + contentType: "application/octet-stream", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(arrayBufferView); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="view.bin"', + "Content-Type": "application/octet-stream", + "Content-Length": "10", // arrayBufferView.byteLength + }); + }); + + it("should handle ArrayBufferView passed directly", async () => { + const arrayBuffer = new ArrayBuffer(10); + const arrayBufferView = new Int8Array(arrayBuffer); + + const result = await toBinaryUploadRequest(arrayBufferView); + + expect(result.body).toBe(arrayBufferView); + expect(result.headers).toEqual({ + "Content-Length": "10", // arrayBufferView.byteLength + }); + }); + }); + + describe("Edge cases", () => { + it("should handle empty headers when no metadata is available", async () => { + const buffer = Buffer.from(""); + const input: Uploadable.WithMetadata = { + data: buffer, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(buffer); + expect(result.headers).toEqual({ + "Content-Length": "0", + }); + }); + + it("should handle zero contentLength", async () => { + const buffer = Buffer.from("test"); + const input: Uploadable.WithMetadata = { + data: buffer, + contentLength: 0, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(buffer); + expect(result.headers).toEqual({ + "Content-Length": "0", // Should use provided 0 + }); + }); + + it("should handle null filename", async () => { + const buffer = Buffer.from("test"); + const input: Uploadable.WithMetadata = { + data: buffer, + filename: undefined, + contentType: "text/plain", + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(buffer); + expect(result.headers).toEqual({ + "Content-Type": "text/plain", + "Content-Length": "4", + // No Content-Disposition since filename is undefined + }); + }); + + it("should handle null contentType", async () => { + const buffer = Buffer.from("test"); + const input: Uploadable.WithMetadata = { + data: buffer, + filename: "test.txt", + contentType: undefined, + }; + + const result = await toBinaryUploadRequest(input); + + expect(result.body).toBe(buffer); + expect(result.headers).toEqual({ + "Content-Disposition": 'attachment; filename="test.txt"', + "Content-Length": "4", + // No Content-Type since contentType is undefined + }); + }); + }); +}); diff --git a/tests/unit/form-data-utils/encodeAsFormParameter.test.ts b/tests/unit/form-data-utils/encodeAsFormParameter.test.ts new file mode 100644 index 0000000..d4b0c45 --- /dev/null +++ b/tests/unit/form-data-utils/encodeAsFormParameter.test.ts @@ -0,0 +1,344 @@ +import { encodeAsFormParameter } from "../../../src/core/form-data-utils/encodeAsFormParameter"; + +describe("encodeAsFormParameter", () => { + describe("Basic functionality", () => { + it("should return empty object for null/undefined", () => { + expect(encodeAsFormParameter(null)).toEqual({}); + expect(encodeAsFormParameter(undefined)).toEqual({}); + }); + + it("should return empty object for primitive values", () => { + expect(encodeAsFormParameter("hello")).toEqual({}); + expect(encodeAsFormParameter(42)).toEqual({}); + expect(encodeAsFormParameter(true)).toEqual({}); + }); + + it("should handle simple key-value pairs", () => { + const obj = { name: "John", age: 30 }; + expect(encodeAsFormParameter(obj)).toEqual({ + name: "John", + age: "30", + }); + }); + + it("should handle empty objects", () => { + expect(encodeAsFormParameter({})).toEqual({}); + }); + }); + + describe("Array handling", () => { + it("should handle arrays with indices format (default)", () => { + const obj = { items: ["a", "b", "c"] }; + expect(encodeAsFormParameter(obj)).toEqual({ + "items[0]": "a", + "items[1]": "b", + "items[2]": "c", + }); + }); + + it("should handle empty arrays", () => { + const obj = { items: [] }; + expect(encodeAsFormParameter(obj)).toEqual({}); + }); + + it("should handle arrays with mixed types", () => { + const obj = { mixed: ["string", 42, true, false] }; + expect(encodeAsFormParameter(obj)).toEqual({ + "mixed[0]": "string", + "mixed[1]": "42", + "mixed[2]": "true", + "mixed[3]": "false", + }); + }); + + it("should handle arrays with objects", () => { + const obj = { users: [{ name: "John" }, { name: "Jane" }] }; + expect(encodeAsFormParameter(obj)).toEqual({ + "users[0][name]": "John", + "users[1][name]": "Jane", + }); + }); + + it("should handle arrays with null/undefined values", () => { + const obj = { items: ["a", null, "c", undefined, "e"] }; + expect(encodeAsFormParameter(obj)).toEqual({ + "items[0]": "a", + "items[1]": "", + "items[2]": "c", + "items[4]": "e", + }); + }); + }); + + describe("Nested objects", () => { + it("should handle nested objects", () => { + const obj = { user: { name: "John", age: 30 } }; + expect(encodeAsFormParameter(obj)).toEqual({ + "user[name]": "John", + "user[age]": "30", + }); + }); + + it("should handle deeply nested objects", () => { + const obj = { user: { profile: { name: "John", settings: { theme: "dark" } } } }; + expect(encodeAsFormParameter(obj)).toEqual({ + "user[profile][name]": "John", + "user[profile][settings][theme]": "dark", + }); + }); + + it("should handle empty nested objects", () => { + const obj = { user: {} }; + expect(encodeAsFormParameter(obj)).toEqual({}); + }); + }); + + describe("Special characters and encoding", () => { + it("should not encode values (encode: false is used)", () => { + const obj = { name: "John Doe", email: "john@example.com" }; + expect(encodeAsFormParameter(obj)).toEqual({ + name: "John Doe", + email: "john@example.com", + }); + }); + + it("should not encode special characters in keys", () => { + const obj = { "user name": "John", "email[primary]": "john@example.com" }; + expect(encodeAsFormParameter(obj)).toEqual({ + "user name": "John", + "email[primary]": "john@example.com", + }); + }); + + it("should handle values that contain special characters", () => { + const obj = { + query: "search term with spaces", + filter: "category:electronics", + }; + expect(encodeAsFormParameter(obj)).toEqual({ + query: "search term with spaces", + filter: "category:electronics", + }); + }); + + it("should handle ampersand and equals characters (edge case)", () => { + // Note: Values containing & and = may be problematic because + // encodeAsFormParameter splits on these characters when parsing the stringified result + const obj = { + message: "Hello & welcome", + equation: "x = y + z", + }; + // This demonstrates the limitation - ampersands and equals signs in values + // will cause the parameter to be split incorrectly + const result = encodeAsFormParameter(obj); + + // We expect this to be parsed incorrectly due to the implementation + expect(result.message).toBe("Hello "); + expect(result[" welcome"]).toBeUndefined(); + expect(result.equation).toBe("x "); + expect(result[" y + z"]).toBeUndefined(); + }); + }); + + describe("Form data specific scenarios", () => { + it("should handle file upload metadata", () => { + const metadata = { + file: { + name: "document.pdf", + size: 1024, + type: "application/pdf", + }, + options: { + compress: true, + quality: 0.8, + }, + }; + expect(encodeAsFormParameter(metadata)).toEqual({ + "file[name]": "document.pdf", + "file[size]": "1024", + "file[type]": "application/pdf", + "options[compress]": "true", + "options[quality]": "0.8", + }); + }); + + it("should handle form validation data", () => { + const formData = { + fields: ["name", "email", "phone"], + validation: { + required: ["name", "email"], + patterns: { + email: "^[^@]+@[^@]+\\.[^@]+$", + phone: "^\\+?[1-9]\\d{1,14}$", + }, + }, + }; + expect(encodeAsFormParameter(formData)).toEqual({ + "fields[0]": "name", + "fields[1]": "email", + "fields[2]": "phone", + "validation[required][0]": "name", + "validation[required][1]": "email", + "validation[patterns][email]": "^[^@]+@[^@]+\\.[^@]+$", + "validation[patterns][phone]": "^\\+?[1-9]\\d{1,14}$", + }); + }); + + it("should handle search/filter parameters", () => { + const searchParams = { + filters: { + status: ["active", "pending"], + category: { + type: "electronics", + subcategories: ["phones", "laptops"], + }, + }, + sort: { field: "name", direction: "asc" }, + pagination: { page: 1, limit: 20 }, + }; + expect(encodeAsFormParameter(searchParams)).toEqual({ + "filters[status][0]": "active", + "filters[status][1]": "pending", + "filters[category][type]": "electronics", + "filters[category][subcategories][0]": "phones", + "filters[category][subcategories][1]": "laptops", + "sort[field]": "name", + "sort[direction]": "asc", + "pagination[page]": "1", + "pagination[limit]": "20", + }); + }); + }); + + describe("Edge cases", () => { + it("should handle boolean values", () => { + const obj = { enabled: true, disabled: false }; + expect(encodeAsFormParameter(obj)).toEqual({ + enabled: "true", + disabled: "false", + }); + }); + + it("should handle empty strings", () => { + const obj = { name: "", description: "test" }; + expect(encodeAsFormParameter(obj)).toEqual({ + name: "", + description: "test", + }); + }); + + it("should handle zero values", () => { + const obj = { count: 0, price: 0.0 }; + expect(encodeAsFormParameter(obj)).toEqual({ + count: "0", + price: "0", + }); + }); + + it("should handle numeric keys", () => { + const obj = { "0": "zero", "1": "one" }; + expect(encodeAsFormParameter(obj)).toEqual({ + "0": "zero", + "1": "one", + }); + }); + + it("should handle objects with null/undefined values", () => { + const obj = { name: "John", age: null, email: undefined, active: true }; + expect(encodeAsFormParameter(obj)).toEqual({ + name: "John", + age: "", + active: "true", + }); + }); + }); + + describe("Integration with form submission", () => { + it("should produce form-compatible key-value pairs", () => { + const formObject = { + username: "john_doe", + preferences: { + theme: "dark", + notifications: ["email", "push"], + settings: { + autoSave: true, + timeout: 300, + }, + }, + }; + + const result = encodeAsFormParameter(formObject); + + // Verify all values are strings (as required for form data) + Object.values(result).forEach((value) => { + expect(typeof value).toBe("string"); + }); + + // Verify the structure can be reconstructed + expect(result).toEqual({ + username: "john_doe", + "preferences[theme]": "dark", + "preferences[notifications][0]": "email", + "preferences[notifications][1]": "push", + "preferences[settings][autoSave]": "true", + "preferences[settings][timeout]": "300", + }); + }); + + it("should handle complex nested arrays for API parameters", () => { + const apiParams = { + query: { + filters: [ + { field: "status", operator: "eq", value: "active" }, + { field: "created", operator: "gte", value: "2023-01-01" }, + ], + sort: [ + { field: "name", direction: "asc" }, + { field: "created", direction: "desc" }, + ], + }, + }; + + const result = encodeAsFormParameter(apiParams); + expect(result).toEqual({ + "query[filters][0][field]": "status", + "query[filters][0][operator]": "eq", + "query[filters][0][value]": "active", + "query[filters][1][field]": "created", + "query[filters][1][operator]": "gte", + "query[filters][1][value]": "2023-01-01", + "query[sort][0][field]": "name", + "query[sort][0][direction]": "asc", + "query[sort][1][field]": "created", + "query[sort][1][direction]": "desc", + }); + }); + }); + + describe("Error cases and malformed input", () => { + it("should handle circular references gracefully", () => { + const obj: any = { name: "test" }; + obj.self = obj; + + // This will throw a RangeError due to stack overflow - this is expected behavior + expect(() => encodeAsFormParameter(obj)).toThrow("Maximum call stack size exceeded"); + }); + + it("should handle very deeply nested objects", () => { + let deepObj: any = { value: "deep" }; + for (let i = 0; i < 100; i++) { + deepObj = { level: deepObj }; + } + + expect(() => encodeAsFormParameter(deepObj)).not.toThrow(); + const result = encodeAsFormParameter(deepObj); + expect(Object.keys(result).length).toBeGreaterThan(0); + }); + + it("should handle empty string splitting edge case", () => { + // Test what happens when qs returns an empty string + const result = encodeAsFormParameter({}); + expect(result).toEqual({}); + }); + }); +}); diff --git a/tests/unit/form-data-utils/formDataWrapper.test.ts b/tests/unit/form-data-utils/formDataWrapper.test.ts new file mode 100644 index 0000000..4770508 --- /dev/null +++ b/tests/unit/form-data-utils/formDataWrapper.test.ts @@ -0,0 +1,346 @@ +import { Blob, File } from "buffer"; +import { join } from "path"; +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { Readable } from "stream"; +import { FormDataWrapper, newFormData } from "../../../src/core/form-data-utils/FormDataWrapper"; + +// Helper function to serialize FormData to string for inspection +async function serializeFormData(formData: FormData): Promise { + const request = new Request("http://localhost", { + method: "POST", + body: formData, + }); + + const buffer = await request.arrayBuffer(); + return new TextDecoder().decode(buffer); +} + +describe("FormDataWrapper", () => { + let formData: FormDataWrapper; + + beforeEach(async () => { + formData = new FormDataWrapper(); + await formData.setup(); + }); + + it("Upload file by path", async () => { + await formData.appendFile("file", { + path: join(__dirname, "..", "test-file.txt"), + }); + + const serialized = await serializeFormData(formData.getRequest().body); + + expect(serialized).toContain('Content-Disposition: form-data; name="file"'); + expect(serialized).toContain('filename="test-file.txt"'); + expect(serialized).toContain("This is a test file!"); + }); + + it("Upload file by path with filename", async () => { + await formData.appendFile("file", { + path: join(__dirname, "..", "test-file.txt"), + filename: "custom-file.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + + expect(serialized).toContain('Content-Disposition: form-data; name="file"'); + expect(serialized).toContain('filename="custom-file.txt"'); + expect(serialized).toContain("This is a test file!"); + }); + + describe("Stream handling", () => { + it("serializes Node.js Readable stream with filename", async () => { + const stream = Readable.from(["file content"]); + await formData.appendFile("file", { + data: stream, + filename: "testfile.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + + expect(serialized).toContain('Content-Disposition: form-data; name="file"'); + expect(serialized).toContain('filename="testfile.txt"'); + expect(serialized).toContain("file content"); + }); + + it("auto-detects filename from stream path property", async () => { + const stream = Readable.from(["file content"]); + (stream as { path?: string }).path = "/test/path/testfile.txt"; + + await formData.appendFile("file", stream); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="testfile.txt"'); + }); + + it("handles Windows-style paths", async () => { + const stream = Readable.from(["file content"]); + (stream as { path?: string }).path = "C:\\test\\path\\testfile.txt"; + + await formData.appendFile("file", stream); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="testfile.txt"'); + }); + + it("handles empty streams", async () => { + const stream = Readable.from([]); + await formData.appendFile("file", { + data: stream, + filename: "empty.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="empty.txt"'); + expect(serialized).toMatch(/------formdata-undici-\w+|------WebKitFormBoundary\w+/); + }); + + it("serializes Web ReadableStream with filename", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("web stream content")); + controller.close(); + }, + }); + + await formData.appendFile("file", { + data: stream, + filename: "webstream.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="webstream.txt"'); + expect(serialized).toContain("web stream content"); + }); + + it("handles empty Web ReadableStream", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + await formData.appendFile("file", { + data: stream, + filename: "empty.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="empty.txt"'); + expect(serialized).toMatch(/------formdata-undici-\w+|------WebKitFormBoundary\w+/); + }); + }); + + describe("Blob and File types", () => { + it("serializes Blob with specified filename", async () => { + const blob = new Blob(["file content"], { type: "text/plain" }); + await formData.appendFile("file", { + data: blob, + filename: "testfile.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="testfile.txt"'); + expect(serialized).toContain("Content-Type: text/plain"); + expect(serialized).toContain("file content"); + }); + + it("uses default filename for Blob without explicit filename", async () => { + const blob = new Blob(["file content"], { type: "text/plain" }); + await formData.appendFile("file", blob); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="blob"'); + }); + + it("preserves File object filename", async () => { + if (typeof File !== "undefined") { + const file = new File(["file content"], "original.txt", { type: "text/plain" }); + await formData.appendFile("file", file); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="original.txt"'); + expect(serialized).toContain("file content"); + } + }); + + it("allows filename override for File objects", async () => { + if (typeof File !== "undefined") { + const file = new File(["file content"], "original.txt", { type: "text/plain" }); + await formData.appendFile("file", { + data: file, + filename: "override.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="override.txt"'); + expect(serialized).not.toContain('filename="original.txt"'); + } + }); + }); + + describe("Binary data types", () => { + it("serializes ArrayBuffer with filename", async () => { + const arrayBuffer = new ArrayBuffer(8); + new Uint8Array(arrayBuffer).set([1, 2, 3, 4, 5, 6, 7, 8]); + + await formData.appendFile("file", { + data: arrayBuffer, + filename: "binary.bin", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="binary.bin"'); + expect(serialized).toMatch(/------formdata-undici-\w+|------WebKitFormBoundary\w+/); + }); + + it("serializes Uint8Array with filename", async () => { + const uint8Array = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + await formData.appendFile("file", { + data: uint8Array, + filename: "binary.bin", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="binary.bin"'); + expect(serialized).toContain("Hello"); + }); + + it("serializes other typed arrays", async () => { + const int16Array = new Int16Array([1000, 2000, 3000]); + await formData.appendFile("file", { + data: int16Array, + filename: "numbers.bin", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="numbers.bin"'); + }); + + it("serializes Buffer data with filename", async () => { + if (typeof Buffer !== "undefined" && typeof Buffer.isBuffer === "function") { + const buffer = Buffer.from("test content"); + await formData.appendFile("file", { + data: buffer, + filename: "test.txt", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="test.txt"'); + expect(serialized).toContain("test content"); + } + }); + }); + + describe("Text and primitive types", () => { + it("serializes string as regular form field", async () => { + formData.append("text", "test string"); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('name="text"'); + expect(serialized).not.toContain("filename="); + expect(serialized).toContain("test string"); + }); + + it("serializes numbers and booleans as strings", async () => { + formData.append("number", 12345); + formData.append("flag", true); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain("12345"); + expect(serialized).toContain("true"); + }); + }); + + describe("Edge cases and error handling", () => { + it("handles empty filename gracefully", async () => { + await formData.appendFile("file", { + data: new Blob(["content"], { type: "text/plain" }), + filename: "", + }); + + const serialized = await serializeFormData(formData.getRequest().body); + expect(serialized).toContain('filename="blob"'); // Default fallback + }); + + it("handles multiple files in single form", async () => { + await formData.appendFile("file1", { + data: new Blob(["content1"], { type: "text/plain" }), + filename: "file1.txt", + }); + await formData.appendFile("file2", { + data: new Blob(["content2"], { type: "text/plain" }), + filename: "file2.txt", + }); + formData.append("text", "regular field"); + + const serialized = await serializeFormData(formData.getRequest().body); + + expect(serialized).toContain('filename="file1.txt"'); + expect(serialized).toContain('filename="file2.txt"'); + expect(serialized).toContain('name="text"'); + expect(serialized).not.toContain('filename="text"'); + }); + }); + + describe("Request structure", () => { + it("returns correct request structure", async () => { + await formData.appendFile("file", { + data: new Blob(["content"], { type: "text/plain" }), + filename: "test.txt", + }); + + const request = formData.getRequest(); + + expect(request).toHaveProperty("body"); + expect(request).toHaveProperty("headers"); + expect(request).toHaveProperty("duplex"); + expect(request.body).toBeInstanceOf(FormData); + expect(request.headers).toEqual({}); + expect(request.duplex).toBe("half"); + }); + + it("generates proper multipart boundary structure", async () => { + await formData.appendFile("file", { + data: new Blob(["test content"], { type: "text/plain" }), + filename: "test.txt", + }); + formData.append("field", "value"); + + const serialized = await serializeFormData(formData.getRequest().body); + + expect(serialized).toMatch(/------formdata-undici-\w+|------WebKitFormBoundary\w+/); + expect(serialized).toContain("Content-Disposition: form-data;"); + expect(serialized).toMatch(/------formdata-undici-\w+--|------WebKitFormBoundary\w+--/); + }); + }); + + describe("Factory function", () => { + it("returns FormDataWrapper instance", async () => { + const formData = await newFormData(); + expect(formData).toBeInstanceOf(FormDataWrapper); + }); + + it("creates independent instances", async () => { + const formData1 = await newFormData(); + const formData2 = await newFormData(); + + await formData1.setup(); + await formData2.setup(); + + formData1.append("test1", "value1"); + formData2.append("test2", "value2"); + + const request1 = formData1.getRequest() as { body: FormData }; + const request2 = formData2.getRequest() as { body: FormData }; + + const entries1 = Array.from(request1.body.entries()); + const entries2 = Array.from(request2.body.entries()); + + expect(entries1).toHaveLength(1); + expect(entries2).toHaveLength(1); + expect(entries1[0][0]).toBe("test1"); + expect(entries2[0][0]).toBe("test2"); + }); + }); +}); diff --git a/tests/unit/test-file.txt b/tests/unit/test-file.txt new file mode 100644 index 0000000..c66d471 --- /dev/null +++ b/tests/unit/test-file.txt @@ -0,0 +1 @@ +This is a test file!