From 390844664daea94619cfb67144c6ea68ca825f7d Mon Sep 17 00:00:00 2001 From: YIFAN WU Date: Wed, 22 Nov 2023 17:16:48 -0500 Subject: [PATCH 1/4] Attachments api --- src/apiClient.ts | 36 +++++++++++++ src/models/attachments.ts | 52 ++++++++++++++++++ src/nylas.ts | 6 +++ src/resources/attachments.ts | 82 +++++++++++++++++++++++++++++ src/resources/resource.ts | 13 +++++ tests/nylas.spec.ts | 1 + tests/resources/attachments.spec.ts | 72 +++++++++++++++++++++++++ 7 files changed, 262 insertions(+) create mode 100644 src/models/attachments.ts create mode 100644 src/resources/attachments.ts create mode 100644 tests/resources/attachments.spec.ts diff --git a/src/apiClient.ts b/src/apiClient.ts index 320b9017..39c8deb8 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -208,4 +208,40 @@ export default class APIClient { return this.requestWithResponse(response); } + + async requestRaw(options: RequestOptionsParams): Promise { + const req = this.newRequest(options); + const controller: AbortController = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + throw new NylasSdkTimeoutError(req.url, this.timeout); + }, this.timeout); + + const response = await fetch(req, { signal: controller.signal }); + clearTimeout(timeout); + + if (typeof response === 'undefined') { + throw new Error('Failed to fetch response'); + } + + // Handle error response + if (response.status > 299) { + const text = await response.text(); + let error: Error; + try { + const parsedError = JSON.parse(text); + const camelCaseError = objKeysToCamelCase(parsedError); + error = new NylasApiError(camelCaseError, response.status); + } catch (e) { + throw new Error( + `Received an error but could not parse response from the server: ${text}` + ); + } + + throw error; + } + + // Return the raw buffer + return response.buffer(); + } } diff --git a/src/models/attachments.ts b/src/models/attachments.ts new file mode 100644 index 00000000..3fd22975 --- /dev/null +++ b/src/models/attachments.ts @@ -0,0 +1,52 @@ +/** + * Interface of an attachment object from Nylas. + */ +export interface Attachment { + /** + * A globally unique object identifier. + * Constraints: Minimum 1 character. + */ + id: string; + + /** + * Attachment's name. + * Constraints: Minimum 1 character. + */ + filename: string; + + /** + * Attachment's content type. + * Constraints: Minimum 1 character. + */ + contentType: string; + + /** + * Grant ID of the Nylas account. + */ + grantId: string; + + /** + * If it's an inline attachment. + */ + isInline: boolean; + + /** + * Attachment's size in bytes. + */ + size: number; +} + +/** + * Interface representing of the query parameters for finding an attachment's metadata. + */ +export interface FindAttachmentQueryParams { + /** + * ID of the message the attachment belongs to. + */ + messageId: string; +} + +/** + * Interface representing of the query parameters for downloading an attachment. + */ +export type DownloadAttachmentQueryParams = Partial; diff --git a/src/nylas.ts b/src/nylas.ts index 731206c9..636da8c3 100644 --- a/src/nylas.ts +++ b/src/nylas.ts @@ -10,6 +10,7 @@ import { Drafts } from './resources/drafts.js'; import { Threads } from './resources/threads.js'; import { Connectors } from './resources/connectors.js'; import { Folders } from './resources/folders.js'; +import { Attachments } from './resources/attachments.js'; /** * The entry point to the Node SDK @@ -58,6 +59,10 @@ export default class Nylas { * Access the Folders API */ public folders: Folders; + /** + * Access the Attachments API + */ + public attachments: Attachments; /** * The configured API client @@ -85,6 +90,7 @@ export default class Nylas { this.threads = new Threads(this.apiClient); this.webhooks = new Webhooks(this.apiClient); this.folders = new Folders(this.apiClient); + this.attachments = new Attachments(this.apiClient); return this; } diff --git a/src/resources/attachments.ts b/src/resources/attachments.ts new file mode 100644 index 00000000..0bea8578 --- /dev/null +++ b/src/resources/attachments.ts @@ -0,0 +1,82 @@ +import { Overrides } from '../config.js'; +import { + Attachment, + FindAttachmentQueryParams, + DownloadAttachmentQueryParams, +} from '../models/attachments.js'; +import { NylasResponse } from '../models/response.js'; +import { Resource } from './resource.js'; + +/** + * @property identifier The ID of the grant to act upon. Use "me" to refer to the grant associated with an access token. + * @property attachmentId The ID of the attachment to act upon. + * @property queryParams The query parameters to include in the request + */ +interface FindAttachmentParams { + identifier: string; + attachmentId: string; + queryParams: FindAttachmentQueryParams; +} + +/** + * @property identifier The ID of the grant to act upon. Use "me" to refer to the grant associated with an access token. + * @property attachmentId The ID of the attachment to act upon. + * @property queryParams The query parameters to include in the request + */ +interface DownloadAttachmentParams { + identifier: string; + attachmentId: string; + queryParams: DownloadAttachmentQueryParams; +} + +/** + * Nylas Attachments API + * + * The Nylas Attachments API allows you to retrieve metadata and download attachments. + * + * You can use the attachments schema in a Send v3 request to send attachments regardless of the email provider. + * To include attachments in a Send v3 request, all attachments must be base64 encoded and the encoded data must be placed in the content field in the request body. + * + * If you are using draft support, the draft including the attachment is stored on the provider. If you are not using draft support, Nylas stores the attachment. + * + * Attachment size is currently limited by each provider. + * 3MB for Gmail messages + * 10MB for Microsoft messages + * + * You can also send attachments inline in an email message, for example for images that should be displayed in the email body content. + */ +export class Attachments extends Resource { + /** + * Returns an attachment by ID. + * @return The Attachment metadata + */ + public find({ + identifier, + attachmentId, + queryParams, + overrides, + }: FindAttachmentParams & Overrides): Promise> { + return super._find({ + path: `/v3/grants/${identifier}/attachments/${attachmentId}`, + queryParams, + overrides, + }); + } + + /** + * Returns an attachment by ID. + * @return The Attachment file in binary format + */ + public download({ + identifier, + attachmentId, + queryParams, + overrides, + }: DownloadAttachmentParams & Overrides): Promise { + return super._getRaw({ + path: `/v3/grants/${identifier}/attachments/${attachmentId}/download`, + queryParams, + overrides, + }); + } +} diff --git a/src/resources/resource.ts b/src/resources/resource.ts index ecbad890..1ef29bde 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -192,6 +192,19 @@ export class Resource { overrides, }); } + + protected _getRaw({ + path, + queryParams, + overrides, + }: FindParams): Promise { + return this.apiClient.requestRaw({ + method: 'GET', + path, + queryParams, + overrides, + }); + } } type ListYieldReturn = T & { diff --git a/tests/nylas.spec.ts b/tests/nylas.spec.ts index b5127395..b78e1642 100644 --- a/tests/nylas.spec.ts +++ b/tests/nylas.spec.ts @@ -21,6 +21,7 @@ describe('Nylas', () => { expect(nylas.events.constructor.name).toBe('Events'); expect(nylas.webhooks.constructor.name).toBe('Webhooks'); expect(nylas.folders.constructor.name).toBe('Folders'); + expect(nylas.attachments.constructor.name).toBe('Attachments'); }); it('should configure the apiClient', () => { diff --git a/tests/resources/attachments.spec.ts b/tests/resources/attachments.spec.ts new file mode 100644 index 00000000..073185ee --- /dev/null +++ b/tests/resources/attachments.spec.ts @@ -0,0 +1,72 @@ +import APIClient from '../../src/apiClient'; +import { Attachments } from '../../src/resources/attachments'; +jest.mock('../src/apiClient'); + +describe('Attachments', () => { + let apiClient: jest.Mocked; + let attachments: Attachments; + + beforeAll(() => { + apiClient = new APIClient({ + apiKey: 'apiKey', + apiUri: 'https://test.api.nylas.com', + timeout: 30, + }) as jest.Mocked; + + attachments = new Attachments(apiClient); + apiClient.request.mockResolvedValue({}); + apiClient.requestRaw.mockResolvedValue(Buffer.from('')); + }); + + describe('find', () => { + it('should call apiClient.request with the correct params for attachment metadata', async () => { + await attachments.find({ + identifier: 'id123', + attachmentId: 'attach123', + queryParams: { + messageId: 'message123', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/grants/id123/attachments/attach123', + queryParams: { + messageId: 'message123', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + }, + }); + }); + }); + + describe('download', () => { + it('should call apiClient.requestRaw with the correct params for downloading an attachment', async () => { + await attachments.download({ + identifier: 'id123', + attachmentId: 'attach123', + queryParams: { + messageId: 'message123', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + }, + }); + + expect(apiClient.requestRaw).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/grants/id123/attachments/attach123/download', + queryParams: { + messageId: 'message123', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + }, + }); + }); + }); +}); From 2b4c7bb41dae2a581e3c2cab061376b6e4e5c797 Mon Sep 17 00:00:00 2001 From: YIFAN WU Date: Mon, 27 Nov 2023 16:56:17 -0500 Subject: [PATCH 2/4] Added download method for returning stream --- src/apiClient.ts | 37 +++++++++++++++++++++++++++++ src/resources/attachments.ts | 35 ++++++++++++++++++++++++--- src/resources/resource.ts | 13 ++++++++++ tests/resources/attachments.spec.ts | 34 ++++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/apiClient.ts b/src/apiClient.ts index 39c8deb8..a08e4a00 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -244,4 +244,41 @@ export default class APIClient { // Return the raw buffer return response.buffer(); } + + async requestStream( + options: RequestOptionsParams + ): Promise { + const req = this.newRequest(options); + const controller: AbortController = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + throw new NylasSdkTimeoutError(req.url, this.timeout); + }, this.timeout); + + const response = await fetch(req, { signal: controller.signal }); + clearTimeout(timeout); + + if (typeof response === 'undefined') { + throw new Error('Failed to fetch response'); + } + + // Handle error response + if (response.status > 299) { + const text = await response.text(); + let error: Error; + try { + const parsedError = JSON.parse(text); + const camelCaseError = objKeysToCamelCase(parsedError); + error = new NylasApiError(camelCaseError, response.status); + } catch (e) { + throw new Error( + `Received an error but could not parse response from the server: ${text}` + ); + } + + throw error; + } + + return response.body; + } } diff --git a/src/resources/attachments.ts b/src/resources/attachments.ts index 0bea8578..4390c329 100644 --- a/src/resources/attachments.ts +++ b/src/resources/attachments.ts @@ -62,16 +62,45 @@ export class Attachments extends Resource { overrides, }); } - + /** - * Returns an attachment by ID. - * @return The Attachment file in binary format + * Download the attachment data + * + * This method returns a NodeJS.ReadableStream which can be used to stream the attachment data. + * This is particularly useful for handling large attachments efficiently, as it avoids loading + * the entire file into memory. The stream can be piped to a file stream or used in any other way + * that Node.js streams are typically used. + * + * @param identifier Grant ID or email account to query + * @param attachmentId The id of the attachment to download. + * @param queryParams The query parameters to include in the request + * @returns {NodeJS.ReadableStream} The ReadableStream containing the file data. */ public download({ identifier, attachmentId, queryParams, overrides, + }: DownloadAttachmentParams & Overrides): Promise { + return this._getStream({ + path: `/v3/grants/${identifier}/attachments/${attachmentId}/download`, + queryParams, + overrides, + }); + } + + /** + * Download the attachment as a byte array + * @param identifier Grant ID or email account to query + * @param attachmentId The id of the attachment to download. + * @param queryParams The query parameters to include in the request + * @return The raw file data + */ + public downloadBytes({ + identifier, + attachmentId, + queryParams, + overrides, }: DownloadAttachmentParams & Overrides): Promise { return super._getRaw({ path: `/v3/grants/${identifier}/attachments/${attachmentId}/download`, diff --git a/src/resources/resource.ts b/src/resources/resource.ts index 1ef29bde..2e597c46 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -205,6 +205,19 @@ export class Resource { overrides, }); } + + protected _getStream({ + path, + queryParams, + overrides, + }: FindParams): Promise { + return this.apiClient.requestStream({ + method: 'GET', + path, + queryParams, + overrides, + }); + } } type ListYieldReturn = T & { diff --git a/tests/resources/attachments.spec.ts b/tests/resources/attachments.spec.ts index 073185ee..8035f631 100644 --- a/tests/resources/attachments.spec.ts +++ b/tests/resources/attachments.spec.ts @@ -1,5 +1,6 @@ import APIClient from '../../src/apiClient'; import { Attachments } from '../../src/resources/attachments'; +import { Readable } from 'stream'; jest.mock('../src/apiClient'); describe('Attachments', () => { @@ -16,6 +17,9 @@ describe('Attachments', () => { attachments = new Attachments(apiClient); apiClient.request.mockResolvedValue({}); apiClient.requestRaw.mockResolvedValue(Buffer.from('')); + + const mockStream = new Readable(); + apiClient.requestStream.mockResolvedValue(Promise.resolve(mockStream)); }); describe('find', () => { @@ -44,8 +48,34 @@ describe('Attachments', () => { }); }); + describe('downloadBytes', () => { + it('should call apiClient.requestRaw with the correct params for downloading an attachment as bytes', async () => { + await attachments.downloadBytes({ + identifier: 'id123', + attachmentId: 'attach123', + queryParams: { + messageId: 'message123', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + }, + }); + + expect(apiClient.requestRaw).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/grants/id123/attachments/attach123/download', + queryParams: { + messageId: 'message123', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + }, + }); + }); + }); + describe('download', () => { - it('should call apiClient.requestRaw with the correct params for downloading an attachment', async () => { + it('should call apiClient.requestStream with the correct params for streaming an attachment', async () => { await attachments.download({ identifier: 'id123', attachmentId: 'attach123', @@ -57,7 +87,7 @@ describe('Attachments', () => { }, }); - expect(apiClient.requestRaw).toHaveBeenCalledWith({ + expect(apiClient.requestStream).toHaveBeenCalledWith({ method: 'GET', path: '/v3/grants/id123/attachments/attach123/download', queryParams: { From f0154b3219860232d0594f0a2158657f61120dc8 Mon Sep 17 00:00:00 2001 From: YIFAN WU Date: Mon, 27 Nov 2023 16:58:40 -0500 Subject: [PATCH 3/4] lint --- src/resources/attachments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/attachments.ts b/src/resources/attachments.ts index 4390c329..f3835fb5 100644 --- a/src/resources/attachments.ts +++ b/src/resources/attachments.ts @@ -62,7 +62,7 @@ export class Attachments extends Resource { overrides, }); } - + /** * Download the attachment data * @@ -70,7 +70,7 @@ export class Attachments extends Resource { * This is particularly useful for handling large attachments efficiently, as it avoids loading * the entire file into memory. The stream can be piped to a file stream or used in any other way * that Node.js streams are typically used. - * + * * @param identifier Grant ID or email account to query * @param attachmentId The id of the attachment to download. * @param queryParams The query parameters to include in the request From 9965462ecc83b50b6da7f0e962481c1f6ec8365a Mon Sep 17 00:00:00 2001 From: YIFAN WU Date: Tue, 5 Dec 2023 13:42:38 -0500 Subject: [PATCH 4/4] PR Review request updates --- src/apiClient.ts | 155 ++++++++++++----------------------- src/models/attachments.ts | 5 +- src/resources/attachments.ts | 11 --- 3 files changed, 53 insertions(+), 118 deletions(-) diff --git a/src/apiClient.ts b/src/apiClient.ts index a08e4a00..b709473b 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -120,6 +120,55 @@ export default class APIClient { }; } + private async sendRequest(options: RequestOptionsParams): Promise { + const req = this.newRequest(options); + const controller: AbortController = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + throw new NylasSdkTimeoutError(req.url, this.timeout); + }, this.timeout); + + try { + const response = await fetch(req, { signal: controller.signal }); + clearTimeout(timeout); + + if (typeof response === 'undefined') { + throw new Error('Failed to fetch response'); + } + + if (response.status > 299) { + const text = await response.text(); + let error: Error; + try { + const parsedError = JSON.parse(text); + const camelCaseError = objKeysToCamelCase(parsedError); + + // Check if the request is an authentication request + const isAuthRequest = + options.path.includes('connect/token') || + options.path.includes('connect/revoke'); + + if (isAuthRequest) { + error = new NylasOAuthError(camelCaseError, response.status); + } else { + error = new NylasApiError(camelCaseError, response.status); + } + } catch (e) { + throw new Error( + `Received an error but could not parse response from the server: ${text}` + ); + } + + throw error; + } + + return response; + } catch (error) { + clearTimeout(timeout); + throw error; + } + } + requestOptions(optionParams: RequestOptionsParams): RequestOptions { const requestOptions = {} as RequestOptions; @@ -166,119 +215,19 @@ export default class APIClient { } async request(options: RequestOptionsParams): Promise { - const req = this.newRequest(options); - const controller: AbortController = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - throw new NylasSdkTimeoutError(req.url, this.timeout); - }, this.timeout); - - const response = await fetch(req, { signal: controller.signal }); - clearTimeout(timeout); - - if (typeof response === 'undefined') { - throw new Error('Failed to fetch response'); - } - - // handle error response - if (response.status > 299) { - const authErrorResponse = - options.path.includes('connect/token') || - options.path.includes('connect/revoke'); - - const text = await response.text(); - let error: Error; - try { - const parsedError = JSON.parse(text); - const camelCaseError = objKeysToCamelCase(parsedError); - - if (authErrorResponse) { - error = new NylasOAuthError(camelCaseError, response.status); - } else { - error = new NylasApiError(camelCaseError, response.status); - } - } catch (e) { - throw new Error( - `Received an error but could not parse response from the server: ${text}` - ); - } - - throw error; - } - + const response = await this.sendRequest(options); return this.requestWithResponse(response); } async requestRaw(options: RequestOptionsParams): Promise { - const req = this.newRequest(options); - const controller: AbortController = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - throw new NylasSdkTimeoutError(req.url, this.timeout); - }, this.timeout); - - const response = await fetch(req, { signal: controller.signal }); - clearTimeout(timeout); - - if (typeof response === 'undefined') { - throw new Error('Failed to fetch response'); - } - - // Handle error response - if (response.status > 299) { - const text = await response.text(); - let error: Error; - try { - const parsedError = JSON.parse(text); - const camelCaseError = objKeysToCamelCase(parsedError); - error = new NylasApiError(camelCaseError, response.status); - } catch (e) { - throw new Error( - `Received an error but could not parse response from the server: ${text}` - ); - } - - throw error; - } - - // Return the raw buffer + const response = await this.sendRequest(options); return response.buffer(); } async requestStream( options: RequestOptionsParams ): Promise { - const req = this.newRequest(options); - const controller: AbortController = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - throw new NylasSdkTimeoutError(req.url, this.timeout); - }, this.timeout); - - const response = await fetch(req, { signal: controller.signal }); - clearTimeout(timeout); - - if (typeof response === 'undefined') { - throw new Error('Failed to fetch response'); - } - - // Handle error response - if (response.status > 299) { - const text = await response.text(); - let error: Error; - try { - const parsedError = JSON.parse(text); - const camelCaseError = objKeysToCamelCase(parsedError); - error = new NylasApiError(camelCaseError, response.status); - } catch (e) { - throw new Error( - `Received an error but could not parse response from the server: ${text}` - ); - } - - throw error; - } - + const response = await this.sendRequest(options); return response.body; } } diff --git a/src/models/attachments.ts b/src/models/attachments.ts index 3fd22975..b8cf282e 100644 --- a/src/models/attachments.ts +++ b/src/models/attachments.ts @@ -4,19 +4,16 @@ export interface Attachment { /** * A globally unique object identifier. - * Constraints: Minimum 1 character. */ id: string; /** * Attachment's name. - * Constraints: Minimum 1 character. */ filename: string; /** * Attachment's content type. - * Constraints: Minimum 1 character. */ contentType: string; @@ -49,4 +46,4 @@ export interface FindAttachmentQueryParams { /** * Interface representing of the query parameters for downloading an attachment. */ -export type DownloadAttachmentQueryParams = Partial; +export type DownloadAttachmentQueryParams = FindAttachmentQueryParams; diff --git a/src/resources/attachments.ts b/src/resources/attachments.ts index f3835fb5..9100920a 100644 --- a/src/resources/attachments.ts +++ b/src/resources/attachments.ts @@ -33,17 +33,6 @@ interface DownloadAttachmentParams { * Nylas Attachments API * * The Nylas Attachments API allows you to retrieve metadata and download attachments. - * - * You can use the attachments schema in a Send v3 request to send attachments regardless of the email provider. - * To include attachments in a Send v3 request, all attachments must be base64 encoded and the encoded data must be placed in the content field in the request body. - * - * If you are using draft support, the draft including the attachment is stored on the provider. If you are not using draft support, Nylas stores the attachment. - * - * Attachment size is currently limited by each provider. - * 3MB for Gmail messages - * 10MB for Microsoft messages - * - * You can also send attachments inline in an email message, for example for images that should be displayed in the email body content. */ export class Attachments extends Resource { /**