diff --git a/package-lock.json b/package-lock.json index 78ce4021..88e6a7e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "license": "MIT", "dependencies": { "change-case": "^4.1.2", - "eslint-plugin-import": "^2.28.1", - "form-data": "^4.0.0", + "form-data-encoder": "^4.0.2", + "formdata-node": "^6.0.3", "mime-types": "^2.1.35", "node-fetch": "^2.6.12", "uuid": "^8.3.2" @@ -1913,7 +1913,8 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -2425,6 +2426,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -2545,6 +2547,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -3351,17 +3354,22 @@ "is-callable": "^1.1.3" } }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, + "node_modules/form-data-encoder": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.0.2.tgz", + "integrity": "sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">= 18" + } + }, + "node_modules/formdata-node": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz", + "integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==", + "license": "MIT", + "engines": { + "node": ">= 18" } }, "node_modules/fs.realpath": { @@ -9437,7 +9445,8 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "available-typed-arrays": { "version": "1.0.5", @@ -9819,6 +9828,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -9914,7 +9924,8 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true }, "detect-newline": { "version": "3.1.0", @@ -10546,15 +10557,15 @@ "is-callable": "^1.1.3" } }, - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } + "form-data-encoder": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.0.2.tgz", + "integrity": "sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==" + }, + "formdata-node": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz", + "integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==" }, "fs.realpath": { "version": "1.0.0", diff --git a/package.json b/package.json index 7abb47f0..001f50f6 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "license": "MIT", "dependencies": { "change-case": "^4.1.2", - "form-data": "^4.0.0", + "form-data-encoder": "^4.0.2", + "formdata-node": "^6.0.3", "mime-types": "^2.1.35", "node-fetch": "^2.6.12", "uuid": "^8.3.2" @@ -56,8 +57,8 @@ "eslint": "^5.14.0", "eslint-config-prettier": "^4.0.0", "eslint-plugin-custom-rules": "^0.0.0", - "eslint-plugin-prettier": "^3.0.1", "eslint-plugin-import": "^2.28.1", + "eslint-plugin-prettier": "^3.0.1", "jest": "^29.6.1", "prettier": "^1.19.1", "ts-jest": "^29.1.1", diff --git a/src/apiClient.ts b/src/apiClient.ts index 1e118b3b..a9a5595c 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -7,7 +7,8 @@ import { } from './models/error.js'; import { objKeysToCamelCase, objKeysToSnakeCase } from './utils.js'; import { SDK_VERSION } from './version.js'; -import FormData from 'form-data'; +import { FormData } from 'formdata-node'; +import { FormDataEncoder } from 'form-data-encoder'; import { snakeCase } from 'change-case'; /** @@ -26,7 +27,7 @@ export interface RequestOptionsParams { headers?: Record; queryParams?: Record; body?: any; - form?: FormData; + form?: FormData | any; // Support both formdata-node and legacy form-data types overrides?: OverridableNylasConfig; } @@ -209,9 +210,10 @@ export default class APIClient { if (optionParams.form) { requestOptions.body = optionParams.form; + const encoder = new FormDataEncoder(optionParams.form); requestOptions.headers = { ...requestOptions.headers, - ...optionParams.form.getHeaders(), + 'content-type': encoder.contentType, }; } diff --git a/src/resources/messages.ts b/src/resources/messages.ts index 734b0f50..d7269f68 100644 --- a/src/resources/messages.ts +++ b/src/resources/messages.ts @@ -1,4 +1,5 @@ -import FormData from 'form-data'; +import { FormData, File } from 'formdata-node'; +import { ReadableStream } from 'node:stream/web'; import APIClient, { RequestOptionsParams } from '../apiClient.js'; import { Overrides } from '../config.js'; import { @@ -220,7 +221,7 @@ export class Messages extends Resource { }, 0) || 0; if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { - requestOptions.form = Messages._buildFormRequest(requestBody); + requestOptions.form = await Messages._buildFormRequest(requestBody); } else { if (requestBody.attachments) { const processedAttachments = await encodeAttachmentStreams( @@ -308,13 +309,10 @@ export class Messages extends Resource { }); } - static _buildFormRequest( + static async _buildFormRequest( requestBody: CreateDraftRequest | UpdateDraftRequest | SendMessageRequest - ): FormData { - // FormData imports are funky, cjs needs to use .default, es6 doesn't - const FD = require('form-data'); - const FormDataConstructor = FD.default || FD; - const form: FormData = new FormDataConstructor(); + ): Promise { + const form = new FormData(); // Split out the message payload from the attachments const messagePayload = { @@ -324,13 +322,39 @@ export class Messages extends Resource { form.append('message', JSON.stringify(objKeysToSnakeCase(messagePayload))); // Add a separate form field for each attachment - requestBody.attachments?.forEach((attachment, index) => { - const contentId = attachment.contentId || `file${index}`; - form.append(contentId, attachment.content, { - filename: attachment.filename, - contentType: attachment.contentType, - }); - }); + if (requestBody.attachments) { + for (const [index, attachment] of requestBody.attachments.entries()) { + const contentId = attachment.contentId || `file${index}`; + // Handle different types of content (Buffer, ReadableStream, string) + let file; + // Type guard for Node.js streams + const isNodeStream = (value: unknown): value is { pipe: Function } => { + return ( + value !== null && + typeof value === 'object' && + typeof (value as any).pipe === 'function' + ); + }; + + if (attachment.content instanceof ReadableStream) { + // For web ReadableStream + file = attachment.content; + } else if (isNodeStream(attachment.content)) { + // For Node.js streams (which have pipe method) + file = attachment.content; + } else if ( + attachment.content instanceof Buffer || + typeof attachment.content === 'string' + ) { + file = new File([attachment.content], attachment.filename, { + type: attachment.contentType, + }); + } else { + throw new Error('Unsupported attachment content type'); + } + form.append(contentId, file); + } + } return form; } diff --git a/tests/resources/drafts.spec.ts b/tests/resources/drafts.spec.ts index 47ea8f4e..967c47b0 100644 --- a/tests/resources/drafts.spec.ts +++ b/tests/resources/drafts.spec.ts @@ -6,16 +6,50 @@ import { createReadableStream, MockedFormData } from '../testUtils'; jest.mock('../src/apiClient'); // Mock the FormData constructor -jest.mock('form-data', () => { - return jest.fn().mockImplementation(function(this: MockedFormData) { - const appendedData: Record = {}; +jest.mock('formdata-node', () => { + return { + FormData: jest.fn().mockImplementation(() => { + const appendedData: Record = {}; - this.append = (key: string, value: any): void => { - appendedData[key] = value; - }; + const getAppendedData = () => appendedData; - this._getAppendedData = (): Record => appendedData; - }); + const createMockFormData = () => { + const formData = { + append(key: string, value: any): void { + if (value && typeof value === 'object' && 'content' in value) { + // Handle File objects + appendedData[key] = value.content; + } else { + appendedData[key] = value; + } + }, + _getAppendedData: getAppendedData, + form: { + _getAppendedData: getAppendedData, + append(key: string, value: any): void { + if (value && typeof value === 'object' && 'content' in value) { + // Handle File objects + appendedData[key] = value.content; + } else { + appendedData[key] = value; + } + }, + }, + }; + + return formData as MockedFormData; + }; + + const mockFormData = createMockFormData() as MockedFormData; + + return mockFormData; + }), + File: jest.fn().mockImplementation((content: any[], name: string) => ({ + content, + name, + })), + Blob: jest.fn(), + }; }); describe('Drafts', () => { diff --git a/tests/resources/messages.spec.ts b/tests/resources/messages.spec.ts index 20393683..a7116ee2 100644 --- a/tests/resources/messages.spec.ts +++ b/tests/resources/messages.spec.ts @@ -5,16 +5,18 @@ import { CreateAttachmentRequest } from '../../src/models/attachments'; jest.mock('../src/apiClient'); // Mock the FormData constructor -jest.mock('form-data', () => { - return jest.fn().mockImplementation(function(this: MockedFormData) { - const appendedData: Record = {}; +jest.mock('formdata-node', () => { + return { + FormData: jest.fn().mockImplementation(function(this: MockedFormData) { + const appendedData: Record = {}; - this.append = (key: string, value: any): void => { - appendedData[key] = value; - }; + this.append = (key: string, value: any): void => { + appendedData[key] = value; + }; - this._getAppendedData = (): Record => appendedData; - }); + this._getAppendedData = (): Record => appendedData; + }), + }; }); describe('Messages', () => { diff --git a/tests/testUtils.ts b/tests/testUtils.ts index 07816cac..0a2b5cdc 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -5,6 +5,9 @@ import { Readable } from 'stream'; export interface MockedFormData { append(key: string, value: any): void; _getAppendedData(): Record; + form: MockedFormData & { + _getAppendedData(): Record; + }; } export const mockedFetch = fetch as jest.MockedFunction;