mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-09 16:13:31 +01:00
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 <github@almeidx.dev> * chore: requested change * chore: requested change --------- Co-authored-by: Almeida <github@almeidx.dev>
This commit is contained in:
committed by
GitHub
parent
315f422781
commit
68bb8af58a
55
packages/builders/__tests__/messages/fileBody.test.ts
Normal file
55
packages/builders/__tests__/messages/fileBody.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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\[(?<placeholder>\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),
|
||||
});
|
||||
|
||||
@@ -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<RESTAPIAttachment> {
|
||||
*/
|
||||
private readonly data: Partial<RESTAPIAttachment>;
|
||||
|
||||
/**
|
||||
* 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<Pick<RawFile, 'contentType' | 'data'>>;
|
||||
|
||||
/**
|
||||
* Creates a new attachment builder.
|
||||
*
|
||||
@@ -19,6 +31,7 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
|
||||
*/
|
||||
public constructor(data: Partial<RESTAPIAttachment> = {}) {
|
||||
this.data = structuredClone(data);
|
||||
this.fileData = {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +39,7 @@ export class AttachmentBuilder implements JSONEncodable<RESTAPIAttachment> {
|
||||
*
|
||||
* @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<RESTAPIAttachment> {
|
||||
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<RawFile> | 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.
|
||||
*
|
||||
|
||||
@@ -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<RESTPostAPIChannelMessageJSONBody> {
|
||||
export class MessageBuilder
|
||||
implements JSONEncodable<RESTPostAPIChannelMessageJSONBody>, FileBodyEncodable<RESTPostAPIChannelMessageJSONBody>
|
||||
{
|
||||
/**
|
||||
* The API data associated with this message.
|
||||
*/
|
||||
@@ -661,4 +663,31 @@ export class MessageBuilder implements JSONEncodable<RESTPostAPIChannelMessageJS
|
||||
|
||||
return data as RESTPostAPIChannelMessageJSONBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes this builder to both JSON body and file data for multipart/form-data requests.
|
||||
*
|
||||
* @param validationOverride - Force validation to run/not run regardless of your global preference
|
||||
* @remarks
|
||||
* This method extracts file data from attachments that have files set via {@link AttachmentBuilder.setFileData}.
|
||||
* The returned body includes attachment metadata, while files contains the binary data for upload.
|
||||
*/
|
||||
public toFileBody(validationOverride?: boolean): FileBodyEncodableResult<RESTPostAPIChannelMessageJSONBody> {
|
||||
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<RESTPostAPIChannelMessageJSONBody>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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<Value> {
|
||||
/**
|
||||
* 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<unknown> {
|
||||
return maybeEncodable !== null && typeof maybeEncodable === 'object' && 'toJSON' in maybeEncodable;
|
||||
}
|
||||
34
packages/util/src/RawFile.ts
Normal file
34
packages/util/src/RawFile.ts
Normal file
@@ -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;
|
||||
}
|
||||
61
packages/util/src/encodables.ts
Normal file
61
packages/util/src/encodables.ts
Normal file
@@ -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<Value> {
|
||||
/**
|
||||
* 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<unknown> {
|
||||
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<BodyValue> {
|
||||
/**
|
||||
* 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<BodyValue> {
|
||||
/**
|
||||
* Transforms this object to its file body format, separating the JSON body from file attachments.
|
||||
*/
|
||||
toFileBody(): FileBodyEncodableResult<BodyValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<unknown> {
|
||||
return maybeEncodable !== null && typeof maybeEncodable === 'object' && 'toFileBody' in maybeEncodable;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user