Skip to content

Commit

Permalink
#347: resolve file upload issues with ReadableStream and Readable in …
Browse files Browse the repository at this point in the history
…Node.js (#348)
  • Loading branch information
MrRefactoring authored Jan 9, 2025
1 parent c41d3b9 commit 99e340e
Show file tree
Hide file tree
Showing 10 changed files with 486 additions and 175 deletions.
270 changes: 135 additions & 135 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run tests/unit --maxWorkers=8 --sequence.concurrent",
"test:integration": "vitest run tests/integration --bail=1 --no-file-parallelism --max-concurrency 1 -c vitest.config.mts --hookTimeout 100000 --testTimeout 100000",
"replace:all": "npm run replace:permissions:version2 && npm run replace:permissions:version3 && npm run replace:pagination:version2 && npm run replace:pagination:version3 && npm run replace:async:version2 && npm run replace:async:version3 && npm run replace:expansion:version2 && npm run replace:expansion:version3 && npm run replace:ordering:version2 && npm run replace:ordering:version3 && npm run replace:groupMember:version2 && npm run replace:workflowPaginated:version2",
"replace:all": "npm run replace:permissions:version2 && npm run replace:permissions:version3 && npm run replace:pagination:version2 && npm run replace:pagination:version3 && npm run replace:async:version2 && npm run replace:async:version3 && npm run replace:expansion:version2 && npm run replace:expansion:version3 && npm run replace:ordering:version2 && npm run replace:ordering:version3 && npm run replace:groupMember:version2 && npm run replace:workflowPaginated:version2 && npm run replace:attachment:serviceDesk",
"replace:permissions:version2": "grep -rl \"(#permissions)\" ./src/version2 | xargs sed -i '' 's/(#permissions)/(https:\\/\\/developer.atlassian.com\\/cloud\\/jira\\/platform\\/rest\\/v2\\/intro\\/#permissions)/g'",
"replace:permissions:version3": "grep -rl \"(#permissions)\" ./src/version3 | xargs sed -i '' 's/(#permissions)/(https:\\/\\/developer.atlassian.com\\/cloud\\/jira\\/platform\\/rest\\/v3\\/intro\\/#permissions)/g'",
"replace:pagination:version2": "grep -rl \"(#pagination)\" ./src/version2 | xargs sed -i '' 's/(#pagination)/(https:\\/\\/developer.atlassian.com\\/cloud\\/jira\\/platform\\/rest\\/v2\\/intro\\/#pagination)/g'",
Expand All @@ -50,13 +50,14 @@
"replace:ordering:version3": "grep -rl \"(#ordering)\" ./src/version3 | xargs sed -i '' 's/(#ordering)/(https:\\/\\/developer.atlassian.com\\/cloud\\/jira\\/platform\\/rest\\/v3\\/intro\\/#ordering)/g'",
"replace:groupMember:version2": "grep -rl \"(#api-rest-api-2-group-member-get)\" ./src/version2 | xargs sed -i '' 's/(#api-rest-api-2-group-member-get)/(https:\\/\\/developer.atlassian.com\\/cloud\\/jira\\/platform\\/rest\\/v2\\/api-group-groups\\/#api-rest-api-2-group-member-get)/g'",
"replace:workflowPaginated:version2": "grep -rl \"(#api-rest-api-2-workflow-search-get)\" ./src/version2 | xargs sed -i '' 's/(#api-rest-api-2-workflow-search-get)/(https:\\/\\/developer.atlassian.com\\/cloud\\/jira\\/platform\\/rest\\/v2\\/api-group-workflows\\/#api-rest-api-2-workflow-search-get)/g'",
"replace:attachment:serviceDesk": "grep -rl \"(#api-request-issueIdOrKey-attachment-post)\" ./src/serviceDesk | xargs sed -i '' 's/(#api-request-issueIdOrKey-attachment-post)/(https:\\/\\/developer.atlassian.com\\/cloud\\/jira\\/service-desk\\/rest\\/api-group-servicedesk\\/#api-rest-servicedeskapi-servicedesk-servicedeskid-attachtemporaryfile-post)/g'",
"code:formatting": "npm run replace:all && npm run prettier && npm run lint:fix"
},
"devDependencies": {
"@types/node": "^18.19.70",
"@types/sinon": "^17.0.3",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"dotenv": "^16.4.7",
"eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0",
Expand All @@ -66,7 +67,7 @@
"prettier-plugin-jsdoc": "^1.3.2",
"sinon": "^18.0.1",
"typedoc": "^0.27.6",
"typescript": "^5.7.2",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
},
Expand Down
4 changes: 3 additions & 1 deletion src/serviceDesk/parameters/attachTemporaryFile.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Readable } from 'node:stream';

/**
* Represents an attachment to be temporarily attached to a Service Desk.
*
Expand Down Expand Up @@ -35,7 +37,7 @@ export interface Attachment {
* const fileContent = Buffer.from('Example content here');
* ```
*/
file: Buffer | ReadableStream | string | Blob | File;
file: Buffer | ReadableStream | Readable | string | Blob | File;

/**
* Optional MIME type of the attachment. Example values include:
Expand Down
98 changes: 90 additions & 8 deletions src/serviceDesk/serviceDesk.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FormData, File } from 'formdata-node';
import type { Mime } from 'mime' with { 'resolution-mode': 'import' };
import * as Models from './models';
import * as Parameters from './parameters';
import { Callback } from '../callback';
Expand Down Expand Up @@ -85,7 +86,7 @@ export class ServiceDesk {
/**
* This method adds one or more temporary attachments to a service desk, which can then be permanently attached to a
* customer request using
* [servicedeskapi/request/{issueIdOrKey}/attachment](#api-request-issueIdOrKey-attachment-post).
* [servicedeskapi/request/{issueIdOrKey}/attachment](https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-attachtemporaryfile-post).
*
* **Note**: It is possible for a service desk administrator to turn off the ability to add attachments to a service
* desk.
Expand All @@ -100,7 +101,7 @@ export class ServiceDesk {
/**
* This method adds one or more temporary attachments to a service desk, which can then be permanently attached to a
* customer request using
* [servicedeskapi/request/{issueIdOrKey}/attachment](#api-request-issueIdOrKey-attachment-post).
* [servicedeskapi/request/{issueIdOrKey}/attachment](https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-servicedesk/#api-rest-servicedeskapi-servicedesk-servicedeskid-attachtemporaryfile-post).
*
* **Note**: It is possible for a service desk administrator to turn off the ability to add attachments to a service
* desk.
Expand All @@ -118,14 +119,24 @@ export class ServiceDesk {

const { default: mime } = await import('mime');

attachments.forEach(attachment => {
const mimeType = attachment.mimeType ?? (mime.getType(attachment.filename) || undefined);
const file = Buffer.isBuffer(attachment.file)
? new File([attachment.file], attachment.filename, { type: mimeType })
: attachment.file;
let Readable: typeof import('stream').Readable | undefined;

if (typeof window === 'undefined') {
const { Readable: NodeReadable } = await import('stream');

Readable = NodeReadable;
}

// eslint-disable-next-line no-restricted-syntax
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);
});
}

const config: RequestConfig = {
url: `/rest/servicedeskapi/servicedesk/${parameters.serviceDeskId}/attachTemporaryFile`,
Expand Down Expand Up @@ -808,4 +819,75 @@ export class ServiceDesk {

return this.client.sendRequest(config, callback);
}

private async _convertToFile(
attachment: Parameters.Attachment,
mime: Mime,
Readable?: typeof import('stream').Readable,
): Promise<File | Blob> {
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(
stream: import('stream').Readable | ReadableStream,
filename: string,
mimeType?: string,
): Promise<File> {
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) {
// eslint-disable-next-line no-await-in-loop
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.');
}
}
106 changes: 88 additions & 18 deletions src/version2/issueAttachments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FormData, File } from 'formdata-node';
import type { Mime } from 'mime' with { 'resolution-mode': 'import' };
import * as Models from './models';
import * as Parameters from './parameters';
import { Callback } from '../callback';
Expand Down Expand Up @@ -379,12 +380,6 @@ export class IssueAttachments {
* Adds one or more attachments to an issue. Attachments are posted as multipart/form-data ([RFC
* 1867](https://www.ietf.org/rfc/rfc1867.txt)).
*
* Note that:
*
* - The request must have a `X-Atlassian-Token: no-check` header, if not it is blocked. See [Special
* headers](#special-request-headers) for more information.
* - The name of the multipart/form-data parameter that contains the attachments must be `file`.
*
* This operation can be accessed anonymously.
*
* **[Permissions](https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/#permissions) required:**
Expand All @@ -402,12 +397,6 @@ export class IssueAttachments {
* Adds one or more attachments to an issue. Attachments are posted as multipart/form-data ([RFC
* 1867](https://www.ietf.org/rfc/rfc1867.txt)).
*
* Note that:
*
* - The request must have a `X-Atlassian-Token: no-check` header, if not it is blocked. See [Special
* headers](#special-request-headers) for more information.
* - The name of the multipart/form-data parameter that contains the attachments must be `file`.
*
* This operation can be accessed anonymously.
*
* **[Permissions](https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/#permissions) required:**
Expand All @@ -427,14 +416,24 @@ export class IssueAttachments {

const { default: mime } = await import('mime');

attachments.forEach(attachment => {
const mimeType = attachment.mimeType ?? (mime.getType(attachment.filename) || undefined);
const file = Buffer.isBuffer(attachment.file)
? new File([attachment.file], attachment.filename, { type: mimeType })
: attachment.file;
let Readable: typeof import('stream').Readable | undefined;

if (typeof window === 'undefined') {
const { Readable: NodeReadable } = await import('stream');

Readable = NodeReadable;
}

// eslint-disable-next-line no-restricted-syntax
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);
});
}

const config: RequestConfig = {
url: `/rest/api/2/issue/${parameters.issueIdOrKey}/attachments`,
Expand All @@ -450,4 +449,75 @@ export class IssueAttachments {

return this.client.sendRequest(config, callback);
}

private async _convertToFile(
attachment: Parameters.Attachment,
mime: Mime,
Readable?: typeof import('stream').Readable,
): Promise<File | Blob> {
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(
stream: import('stream').Readable | ReadableStream,
filename: string,
mimeType?: string,
): Promise<File> {
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) {
// eslint-disable-next-line no-await-in-loop
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.');
}
}
4 changes: 3 additions & 1 deletion src/version2/parameters/addAttachment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Readable } from 'node:stream';

/**
* Represents an attachment to be added to an issue.
*
Expand Down Expand Up @@ -35,7 +37,7 @@ export interface Attachment {
* const fileContent = fs.readFileSync('./document.pdf');
* ```
*/
file: Buffer | ReadableStream | string | Blob | File;
file: Buffer | ReadableStream | Readable | string | Blob | File;

/**
* Optional MIME type of the attachment. Example values include:
Expand Down
Loading

0 comments on commit 99e340e

Please sign in to comment.