Rebuild fern client sdk to 1.06 (#4331)
This commit is contained in:
@@ -12,6 +12,7 @@ The Skyvern TypeScript library provides convenient access to the Skyvern APIs fr
|
||||
- [Usage](#usage)
|
||||
- [Request and Response Types](#request-and-response-types)
|
||||
- [Exception Handling](#exception-handling)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Advanced](#advanced)
|
||||
- [Additional Headers](#additional-headers)
|
||||
- [Additional Query String Parameters](#additional-query-string-parameters)
|
||||
@@ -56,7 +57,7 @@ following namespace:
|
||||
```typescript
|
||||
import { Skyvern } from "@skyvern/client";
|
||||
|
||||
const request: Skyvern.SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest = {
|
||||
const request: Skyvern.RunTaskRequest = {
|
||||
...
|
||||
};
|
||||
```
|
||||
@@ -81,6 +82,50 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
## File Uploads
|
||||
|
||||
You can upload files using the client:
|
||||
|
||||
```typescript
|
||||
import { createReadStream } from "fs";
|
||||
import { SkyvernClient } from "@skyvern/client";
|
||||
import * as fs from "fs";
|
||||
|
||||
const client = new SkyvernClient({ apiKey: "YOUR_API_KEY" });
|
||||
await client.uploadFile({
|
||||
file: fs.createReadStream("/path/to/your/file")
|
||||
});
|
||||
```
|
||||
The client accepts a variety of types for file upload parameters:
|
||||
* Stream types: `fs.ReadStream`, `stream.Readable`, and `ReadableStream`
|
||||
* Buffered types: `Buffer`, `Blob`, `File`, `ArrayBuffer`, `ArrayBufferView`, and `Uint8Array`
|
||||
|
||||
### Metadata
|
||||
|
||||
You can configure metadata when uploading a file:
|
||||
```typescript
|
||||
const file: Uploadable.WithMetadata = {
|
||||
data: createReadStream("path/to/file"),
|
||||
filename: "my-file", // optional
|
||||
contentType: "audio/mpeg", // optional
|
||||
contentLength: 1949, // optional
|
||||
};
|
||||
```
|
||||
|
||||
Alternatively, you can upload a file directly from a file path:
|
||||
```typescript
|
||||
const file : Uploadable.FromPath = {
|
||||
path: "path/to/file",
|
||||
filename: "my-file", // optional
|
||||
contentType: "audio/mpeg", // optional
|
||||
contentLength: 1949, // optional
|
||||
};
|
||||
```
|
||||
|
||||
The metadata is used to set the `Content-Length`, `Content-Type`, and `Content-Disposition` headers. If not provided, the client will attempt to determine them automatically.
|
||||
For example, `fs.ReadStream` has a `path` property which the SDK uses to retrieve the file size from the filesystem without loading it into memory.
|
||||
|
||||
|
||||
## Advanced
|
||||
|
||||
### Additional Headers
|
||||
|
||||
375
skyvern-ts/client/package-lock.json
generated
375
skyvern-ts/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@skyvern/client",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.6",
|
||||
"private": false,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -68,83 +68,6 @@ await client.runSdkAction({
|
||||
</details>
|
||||
|
||||
##
|
||||
## Workflows
|
||||
<details><summary><code>client.workflows.<a href="/src/api/resources/workflows/client/Client.ts">setWorkflowTemplateStatus</a>(workflowPermanentId, { ...params }) -> Record<string, unknown></code></summary>
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
#### 📝 Description
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
Set or unset a workflow as a template.
|
||||
|
||||
Template status is stored at the workflow_permanent_id level (not per-version),
|
||||
meaning all versions of a workflow share the same template status.
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
#### 🔌 Usage
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
```typescript
|
||||
await client.workflows.setWorkflowTemplateStatus("workflow_permanent_id", {
|
||||
is_template: true
|
||||
});
|
||||
|
||||
```
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
#### ⚙️ Parameters
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
**workflowPermanentId:** `string`
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
**request:** `Skyvern.SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest`
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
|
||||
**requestOptions:** `Workflows.RequestOptions`
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
## Scripts
|
||||
<details><summary><code>client.scripts.<a href="/src/api/resources/scripts/client/Client.ts">runScript</a>(scriptId) -> unknown</code></summary>
|
||||
<dl>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as Skyvern from "./api/index.js";
|
||||
import { Scripts } from "./api/resources/scripts/client/Client.js";
|
||||
import { Workflows } from "./api/resources/workflows/client/Client.js";
|
||||
import type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js";
|
||||
import { mergeHeaders, mergeOnlyDefinedHeaders } from "./core/headers.js";
|
||||
import * as core from "./core/index.js";
|
||||
@@ -17,7 +16,6 @@ export declare namespace SkyvernClient {
|
||||
|
||||
export class SkyvernClient {
|
||||
protected readonly _options: SkyvernClient.Options;
|
||||
protected _workflows: Workflows | undefined;
|
||||
protected _scripts: Scripts | undefined;
|
||||
|
||||
constructor(_options: SkyvernClient.Options = {}) {
|
||||
@@ -28,8 +26,8 @@ export class SkyvernClient {
|
||||
"x-api-key": _options?.apiKey,
|
||||
"X-Fern-Language": "JavaScript",
|
||||
"X-Fern-SDK-Name": "@skyvern/client",
|
||||
"X-Fern-SDK-Version": "1.0.3",
|
||||
"User-Agent": "@skyvern/client/1.0.3",
|
||||
"X-Fern-SDK-Version": "1.0.6",
|
||||
"User-Agent": "@skyvern/client/1.0.6",
|
||||
"X-Fern-Runtime": core.RUNTIME.type,
|
||||
"X-Fern-Runtime-Version": core.RUNTIME.version,
|
||||
},
|
||||
@@ -38,10 +36,6 @@ export class SkyvernClient {
|
||||
};
|
||||
}
|
||||
|
||||
public get workflows(): Workflows {
|
||||
return (this._workflows ??= new Workflows(this._options));
|
||||
}
|
||||
|
||||
public get scripts(): Scripts {
|
||||
return (this._scripts ??= new Scripts(this._options));
|
||||
}
|
||||
@@ -948,6 +942,7 @@ export class SkyvernClient {
|
||||
* Retry sending the webhook for a run
|
||||
*
|
||||
* @param {string} runId - The id of the task run or the workflow run.
|
||||
* @param {Skyvern.RetryRunWebhookRequest} request
|
||||
* @param {SkyvernClient.RequestOptions} requestOptions - Request-specific configuration.
|
||||
*
|
||||
* @throws {@link Skyvern.UnprocessableEntityError}
|
||||
@@ -957,13 +952,15 @@ export class SkyvernClient {
|
||||
*/
|
||||
public retryRunWebhook(
|
||||
runId: string,
|
||||
request?: Skyvern.RetryRunWebhookRequest,
|
||||
requestOptions?: SkyvernClient.RequestOptions,
|
||||
): core.HttpResponsePromise<unknown> {
|
||||
return core.HttpResponsePromise.fromPromise(this.__retryRunWebhook(runId, requestOptions));
|
||||
return core.HttpResponsePromise.fromPromise(this.__retryRunWebhook(runId, request, requestOptions));
|
||||
}
|
||||
|
||||
private async __retryRunWebhook(
|
||||
runId: string,
|
||||
request?: Skyvern.RetryRunWebhookRequest,
|
||||
requestOptions?: SkyvernClient.RequestOptions,
|
||||
): Promise<core.WithRawResponse<unknown>> {
|
||||
const _headers: core.Fetcher.Args["headers"] = mergeHeaders(
|
||||
@@ -980,7 +977,10 @@ export class SkyvernClient {
|
||||
),
|
||||
method: "POST",
|
||||
headers: _headers,
|
||||
contentType: "application/json",
|
||||
queryParameters: requestOptions?.queryParams,
|
||||
requestType: "json",
|
||||
body: request != null ? request : undefined,
|
||||
timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000,
|
||||
maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries,
|
||||
abortSignal: requestOptions?.abortSignal,
|
||||
@@ -1102,6 +1102,91 @@ export class SkyvernClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Skyvern.BodyUploadFileV1UploadFilePost} request
|
||||
* @param {SkyvernClient.RequestOptions} requestOptions - Request-specific configuration.
|
||||
*
|
||||
* @throws {@link Skyvern.UnprocessableEntityError}
|
||||
*
|
||||
* @example
|
||||
* import { createReadStream } from "fs";
|
||||
* await client.uploadFile({
|
||||
* file: fs.createReadStream("/path/to/your/file")
|
||||
* })
|
||||
*/
|
||||
public uploadFile(
|
||||
request: Skyvern.BodyUploadFileV1UploadFilePost,
|
||||
requestOptions?: SkyvernClient.RequestOptions,
|
||||
): core.HttpResponsePromise<Skyvern.UploadFileResponse> {
|
||||
return core.HttpResponsePromise.fromPromise(this.__uploadFile(request, requestOptions));
|
||||
}
|
||||
|
||||
private async __uploadFile(
|
||||
request: Skyvern.BodyUploadFileV1UploadFilePost,
|
||||
requestOptions?: SkyvernClient.RequestOptions,
|
||||
): Promise<core.WithRawResponse<Skyvern.UploadFileResponse>> {
|
||||
const _request = await core.newFormData();
|
||||
await _request.appendFile("file", request.file);
|
||||
const _maybeEncodedRequest = await _request.getRequest();
|
||||
const _headers: core.Fetcher.Args["headers"] = mergeHeaders(
|
||||
this._options?.headers,
|
||||
mergeOnlyDefinedHeaders({
|
||||
"x-api-key": requestOptions?.apiKey ?? this._options?.apiKey,
|
||||
..._maybeEncodedRequest.headers,
|
||||
}),
|
||||
requestOptions?.headers,
|
||||
);
|
||||
const _response = await core.fetcher({
|
||||
url: core.url.join(
|
||||
(await core.Supplier.get(this._options.baseUrl)) ??
|
||||
(await core.Supplier.get(this._options.environment)) ??
|
||||
environments.SkyvernEnvironment.Cloud,
|
||||
"v1/upload_file",
|
||||
),
|
||||
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 Skyvern.UploadFileResponse, rawResponse: _response.rawResponse };
|
||||
}
|
||||
|
||||
if (_response.error.reason === "status-code") {
|
||||
switch (_response.error.statusCode) {
|
||||
case 422:
|
||||
throw new Skyvern.UnprocessableEntityError(_response.error.body as unknown, _response.rawResponse);
|
||||
default:
|
||||
throw new errors.SkyvernError({
|
||||
statusCode: _response.error.statusCode,
|
||||
body: _response.error.body,
|
||||
rawResponse: _response.rawResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
switch (_response.error.reason) {
|
||||
case "non-json":
|
||||
throw new errors.SkyvernError({
|
||||
statusCode: _response.error.statusCode,
|
||||
body: _response.error.rawBody,
|
||||
rawResponse: _response.rawResponse,
|
||||
});
|
||||
case "timeout":
|
||||
throw new errors.SkyvernTimeoutError("Timeout exceeded when calling POST /v1/upload_file.");
|
||||
case "unknown":
|
||||
throw new errors.SkyvernError({
|
||||
message: _response.error.errorMessage,
|
||||
rawResponse: _response.rawResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all browser profiles for the organization
|
||||
*
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
import type * as core from "../../../core/index.js";
|
||||
|
||||
/**
|
||||
* @example
|
||||
* {
|
||||
* file: fs.createReadStream("/path/to/your/file")
|
||||
* }
|
||||
*/
|
||||
export interface BodyUploadFileV1UploadFilePost {
|
||||
file: core.file.Uploadable;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { BodyUploadFileV1UploadFilePost } from "./BodyUploadFileV1UploadFilePost.js";
|
||||
export type { CreateBrowserProfileRequest } from "./CreateBrowserProfileRequest.js";
|
||||
export type { CreateBrowserSessionRequest } from "./CreateBrowserSessionRequest.js";
|
||||
export type { CreateCredentialRequest } from "./CreateCredentialRequest.js";
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export * as scripts from "./scripts/index.js";
|
||||
export * from "./workflows/client/requests/index.js";
|
||||
export * as workflows from "./workflows/index.js";
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
import type { BaseClientOptions, BaseRequestOptions } from "../../../../BaseClient.js";
|
||||
import { mergeHeaders, mergeOnlyDefinedHeaders } from "../../../../core/headers.js";
|
||||
import * as core from "../../../../core/index.js";
|
||||
import * as environments from "../../../../environments.js";
|
||||
import * as errors from "../../../../errors/index.js";
|
||||
import * as Skyvern from "../../../index.js";
|
||||
|
||||
export declare namespace Workflows {
|
||||
export interface Options extends BaseClientOptions {}
|
||||
|
||||
export interface RequestOptions extends BaseRequestOptions {}
|
||||
}
|
||||
|
||||
export class Workflows {
|
||||
protected readonly _options: Workflows.Options;
|
||||
|
||||
constructor(_options: Workflows.Options = {}) {
|
||||
this._options = _options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or unset a workflow as a template.
|
||||
*
|
||||
* Template status is stored at the workflow_permanent_id level (not per-version),
|
||||
* meaning all versions of a workflow share the same template status.
|
||||
*
|
||||
* @param {string} workflowPermanentId
|
||||
* @param {Skyvern.SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest} request
|
||||
* @param {Workflows.RequestOptions} requestOptions - Request-specific configuration.
|
||||
*
|
||||
* @throws {@link Skyvern.UnprocessableEntityError}
|
||||
*
|
||||
* @example
|
||||
* await client.workflows.setWorkflowTemplateStatus("workflow_permanent_id", {
|
||||
* is_template: true
|
||||
* })
|
||||
*/
|
||||
public setWorkflowTemplateStatus(
|
||||
workflowPermanentId: string,
|
||||
request: Skyvern.SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest,
|
||||
requestOptions?: Workflows.RequestOptions,
|
||||
): core.HttpResponsePromise<Record<string, unknown>> {
|
||||
return core.HttpResponsePromise.fromPromise(
|
||||
this.__setWorkflowTemplateStatus(workflowPermanentId, request, requestOptions),
|
||||
);
|
||||
}
|
||||
|
||||
private async __setWorkflowTemplateStatus(
|
||||
workflowPermanentId: string,
|
||||
request: Skyvern.SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest,
|
||||
requestOptions?: Workflows.RequestOptions,
|
||||
): Promise<core.WithRawResponse<Record<string, unknown>>> {
|
||||
const { is_template: isTemplate } = request;
|
||||
const _queryParams: Record<string, string | string[] | object | object[] | null> = {};
|
||||
_queryParams.is_template = isTemplate.toString();
|
||||
const _headers: core.Fetcher.Args["headers"] = mergeHeaders(
|
||||
this._options?.headers,
|
||||
mergeOnlyDefinedHeaders({ "x-api-key": requestOptions?.apiKey ?? this._options?.apiKey }),
|
||||
requestOptions?.headers,
|
||||
);
|
||||
const _response = await core.fetcher({
|
||||
url: core.url.join(
|
||||
(await core.Supplier.get(this._options.baseUrl)) ??
|
||||
(await core.Supplier.get(this._options.environment)) ??
|
||||
environments.SkyvernEnvironment.Cloud,
|
||||
`v1/workflows/${core.url.encodePathParam(workflowPermanentId)}/template`,
|
||||
),
|
||||
method: "PUT",
|
||||
headers: _headers,
|
||||
queryParameters: { ..._queryParams, ...requestOptions?.queryParams },
|
||||
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 Record<string, unknown>, rawResponse: _response.rawResponse };
|
||||
}
|
||||
|
||||
if (_response.error.reason === "status-code") {
|
||||
switch (_response.error.statusCode) {
|
||||
case 422:
|
||||
throw new Skyvern.UnprocessableEntityError(_response.error.body as unknown, _response.rawResponse);
|
||||
default:
|
||||
throw new errors.SkyvernError({
|
||||
statusCode: _response.error.statusCode,
|
||||
body: _response.error.body,
|
||||
rawResponse: _response.rawResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
switch (_response.error.reason) {
|
||||
case "non-json":
|
||||
throw new errors.SkyvernError({
|
||||
statusCode: _response.error.statusCode,
|
||||
body: _response.error.rawBody,
|
||||
rawResponse: _response.rawResponse,
|
||||
});
|
||||
case "timeout":
|
||||
throw new errors.SkyvernTimeoutError(
|
||||
"Timeout exceeded when calling PUT /v1/workflows/{workflow_permanent_id}/template.",
|
||||
);
|
||||
case "unknown":
|
||||
throw new errors.SkyvernError({
|
||||
message: _response.error.errorMessage,
|
||||
rawResponse: _response.rawResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./requests/index.js";
|
||||
@@ -1,11 +0,0 @@
|
||||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
/**
|
||||
* @example
|
||||
* {
|
||||
* is_template: true
|
||||
* }
|
||||
*/
|
||||
export interface SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest {
|
||||
is_template: boolean;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type { SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest } from "./SetWorkflowTemplateStatusV1WorkflowsWorkflowPermanentIdTemplatePutRequest.js";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./client/index.js";
|
||||
@@ -7,7 +7,7 @@ export interface PromptAction {
|
||||
/** The prompt to send to the LLM */
|
||||
prompt: string;
|
||||
/** Optional JSON schema to structure the response */
|
||||
schema?: Record<string, unknown>;
|
||||
response_schema?: Record<string, unknown>;
|
||||
/** Optional model configuration */
|
||||
model?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
export interface RetryRunWebhookRequest {
|
||||
/** Optional webhook URL to send the payload to instead of the stored configuration */
|
||||
webhook_url?: string;
|
||||
}
|
||||
8
skyvern-ts/client/src/api/types/UploadFileResponse.ts
Normal file
8
skyvern-ts/client/src/api/types/UploadFileResponse.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
export interface UploadFileResponse {
|
||||
/** S3 URI where the file was uploaded */
|
||||
s3_uri: string;
|
||||
/** Presigned URL to access the uploaded file */
|
||||
presigned_url: string;
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export * from "./PdfParserBlockYaml.js";
|
||||
export * from "./PromptAction.js";
|
||||
export * from "./PromptBranchCriteria.js";
|
||||
export * from "./ProxyLocation.js";
|
||||
export * from "./RetryRunWebhookRequest.js";
|
||||
export * from "./RunEngine.js";
|
||||
export * from "./RunSdkActionRequestAction.js";
|
||||
export * from "./RunSdkActionResponse.js";
|
||||
@@ -127,6 +128,7 @@ export * from "./ThoughtType.js";
|
||||
export * from "./TotpCode.js";
|
||||
export * from "./TotpType.js";
|
||||
export * from "./UploadFileAction.js";
|
||||
export * from "./UploadFileResponse.js";
|
||||
export * from "./UploadToS3Block.js";
|
||||
export * from "./UploadToS3BlockYaml.js";
|
||||
export * from "./UrlBlock.js";
|
||||
|
||||
1
skyvern-ts/client/src/core/exports.ts
Normal file
1
skyvern-ts/client/src/core/exports.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./file/exports.js";
|
||||
1
skyvern-ts/client/src/core/file/exports.ts
Normal file
1
skyvern-ts/client/src/core/file/exports.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { Uploadable } from "./types.js";
|
||||
217
skyvern-ts/client/src/core/file/file.ts
Normal file
217
skyvern-ts/client/src/core/file/file.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { Uploadable } from "./types.js";
|
||||
|
||||
export async function toBinaryUploadRequest(
|
||||
file: Uploadable,
|
||||
): Promise<{ body: Uploadable.FileLike; headers?: Record<string, string> }> {
|
||||
const { data, filename, contentLength, contentType } = await getFileWithMetadata(file);
|
||||
const request = {
|
||||
body: data,
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
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<Uploadable.WithMetadata> {
|
||||
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<number | undefined> {
|
||||
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<number | undefined> {
|
||||
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;
|
||||
}
|
||||
2
skyvern-ts/client/src/core/file/index.ts
Normal file
2
skyvern-ts/client/src/core/file/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./file.js";
|
||||
export * from "./types.js";
|
||||
81
skyvern-ts/client/src/core/file/types.ts
Normal file
81
skyvern-ts/client/src/core/file/types.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
140
skyvern-ts/client/src/core/form-data-utils/FormDataWrapper.ts
Normal file
140
skyvern-ts/client/src/core/form-data-utils/FormDataWrapper.ts
Normal file
@@ -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: Body;
|
||||
headers: Record<string, string>;
|
||||
duplex?: "half";
|
||||
}
|
||||
|
||||
export async function newFormData(): Promise<FormDataWrapper> {
|
||||
return new FormDataWrapper();
|
||||
}
|
||||
|
||||
export class FormDataWrapper {
|
||||
private fd: FormData = new FormData();
|
||||
|
||||
public async setup(): Promise<void> {
|
||||
// noop
|
||||
}
|
||||
|
||||
public append(key: string, value: unknown): void {
|
||||
this.fd.append(key, String(value));
|
||||
}
|
||||
|
||||
public async appendFile(key: string, value: Uploadable): Promise<void> {
|
||||
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<FormData> {
|
||||
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<Buffer> {
|
||||
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<Blob> {
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { toQueryString } from "../url/qs.js";
|
||||
|
||||
export function encodeAsFormParameter(value: unknown): Record<string, string> {
|
||||
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);
|
||||
}
|
||||
2
skyvern-ts/client/src/core/form-data-utils/index.ts
Normal file
2
skyvern-ts/client/src/core/form-data-utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { encodeAsFormParameter } from "./encodeAsFormParameter.js";
|
||||
export * from "./FormDataWrapper.js";
|
||||
@@ -1,3 +1,5 @@
|
||||
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 * as url from "./url/index.js";
|
||||
|
||||
1
skyvern-ts/client/src/exports.ts
Normal file
1
skyvern-ts/client/src/exports.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./core/exports.js";
|
||||
@@ -3,5 +3,6 @@ export type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js";
|
||||
export { SkyvernClient } from "./Client.js";
|
||||
export { SkyvernEnvironment } from "./environments.js";
|
||||
export { SkyvernError, SkyvernTimeoutError } from "./errors/index.js";
|
||||
export * from "./exports.js";
|
||||
export { Skyvern, SkyvernBrowser, SkyvernBrowserPageAgent, SkyvernBrowserPageAi } from "./library/index.js";
|
||||
export type { SkyvernOptions, SkyvernBrowserPage } from "./library/index.js";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const SDK_VERSION = "1.0.3";
|
||||
export const SDK_VERSION = "1.0.6";
|
||||
|
||||
498
skyvern-ts/client/tests/unit/file/file.test.ts
Normal file
498
skyvern-ts/client/tests/unit/file/file.test.ts
Normal file
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
1
skyvern-ts/client/tests/unit/test-file.txt
Normal file
1
skyvern-ts/client/tests/unit/test-file.txt
Normal file
@@ -0,0 +1 @@
|
||||
This is a test file!
|
||||
@@ -1207,7 +1207,7 @@ describe("SkyvernClient", () => {
|
||||
.build();
|
||||
|
||||
await expect(async () => {
|
||||
return await client.retryRunWebhook("run_id");
|
||||
return await client.retryRunWebhook("run_id", undefined);
|
||||
}).rejects.toThrow(Skyvern.UnprocessableEntityError);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
import * as Skyvern from "../../src/api/index";
|
||||
import { SkyvernClient } from "../../src/Client";
|
||||
import { mockServerPool } from "../mock-server/MockServerPool";
|
||||
|
||||
describe("Workflows", () => {
|
||||
test("setWorkflowTemplateStatus (1)", async () => {
|
||||
const server = mockServerPool.createServer();
|
||||
const client = new SkyvernClient({ apiKey: "test", environment: server.baseUrl });
|
||||
|
||||
const rawResponseBody = { key: "value" };
|
||||
server
|
||||
.mockEndpoint()
|
||||
.put("/v1/workflows/workflow_permanent_id/template")
|
||||
.respondWith()
|
||||
.statusCode(200)
|
||||
.jsonBody(rawResponseBody)
|
||||
.build();
|
||||
|
||||
const response = await client.workflows.setWorkflowTemplateStatus("workflow_permanent_id", {
|
||||
is_template: true,
|
||||
});
|
||||
expect(response).toEqual({
|
||||
key: "value",
|
||||
});
|
||||
});
|
||||
|
||||
test("setWorkflowTemplateStatus (2)", async () => {
|
||||
const server = mockServerPool.createServer();
|
||||
const client = new SkyvernClient({ apiKey: "test", environment: server.baseUrl });
|
||||
|
||||
const rawResponseBody = { key: "value" };
|
||||
server
|
||||
.mockEndpoint()
|
||||
.put("/v1/workflows/workflow_permanent_id/template")
|
||||
.respondWith()
|
||||
.statusCode(422)
|
||||
.jsonBody(rawResponseBody)
|
||||
.build();
|
||||
|
||||
await expect(async () => {
|
||||
return await client.workflows.setWorkflowTemplateStatus("workflow_permanent_id", {
|
||||
is_template: true,
|
||||
});
|
||||
}).rejects.toThrow(Skyvern.UnprocessableEntityError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user