UNPKG

@nestjs/common

Version:

Decorators, pipes, exceptions and utilities for NestJS applications: @Controller(), @Injectable(), @Body(), ValidationPipe, HttpException and more.

485 lines (484 loc) • 22.2 kB
import { __decorate, __metadata, __param } from "tslib"; import { iterate } from 'iterare'; import { Injectable } from '../decorators/core/index.js'; import { Optional } from '../decorators/index.js'; import { HttpStatus } from '../enums/http-status.enum.js'; import { HttpErrorByCode, } from '../utils/http-error-by-code.util.js'; import { closeArrayGaps } from '../utils/close-array-gaps.util.js'; import { loadPackage } from '../utils/load-package.util.js'; import { isFunction, isNil, isObject, isString, isUndefined, } from '../utils/shared.utils.js'; import { stripProtoKeys } from '../utils/strip-proto-keys.util.js'; import { OMITTED_ERRORS_MESSAGE, resolveMaxErrorMessagesLength, } from './validation-errors.constants.js'; // Metadata types of class-validator ("ValidationTypes") const NESTED_VALIDATION = 'nestedValidation'; const CONDITIONAL_VALIDATION = 'conditionalValidation'; let classValidator = {}; let classTransformer = {}; // The packages of the pipe created last, which may still be loading. // "transform()" resolves them into the variables above, so that a pipe // created while a value is validated (e.g. a request-scoped one) doesn't put // promises in place of the packages that validation uses. let pendingClassValidator; let pendingClassTransformer; /** * @see [Validation](https://docs.nestjs.com/techniques/validation) * * @publicApi */ let ValidationPipe = class ValidationPipe { isTransformEnabled; isDetailedOutputDisabled; validatorOptions; transformOptions; errorHttpStatusCode; expectedType; exceptionFactory; validateCustomDecorators; errorFormat; maxErrorMessagesLength; constructor(options) { options = options || {}; const { transform, disableErrorMessages, errorHttpStatusCode, expectedType, transformOptions, exceptionFactory, validatorPackage, transformerPackage, validateCustomDecorators, errorFormat, maxErrorMessagesLength, ...validatorOptions } = options; // @see [https://github.com/nestjs/nest/issues/10683#issuecomment-1413690508](https://github.com/nestjs/nest/issues/10683#issuecomment-1413690508) this.validatorOptions = { forbidUnknownValues: false, ...validatorOptions }; this.isTransformEnabled = !!transform; this.transformOptions = transformOptions; this.isDetailedOutputDisabled = disableErrorMessages; this.validateCustomDecorators = validateCustomDecorators || false; this.errorHttpStatusCode = errorHttpStatusCode || HttpStatus.BAD_REQUEST; this.expectedType = expectedType; this.errorFormat = errorFormat || 'list'; this.maxErrorMessagesLength = resolveMaxErrorMessagesLength(maxErrorMessagesLength); this.exceptionFactory = exceptionFactory || this.createExceptionFactory(); pendingClassValidator = this.loadValidator(validatorPackage); pendingClassTransformer = this.loadTransformer(transformerPackage); if (!(pendingClassValidator instanceof Promise)) { classValidator = pendingClassValidator; } if (!(pendingClassTransformer instanceof Promise)) { classTransformer = pendingClassTransformer; } } loadValidator(validatorPackage) { return (validatorPackage ?? loadPackage('class-validator', 'ValidationPipe', () => import('class-validator'))); } loadTransformer(transformerPackage) { return (transformerPackage ?? loadPackage('class-transformer', 'ValidationPipe', () => import('class-transformer'))); } async transform(value, metadata) { if (this.expectedType) { metadata = { ...metadata, metatype: this.expectedType }; } const metatype = metadata.metatype; if (!metatype || !this.toValidate(metadata)) { return this.isTransformEnabled ? this.transformPrimitive(value, metadata) : value; } classValidator = (await pendingClassValidator); classTransformer = (await pendingClassTransformer); const originalValue = value; value = this.toEmptyIfNil(value, metatype); // class-transformer copies an array without its gaps, so the array of a // sparse value would be validated without them and returned with them // when the value is returned as it came. The gaps are closed first, in // place, as the prototype keys are stripped below. this.closeArrayGaps(value); const isNil = value !== originalValue; const isPrimitive = this.isPrimitive(value); // class-transformer copies a binary value as it is, and class-validator // would then look for the properties of the class among its bytes (and, // with "whitelist", fail to delete them), so it gets a stand-in too. const isStandIn = isPrimitive || this.isBinary(value); this.stripProtoKeys(value); let entity = classTransformer.plainToInstance(metatype, value, this.transformOptions); const originalEntity = entity; const isCtorNotEqual = entity.constructor !== metatype; if (isCtorNotEqual && !isStandIn) { entity.constructor = metatype; } else if (isCtorNotEqual) { // when "entity" is a primitive or a binary value, we have to // temporarily replace the entity to perform the validation against the // original metatype defined inside the handler entity = { constructor: metatype }; } const binaryErrors = this.findNestedBinaryValues(entity); if (binaryErrors.length > 0) { // A binary value (e.g. an attachment of a socket.io message) is never // an instance of a nested class: class-transformer copies it instead // of building the class from it, and class-validator then looks for // the rules of its own type (there are none), not those of the class. throw await this.exceptionFactory(binaryErrors); } const errors = await this.validate(entity, { ...this.validatorOptions, // The target of an error is the whole object it was found in, so an // exception factory that sends the errors would repeat that object // once per error. validationError: { target: false, ...this.validatorOptions.validationError, }, }); if (isCtorNotEqual && !isStandIn) { // Left on the value, the stand-in would go wherever the value is // copied (e.g. `{ ...dto }`), and class-transformer would then take // the copy for an instance of the class. delete entity.constructor; } if (errors.length > 0) { if (isUndefined(this.validatorOptions.validationError?.value)) { this.stripParentErrorValues(errors); } throw await this.exceptionFactory(errors); } if (isCtorNotEqual && (isStandIn || Array.isArray(value)) && (await this.hasValidationRules(metatype))) { // An array, a primitive or a binary value is never an instance of the // class: the validation above has only seen a stand-in without any // property set, which passes when none of them is required. throw await this.exceptionFactory([ { property: metadata.data || metadata.type, constraints: { isObject: 'Validation failed (object is expected)' }, children: [], }, ]); } if (originalValue === undefined && originalEntity === '') { // Since SWC requires empty string for validation (to avoid an error), // a fallback is needed to revert to the original value (when undefined). // @see [https://github.com/nestjs/nest/issues/14430](https://github.com/nestjs/nest/issues/14430) return originalValue; } if (isStandIn) { // if the value is a primitive (or binary) value and the validation process has been successfully completed // we have to revert the original value passed through the pipe entity = originalEntity; } if (this.isTransformEnabled) { return entity; } if (isNil) { // if the value was originally undefined or null, revert it back return originalValue; } // we check if the number of keys of the "validatorOptions" is higher than 1 (instead of 0) // because the "forbidUnknownValues" now fallbacks to "false" (in case it wasn't explicitly specified) const shouldTransformToPlain = Object.keys(this.validatorOptions).length > 1; return shouldTransformToPlain ? classTransformer.classToPlain(entity, this.transformOptions) : value; } createExceptionFactory() { return (validationErrors = []) => { if (this.isDetailedOutputDisabled) { return new HttpErrorByCode[this.errorHttpStatusCode](); } const budget = { remaining: this.maxErrorMessagesLength, isExceeded: false, }; validationErrors = this.limitValidationErrors(validationErrors, budget); if (this.errorFormat === 'grouped') { let errors = this.groupValidationErrors(validationErrors); if (budget.isExceeded) { errors = { ...errors, '...': [OMITTED_ERRORS_MESSAGE] }; } // Custom (object) bodies are returned verbatim by // `HttpException.createBody`, so add the standard envelope fields // explicitly to match the shape of the "list" format. const { message: error, statusCode } = new HttpErrorByCode[this.errorHttpStatusCode]().getResponse(); return new HttpErrorByCode[this.errorHttpStatusCode]({ message: errors, error, statusCode, }); } let errors = this.flattenValidationErrors(validationErrors); if (budget.isExceeded) { errors = [...errors, OMITTED_ERRORS_MESSAGE]; } return new HttpErrorByCode[this.errorHttpStatusCode](errors); }; } /** * Leaves out the errors that come after the first "maxErrorMessagesLength" * characters of messages. Each message counts with the path of its * property, which is what makes the response to a deeply nested payload * grow faster than the payload. The errors past the limit are not visited. */ limitValidationErrors(validationErrors, budget, parentPathLength = 0) { const limited = []; let isChanged = false; for (const error of validationErrors) { if (budget.remaining <= 0) { budget.isExceeded = true; return limited; } const pathLength = parentPathLength + String(error.property).length + 1; for (const key in error.constraints) { budget.remaining -= pathLength + String(error.constraints[key]).length; } const children = error.children && this.limitValidationErrors(error.children, budget, pathLength); if (children === error.children) { limited.push(error); } else { limited.push({ ...error, children }); isChanged = true; } } return isChanged ? limited : validationErrors; } /** * Removes the value of the errors that have children. That value is the * object or array in which the children were found, so an exception * factory that sends the errors would write a nested value once for each * level above it. The values left are those of separate parts of the * validated value. */ stripParentErrorValues(validationErrors) { // Not recursive, since the validated value decides how deep errors go. const pending = [validationErrors]; while (pending.length > 0) { for (const error of pending.pop()) { if (error.children && error.children.length > 0) { delete error.value; pending.push(error.children); } } } } toValidate(metadata) { const { metatype, type } = metadata; if (type === 'custom' && !this.validateCustomDecorators) { return false; } const types = [String, Boolean, Number, Array, Object, Buffer, Date]; return !types.some(t => metatype === t) && !isNil(metatype); } transformPrimitive(value, metadata) { if (!metadata.data) { // leave top-level query/param objects unmodified return value; } const { type, metatype } = metadata; if (type !== 'param' && type !== 'query') { return value; } if (metatype === Boolean) { if (isUndefined(value)) { // This is an workaround to deal with optional boolean values since // optional booleans shouldn't be parsed to a valid boolean when // they were not defined return undefined; } // Any fasly value but `undefined` will be parsed to `false` return value === true || value === 'true'; } if (metatype === Number) { if (isUndefined(value)) { // This is a workaround to deal with optional numeric values since // optional numerics shouldn't be parsed to a valid number when // they were not defined return undefined; } return +value; } if (metatype === String && !isUndefined(value)) { return String(value); } return value; } toEmptyIfNil(value, metatype) { if (!isNil(value)) { return value; } if (typeof metatype === 'function' || (metatype && 'prototype' in metatype && metatype.prototype?.constructor)) { return {}; } // SWC requires empty string to be returned instead of an empty object // when the value is nil and the metatype is not a class instance, but a plain object (enum, for example). // Otherwise, the error will be thrown. // @see [https://github.com/nestjs/nest/issues/12680](https://github.com/nestjs/nest/issues/12680) return ''; } /** * Closes the gaps of the sparse arrays in the value to validate, in place * (`[, 'a']` becomes `['a']`), so that the arrays that are validated are * the ones that are returned. */ closeArrayGaps(value) { closeArrayGaps(value); } isPrimitive(value) { return ['number', 'boolean', 'string'].includes(typeof value); } isBinary(value) { return ArrayBuffer.isView(value) || value instanceof ArrayBuffer; } stripProtoKeys(value) { stripProtoKeys(value); } validate(object, validatorOptions) { return classValidator.validate(object, validatorOptions); } /** * Returns an error for each binary value (a Buffer, a typed array or an * ArrayBuffer) that a property validated as a nested object holds, directly * or as an item, in the shape of class-validator's error for a primitive in * that place. The properties are read from class-validator's metadata * storage, once per class: a validator package without one is not checked. */ findNestedBinaryValues(object, metadataCache = new Map()) { const metadataStorage = classValidator.getMetadataStorage?.(); if (!metadataStorage || !isObject(object) || Array.isArray(object) || this.isBinary(object) || !isFunction(object.constructor) || // an object that holds nothing but primitives needs no look !Object.values(object).some(isObject)) { return []; } const target = object; let metadatas = metadataCache.get(target.constructor); if (!metadatas) { const { always = false, strictGroups = false, groups, } = this.validatorOptions; metadatas = metadataStorage.getTargetValidationMetadatas(target.constructor, undefined, always, strictGroups, groups); metadataCache.set(target.constructor, metadatas); } const errors = []; const checked = new Set(); for (const metadata of metadatas) { const property = metadata.propertyName; if (metadata.type !== NESTED_VALIDATION || checked.has(property)) { continue; } checked.add(property); const value = target[property]; // class-validator does not look at a value that a condition of the // property (e.g. "@ValidateIf()" or "@IsOptional()") rules out const isValidated = () => metadatas.every(item => item.type !== CONDITIONAL_VALIDATION || item.propertyName !== property || item.constraints[0](target, value)); const message = (isString(metadata.message) ? metadata.message : `${metadata.each ? 'each value in ' : ''}nested property $property must be either object or array`).replace(/\$property/g, property); const error = this.toNestedBinaryError(property, value, message, metadataCache); if (error && isValidated()) { errors.push(error); } } return errors; } toNestedBinaryError(property, value, message, metadataCache) { if (this.isBinary(value)) { return { property, constraints: { [NESTED_VALIDATION]: message }, children: [], }; } let children = []; if (Array.isArray(value) || value instanceof Set || value instanceof Map) { // class-validator validates each item, with or without "each" const items = value instanceof Set ? Array.from(value) : value; items.forEach((item, key) => { const error = this.toNestedBinaryError(String(key), item, message, metadataCache); if (error) { children.push(error); } }); } else { children = this.findNestedBinaryValues(value, metadataCache); } return children.length > 0 ? { property, children } : undefined; } async hasValidationRules(metatype) { // "forbidUnknownValues" rejects a class without any rule that applies const errors = await this.validate({ constructor: metatype }, { ...this.validatorOptions, forbidUnknownValues: true }); return !errors.some(error => error.constraints?.unknownValue); } flattenValidationErrors(validationErrors) { return iterate(validationErrors) .map(error => this.mapChildrenToValidationErrors(error)) .flatten() .filter(item => !!item.constraints) .map(item => Object.values(item.constraints)) .flatten() .toArray(); } groupValidationErrors(validationErrors, parentPath, result = {}) { for (const error of validationErrors) { const path = parentPath ? `${parentPath}.${error.property}` : error.property; if (error.constraints) { result[path] = Object.values(error.constraints); } if (error.children && error.children.length) { // Nested errors go into the same result: copying each level's result // into its parent took as many insertions as depth times leaves. const nested = this.groupValidationErrors(error.children, path, result); if (nested !== result) { // an override returned a result of its own Object.assign(result, nested); } } } return result; } mapChildrenToValidationErrors(error, parentPath, validationErrors = []) { const currentError = parentPath ? this.prependConstraintsWithParentProp(parentPath, error) : error; if (!(error.children && error.children.length)) { validationErrors.push(currentError); return validationErrors; } if (error.constraints) { validationErrors.push(currentError); } parentPath = parentPath ? `${parentPath}.${error.property}` : error.property; for (const item of error.children) { // Nested errors go into the same list: copying each level's list into // its parent took as many copies as depth times leaves, and spreading // a long one into push() threw a RangeError. const nested = this.mapChildrenToValidationErrors(item, parentPath, validationErrors); if (nested !== validationErrors) { // an override returned a list of its own for (const nestedError of nested) { validationErrors.push(nestedError); } } } return validationErrors; } prependConstraintsWithParentProp(parentPath, error) { const constraints = {}; for (const key in error.constraints) { constraints[key] = `${parentPath}.${error.constraints[key]}`; } return { ...error, constraints, }; } }; ValidationPipe = __decorate([ Injectable(), __param(0, Optional()), __metadata("design:paramtypes", [Object]) ], ValidationPipe); export { ValidationPipe };