diff --git a/src/node/createAttachmentFromPath.ts b/src/node/createAttachmentFromPath.ts new file mode 100644 index 0000000000..23328a98e8 --- /dev/null +++ b/src/node/createAttachmentFromPath.ts @@ -0,0 +1,18 @@ +import { open } from 'fs/promises'; +import { basename } from 'path'; +import type { Attachment } from '../version2/parameters'; + +export async function createAttachmentFromPath(path: string, contentType?: string): Promise { + const filename = basename(path); + + const fileHandle = await open(path, 'r'); + + const { size } = await fileHandle.stat(); + + return { + filename, + content: fileHandle.readableWebStream(), + contentType, + contentLength: size, + }; +} diff --git a/src/services/formDataService/formDataService.ts b/src/services/formDataService/formDataService.ts new file mode 100644 index 0000000000..d87a3b3d4b --- /dev/null +++ b/src/services/formDataService/formDataService.ts @@ -0,0 +1,78 @@ +import mime from 'mime'; +import type { ReadableStream as ReadableNodeStream } from 'node:stream/web'; + +type FormDataValue = string | Blob | ArrayBuffer | ReadableStream | ReadableNodeStream; + +class FileWithSize extends File { + size: number = 0; +} + +interface AppendOptions { + contentLength?: number; + contentType?: string; +} + +export class FormDataService { + formData: FormData; + + constructor() { + this.formData = new FormData(); + } + + async append(value: FormDataValue, filename: string, options: AppendOptions = {}) { + const blobOptions = { + type: options.contentType ?? mime.getType(filename) ?? undefined, + }; + + if (typeof value === 'string') { + this.formData.append('file', new Blob([value], blobOptions), filename); + } else if (value instanceof Blob) { + this.formData.append('file', new Blob([value], blobOptions), filename); + } else if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) { + this.formData.append('file', new Blob([value], blobOptions), filename); + } else if (value instanceof ReadableStream) { + const file = new FileWithSize([], filename, blobOptions); + + if (options.contentLength != undefined) { + file.size = options.contentLength; + file.stream = () => value as ReadableStream; + } else { + const [streamForSize, streamForContent] = value.tee(); + + file.size = await this.getStreamSize(streamForSize); + file.stream = () => streamForContent as ReadableStream; + } + + this.formData.append('file', file); + } else { + throw new Error('Invalid value'); // todo error handling + } + } + + private async getStreamSize(stream: ReadableStream | ReadableNodeStream): Promise { + let totalSize = 0; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (value instanceof Uint8Array) { + totalSize += value.length; + } else if (typeof value === 'string') { + totalSize += new TextEncoder().encode(value).length; + } else if (value instanceof Blob) { + totalSize += value.size; + } else if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) { + totalSize += value.byteLength; + } else if (value === null || value === undefined) { + continue; + } else { + throw new Error(`Unsupported value type: ${typeof value}`); + } + } + + return totalSize; + } +} diff --git a/src/services/formDataService/index.ts b/src/services/formDataService/index.ts new file mode 100644 index 0000000000..2b23dbc814 --- /dev/null +++ b/src/services/formDataService/index.ts @@ -0,0 +1 @@ +export * from './formDataService'; diff --git a/src/version2/issueAttachments.ts b/src/version2/issueAttachments.ts index dc728d6b49..5b1261620a 100644 --- a/src/version2/issueAttachments.ts +++ b/src/version2/issueAttachments.ts @@ -1,10 +1,9 @@ -import type { Mime } from 'mime'; -import mime from 'mime'; import type * as Models from './models'; import type * as Parameters from './parameters'; import type { Client } from '../clients'; import type { Callback } from '../callback'; import type { Request } from '../request'; +import { FormDataService } from '../services/formDataService'; export class IssueAttachments { constructor(private client: Client) {} @@ -121,6 +120,7 @@ export class IssueAttachments { async getAttachmentThumbnail(parameters: Parameters.GetAttachmentThumbnail | string): Promise { const id = typeof parameters === 'string' ? parameters : parameters.id; + // todo const config: Request = { url: `/rest/api/2/attachment/thumbnail/${id}`, method: 'GET', @@ -381,112 +381,29 @@ export class IssueAttachments { */ async addAttachment(parameters: Parameters.AddAttachment, callback?: never): Promise; async addAttachment(parameters: Parameters.AddAttachment): Promise { - const formData = new FormData(); + const formDataService = new FormDataService(); const attachments = Array.isArray(parameters.attachment) ? parameters.attachment : [parameters.attachment]; - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - let Readable: typeof import('stream').Readable | undefined; - - if (typeof window === 'undefined') { - const { Readable: NodeReadable } = await import('stream'); - - Readable = NodeReadable; - } - - for await (const attachment of attachments) { - const file = await this._convertToFile(attachment, mime, Readable); - - if (!(file instanceof File || file instanceof Blob)) { - throw new Error(`Unsupported file type for attachment: ${typeof file}`); - } - - formData.append('file', file, attachment.filename); - } + await Promise.all( + attachments.map(attachment => + formDataService.append(attachment.content, attachment.filename, { + contentLength: attachment.contentLength, + contentType: attachment.contentType, + }), + ), + ); const config: Request = { url: `/rest/api/2/issue/${parameters.issueIdOrKey}/attachments`, method: 'POST', headers: { 'X-Atlassian-Token': 'no-check', - // 'Content-Type': 'multipart/form-data', }, - body: formData, - // maxBodyLength: Infinity, // todo - // maxContentLength: Infinity, // todo + body: formDataService.formData, + // maxBodyLength: Infinity, // todo needed? + // maxContentLength: Infinity, // todo needed? }; return this.client.sendRequest(config); } - - private async _convertToFile( - attachment: Parameters.Attachment, - mime: Mime, - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - Readable?: typeof import('stream').Readable, - ): Promise { - const mimeType = attachment.mimeType ?? (mime.getType(attachment.filename) || undefined); - - if (attachment.file instanceof Blob || attachment.file instanceof File) { - return attachment.file; - } - - if (typeof attachment.file === 'string') { - return new File([attachment.file], attachment.filename, { type: mimeType }); - } - - if (Readable && attachment.file instanceof Readable) { - return this._streamToBlob(attachment.file, attachment.filename, mimeType); - } - - if (attachment.file instanceof ReadableStream) { - return this._streamToBlob(attachment.file, attachment.filename, mimeType); - } - - if (ArrayBuffer.isView(attachment.file) || attachment.file instanceof ArrayBuffer) { - return new File([attachment.file], attachment.filename, { type: mimeType }); - } - - throw new Error('Unsupported attachment file type.'); - } - - private async _streamToBlob( - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - stream: import('stream').Readable | ReadableStream, - filename: string, - mimeType?: string, - ): Promise { - if (typeof window === 'undefined' && stream instanceof (await import('stream')).Readable) { - return new Promise((resolve, reject) => { - const chunks: Uint8Array[] = []; - - stream.on('data', chunk => chunks.push(chunk)); - stream.on('end', () => { - const blob = new Blob(chunks, { type: mimeType }); - - resolve(new File([blob], filename, { type: mimeType })); - }); - stream.on('error', reject); - }); - } - - if (stream instanceof ReadableStream) { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - - let done = false; - - while (!done) { - const { value, done: streamDone } = await reader.read(); - - if (value) chunks.push(value); - done = streamDone; - } - - const blob = new Blob(chunks, { type: mimeType }); - - return new File([blob], filename, { type: mimeType }); - } - - throw new Error('Unsupported stream type.'); - } } diff --git a/src/version2/parameters/addAttachment.ts b/src/version2/parameters/addAttachment.ts index 7a0682f58b..4d58239199 100644 --- a/src/version2/parameters/addAttachment.ts +++ b/src/version2/parameters/addAttachment.ts @@ -1,4 +1,4 @@ -import type { Readable } from 'node:stream'; +import type { ReadableStream as ReadableNodeStream } from 'node:stream/web'; /** * Represents an attachment to be added to an issue. @@ -13,6 +13,7 @@ import type { Readable } from 'node:stream'; * ``` */ export interface Attachment { + // todo JSDoc /** * The name of the attachment file. * @@ -37,22 +38,20 @@ export interface Attachment { * const fileContent = fs.readFileSync('./document.pdf'); * ``` */ - file: Buffer | ReadableStream | Readable | string | Blob | File; + content: ArrayBuffer | ReadableStream | ReadableNodeStream | string | Blob; /** - * Optional MIME type of the attachment. Example values include: - * - * - 'application/pdf' - * - 'image/png' + * Optional MIME type of the attachment. * * If not provided, the MIME type will be automatically detected based on the filename. * * @example * ```typescript - * const mimeType = 'application/pdf'; + * 'application/pdf' * ``` */ - mimeType?: string; + contentType?: string; + contentLength?: number; // todo JSDoc } /** @@ -99,5 +98,5 @@ export interface AddAttachment { * ]; * ``` */ - attachment: Attachment | Attachment[]; + attachment: Attachment | Attachment[]; // todo JSDoc } diff --git a/tests/integration/version2/issueAttachments.test.ts b/tests/integration/version2/issueAttachments.test.ts index a1390be4d4..2951767b8f 100644 --- a/tests/integration/version2/issueAttachments.test.ts +++ b/tests/integration/version2/issueAttachments.test.ts @@ -1,109 +1,135 @@ import * as fs from 'node:fs'; -import { afterAll, beforeAll, test } from 'vitest'; +import { open } from 'node:fs/promises'; +import { afterAll, beforeAll, describe, test, it, expect } from 'vitest'; import type { Attachment, Issue } from '@jirajs/version2/models'; import { Constants } from '@tests/integration/constants'; import { cleanupEnvironment, getVersion2Client, prepareEnvironment } from '@tests/integration/utils'; -import { Readable } from 'node:stream'; -const client = getVersion2Client({ noCheckAtlassianToken: true }); +describe('IssueAttachments', () => { + const client = getVersion2Client({ noCheckAtlassianToken: true }); -let issue: Issue; -let attachments: Attachment[]; + let issue: Issue; + let attachments: Attachment[]; -beforeAll(async () => { - await prepareEnvironment(); -}); - -afterAll(async () => { - await cleanupEnvironment(); -}); + beforeAll(async () => { + await prepareEnvironment(); -test.sequential('should add attachment', async ({ expect }) => { - issue = await client.issues.createIssue({ - fields: { - summary: 'Issue with attachment', - project: { - key: Constants.testProjectKey, + issue = await client.issues.createIssue({ + fields: { + summary: 'Issue with attachment', + project: { + key: Constants.testProjectKey, + }, + issuetype: { + name: 'Task', + }, }, - issuetype: { - name: 'Task', - }, - }, + }); + }); + + afterAll(async () => { + await cleanupEnvironment(); }); - expect(!!issue).toBeTruthy(); + test.sequential('should add attachment', async ({ expect }) => { + attachments = await client.issueAttachments.addAttachment({ + issueIdOrKey: issue.key, + attachment: { + filename: 'issueAttachments.test.ts', + content: fs.readFileSync('./tests/integration/version2/issueAttachments.test.ts').toString(), + }, + }); - attachments = await client.issueAttachments.addAttachment({ - issueIdOrKey: issue.key, - attachment: { - filename: 'issueAttachments.test.ts', - file: fs.readFileSync('./tests/integration/version2/issueAttachments.test.ts'), - }, + expect(!!attachments).toBeTruthy(); + expect(attachments[0].filename).toBe('issueAttachments.test.ts'); + expect(attachments[0].mimeType).toBe('video/mp2t'); }); - expect(!!attachments).toBeTruthy(); - expect(attachments[0].filename).toBe('issueAttachments.test.ts'); - expect(attachments[0].mimeType).toBe('video/mp2t'); -}); + test.sequential('should add attachment with custom MIME type', async ({ expect }) => { + const customMimeType = 'application/typescript'; -test.sequential('should add attachment with custom MIME type', async ({ expect }) => { - const customMimeType = 'application/typescript'; + const customAttachment = await client.issueAttachments.addAttachment({ + issueIdOrKey: issue.key, + attachment: { + filename: 'issueAttachments.test.ts', + content: fs.readFileSync('./tests/integration/version2/issueAttachments.test.ts'), + contentType: customMimeType, + }, + }); - const customAttachment = await client.issueAttachments.addAttachment({ - issueIdOrKey: issue.key, - attachment: { - filename: 'issueAttachments.test.ts', - file: fs.readFileSync('./tests/integration/version2/issueAttachments.test.ts'), - mimeType: customMimeType, - }, + expect(!!customAttachment).toBeTruthy(); + expect(customAttachment[0].filename).toBe('issueAttachments.test.ts'); + expect(customAttachment[0].mimeType).toBe(customMimeType); }); - expect(!!customAttachment).toBeTruthy(); - expect(customAttachment[0].filename).toBe('issueAttachments.test.ts'); - expect(customAttachment[0].mimeType).toBe(customMimeType); -}); + test.sequential('should add attachment with ReadableStream', async ({ expect }) => { + const readableStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('This is a test content for ReadableStream.')); + controller.close(); + }, + }); -test.sequential('should add attachment with ReadableStream', async ({ expect }) => { - const readableStream = Readable.from(['This is a test content for ReadableStream.']); + attachments = await client.issueAttachments.addAttachment({ + issueIdOrKey: issue.key, + attachment: { + filename: 'readableStreamAttachment.txt', + content: readableStream, + }, + }); - attachments = await client.issueAttachments.addAttachment({ - issueIdOrKey: issue.key, - attachment: { - filename: 'readableStreamAttachment.txt', - file: readableStream, - }, + expect(!!attachments).toBeTruthy(); + expect(attachments[0].filename).toBe('readableStreamAttachment.txt'); + expect(attachments[0].mimeType).toBe('text/plain'); }); - expect(!!attachments).toBeTruthy(); - expect(attachments[0].filename).toBe('readableStreamAttachment.txt'); - expect(attachments[0].mimeType).toBe('text/plain'); -}); + it.sequential('should add attachment with readableWebStream and predefined contentLength', async () => { + const customMimeType = 'application/typescript'; + const fileStream = await open('./tests/integration/version2/issueAttachments.test.ts'); + + const { size: contentLength } = await fileStream.stat(); + + attachments = await client.issueAttachments.addAttachment({ + issueIdOrKey: issue.key, + attachment: { + filename: 'fsReadStreamAttachment.ts', + content: fileStream.readableWebStream(), + contentType: customMimeType, + contentLength, + }, + }); -test.sequential('should add attachment with fs.createReadStream', async ({ expect }) => { - const customMimeType = 'application/typescript'; - const fileStream = fs.createReadStream('./tests/integration/version2/issueAttachments.test.ts'); - - attachments = await client.issueAttachments.addAttachment({ - issueIdOrKey: issue.key, - attachment: { - filename: 'fsReadStreamAttachment.ts', - file: fileStream, - mimeType: customMimeType, - }, + expect(!!attachments).toBeTruthy(); + expect(attachments[0].filename).toBe('fsReadStreamAttachment.ts'); + expect(attachments[0].mimeType).toBe(customMimeType); }); - expect(!!attachments).toBeTruthy(); - expect(attachments[0].filename).toBe('fsReadStreamAttachment.ts'); - expect(attachments[0].mimeType).toBe(customMimeType); -}); + test.sequential('should add attachment with fs.createReadStream', async ({ expect }) => { + const customMimeType = 'application/typescript'; + const fileStream = await open('./tests/integration/version2/issueAttachments.test.ts'); + + attachments = await client.issueAttachments.addAttachment({ + issueIdOrKey: issue.key, + attachment: { + filename: 'fsReadStreamAttachment.ts', + content: fileStream.readableWebStream(), + contentType: customMimeType, + }, + }); -test.sequential('should getAttachmentContent', async ({ expect }) => { - const { content, contentType } = await client.issueAttachments.getAttachmentContent({ id: attachments[0].id }); + expect(!!attachments).toBeTruthy(); + expect(attachments[0].filename).toBe('fsReadStreamAttachment.ts'); + expect(attachments[0].mimeType).toBe(customMimeType); + }); - expect(ArrayBuffer.isView(content) || content instanceof ArrayBuffer).toBeTruthy(); - expect(['text/plain', 'video/mp2t'].includes(contentType)).toBeTruthy(); -}); + test.sequential('should getAttachmentContent', async ({ expect }) => { + const { content, contentType } = await client.issueAttachments.getAttachmentContent({ id: attachments[0].id }); -test.sequential('should remove attachment', async () => { - await client.issues.deleteIssue({ issueIdOrKey: issue.key }); + expect(ArrayBuffer.isView(content) || content instanceof ArrayBuffer).toBeTruthy(); + expect(['text/plain', 'video/mp2t'].includes(contentType)).toBeTruthy(); + }); + + test.sequential('should remove attachment', async () => { + await client.issues.deleteIssue({ issueIdOrKey: issue.key }); + }); });