UNPKG

@decaf-ts/decorator-validation

Version:
424 lines 19.7 kB
import { ModelErrorDefinition } from "./ModelErrorDefinition.js"; import { ModelKeys } from "./../utils/constants.js"; import { Model } from "./Model.js"; import { Validation } from "./../validation/Validation.js"; import { ValidationKeys } from "./../validation/Validators/constants.js"; import { PathProxyEngine } from "./../utils/PathProxy.js"; import { ASYNC_META_KEY, VALIDATION_PARENT_KEY } from "./../constants/index.js"; import { Reflection } from "@decaf-ts/reflection"; import { toConditionalPromise } from "./utils.js"; /** * Retrieves the validation metadata decorators associated with a specific property of a model, * using the reflective metadata key. * * @param model - The model instance or class containing the decorated property. * @param {string} prop - The name of the property whose decorators should be retrieved. * @param {string} reflectKey - The metadata key used to retrieve the decorators. * Defaults to `ValidationKeys.REFLECT`. * * @returns The validation decorators applied to the property */ export function getValidationDecorators(model, prop, reflectKey = ValidationKeys.REFLECT) { return Reflection.getPropertyDecorators(reflectKey, model, prop); } /** * @description * Retrieves all validatable property decorators from a given model, excluding specified properties. * * @summary * Iterates through the own enumerable properties of a model instance, filtering out any properties * listed in the `propsToIgnore` array. For each remaining property, it checks whether validation * decorators are present using `getValidationDecorators`, and if so, collects them in the result array. * * @template M - A generic parameter extending the `Model` class, representing the model type being inspected. * * @param {M} model - An instance of a class extending `Model` from which validatable properties will be extracted. * @param {string[]} propsToIgnore - An array of property names that should be excluded from validation inspection. * * @return {ValidationPropertyDecoratorDefinition[]} An array of validation decorator definitions * associated with the model's properties, excluding those listed in `propsToIgnore`. * * @function getValidatableProperties */ export function getValidatableProperties(model, propsToIgnore) { const decoratedProperties = []; for (const prop in model) { if (Object.prototype.hasOwnProperty.call(model, prop) && !propsToIgnore.includes(prop)) { const dec = getValidationDecorators(model, prop); if (dec) decoratedProperties.push(dec); } } return decoratedProperties; } /** * Safely sets temporary metadata on an object */ function setTemporaryContext(target, key, value) { if (!Object.hasOwnProperty.call(target, key)) target[key] = value; } /** * Safely removes temporary metadata from an object */ function cleanupTemporaryContext(target, key) { if (Object.hasOwnProperty.call(target, key)) delete target[key]; } /** * Executes validation with temporary context and returns the validation result * * @param nestedModel - The instance to validate * @param parentModel - Reference to a parent object for nested validation * @param isAsync - Whether to perform async validation * @returns Validation result from hasErrors() */ function getNestedValidationErrors(nestedModel, parentModel, isAsync, ...propsToIgnore) { // Set temporary context for nested models if (parentModel) { setTemporaryContext(nestedModel, VALIDATION_PARENT_KEY, parentModel); } setTemporaryContext(nestedModel, ASYNC_META_KEY, !!isAsync); const errs = nestedModel.hasErrors(...propsToIgnore); cleanupTemporaryContext(nestedModel, VALIDATION_PARENT_KEY); cleanupTemporaryContext(nestedModel, ASYNC_META_KEY); return errs; } export function validateChildValue(prop, childValue, parentModel, allowedTypes, async, ...propsToIgnore) { let err = undefined; let atLeastOneMatched = false; for (const allowedType of allowedTypes) { const Constr = Model.get(allowedType); if (!Constr) { err = new ModelErrorDefinition({ [prop]: { [ValidationKeys.TYPE]: `Unable to verify type consistency, missing model registry for ${allowedType}`, }, }); } if (childValue instanceof Constr) { atLeastOneMatched = true; err = getNestedValidationErrors(childValue, parentModel, async, ...propsToIgnore); break; } } if (atLeastOneMatched) return err; return (err || new ModelErrorDefinition({ [prop]: { [ValidationKeys.TYPE]: `Value must be an instance of one of the expected types: ${allowedTypes.join(", ")}`, }, })); } export function validateDecorator(model, value, decorator, async) { const validator = Validation.get(decorator.key); if (!validator) { throw new Error(`Missing validator for ${decorator.key}`); } // skip async decorators if validateDecorators is called synchronously (async = false) if (!async && decorator.props.async) return undefined; const decoratorProps = decorator.key === ModelKeys.TYPE ? [decorator.props] : decorator.props || {}; const context = PathProxyEngine.create(model, { ignoreUndefined: true, ignoreNull: true, }); const validatorOptions = decorator.key === ModelKeys.TYPE ? { type: decoratorProps[0].name } : decoratorProps; const maybeAsyncErrors = validator.hasErrors(value, validatorOptions, context); return toConditionalPromise(maybeAsyncErrors, async); } /** * @description * Executes validation logic for a set of decorators applied to a model's property, handling both * synchronous and asynchronous validations, including support for nested validations and lists. * * @summary * Iterates over an array of decorator metadata objects and applies each validation rule to the * provided value. For list decorators (`ValidationKeys.LIST`), it performs element-wise validation, * supporting nested model validation and type checks. If the `async` flag is set, asynchronous * validation is supported using `Promise.all`. The result is a record mapping validation keys to * error messages, or `undefined` if no errors are found. * * @template M - A type parameter extending `Model`, representing the model type being validated. * @template Async - A boolean indicating whether validation should be performed asynchronously. * * @param {M} model - The model instance that the validation is associated with. * @param {string} prop - The model field name * @param {any} value - The value to be validated against the provided decorators. * @param {DecoratorMetadataAsync[]} decorators - An array of metadata objects representing validation decorators. * @param {Async} [async] - Optional flag indicating whether validation should be performed asynchronously. * * @return {ConditionalAsync<Async, Record<string, string>> | undefined} * Returns either a record of validation errors (keyed by the decorator key) or `undefined` if no errors are found. * If `async` is true, the return value is a Promise resolving to the same structure. * * @function validateDecorators */ export function validateDecorators(model, prop, value, decorators, async, ...propsToIgnore) { const result = {}; for (const decorator of decorators) { // skip async decorators if validateDecorators is called synchronously (async = false) if (!async && decorator.props.async) continue; let validationErrors = validateDecorator(model, value, decorator, async); /* If the decorator is a list, each element must be checked. When 'async' is true, the 'err' will always be a pending promise initially, so the '!err' check will evaluate to false (even if the promise later resolves with no errors) */ if (decorator.key === ValidationKeys.LIST && (!validationErrors || async)) { const values = value instanceof Set ? [...value] : value; if (values && values.length > 0) { let types = (decorator.props.class || decorator.props.clazz || decorator.props.customTypes); types = (Array.isArray(types) ? types : [types]).map((e) => { e = typeof e === "function" && !e.name ? e() : e; return e.name ? e.name : e; }); const allowedTypes = [types].flat().map((t) => String(t).toLowerCase()); // const reserved = Object.values(ReservedModels).map((v) => v.toLowerCase()) as string[]; const errs = values.map((childValue) => { // if (Model.isModel(v) && !reserved.includes(v) { if (Model.isModel(childValue)) { return validateChildValue(prop, childValue, model, types.flat(), !!async, ...propsToIgnore); // return getNestedValidationErrors(childValue, model, async); } return allowedTypes.includes(typeof childValue) ? undefined : "Value has no validatable type"; }); if (async) { validationErrors = Promise.all(errs).then((result) => { const allEmpty = result.every((r) => !r); return allEmpty ? undefined : result; }); } else { const allEmpty = errs.every((r) => !r); validationErrors = errs.length > 0 && !allEmpty ? errs : undefined; } } } const name = decorator.key === ModelKeys.TYPE ? ValidationKeys.TYPE : decorator.key; if (validationErrors) result[name] = validationErrors; } if (!async) return Object.keys(result).length > 0 ? result : undefined; const keys = Object.keys(result); const promises = Object.values(result); return Promise.all(promises).then((resolvedValues) => { const res = {}; for (let i = 0; i < resolvedValues.length; i++) { const val = resolvedValues[i]; if (val !== undefined) { res[keys[i]] = val; } } return Object.keys(res).length > 0 ? res : undefined; }); } /** * @function validate * @template M * @template Async * @memberOf module:decorator-validation * @category Model * * @description * Validates the properties of a {@link Model} instance using registered decorators. * Supports both synchronous and asynchronous validation flows, depending on the `async` flag. * * @summary * This function inspects a given model object, identifies decorated properties that require validation, * and applies the corresponding validation rules. It also supports nested model validation and gracefully * merges any validation errors. For collections (Array/Set), it enforces the presence of the `@list` decorator * and checks the type of elements. If a property is a nested model, it will call `hasErrors` on it and flatten * the nested error keys using dot notation. * * @param {M} model - The model instance to be validated. Must extend from {@link Model}. * @param {Async} [async] - A flag indicating whether validation should be asynchronous. * @param {...string} propsToIgnore - A variadic list of property names that should be skipped during validation. * * @returns {ConditionalAsync<Async, ModelErrorDefinition | undefined>} * Returns either a {@link ModelErrorDefinition} containing validation errors, * or `undefined` if no errors are found. When `async` is `true`, returns a Promise. * * @see {@link Model} * @see {@link ModelErrorDefinition} * @see {@link validateDecorators} * @see {@link getValidatableProperties} * * @mermaid * sequenceDiagram * participant Caller * participant validate * participant getValidatableProperties * participant validateDecorators * participant ModelInstance * Caller->>validate: call with obj, async, propsToIgnore * validate->>getValidatableProperties: retrieve decorated props * loop for each property * validate->>validateDecorators: validate using decorators * alt is nested model * validate->>ModelInstance: call hasErrors() * end * end * alt async * validate->>validate: Promise.allSettled for errors * end * validate-->>Caller: return ModelErrorDefinition | undefined */ export function validate(model, async, ...propsToIgnore) { const decoratedProperties = getValidatableProperties(model, propsToIgnore); const result = {}; const nestedErrors = {}; for (const { prop, decorators } of decoratedProperties) { const propKey = String(prop); let propValue = model[prop]; if (!decorators?.length) continue; // Get the default type validator const priority = [ValidationKeys.TYPE, ModelKeys.TYPE]; const designTypeDec = priority .map((key) => decorators.find((d) => d.key === key)) .find(Boolean); // Ensures that only one type decorator remains. if (designTypeDec?.key === ValidationKeys.TYPE) { decorators.splice(0, decorators.length, ...decorators.filter((d) => d.key !== ModelKeys.TYPE)); } if (!designTypeDec) continue; const designType = designTypeDec.props.class || designTypeDec.props.clazz || designTypeDec.props.customTypes || designTypeDec.props.name; // TS emits "Object" as design:type for unions (string | number) and intersections (A & B). // Since this metadata is ambiguous for validation, skip design:type checks in these cases. // To enforce design:type validation explicitly, the @type validator can be used. if (designTypeDec.key === ModelKeys.TYPE && designType === "Object") decorators.shift(); const designTypes = (Array.isArray(designType) ? designType : [designType]).map((e) => { e = typeof e === "function" && !e.name ? e() : e; return e.name ? e.name : e; }); // Handle array or Set types and enforce the presence of @list decorator // if ([Array.name, Set.name].includes(designType)) {} if (designTypes.some((t) => [Array.name, Set.name].includes(t))) { if (!decorators.some((d) => d.key === ValidationKeys.LIST)) { result[propKey] = { [ValidationKeys.TYPE]: `Array or Set property '${propKey}' requires a @list decorator`, }; continue; } if (propValue && !(Array.isArray(propValue) || propValue instanceof Set)) { result[propKey] = { [ValidationKeys.TYPE]: `Property '${String(prop)}' must be either an Array or a Set`, }; continue; } // Remove design:type decorator, since @list decorator already ensures type for (let i = decorators.length - 1; i >= 0; i--) { if (decorators[i].key === ModelKeys.TYPE) { decorators.splice(i, 1); } } propValue = propValue instanceof Set ? [...propValue] : propValue; } const propErrors = validateDecorators(model, propKey, propValue, decorators, async, ...propsToIgnore) || {}; // Check for nested properties. // To prevent unnecessary processing, "propValue" must be defined and validatable // let nestedErrors: Record<string, any> = {}; const isConstr = Model.isPropertyModel(model, propKey); const hasPropValue = propValue !== null && propValue !== undefined; if (isConstr && hasPropValue) { const instance = propValue; const isInvalidModel = typeof instance !== "object" || typeof instance.hasErrors !== "function"; if (isInvalidModel) { // propErrors[ValidationKeys.TYPE] = "Model should be validatable but it's not."; console.warn("Model should be validatable but it's not."); } else { const Constr = (Array.isArray(designType) ? designType : [designType]) .map((d) => { if (typeof d === "function" && !d.name) d = d(); return Model.get(d.name || d); }) .find((d) => !!d); // Ensure instance is of the expected model class. if (!Constr || !(instance instanceof Constr)) { propErrors[ValidationKeys.TYPE] = !Constr ? `Unable to verify type consistency, missing model registry for ${designTypes.toString()} on prop ${propKey}` : `Value must be an instance of ${Constr.name}`; delete propErrors[ModelKeys.TYPE]; // remove duplicate type error } else { nestedErrors[propKey] = getNestedValidationErrors(instance, model, async, ...propsToIgnore); } } } // Add to the result if we have any errors // Async mode returns a Promise that resolves to undefined when no errors exist if (Object.keys(propErrors).length > 0 || async) result[propKey] = propErrors; // Then merge any nested errors if (!async) { Object.entries(nestedErrors[propKey] || {}).forEach(([key, error]) => { if (error !== undefined) { result[`${propKey}.${key}`] = error; } }); } } // Synchronous return if (!async) { return (Object.keys(result).length > 0 ? new ModelErrorDefinition(result) : undefined); } const merged = result; // TODO: apply filtering const keys = Object.keys(merged); const promises = Object.values(merged); return Promise.allSettled(promises).then(async (results) => { const result = {}; for (const [parentProp, nestedErrPromise] of Object.entries(nestedErrors)) { const nestedPropDecErrors = (await nestedErrPromise); if (nestedPropDecErrors) Object.entries(nestedPropDecErrors).forEach(([nestedProp, nestedPropDecError]) => { if (nestedPropDecError !== undefined) { const nestedKey = [parentProp, nestedProp].join("."); result[nestedKey] = nestedPropDecError; } }); } for (let i = 0; i < results.length; i++) { const key = keys[i]; const res = results[i]; if (res.status === "fulfilled" && res.value !== undefined) { result[key] = res.value; } else if (res.status === "rejected") { result[key] = res.reason instanceof Error ? res.reason.message : String(res.reason || "Validation failed"); } } return Object.keys(result).length > 0 ? new ModelErrorDefinition(result) : undefined; }); } //# sourceMappingURL=validation.js.map