From 68bb8af58ad8720abb998dcaa297f89d3317ffa8 Mon Sep 17 00:00:00 2001 From: Denis-Adrian Cristea Date: Wed, 19 Nov 2025 23:59:51 +0200 Subject: [PATCH] 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 * chore: requested change * chore: requested change --------- Co-authored-by: Almeida --- .../__tests__/messages/fileBody.test.ts | 55 ++++++++++++++ packages/builders/src/index.ts | 3 + packages/builders/src/messages/Assertions.ts | 19 +++++ packages/builders/src/messages/Attachment.ts | 71 ++++++++++++++++++- packages/builders/src/messages/Message.ts | 35 ++++++++- packages/rest/src/lib/utils/types.ts | 26 +------ packages/util/src/JSONEncodable.ts | 20 ------ packages/util/src/RawFile.ts | 34 +++++++++ packages/util/src/encodables.ts | 61 ++++++++++++++++ packages/util/src/index.ts | 3 +- 10 files changed, 277 insertions(+), 50 deletions(-) create mode 100644 packages/builders/__tests__/messages/fileBody.test.ts delete mode 100644 packages/util/src/JSONEncodable.ts create mode 100644 packages/util/src/RawFile.ts create mode 100644 packages/util/src/encodables.ts diff --git a/packages/builders/__tests__/messages/fileBody.test.ts b/packages/builders/__tests__/messages/fileBody.test.ts new file mode 100644 index 000000000..2759ce610 --- /dev/null +++ b/packages/builders/__tests__/messages/fileBody.test.ts @@ -0,0 +1,55 @@ +import { Buffer } from 'node:buffer'; +import type { RawFile } from '@discordjs/util'; +import { test, expect } from 'vitest'; +import { AttachmentBuilder, MessageBuilder } from '../../src/index.js'; + +test('AttachmentBuilder stores and exposes file data', () => { + const data = Buffer.from('hello world'); + const attachment = new AttachmentBuilder() + .setId('0') + .setFilename('greeting.txt') + .setFileData(data) + .setFileContentType('text/plain'); + + expect(attachment.getRawFile()).toStrictEqual({ + contentType: 'text/plain', + data, + key: 'files[0]', + name: 'greeting.txt', + }); + + attachment.clearFileData(); + attachment.clearFileContentType(); + attachment.clearFilename(); + expect(attachment.getRawFile()).toBe(undefined); +}); + +test('MessageBuilder.toFileBody returns JSON body and files', () => { + const msg = new MessageBuilder().setContent('here is a file').addAttachments( + new AttachmentBuilder() + .setId('0') + .setFilename('file.bin') + .setFileData(Buffer.from([1, 2, 3])) + .setFileContentType('application/octet-stream'), + ); + + const { body, files } = msg.toFileBody(); + + // body should match toJSON() + expect(body).toStrictEqual(msg.toJSON()); + + // files should contain the uploaded file + expect(files).toHaveLength(1); + const [fileEntry] = files as [RawFile]; + expect(fileEntry.name).toBe('file.bin'); + expect(fileEntry.contentType).toBe('application/octet-stream'); + expect(fileEntry.data).toBeDefined(); +}); + +test('MessageBuilder.toFileBody returns empty files when attachments reference existing uploads', () => { + const msg = new MessageBuilder().addAttachments(new AttachmentBuilder().setId('123').setFilename('existing.png')); + + const { body, files } = msg.toFileBody(); + expect(body).toEqual(msg.toJSON()); + expect(files.length).toBe(0); +}); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 7a99a3e56..30dfe6ba4 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -97,6 +97,9 @@ export * from './util/ValidationError.js'; export * from './Assertions.js'; +// We expose this type in our public API. We shouldn't assume every user of builders is also using REST +export type { RawFile } from '@discordjs/util'; + /** * The {@link https://github.com/discordjs/discord.js/blob/main/packages/builders#readme | @discordjs/builders} version * that you are currently using. diff --git a/packages/builders/src/messages/Assertions.ts b/packages/builders/src/messages/Assertions.ts index 68e9d079e..32f97512e 100644 --- a/packages/builders/src/messages/Assertions.ts +++ b/packages/builders/src/messages/Assertions.ts @@ -1,9 +1,20 @@ +import { Buffer } from 'node:buffer'; import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10'; import { z } from 'zod'; import { embedPredicate } from './embed/Assertions.js'; import { pollPredicate } from './poll/Assertions.js'; +const fileKeyRegex = /^files\[(?\d+?)]$/; + +export const rawFilePredicate = z.object({ + data: z.union([z.instanceof(Buffer), z.instanceof(Uint8Array), z.string()]), + name: z.string().min(1), + contentType: z.string().optional(), + key: z.string().regex(fileKeyRegex).optional(), +}); + export const attachmentPredicate = z.object({ + // As a string it only makes sense for edits when we do have an attachment snowflake id: z.union([z.string(), z.number()]), description: z.string().max(1_024).optional(), duration_secs: z @@ -125,3 +136,11 @@ const messageComponentsV2Predicate = baseMessagePredicate.extend({ }); export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]); + +// This validator does not assert file.key <-> attachment.id coherence. This is fine, because the builders +// should effectively guarantee that. +export const fileBodyMessagePredicate = z.object({ + body: messagePredicate, + // No min length to support message edits + files: rawFilePredicate.array().max(10), +}); diff --git a/packages/builders/src/messages/Attachment.ts b/packages/builders/src/messages/Attachment.ts index a6cdb58b7..ba631a539 100644 --- a/packages/builders/src/messages/Attachment.ts +++ b/packages/builders/src/messages/Attachment.ts @@ -1,4 +1,5 @@ -import type { JSONEncodable } from '@discordjs/util'; +import type { Buffer } from 'node:buffer'; +import type { JSONEncodable, RawFile } from '@discordjs/util'; import type { RESTAPIAttachment, Snowflake } from 'discord-api-types/v10'; import { validate } from '../util/validation.js'; import { attachmentPredicate } from './Assertions.js'; @@ -12,6 +13,17 @@ export class AttachmentBuilder implements JSONEncodable { */ private readonly data: Partial; + /** + * This data is not included in the output of `toJSON()`. For this class specifically, this refers to binary data + * that will wind up being included in the multipart/form-data request, if used with the `MessageBuilder`. + * To retrieve this data, use {@link getRawFile}. + * + * @remarks This cannot be set via the constructor, primarily because of the behavior described + * {@link https://discord.com/developers/docs/reference#editing-message-attachments | here}. + * That is, when editing a message's attachments, you should only be providing file data for new attachments. + */ + private readonly fileData: Partial>; + /** * Creates a new attachment builder. * @@ -19,6 +31,7 @@ export class AttachmentBuilder implements JSONEncodable { */ public constructor(data: Partial = {}) { this.data = structuredClone(data); + this.fileData = {}; } /** @@ -26,7 +39,7 @@ export class AttachmentBuilder implements JSONEncodable { * * @param id - The id of the attachment */ - public setId(id: Snowflake): this { + public setId(id: Snowflake | number): this { this.data.id = id; return this; } @@ -85,6 +98,60 @@ export class AttachmentBuilder implements JSONEncodable { return this; } + /** + * Sets the file data to upload with this attachment. + * + * @param data - The file data + * @remarks Note that this data is NOT included in the {@link toJSON} output. To retrieve it, use {@link getRawFile}. + */ + public setFileData(data: Buffer | Uint8Array | string): this { + this.fileData.data = data; + return this; + } + + /** + * Clears the file data from this attachment. + */ + public clearFileData(): this { + this.fileData.data = undefined; + return this; + } + + /** + * Sets the content type of the file data to upload with this attachment. + * + * @remarks Note that this data is NOT included in the {@link toJSON} output. To retrieve it, use {@link getRawFile}. + */ + public setFileContentType(contentType: string): this { + this.fileData.contentType = contentType; + return this; + } + + /** + * Clears the content type of the file data from this attachment. + */ + public clearFileContentType(): this { + this.fileData.contentType = undefined; + return this; + } + + /** + * Converts this attachment to a {@link RawFile} for uploading. + * + * @returns A {@link RawFile} object, or `undefined` if no file data is set + */ + public getRawFile(): Partial | undefined { + if (!this.fileData?.data) { + return; + } + + return { + ...this.fileData, + name: this.data.filename, + key: this.data.id ? `files[${this.data.id}]` : undefined, + }; + } + /** * Sets the title of this attachment. * diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts index 1c7521e8a..85a5bed7c 100644 --- a/packages/builders/src/messages/Message.ts +++ b/packages/builders/src/messages/Message.ts @@ -1,4 +1,4 @@ -import type { JSONEncodable } from '@discordjs/util'; +import type { FileBodyEncodable, FileBodyEncodableResult, JSONEncodable, RawFile } from '@discordjs/util'; import type { APIActionRowComponent, APIAllowedMentions, @@ -32,7 +32,7 @@ import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; import { resolveBuilder } from '../util/resolveBuilder.js'; import { validate } from '../util/validation.js'; import { AllowedMentionsBuilder } from './AllowedMentions.js'; -import { messagePredicate } from './Assertions.js'; +import { fileBodyMessagePredicate, messagePredicate } from './Assertions.js'; import { AttachmentBuilder } from './Attachment.js'; import { MessageReferenceBuilder } from './MessageReference.js'; import { EmbedBuilder } from './embed/Embed.js'; @@ -56,7 +56,9 @@ export interface MessageBuilderData /** * A builder that creates API-compatible JSON data for messages. */ -export class MessageBuilder implements JSONEncodable { +export class MessageBuilder + implements JSONEncodable, FileBodyEncodable +{ /** * The API data associated with this message. */ @@ -661,4 +663,31 @@ export class MessageBuilder implements JSONEncodable { + const body = this.toJSON(false); + + const files: RawFile[] = []; + for (const attachment of this.data.attachments) { + const rawFile = attachment.getRawFile(); + // Only if data or content type are set, since that implies the intent is to send a new file. + // In case it's contentType but not data, a validation error will be thrown right after. + if (rawFile?.data || rawFile?.contentType) { + files.push(rawFile as RawFile); + } + } + + const combined = { body, files }; + validate(fileBodyMessagePredicate, combined, validationOverride); + + return combined as FileBodyEncodableResult; + } } diff --git a/packages/rest/src/lib/utils/types.ts b/packages/rest/src/lib/utils/types.ts index a7bcf5759..fa29be362 100644 --- a/packages/rest/src/lib/utils/types.ts +++ b/packages/rest/src/lib/utils/types.ts @@ -1,7 +1,7 @@ import type { Readable } from 'node:stream'; import type { ReadableStream } from 'node:stream/web'; import type { Collection } from '@discordjs/collection'; -import type { Awaitable } from '@discordjs/util'; +import type { Awaitable, RawFile } from '@discordjs/util'; import type { Agent, Dispatcher, RequestInit, BodyInit, Response } from 'undici'; import type { IHandler } from '../interfaces/Handler.js'; @@ -276,29 +276,7 @@ export interface InvalidRequestWarningData { remainingTime: number; } -/** - * Represents a file to be added to the request - */ -export interface RawFile { - /** - * Content-Type of the file - */ - contentType?: string; - /** - * The actual data for the file - */ - data: Buffer | Uint8Array | boolean | number | string; - /** - * An explicit key to use for key of the formdata field for this file. - * When not provided, the index of the file in the files array is used in the form `files[${index}]`. - * If you wish to alter the placeholder snowflake, you must provide this property in the same form (`files[${placeholder}]`) - */ - key?: string; - /** - * The name of the file - */ - name: string; -} +export type { RawFile } from '@discordjs/util'; export interface AuthData { /** diff --git a/packages/util/src/JSONEncodable.ts b/packages/util/src/JSONEncodable.ts deleted file mode 100644 index 5edfe976e..000000000 --- a/packages/util/src/JSONEncodable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Represents an object capable of representing itself as a JSON object - * - * @typeParam Value - The JSON type corresponding to {@link JSONEncodable.toJSON} outputs. - */ -export interface JSONEncodable { - /** - * Transforms this object to its JSON format - */ - toJSON(): Value; -} - -/** - * Indicates if an object is encodable or not. - * - * @param maybeEncodable - The object to check against - */ -export function isJSONEncodable(maybeEncodable: unknown): maybeEncodable is JSONEncodable { - return maybeEncodable !== null && typeof maybeEncodable === 'object' && 'toJSON' in maybeEncodable; -} diff --git a/packages/util/src/RawFile.ts b/packages/util/src/RawFile.ts new file mode 100644 index 000000000..d8a43a8f4 --- /dev/null +++ b/packages/util/src/RawFile.ts @@ -0,0 +1,34 @@ +import type { Buffer } from 'node:buffer'; + +/** + * Represents a file to be added to a request with multipart/form-data encoding + */ +export interface RawFile { + /** + * Content-Type of the file. + * If not provided, it will be inferred from the file data when possible + * + * @example 'image/png' + * @example 'application/pdf' + */ + contentType?: string; + /** + * The actual data for the file + */ + data: Buffer | Uint8Array | boolean | number | string; + /** + * An explicit key to use for the formdata field for this file. + * When not provided, the index of the file in the files array is used in the form `files[${index}]`. + * If you wish to alter the placeholder snowflake, you must provide this property in the same form (`files[${placeholder}]`) + */ + key?: string; + /** + * The name of the file. This is the actual filename that will be used when uploading to Discord. + * This is also the name you'll use to reference the file with attachment:// URLs. + * + * @example 'image.png' + * @example 'document.pdf' + * @example 'SPOILER_secret.jpeg' + */ + name: string; +} diff --git a/packages/util/src/encodables.ts b/packages/util/src/encodables.ts new file mode 100644 index 000000000..392d7b440 --- /dev/null +++ b/packages/util/src/encodables.ts @@ -0,0 +1,61 @@ +import type { RawFile } from './RawFile.js'; + +/** + * Represents an object capable of representing itself as a JSON object + * + * @typeParam Value - The JSON type corresponding to {@link JSONEncodable.toJSON} outputs. + */ +export interface JSONEncodable { + /** + * Transforms this object to its JSON format + */ + toJSON(): Value; +} + +/** + * Indicates if an object is encodable or not. + * + * @param maybeEncodable - The object to check against + */ +export function isJSONEncodable(maybeEncodable: unknown): maybeEncodable is JSONEncodable { + return maybeEncodable !== null && typeof maybeEncodable === 'object' && 'toJSON' in maybeEncodable; +} + +/** + * Result of encoding an object that includes file attachments + * + * @typeParam BodyValue - The JSON body type + */ +export interface FileBodyEncodableResult { + /** + * The JSON body to send with the request + */ + body: BodyValue; + /** + * The files to attach to the request + */ + files: RawFile[]; +} + +/** + * Represents an object capable of representing itself as a request body with file attachments. + * Objects implementing this interface can separate JSON body data from binary file data, + * which is necessary for multipart/form-data requests. + * + * @typeParam BodyValue - The JSON body type + */ +export interface FileBodyEncodable { + /** + * Transforms this object to its file body format, separating the JSON body from file attachments. + */ + toFileBody(): FileBodyEncodableResult; +} + +/** + * Indicates if an object is file body encodable or not. + * + * @param maybeEncodable - The object to check against + */ +export function isFileBodyEncodable(maybeEncodable: unknown): maybeEncodable is FileBodyEncodable { + return maybeEncodable !== null && typeof maybeEncodable === 'object' && 'toFileBody' in maybeEncodable; +} diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 53429877f..6e1062188 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -1,6 +1,7 @@ export type * from './types.js'; export * from './functions/index.js'; -export * from './JSONEncodable.js'; +export * from './encodables.js'; +export type * from './RawFile.js'; export * from './Equatable.js'; export * from './gatewayRateLimitError.js';