From 8ab4124ef9818920970d17071cc4cb7dbe63bb61 Mon Sep 17 00:00:00 2001 From: Denis Cristea Date: Sun, 6 Oct 2024 17:43:06 +0300 Subject: [PATCH] feat: implement zod-validation-error (#10534) Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- packages/builders/package.json | 3 +- packages/builders/src/components/ActionRow.ts | 6 +-- .../builders/src/components/button/Button.ts | 7 +--- .../selectMenu/ChannelSelectMenu.ts | 7 +--- .../selectMenu/MentionableSelectMenu.ts | 7 +--- .../components/selectMenu/RoleSelectMenu.ts | 7 +--- .../components/selectMenu/StringSelectMenu.ts | 6 +-- .../selectMenu/StringSelectMenuOption.ts | 7 +--- .../components/selectMenu/UserSelectMenu.ts | 7 +--- .../src/components/textInput/TextInput.ts | 7 +--- .../commands/chatInput/ChatInputCommand.ts | 6 +-- .../chatInput/ChatInputCommandSubcommands.ts | 10 ++--- .../options/ApplicationCommandOptionBase.ts | 7 +--- .../commands/contextMenu/MessageCommand.ts | 7 +--- .../commands/contextMenu/UserCommand.ts | 7 +--- .../builders/src/interactions/modals/Modal.ts | 6 +-- packages/builders/src/messages/embed/Embed.ts | 6 +-- .../src/messages/embed/EmbedAuthor.ts | 7 +--- .../builders/src/messages/embed/EmbedField.ts | 7 +--- .../src/messages/embed/EmbedFooter.ts | 7 +--- packages/builders/src/util/validation.ts | 38 +++++++++++++++++-- pnpm-lock.yaml | 13 +++++++ 22 files changed, 88 insertions(+), 97 deletions(-) diff --git a/packages/builders/package.json b/packages/builders/package.json index d0e288c7c..f678a01e0 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -69,7 +69,8 @@ "discord-api-types": "^0.37.101", "ts-mixer": "^6.0.4", "tslib": "^2.6.3", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-validation-error": "^3.4.0" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index 84d7268dd..9d099356f 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -16,7 +16,7 @@ import type { import { ComponentType } from 'discord-api-types/v10'; import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; import { resolveBuilder } from '../util/resolveBuilder.js'; -import { isValidationEnabled } from '../util/validation.js'; +import { validate } from '../util/validation.js'; import { actionRowPredicate } from './Assertions.js'; import { ComponentBuilder } from './Component.js'; import type { AnyActionRowComponentBuilder } from './Components.js'; @@ -336,9 +336,7 @@ export class ActionRowBuilder extends ComponentBuilder component.toJSON(validationOverride)), }; - if (validationOverride ?? isValidationEnabled()) { - actionRowPredicate.parse(data); - } + validate(actionRowPredicate, data, validationOverride); return data as APIActionRowComponent; } diff --git a/packages/builders/src/components/button/Button.ts b/packages/builders/src/components/button/Button.ts index 448059ddd..94737fa4a 100644 --- a/packages/builders/src/components/button/Button.ts +++ b/packages/builders/src/components/button/Button.ts @@ -1,5 +1,5 @@ import type { APIButtonComponent } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { buttonPredicate } from '../Assertions.js'; import { ComponentBuilder } from '../Component.js'; @@ -24,10 +24,7 @@ export abstract class BaseButtonBuilder e */ public override toJSON(validationOverride?: boolean): ButtonData { const clone = structuredClone(this.data); - - if (validationOverride ?? isValidationEnabled()) { - buttonPredicate.parse(clone); - } + validate(buttonPredicate, clone, validationOverride); return clone as ButtonData; } diff --git a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts index 913d61592..3f7956052 100644 --- a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts @@ -6,7 +6,7 @@ import { SelectMenuDefaultValueType, } from 'discord-api-types/v10'; import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { selectMenuChannelPredicate } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; @@ -108,10 +108,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder option.toJSON(false)), }; - if (validationOverride ?? isValidationEnabled()) { - selectMenuStringPredicate.parse(data); - } + validate(selectMenuStringPredicate, data, validationOverride); return data as APIStringSelectComponent; } diff --git a/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts b/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts index c2faa5361..39723d74e 100644 --- a/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts +++ b/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts @@ -1,6 +1,6 @@ import type { JSONEncodable } from '@discordjs/util'; import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { selectMenuStringOptionPredicate } from '../Assertions.js'; /** @@ -106,10 +106,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable { */ public toJSON(validationOverride?: boolean): APITextInputComponent { const clone = structuredClone(this.data); - - if (validationOverride ?? isValidationEnabled()) { - textInputPredicate.parse(clone); - } + validate(textInputPredicate, clone, validationOverride); return clone as APITextInputComponent; } diff --git a/packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts b/packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts index 422b5d937..14a600dac 100644 --- a/packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts +++ b/packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts @@ -1,6 +1,6 @@ import { ApplicationCommandType, type RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord-api-types/v10'; import { Mixin } from 'ts-mixer'; -import { isValidationEnabled } from '../../../util/validation.js'; +import { validate } from '../../../util/validation.js'; import { CommandBuilder } from '../Command.js'; import { SharedNameAndDescription } from '../SharedNameAndDescription.js'; import { chatInputCommandPredicate } from './Assertions.js'; @@ -28,9 +28,7 @@ export class ChatInputCommandBuilder extends Mixin( options: options?.map((option) => option.toJSON(validationOverride)), }; - if (validationOverride ?? isValidationEnabled()) { - chatInputCommandPredicate.parse(data); - } + validate(chatInputCommandPredicate, data, validationOverride); return data; } diff --git a/packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts b/packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts index 7ecfd6a64..bf350d1de 100644 --- a/packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts +++ b/packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts @@ -7,7 +7,7 @@ import { ApplicationCommandOptionType } from 'discord-api-types/v10'; import { Mixin } from 'ts-mixer'; import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray.js'; import { resolveBuilder } from '../../../util/resolveBuilder.js'; -import { isValidationEnabled } from '../../../util/validation.js'; +import { validate } from '../../../util/validation.js'; import type { SharedNameAndDescriptionData } from '../SharedNameAndDescription.js'; import { SharedNameAndDescription } from '../SharedNameAndDescription.js'; import { chatInputCommandSubcommandGroupPredicate, chatInputCommandSubcommandPredicate } from './Assertions.js'; @@ -69,9 +69,7 @@ export class ChatInputCommandSubcommandGroupBuilder options: options?.map((option) => option.toJSON(validationOverride)) ?? [], }; - if (validationOverride ?? isValidationEnabled()) { - chatInputCommandSubcommandGroupPredicate.parse(data); - } + validate(chatInputCommandSubcommandGroupPredicate, data, validationOverride); return data; } @@ -102,9 +100,7 @@ export class ChatInputCommandSubcommandBuilder options: options?.map((option) => option.toJSON(validationOverride)) ?? [], }; - if (validationOverride ?? isValidationEnabled()) { - chatInputCommandSubcommandPredicate.parse(data); - } + validate(chatInputCommandSubcommandPredicate, data, validationOverride); return data; } diff --git a/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts b/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts index 7016083e6..cb14ef9dc 100644 --- a/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts +++ b/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts @@ -5,7 +5,7 @@ import type { ApplicationCommandOptionType, } from 'discord-api-types/v10'; import type { z } from 'zod'; -import { isValidationEnabled } from '../../../../util/validation.js'; +import { validate } from '../../../../util/validation.js'; import type { SharedNameAndDescriptionData } from '../../SharedNameAndDescription.js'; import { SharedNameAndDescription } from '../../SharedNameAndDescription.js'; import { basicOptionPredicate } from '../Assertions.js'; @@ -49,10 +49,7 @@ export abstract class ApplicationCommandOptionBase */ public toJSON(validationOverride?: boolean): APIApplicationCommandBasicOption { const clone = structuredClone(this.data); - - if (validationOverride ?? isValidationEnabled()) { - (this.constructor as typeof ApplicationCommandOptionBase).predicate.parse(clone); - } + validate((this.constructor as typeof ApplicationCommandOptionBase).predicate, clone, validationOverride); return clone as APIApplicationCommandBasicOption; } diff --git a/packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts b/packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts index ccaab7bc3..4ea63edd9 100644 --- a/packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts +++ b/packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts @@ -1,5 +1,5 @@ import { ApplicationCommandType, type RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../../util/validation.js'; +import { validate } from '../../../util/validation.js'; import { messageCommandPredicate } from './Assertions.js'; import { ContextMenuCommandBuilder } from './ContextMenuCommand.js'; @@ -9,10 +9,7 @@ export class MessageContextCommandBuilder extends ContextMenuCommandBuilder { */ public override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody { const data = { ...structuredClone(this.data), type: ApplicationCommandType.Message }; - - if (validationOverride ?? isValidationEnabled()) { - messageCommandPredicate.parse(data); - } + validate(messageCommandPredicate, data, validationOverride); return data as RESTPostAPIContextMenuApplicationCommandsJSONBody; } diff --git a/packages/builders/src/interactions/commands/contextMenu/UserCommand.ts b/packages/builders/src/interactions/commands/contextMenu/UserCommand.ts index b911fb11f..69279701f 100644 --- a/packages/builders/src/interactions/commands/contextMenu/UserCommand.ts +++ b/packages/builders/src/interactions/commands/contextMenu/UserCommand.ts @@ -1,5 +1,5 @@ import { ApplicationCommandType, type RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../../util/validation.js'; +import { validate } from '../../../util/validation.js'; import { userCommandPredicate } from './Assertions.js'; import { ContextMenuCommandBuilder } from './ContextMenuCommand.js'; @@ -9,10 +9,7 @@ export class UserContextCommandBuilder extends ContextMenuCommandBuilder { */ public override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody { const data = { ...structuredClone(this.data), type: ApplicationCommandType.User }; - - if (validationOverride ?? isValidationEnabled()) { - userCommandPredicate.parse(data); - } + validate(userCommandPredicate, data, validationOverride); return data as RESTPostAPIContextMenuApplicationCommandsJSONBody; } diff --git a/packages/builders/src/interactions/modals/Modal.ts b/packages/builders/src/interactions/modals/Modal.ts index 6191f1d91..3eb42daab 100644 --- a/packages/builders/src/interactions/modals/Modal.ts +++ b/packages/builders/src/interactions/modals/Modal.ts @@ -10,7 +10,7 @@ import { ActionRowBuilder } from '../../components/ActionRow.js'; import { createComponentBuilder } from '../../components/Components.js'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; import { resolveBuilder } from '../../util/resolveBuilder.js'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { modalPredicate } from './Assertions.js'; export interface ModalBuilderData extends Partial> { @@ -162,9 +162,7 @@ export class ModalBuilder implements JSONEncodable component.toJSON(validationOverride)), }; - if (validationOverride ?? isValidationEnabled()) { - modalPredicate.parse(data); - } + validate(modalPredicate, data, validationOverride); return data as APIModalInteractionResponseCallbackData; } diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts index 25e408189..75bddc375 100644 --- a/packages/builders/src/messages/embed/Embed.ts +++ b/packages/builders/src/messages/embed/Embed.ts @@ -3,7 +3,7 @@ import type { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter } from 'di import type { RestOrArray } from '../../util/normalizeArray.js'; import { normalizeArray } from '../../util/normalizeArray.js'; import { resolveBuilder } from '../../util/resolveBuilder.js'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { embedPredicate } from './Assertions.js'; import { EmbedAuthorBuilder } from './EmbedAuthor.js'; import { EmbedFieldBuilder } from './EmbedField.js'; @@ -343,9 +343,7 @@ export class EmbedBuilder implements JSONEncodable { footer: this.data.footer?.toJSON(false), }; - if (validationOverride ?? isValidationEnabled()) { - embedPredicate.parse(data); - } + validate(embedPredicate, data, validationOverride); return data; } diff --git a/packages/builders/src/messages/embed/EmbedAuthor.ts b/packages/builders/src/messages/embed/EmbedAuthor.ts index 0c3d0b6fb..5eb9df58c 100644 --- a/packages/builders/src/messages/embed/EmbedAuthor.ts +++ b/packages/builders/src/messages/embed/EmbedAuthor.ts @@ -1,5 +1,5 @@ import type { APIEmbedAuthor } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { embedAuthorPredicate } from './Assertions.js'; /** @@ -72,10 +72,7 @@ export class EmbedAuthorBuilder { */ public toJSON(validationOverride?: boolean): APIEmbedAuthor { const clone = structuredClone(this.data); - - if (validationOverride ?? isValidationEnabled()) { - embedAuthorPredicate.parse(clone); - } + validate(embedAuthorPredicate, clone, validationOverride); return clone as APIEmbedAuthor; } diff --git a/packages/builders/src/messages/embed/EmbedField.ts b/packages/builders/src/messages/embed/EmbedField.ts index e385fad3e..5025fec0e 100644 --- a/packages/builders/src/messages/embed/EmbedField.ts +++ b/packages/builders/src/messages/embed/EmbedField.ts @@ -1,5 +1,5 @@ import type { APIEmbedField } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { embedFieldPredicate } from './Assertions.js'; /** @@ -56,10 +56,7 @@ export class EmbedFieldBuilder { */ public toJSON(validationOverride?: boolean): APIEmbedField { const clone = structuredClone(this.data); - - if (validationOverride ?? isValidationEnabled()) { - embedFieldPredicate.parse(clone); - } + validate(embedFieldPredicate, clone, validationOverride); return clone as APIEmbedField; } diff --git a/packages/builders/src/messages/embed/EmbedFooter.ts b/packages/builders/src/messages/embed/EmbedFooter.ts index 5b3e0c0f8..8d75b77f6 100644 --- a/packages/builders/src/messages/embed/EmbedFooter.ts +++ b/packages/builders/src/messages/embed/EmbedFooter.ts @@ -1,5 +1,5 @@ import type { APIEmbedFooter } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { validate } from '../../util/validation.js'; import { embedFooterPredicate } from './Assertions.js'; /** @@ -54,10 +54,7 @@ export class EmbedFooterBuilder { */ public toJSON(validationOverride?: boolean): APIEmbedFooter { const clone = structuredClone(this.data); - - if (validationOverride ?? isValidationEnabled()) { - embedFooterPredicate.parse(clone); - } + validate(embedFooterPredicate, clone, validationOverride); return clone as APIEmbedFooter; } diff --git a/packages/builders/src/util/validation.ts b/packages/builders/src/util/validation.ts index 37e5c224b..ce31bbdaa 100644 --- a/packages/builders/src/util/validation.ts +++ b/packages/builders/src/util/validation.ts @@ -1,4 +1,7 @@ -let validate = true; +import type { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; + +let validationEnabled = true; /** * Enables validators. @@ -6,7 +9,7 @@ let validate = true; * @returns Whether validation is occurring. */ export function enableValidators() { - return (validate = true); + return (validationEnabled = true); } /** @@ -15,12 +18,39 @@ export function enableValidators() { * @returns Whether validation is occurring. */ export function disableValidators() { - return (validate = false); + return (validationEnabled = false); } /** * Checks whether validation is occurring. */ export function isValidationEnabled() { - return validate; + return validationEnabled; +} + +/** + * Parses a value with a given validator, accounting for wether validation is enabled. + * + * @param validator - The zod validator to use + * @param value - The value to parse + * @param validationOverride - Force validation to run/not run regardless of your global preference + * @returns The result from parsing + * @internal + */ +export function validate( + validator: Validator, + value: unknown, + validationOverride?: boolean, +): z.output { + if (validationOverride === false || !isValidationEnabled()) { + return value; + } + + const result = validator.safeParse(value); + + if (!result.success) { + throw fromZodError(result.error); + } + + return result.data; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42abf3613..2340a8c5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -685,6 +685,9 @@ importers: zod: specifier: ^3.23.8 version: 3.23.8 + zod-validation-error: + specifier: ^3.4.0 + version: 3.4.0(zod@3.23.8) devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -13661,6 +13664,12 @@ packages: peerDependencies: zod: ^3.18.0 + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -29775,6 +29784,10 @@ snapshots: dependencies: zod: 3.23.8 + zod-validation-error@3.4.0(zod@3.23.8): + dependencies: + zod: 3.23.8 + zod@3.23.8: {} zwitch@2.0.4: {}