Skip to content

Commit c1fd44c

Browse files
#353, #392 Form data enhancements (#398)
* eslint improvements * #388: removing ~ from dist folder * v5.2.1 * new form-data usage PoC * code formatting and fixes * test fixes
1 parent cfecdda commit c1fd44c

File tree

6 files changed

+225
-186
lines changed

6 files changed

+225
-186
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { open } from 'fs/promises';
2+
import { basename } from 'path';
3+
import type { Attachment } from '../version2/parameters';
4+
5+
export async function createAttachmentFromPath(path: string, contentType?: string): Promise<Attachment> {
6+
const filename = basename(path);
7+
8+
const fileHandle = await open(path, 'r');
9+
10+
const { size } = await fileHandle.stat();
11+
12+
return {
13+
filename,
14+
content: fileHandle.readableWebStream(),
15+
contentType,
16+
contentLength: size,
17+
};
18+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import mime from 'mime';
2+
import type { ReadableStream as ReadableNodeStream } from 'node:stream/web';
3+
4+
type FormDataValue = string | Blob | ArrayBuffer | ReadableStream | ReadableNodeStream;
5+
6+
class FileWithSize extends File {
7+
size: number = 0;
8+
}
9+
10+
interface AppendOptions {
11+
contentLength?: number;
12+
contentType?: string;
13+
}
14+
15+
export class FormDataService {
16+
formData: FormData;
17+
18+
constructor() {
19+
this.formData = new FormData();
20+
}
21+
22+
async append(value: FormDataValue, filename: string, options: AppendOptions = {}) {
23+
const blobOptions = {
24+
type: options.contentType ?? mime.getType(filename) ?? undefined,
25+
};
26+
27+
if (typeof value === 'string') {
28+
this.formData.append('file', new Blob([value], blobOptions), filename);
29+
} else if (value instanceof Blob) {
30+
this.formData.append('file', new Blob([value], blobOptions), filename);
31+
} else if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
32+
this.formData.append('file', new Blob([value], blobOptions), filename);
33+
} else if (value instanceof ReadableStream) {
34+
const file = new FileWithSize([], filename, blobOptions);
35+
36+
if (options.contentLength != undefined) {
37+
file.size = options.contentLength;
38+
file.stream = () => value as ReadableStream;
39+
} else {
40+
const [streamForSize, streamForContent] = value.tee();
41+
42+
file.size = await this.getStreamSize(streamForSize);
43+
file.stream = () => streamForContent as ReadableStream;
44+
}
45+
46+
this.formData.append('file', file);
47+
} else {
48+
throw new Error('Invalid value'); // todo error handling
49+
}
50+
}
51+
52+
private async getStreamSize(stream: ReadableStream | ReadableNodeStream): Promise<number> {
53+
let totalSize = 0;
54+
const reader = stream.getReader();
55+
56+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
57+
while (true) {
58+
const { done, value } = await reader.read();
59+
if (done) break;
60+
61+
if (value instanceof Uint8Array) {
62+
totalSize += value.length;
63+
} else if (typeof value === 'string') {
64+
totalSize += new TextEncoder().encode(value).length;
65+
} else if (value instanceof Blob) {
66+
totalSize += value.size;
67+
} else if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
68+
totalSize += value.byteLength;
69+
} else if (value === null || value === undefined) {
70+
continue;
71+
} else {
72+
throw new Error(`Unsupported value type: ${typeof value}`);
73+
}
74+
}
75+
76+
return totalSize;
77+
}
78+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './formDataService';

src/version2/issueAttachments.ts

Lines changed: 14 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import type { Mime } from 'mime';
2-
import mime from 'mime';
31
import type * as Models from './models';
42
import type * as Parameters from './parameters';
53
import type { Client } from '../clients';
64
import type { Callback } from '../callback';
75
import type { Request } from '../request';
6+
import { FormDataService } from '../services/formDataService';
87

98
export class IssueAttachments {
109
constructor(private client: Client) {}
@@ -121,6 +120,7 @@ export class IssueAttachments {
121120
async getAttachmentThumbnail<T = Buffer>(parameters: Parameters.GetAttachmentThumbnail | string): Promise<void | T> {
122121
const id = typeof parameters === 'string' ? parameters : parameters.id;
123122

123+
// todo
124124
const config: Request = {
125125
url: `/rest/api/2/attachment/thumbnail/${id}`,
126126
method: 'GET',
@@ -381,112 +381,29 @@ export class IssueAttachments {
381381
*/
382382
async addAttachment<T = Models.Attachment[]>(parameters: Parameters.AddAttachment, callback?: never): Promise<T>;
383383
async addAttachment<T = Models.Attachment[]>(parameters: Parameters.AddAttachment): Promise<void | T> {
384-
const formData = new FormData();
384+
const formDataService = new FormDataService();
385385
const attachments = Array.isArray(parameters.attachment) ? parameters.attachment : [parameters.attachment];
386386

387-
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
388-
let Readable: typeof import('stream').Readable | undefined;
389-
390-
if (typeof window === 'undefined') {
391-
const { Readable: NodeReadable } = await import('stream');
392-
393-
Readable = NodeReadable;
394-
}
395-
396-
for await (const attachment of attachments) {
397-
const file = await this._convertToFile(attachment, mime, Readable);
398-
399-
if (!(file instanceof File || file instanceof Blob)) {
400-
throw new Error(`Unsupported file type for attachment: ${typeof file}`);
401-
}
402-
403-
formData.append('file', file, attachment.filename);
404-
}
387+
await Promise.all(
388+
attachments.map(attachment =>
389+
formDataService.append(attachment.content, attachment.filename, {
390+
contentLength: attachment.contentLength,
391+
contentType: attachment.contentType,
392+
}),
393+
),
394+
);
405395

406396
const config: Request = {
407397
url: `/rest/api/2/issue/${parameters.issueIdOrKey}/attachments`,
408398
method: 'POST',
409399
headers: {
410400
'X-Atlassian-Token': 'no-check',
411-
// 'Content-Type': 'multipart/form-data',
412401
},
413-
body: formData,
414-
// maxBodyLength: Infinity, // todo
415-
// maxContentLength: Infinity, // todo
402+
body: formDataService.formData,
403+
// maxBodyLength: Infinity, // todo needed?
404+
// maxContentLength: Infinity, // todo needed?
416405
};
417406

418407
return this.client.sendRequest(config);
419408
}
420-
421-
private async _convertToFile(
422-
attachment: Parameters.Attachment,
423-
mime: Mime,
424-
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
425-
Readable?: typeof import('stream').Readable,
426-
): Promise<File | Blob> {
427-
const mimeType = attachment.mimeType ?? (mime.getType(attachment.filename) || undefined);
428-
429-
if (attachment.file instanceof Blob || attachment.file instanceof File) {
430-
return attachment.file;
431-
}
432-
433-
if (typeof attachment.file === 'string') {
434-
return new File([attachment.file], attachment.filename, { type: mimeType });
435-
}
436-
437-
if (Readable && attachment.file instanceof Readable) {
438-
return this._streamToBlob(attachment.file, attachment.filename, mimeType);
439-
}
440-
441-
if (attachment.file instanceof ReadableStream) {
442-
return this._streamToBlob(attachment.file, attachment.filename, mimeType);
443-
}
444-
445-
if (ArrayBuffer.isView(attachment.file) || attachment.file instanceof ArrayBuffer) {
446-
return new File([attachment.file], attachment.filename, { type: mimeType });
447-
}
448-
449-
throw new Error('Unsupported attachment file type.');
450-
}
451-
452-
private async _streamToBlob(
453-
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
454-
stream: import('stream').Readable | ReadableStream,
455-
filename: string,
456-
mimeType?: string,
457-
): Promise<File> {
458-
if (typeof window === 'undefined' && stream instanceof (await import('stream')).Readable) {
459-
return new Promise((resolve, reject) => {
460-
const chunks: Uint8Array[] = [];
461-
462-
stream.on('data', chunk => chunks.push(chunk));
463-
stream.on('end', () => {
464-
const blob = new Blob(chunks, { type: mimeType });
465-
466-
resolve(new File([blob], filename, { type: mimeType }));
467-
});
468-
stream.on('error', reject);
469-
});
470-
}
471-
472-
if (stream instanceof ReadableStream) {
473-
const reader = stream.getReader();
474-
const chunks: Uint8Array[] = [];
475-
476-
let done = false;
477-
478-
while (!done) {
479-
const { value, done: streamDone } = await reader.read();
480-
481-
if (value) chunks.push(value);
482-
done = streamDone;
483-
}
484-
485-
const blob = new Blob(chunks, { type: mimeType });
486-
487-
return new File([blob], filename, { type: mimeType });
488-
}
489-
490-
throw new Error('Unsupported stream type.');
491-
}
492409
}

src/version2/parameters/addAttachment.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Readable } from 'node:stream';
1+
import type { ReadableStream as ReadableNodeStream } from 'node:stream/web';
22

33
/**
44
* Represents an attachment to be added to an issue.
@@ -13,6 +13,7 @@ import type { Readable } from 'node:stream';
1313
* ```
1414
*/
1515
export interface Attachment {
16+
// todo JSDoc
1617
/**
1718
* The name of the attachment file.
1819
*
@@ -37,22 +38,20 @@ export interface Attachment {
3738
* const fileContent = fs.readFileSync('./document.pdf');
3839
* ```
3940
*/
40-
file: Buffer | ReadableStream | Readable | string | Blob | File;
41+
content: ArrayBuffer | ReadableStream | ReadableNodeStream | string | Blob;
4142

4243
/**
43-
* Optional MIME type of the attachment. Example values include:
44-
*
45-
* - 'application/pdf'
46-
* - 'image/png'
44+
* Optional MIME type of the attachment.
4745
*
4846
* If not provided, the MIME type will be automatically detected based on the filename.
4947
*
5048
* @example
5149
* ```typescript
52-
* const mimeType = 'application/pdf';
50+
* 'application/pdf'
5351
* ```
5452
*/
55-
mimeType?: string;
53+
contentType?: string;
54+
contentLength?: number; // todo JSDoc
5655
}
5756

5857
/**
@@ -99,5 +98,5 @@ export interface AddAttachment {
9998
* ];
10099
* ```
101100
*/
102-
attachment: Attachment | Attachment[];
101+
attachment: Attachment | Attachment[]; // todo JSDoc
103102
}

0 commit comments

Comments
 (0)