diff --git a/packages/discord.js/src/client/Client.js b/packages/discord.js/src/client/Client.js index ca9a7d375..53d4044ef 100644 --- a/packages/discord.js/src/client/Client.js +++ b/packages/discord.js/src/client/Client.js @@ -2,6 +2,7 @@ const process = require('node:process'); const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { OAuth2Scopes, Routes } = require('discord-api-types/v10'); const BaseClient = require('./BaseClient'); const ActionsManager = require('./actions/ActionsManager'); @@ -277,13 +278,11 @@ class Client extends BaseClient { */ async fetchInvite(invite, options) { const code = DataResolver.resolveInviteCode(invite); - const query = new URLSearchParams({ + const query = makeURLSearchParams({ with_counts: true, with_expiration: true, + guild_scheduled_event_id: options?.guildScheduledEventId, }); - if (options?.guildScheduledEventId) { - query.set('guild_scheduled_event_id', options.guildScheduledEventId); - } const data = await this.rest.get(Routes.invite(code), { query }); return new Invite(this, data); } @@ -417,10 +416,6 @@ class Client extends BaseClient { if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); if (!this.application) throw new Error('CLIENT_NOT_READY', 'generate an invite link'); - const query = new URLSearchParams({ - client_id: this.application.id, - }); - const { scopes } = options; if (typeof scopes === 'undefined') { throw new TypeError('INVITE_MISSING_SCOPES'); @@ -436,15 +431,16 @@ class Client extends BaseClient { if (invalidScope) { throw new TypeError('INVALID_ELEMENT', 'Array', 'scopes', invalidScope); } - query.set('scope', scopes.join(' ')); + + const query = makeURLSearchParams({ + client_id: this.application.id, + scope: scopes.join(' '), + disable_guild_select: options.disableGuildSelect, + }); if (options.permissions) { const permissions = PermissionsBitField.resolve(options.permissions); - if (permissions) query.set('permissions', permissions); - } - - if (options.disableGuildSelect) { - query.set('disable_guild_select', true); + if (permissions) query.set('permissions', permissions.toString()); } if (options.guild) { diff --git a/packages/discord.js/src/managers/GuildManager.js b/packages/discord.js/src/managers/GuildManager.js index 8c45a126a..fc7fd6a1d 100644 --- a/packages/discord.js/src/managers/GuildManager.js +++ b/packages/discord.js/src/managers/GuildManager.js @@ -3,6 +3,7 @@ const process = require('node:process'); const { setTimeout, clearTimeout } = require('node:timers'); const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { Routes } = require('discord-api-types/v10'); const CachedManager = require('./CachedManager'); const { Guild } = require('../structures/Guild'); @@ -271,12 +272,12 @@ class GuildManager extends CachedManager { } const data = await this.client.rest.get(Routes.guild(id), { - query: new URLSearchParams({ with_counts: options.withCounts ?? true }), + query: makeURLSearchParams({ with_counts: options.withCounts ?? true }), }); return this._add(data, options.cache); } - const data = await this.client.rest.get(Routes.userGuilds(), { query: new URLSearchParams(options) }); + const data = await this.client.rest.get(Routes.userGuilds(), { query: makeURLSearchParams(options) }); return data.reduce((coll, guild) => coll.set(guild.id, new OAuth2Guild(this.client, guild)), new Collection()); } } diff --git a/packages/discord.js/src/managers/GuildMemberManager.js b/packages/discord.js/src/managers/GuildMemberManager.js index 7adc39f5a..54e782426 100644 --- a/packages/discord.js/src/managers/GuildMemberManager.js +++ b/packages/discord.js/src/managers/GuildMemberManager.js @@ -3,6 +3,7 @@ const { Buffer } = require('node:buffer'); const { setTimeout, clearTimeout } = require('node:timers'); const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { DiscordSnowflake } = require('@sapphire/snowflake'); const { Routes, GatewayOpcodes } = require('discord-api-types/v10'); const CachedManager = require('./CachedManager'); @@ -205,7 +206,7 @@ class GuildMemberManager extends CachedManager { */ async search({ query, limit, cache = true } = {}) { const data = await this.client.rest.get(Routes.guildMembersSearch(this.guild.id), { - query: new URLSearchParams({ query, limit }), + query: makeURLSearchParams({ query, limit }), }); return data.reduce((col, member) => col.set(member.user.id, this._add(member, cache)), new Collection()); } @@ -224,10 +225,7 @@ class GuildMemberManager extends CachedManager { * @returns {Promise>} */ async list({ after, limit, cache = true } = {}) { - const query = new URLSearchParams({ limit }); - if (after) { - query.set('after', after); - } + const query = makeURLSearchParams({ limit, after }); const data = await this.client.rest.get(Routes.guildMembers(this.guild.id), { query }); return data.reduce((col, member) => col.set(member.user.id, this._add(member, cache)), new Collection()); } @@ -346,7 +344,7 @@ class GuildMemberManager extends CachedManager { const endpoint = Routes.guildPrune(this.guild.id); const { pruned } = await (dry - ? this.client.rest.get(endpoint, { query: new URLSearchParams(query), reason }) + ? this.client.rest.get(endpoint, { query: makeURLSearchParams(query), reason }) : this.client.rest.post(endpoint, { body: { ...query, compute_prune_count }, reason })); return pruned; diff --git a/packages/discord.js/src/managers/GuildScheduledEventManager.js b/packages/discord.js/src/managers/GuildScheduledEventManager.js index 71fe63289..fb358aefb 100644 --- a/packages/discord.js/src/managers/GuildScheduledEventManager.js +++ b/packages/discord.js/src/managers/GuildScheduledEventManager.js @@ -1,6 +1,7 @@ 'use strict'; const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { GuildScheduledEventEntityType, Routes } = require('discord-api-types/v10'); const CachedManager = require('./CachedManager'); const { TypeError, Error } = require('../errors'); @@ -141,13 +142,13 @@ class GuildScheduledEventManager extends CachedManager { } const data = await this.client.rest.get(Routes.guildScheduledEvent(this.guild.id, id), { - query: new URLSearchParams({ with_user_count: options.withUserCount ?? true }), + query: makeURLSearchParams({ with_user_count: options.withUserCount ?? true }), }); return this._add(data, options.cache); } const data = await this.client.rest.get(Routes.guildScheduledEvents(this.guild.id), { - query: new URLSearchParams({ with_user_count: options.withUserCount ?? true }), + query: makeURLSearchParams({ with_user_count: options.withUserCount ?? true }), }); return data.reduce( @@ -270,25 +271,12 @@ class GuildScheduledEventManager extends CachedManager { const guildScheduledEventId = this.resolveId(guildScheduledEvent); if (!guildScheduledEventId) throw new Error('GUILD_SCHEDULED_EVENT_RESOLVE'); - let { limit, withMember, before, after } = options; - - const query = new URLSearchParams(); - - if (limit) { - query.set('limit', limit); - } - - if (typeof withMember !== 'undefined') { - query.set('with_member', withMember); - } - - if (before) { - query.set('before', before); - } - - if (after) { - query.set('after', after); - } + const query = makeURLSearchParams({ + limit: options.limit, + with_member: options.withMember, + before: options.before, + after: options.after, + }); const data = await this.client.rest.get(Routes.guildScheduledEventUsers(this.guild.id, guildScheduledEventId), { query, diff --git a/packages/discord.js/src/managers/MessageManager.js b/packages/discord.js/src/managers/MessageManager.js index 8e255427c..6642efcac 100644 --- a/packages/discord.js/src/managers/MessageManager.js +++ b/packages/discord.js/src/managers/MessageManager.js @@ -1,6 +1,7 @@ 'use strict'; const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { Routes } = require('discord-api-types/v10'); const CachedManager = require('./CachedManager'); const { TypeError } = require('../errors'); @@ -224,7 +225,7 @@ class MessageManager extends CachedManager { async _fetchMany(options = {}, cache) { const data = await this.client.rest.get(Routes.channelMessages(this.channel.id), { - query: new URLSearchParams(options), + query: makeURLSearchParams(options), }); const messages = new Collection(); for (const message of data) messages.set(message.id, this._add(message, cache)); diff --git a/packages/discord.js/src/managers/ReactionUserManager.js b/packages/discord.js/src/managers/ReactionUserManager.js index f99d3ca3a..5eb151acd 100644 --- a/packages/discord.js/src/managers/ReactionUserManager.js +++ b/packages/discord.js/src/managers/ReactionUserManager.js @@ -1,6 +1,7 @@ 'use strict'; const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { Routes } = require('discord-api-types/v10'); const CachedManager = require('./CachedManager'); const { Error } = require('../errors'); @@ -41,10 +42,7 @@ class ReactionUserManager extends CachedManager { */ async fetch({ limit = 100, after } = {}) { const message = this.reaction.message; - const query = new URLSearchParams({ limit }); - if (after) { - query.set('after', after); - } + const query = makeURLSearchParams({ limit, after }); const data = await this.client.rest.get( Routes.channelMessageReaction(message.channelId, message.id, this.reaction.emoji.identifier), { query }, diff --git a/packages/discord.js/src/managers/ThreadManager.js b/packages/discord.js/src/managers/ThreadManager.js index 0225c88ff..e957f2ff3 100644 --- a/packages/discord.js/src/managers/ThreadManager.js +++ b/packages/discord.js/src/managers/ThreadManager.js @@ -1,6 +1,7 @@ 'use strict'; const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { ChannelType, Routes } = require('discord-api-types/v10'); const CachedManager = require('./CachedManager'); const { TypeError } = require('../errors'); @@ -206,7 +207,7 @@ class ThreadManager extends CachedManager { } let timestamp; let id; - const query = new URLSearchParams(); + const query = makeURLSearchParams({ limit }); if (typeof before !== 'undefined') { if (before instanceof ThreadChannel || /^\d{16,19}$/.test(String(before))) { id = this.resolveId(before); @@ -227,9 +228,6 @@ class ThreadManager extends CachedManager { } } - if (limit) { - query.set('limit', limit); - } const raw = await this.client.rest.get(path, { query }); return this.constructor._mapThreads(raw, this.client, { parent: this.channel, cache }); } diff --git a/packages/discord.js/src/structures/BaseGuild.js b/packages/discord.js/src/structures/BaseGuild.js index 8adf72ec5..c134a7e74 100644 --- a/packages/discord.js/src/structures/BaseGuild.js +++ b/packages/discord.js/src/structures/BaseGuild.js @@ -1,5 +1,6 @@ 'use strict'; +const { makeURLSearchParams } = require('@discordjs/rest'); const { DiscordSnowflake } = require('@sapphire/snowflake'); const { Routes } = require('discord-api-types/v10'); const Base = require('./Base'); @@ -101,7 +102,7 @@ class BaseGuild extends Base { */ async fetch() { const data = await this.client.rest.get(Routes.guild(this.id), { - query: new URLSearchParams({ with_counts: true }), + query: makeURLSearchParams({ with_counts: true }), }); return this.client.guilds._add(data); } diff --git a/packages/discord.js/src/structures/Guild.js b/packages/discord.js/src/structures/Guild.js index 6da0b64dd..3c694aef2 100644 --- a/packages/discord.js/src/structures/Guild.js +++ b/packages/discord.js/src/structures/Guild.js @@ -1,6 +1,7 @@ 'use strict'; const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); const { ChannelType, GuildPremiumTier, Routes } = require('discord-api-types/v10'); const AnonymousGuild = require('./AnonymousGuild'); const GuildAuditLogs = require('./GuildAuditLogs'); @@ -712,15 +713,11 @@ class Guild extends AnonymousGuild { async fetchAuditLogs(options = {}) { if (options.before && options.before instanceof GuildAuditLogsEntry) options.before = options.before.id; - const query = new URLSearchParams(); - - if (options.before) { - query.set('before', options.before); - } - - if (options.limit) { - query.set('limit', options.limit); - } + const query = makeURLSearchParams({ + before: options.before, + limit: options.limit, + action_type: options.type, + }); if (options.user) { const id = this.client.users.resolveId(options.user); @@ -728,10 +725,6 @@ class Guild extends AnonymousGuild { query.set('user_id', id); } - if (options.type) { - query.set('action_type', options.type); - } - const data = await this.client.rest.get(Routes.guildAuditLog(this.id), { query }); return new GuildAuditLogs(this, data); } diff --git a/packages/discord.js/src/structures/Webhook.js b/packages/discord.js/src/structures/Webhook.js index d53909c54..6b27b446b 100644 --- a/packages/discord.js/src/structures/Webhook.js +++ b/packages/discord.js/src/structures/Webhook.js @@ -1,5 +1,6 @@ 'use strict'; +const { makeURLSearchParams } = require('@discordjs/rest'); const { DiscordSnowflake } = require('@sapphire/snowflake'); const { Routes, WebhookType } = require('discord-api-types/v10'); const MessagePayload = require('./MessagePayload'); @@ -199,11 +200,10 @@ class Webhook { messagePayload = MessagePayload.create(this, options).resolveBody(); } - const query = new URLSearchParams({ wait: true }); - - if (messagePayload.options.threadId) { - query.set('thread_id', messagePayload.options.threadId); - } + const query = makeURLSearchParams({ + wait: true, + thread_id: messagePayload.options.threadId, + }); const { body, files } = await messagePayload.resolveFiles(); const d = await this.client.rest.post(Routes.webhook(this.id, this.token), { body, files, query, auth: false }); @@ -232,7 +232,7 @@ class Webhook { if (!this.token) throw new Error('WEBHOOK_TOKEN_UNAVAILABLE'); const data = await this.client.rest.post(Routes.webhookPlatform(this.id, this.token, 'slack'), { - query: new URLSearchParams({ wait: true }), + query: makeURLSearchParams({ wait: true }), auth: false, body, }); @@ -289,11 +289,7 @@ class Webhook { if (!this.token) throw new Error('WEBHOOK_TOKEN_UNAVAILABLE'); const data = await this.client.rest.get(Routes.webhookMessage(this.id, this.token, message), { - query: threadId - ? new URLSearchParams({ - thread_id: threadId, - }) - : undefined, + query: threadId ? makeURLSearchParams({ thread_id: threadId }) : undefined, auth: false, }); return this.client.channels?.cache.get(data.channel_id)?.messages._add(data, cache) ?? data; @@ -322,9 +318,7 @@ class Webhook { body, files, query: messagePayload.options.threadId - ? new URLSearchParams({ - thread_id: messagePayload.options.threadId, - }) + ? makeURLSearchParams({ thread_id: messagePayload.options.threadId }) : undefined, auth: false, }, @@ -362,11 +356,7 @@ class Webhook { await this.client.rest.delete( Routes.webhookMessage(this.id, this.token, typeof message === 'string' ? message : message.id), { - query: threadId - ? new URLSearchParams({ - thread_id: threadId, - }) - : undefined, + query: threadId ? makeURLSearchParams({ thread_id: threadId }) : undefined, auth: false, }, ); diff --git a/packages/rest/__tests__/utils.test.ts b/packages/rest/__tests__/utils.test.ts new file mode 100644 index 000000000..27c138a5b --- /dev/null +++ b/packages/rest/__tests__/utils.test.ts @@ -0,0 +1,60 @@ +import { makeURLSearchParams } from '../src'; + +describe('makeURLSearchParams', () => { + test('GIVEN undefined THEN returns empty URLSearchParams', () => { + const params = makeURLSearchParams(); + + expect([...params.entries()]).toEqual([]); + }); + + test('GIVEN empty object THEN returns empty URLSearchParams', () => { + const params = makeURLSearchParams({}); + + expect([...params.entries()]).toEqual([]); + }); + + test('GIVEN a record of strings THEN returns URLSearchParams with strings', () => { + const params = makeURLSearchParams({ foo: 'bar', hello: 'world' }); + + expect([...params.entries()]).toEqual([ + ['foo', 'bar'], + ['hello', 'world'], + ]); + }); + + test('GIVEN a record of strings with nullish values THEN returns URLSearchParams without nullish values', () => { + const params = makeURLSearchParams({ foo: 'bar', hello: null, one: undefined }); + + expect([...params.entries()]).toEqual([['foo', 'bar']]); + }); + + test('GIVEN a record of non-string values THEN returns URLSearchParams with string values', () => { + const params = makeURLSearchParams({ life: 42, big: 100n, bool: true }); + + expect([...params.entries()]).toEqual([ + ['life', '42'], + ['big', '100'], + ['bool', 'true'], + ]); + }); + + describe('objects', () => { + test('GIVEN a record of date values THEN URLSearchParams with ISO string values', () => { + const params = makeURLSearchParams({ before: new Date('2022-04-04T15:43:05.108Z'), after: new Date(NaN) }); + + expect([...params.entries()]).toEqual([['before', '2022-04-04T15:43:05.108Z']]); + }); + + test('GIVEN a record of plain object values THEN returns empty URLSearchParams', () => { + const params = makeURLSearchParams({ foo: {}, hello: { happy: true } }); + + expect([...params.entries()]).toEqual([]); + }); + + test('GIVEN a record of objects with overridden toString THEN returns non-empty URLSearchParams', () => { + const params = makeURLSearchParams({ foo: { toString: () => 'bar' } }); + + expect([...params.entries()]).toEqual([['foo', 'bar']]); + }); + }); +}); diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index a34b85106..28797f6c4 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -8,3 +8,4 @@ export * from './lib/errors/RateLimitError'; export * from './lib/RequestManager'; export * from './lib/REST'; export * from './lib/utils/constants'; +export { makeURLSearchParams } from './lib/utils/utils'; diff --git a/packages/rest/src/lib/utils/utils.ts b/packages/rest/src/lib/utils/utils.ts index 14f081601..c7263035c 100644 --- a/packages/rest/src/lib/utils/utils.ts +++ b/packages/rest/src/lib/utils/utils.ts @@ -2,6 +2,45 @@ import type { RESTPatchAPIChannelJSONBody } from 'discord-api-types/v10'; import type { Response } from 'node-fetch'; import { RequestMethod } from '../RequestManager'; +function serializeSearchParam(value: unknown): string | null { + switch (typeof value) { + case 'string': + return value; + case 'number': + case 'bigint': + case 'boolean': + return value.toString(); + case 'object': + if (value === null) return null; + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + // eslint-disable-next-line @typescript-eslint/no-base-to-string + if (typeof value.toString === 'function' && value.toString !== Object.prototype.toString) return value.toString(); + return null; + default: + return null; + } +} + +/** + * Creates and populates an URLSearchParams instance from an object, stripping + * out null and undefined values, while also coercing non-strings to strings. + * @param options The options to use + * @returns A populated URLSearchParams instance + */ +export function makeURLSearchParams(options?: Record) { + const params = new URLSearchParams(); + if (!options) return params; + + for (const [key, value] of Object.entries(options)) { + const serialized = serializeSearchParam(value); + if (serialized !== null) params.append(key, serialized); + } + + return params; +} + /** * Converts the response to usable data * @param res The node-fetch response