Skip to content

Commit 68bb8af

Browse files
didinelealmeidx
andauthored
feat(builders): multipart form data output support (#11248)
* feat(builders): multipart form data output support * refactor: proper key management * chore: add missing remarks * chore: requested changes * chore: rename encodables file * chore: requested changes * chore: requested changes * chore: nits Co-authored-by: Almeida <[email protected]> * chore: requested change * chore: requested change --------- Co-authored-by: Almeida <[email protected]>
1 parent 315f422 commit 68bb8af

File tree

10 files changed

+277
-50
lines changed

10 files changed

+277
-50
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Buffer } from 'node:buffer';
2+
import type { RawFile } from '@discordjs/util';
3+
import { test, expect } from 'vitest';
4+
import { AttachmentBuilder, MessageBuilder } from '../../src/index.js';
5+
6+
test('AttachmentBuilder stores and exposes file data', () => {
7+
const data = Buffer.from('hello world');
8+
const attachment = new AttachmentBuilder()
9+
.setId('0')
10+
.setFilename('greeting.txt')
11+
.setFileData(data)
12+
.setFileContentType('text/plain');
13+
14+
expect(attachment.getRawFile()).toStrictEqual({
15+
contentType: 'text/plain',
16+
data,
17+
key: 'files[0]',
18+
name: 'greeting.txt',
19+
});
20+
21+
attachment.clearFileData();
22+
attachment.clearFileContentType();
23+
attachment.clearFilename();
24+
expect(attachment.getRawFile()).toBe(undefined);
25+
});
26+
27+
test('MessageBuilder.toFileBody returns JSON body and files', () => {
28+
const msg = new MessageBuilder().setContent('here is a file').addAttachments(
29+
new AttachmentBuilder()
30+
.setId('0')
31+
.setFilename('file.bin')
32+
.setFileData(Buffer.from([1, 2, 3]))
33+
.setFileContentType('application/octet-stream'),
34+
);
35+
36+
const { body, files } = msg.toFileBody();
37+
38+
// body should match toJSON()
39+
expect(body).toStrictEqual(msg.toJSON());
40+
41+
// files should contain the uploaded file
42+
expect(files).toHaveLength(1);
43+
const [fileEntry] = files as [RawFile];
44+
expect(fileEntry.name).toBe('file.bin');
45+
expect(fileEntry.contentType).toBe('application/octet-stream');
46+
expect(fileEntry.data).toBeDefined();
47+
});
48+
49+
test('MessageBuilder.toFileBody returns empty files when attachments reference existing uploads', () => {
50+
const msg = new MessageBuilder().addAttachments(new AttachmentBuilder().setId('123').setFilename('existing.png'));
51+
52+
const { body, files } = msg.toFileBody();
53+
expect(body).toEqual(msg.toJSON());
54+
expect(files.length).toBe(0);
55+
});

packages/builders/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ export * from './util/ValidationError.js';
9797

9898
export * from './Assertions.js';
9999

100+
// We expose this type in our public API. We shouldn't assume every user of builders is also using REST
101+
export type { RawFile } from '@discordjs/util';
102+
100103
/**
101104
* The {@link https://github.com/discordjs/discord.js/blob/main/packages/builders#readme | @discordjs/builders} version
102105
* that you are currently using.

packages/builders/src/messages/Assertions.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { Buffer } from 'node:buffer';
12
import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
23
import { z } from 'zod';
34
import { embedPredicate } from './embed/Assertions.js';
45
import { pollPredicate } from './poll/Assertions.js';
56

7+
const fileKeyRegex = /^files\[(?<placeholder>\d+?)]$/;
8+
9+
export const rawFilePredicate = z.object({
10+
data: z.union([z.instanceof(Buffer), z.instanceof(Uint8Array), z.string()]),
11+
name: z.string().min(1),
12+
contentType: z.string().optional(),
13+
key: z.string().regex(fileKeyRegex).optional(),
14+
});
15+
616
export const attachmentPredicate = z.object({
17+
// As a string it only makes sense for edits when we do have an attachment snowflake
718
id: z.union([z.string(), z.number()]),
819
description: z.string().max(1_024).optional(),
920
duration_secs: z
@@ -125,3 +136,11 @@ const messageComponentsV2Predicate = baseMessagePredicate.extend({
125136
});
126137

127138
export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]);
139+
140+
// This validator does not assert file.key <-> attachment.id coherence. This is fine, because the builders
141+
// should effectively guarantee that.
142+
export const fileBodyMessagePredicate = z.object({
143+
body: messagePredicate,
144+
// No min length to support message edits
145+
files: rawFilePredicate.array().max(10),
146+
});

packages/builders/src/messages/Attachment.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { JSONEncodable } from '@discordjs/util';
1+
import type { Buffer } from 'node:buffer';
2+
import type { JSONEncodable, RawFile } from '@discordjs/util';
23
import type { RESTAPIAttachment, Snowflake } from 'discord-api-types/v10';
34
import { validate } from '../util/validation.js';
45
import { attachmentPredicate } from './Assertions.js';
@@ -12,21 +13,33 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
1213
*/
1314
private readonly data: Partial<RESTAPIAttachment>;
1415

16+
/**
17+
* This data is not included in the output of `toJSON()`. For this class specifically, this refers to binary data
18+
* that will wind up being included in the multipart/form-data request, if used with the `MessageBuilder`.
19+
* To retrieve this data, use {@link getRawFile}.
20+
*
21+
* @remarks This cannot be set via the constructor, primarily because of the behavior described
22+
* {@link https://discord.com/developers/docs/reference#editing-message-attachments | here}.
23+
* That is, when editing a message's attachments, you should only be providing file data for new attachments.
24+
*/
25+
private readonly fileData: Partial<Pick<RawFile, 'contentType' | 'data'>>;
26+
1527
/**
1628
* Creates a new attachment builder.
1729
*
1830
* @param data - The API data to create this attachment with
1931
*/
2032
public constructor(data: Partial<RESTAPIAttachment> = {}) {
2133
this.data = structuredClone(data);
34+
this.fileData = {};
2235
}
2336

2437
/**
2538
* Sets the id of the attachment.
2639
*
2740
* @param id - The id of the attachment
2841
*/
29-
public setId(id: Snowflake): this {
42+
public setId(id: Snowflake | number): this {
3043
this.data.id = id;
3144
return this;
3245
}
@@ -85,6 +98,60 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
8598
return this;
8699
}
87100

101+
/**
102+
* Sets the file data to upload with this attachment.
103+
*
104+
* @param data - The file data
105+
* @remarks Note that this data is NOT included in the {@link toJSON} output. To retrieve it, use {@link getRawFile}.
106+
*/
107+
public setFileData(data: Buffer | Uint8Array | string): this {
108+
this.fileData.data = data;
109+
return this;
110+
}
111+
112+
/**
113+
* Clears the file data from this attachment.
114+
*/
115+
public clearFileData(): this {
116+
this.fileData.data = undefined;
117+
return this;
118+
}
119+
120+
/**
121+
* Sets the content type of the file data to upload with this attachment.
122+
*
123+
* @remarks Note that this data is NOT included in the {@link toJSON} output. To retrieve it, use {@link getRawFile}.
124+
*/
125+
public setFileContentType(contentType: string): this {
126+
this.fileData.contentType = contentType;
127+
return this;
128+
}
129+
130+
/**
131+
* Clears the content type of the file data from this attachment.
132+
*/
133+
public clearFileContentType(): this {
134+
this.fileData.contentType = undefined;
135+
return this;
136+
}
137+
138+
/**
139+
* Converts this attachment to a {@link RawFile} for uploading.
140+
*
141+
* @returns A {@link RawFile} object, or `undefined` if no file data is set
142+
*/
143+
public getRawFile(): Partial<RawFile> | undefined {
144+
if (!this.fileData?.data) {
145+
return;
146+
}
147+
148+
return {
149+
...this.fileData,
150+
name: this.data.filename,
151+
key: this.data.id ? `files[${this.data.id}]` : undefined,
152+
};
153+
}
154+
88155
/**
89156
* Sets the title of this attachment.
90157
*

packages/builders/src/messages/Message.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { JSONEncodable } from '@discordjs/util';
1+
import type { FileBodyEncodable, FileBodyEncodableResult, JSONEncodable, RawFile } from '@discordjs/util';
22
import type {
33
APIActionRowComponent,
44
APIAllowedMentions,
@@ -32,7 +32,7 @@ import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
3232
import { resolveBuilder } from '../util/resolveBuilder.js';
3333
import { validate } from '../util/validation.js';
3434
import { AllowedMentionsBuilder } from './AllowedMentions.js';
35-
import { messagePredicate } from './Assertions.js';
35+
import { fileBodyMessagePredicate, messagePredicate } from './Assertions.js';
3636
import { AttachmentBuilder } from './Attachment.js';
3737
import { MessageReferenceBuilder } from './MessageReference.js';
3838
import { EmbedBuilder } from './embed/Embed.js';
@@ -56,7 +56,9 @@ export interface MessageBuilderData
5656
/**
5757
* A builder that creates API-compatible JSON data for messages.
5858
*/
59-
export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJSONBody> {
59+
export class MessageBuilder
60+
implements JSONEncodable<RESTPostAPIChannelMessageJSONBody>, FileBodyEncodable<RESTPostAPIChannelMessageJSONBody>
61+
{
6062
/**
6163
* The API data associated with this message.
6264
*/
@@ -661,4 +663,31 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
661663

662664
return data as RESTPostAPIChannelMessageJSONBody;
663665
}
666+
667+
/**
668+
* Serializes this builder to both JSON body and file data for multipart/form-data requests.
669+
*
670+
* @param validationOverride - Force validation to run/not run regardless of your global preference
671+
* @remarks
672+
* This method extracts file data from attachments that have files set via {@link AttachmentBuilder.setFileData}.
673+
* The returned body includes attachment metadata, while files contains the binary data for upload.
674+
*/
675+
public toFileBody(validationOverride?: boolean): FileBodyEncodableResult<RESTPostAPIChannelMessageJSONBody> {
676+
const body = this.toJSON(false);
677+
678+
const files: RawFile[] = [];
679+
for (const attachment of this.data.attachments) {
680+
const rawFile = attachment.getRawFile();
681+
// Only if data or content type are set, since that implies the intent is to send a new file.
682+
// In case it's contentType but not data, a validation error will be thrown right after.
683+
if (rawFile?.data || rawFile?.contentType) {
684+
files.push(rawFile as RawFile);
685+
}
686+
}
687+
688+
const combined = { body, files };
689+
validate(fileBodyMessagePredicate, combined, validationOverride);
690+
691+
return combined as FileBodyEncodableResult<RESTPostAPIChannelMessageJSONBody>;
692+
}
664693
}

packages/rest/src/lib/utils/types.ts

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Readable } from 'node:stream';
22
import type { ReadableStream } from 'node:stream/web';
33
import type { Collection } from '@discordjs/collection';
4-
import type { Awaitable } from '@discordjs/util';
4+
import type { Awaitable, RawFile } from '@discordjs/util';
55
import type { Agent, Dispatcher, RequestInit, BodyInit, Response } from 'undici';
66
import type { IHandler } from '../interfaces/Handler.js';
77

@@ -276,29 +276,7 @@ export interface InvalidRequestWarningData {
276276
remainingTime: number;
277277
}
278278

279-
/**
280-
* Represents a file to be added to the request
281-
*/
282-
export interface RawFile {
283-
/**
284-
* Content-Type of the file
285-
*/
286-
contentType?: string;
287-
/**
288-
* The actual data for the file
289-
*/
290-
data: Buffer | Uint8Array | boolean | number | string;
291-
/**
292-
* An explicit key to use for key of the formdata field for this file.
293-
* When not provided, the index of the file in the files array is used in the form `files[${index}]`.
294-
* If you wish to alter the placeholder snowflake, you must provide this property in the same form (`files[${placeholder}]`)
295-
*/
296-
key?: string;
297-
/**
298-
* The name of the file
299-
*/
300-
name: string;
301-
}
279+
export type { RawFile } from '@discordjs/util';
302280

303281
export interface AuthData {
304282
/**

packages/util/src/JSONEncodable.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

packages/util/src/RawFile.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Buffer } from 'node:buffer';
2+
3+
/**
4+
* Represents a file to be added to a request with multipart/form-data encoding
5+
*/
6+
export interface RawFile {
7+
/**
8+
* Content-Type of the file.
9+
* If not provided, it will be inferred from the file data when possible
10+
*
11+
* @example 'image/png'
12+
* @example 'application/pdf'
13+
*/
14+
contentType?: string;
15+
/**
16+
* The actual data for the file
17+
*/
18+
data: Buffer | Uint8Array | boolean | number | string;
19+
/**
20+
* An explicit key to use for the formdata field for this file.
21+
* When not provided, the index of the file in the files array is used in the form `files[${index}]`.
22+
* If you wish to alter the placeholder snowflake, you must provide this property in the same form (`files[${placeholder}]`)
23+
*/
24+
key?: string;
25+
/**
26+
* The name of the file. This is the actual filename that will be used when uploading to Discord.
27+
* This is also the name you'll use to reference the file with attachment:// URLs.
28+
*
29+
* @example 'image.png'
30+
* @example 'document.pdf'
31+
* @example 'SPOILER_secret.jpeg'
32+
*/
33+
name: string;
34+
}

0 commit comments

Comments
 (0)