UNPKG

bookish-potato-dto

Version:

## Overview A TypeScript decorators-based API for defining Data Transfer Object (DTO) classes, types, and parsers. Simplifies schema validation and type enforcement using intuitive decorators and TypeScript classes.

729 lines (690 loc) 23.7 kB
class ParsingError extends Error { constructor(message) { super(message); this.name = "ParsingError"; } } class PropertyParsingError extends Error { get propertyKey() { return this.data.propertyKey; } constructor(data) { super(); this.data = data; this.message = `Property "${data.propertyKey}" parsing error: ${data.causedBy.message}`; } } const symbolDtoProperties = Symbol('dto-properties-b4759232-c2b5-4780-8855-6e51a2ae58a6'); const symbolDtoExtended = Symbol('dto-extended-0e0b0b3b-9c2f-4b7b-8d3e-2b9d8e5b2b8f'); function registerProperty(data) { const properties = Reflect.get(data.target, symbolDtoProperties) ?? []; if (properties.some(v => v.key === data.propertyData.key)) { throw new Error(`Property with key "${data.propertyData.key}" already defined in ${data.target.name}!`); } const collisionPropertyMapFromWithKey = properties.find(v => checkCollisionsOf(v, data.propertyData)); if (collisionPropertyMapFromWithKey) { throw new Error(`Property "${data.propertyData.key}" and "${collisionPropertyMapFromWithKey.key}" collide with each other in "${data.target.name}" class!` + ` Please use another key for the "mapFrom" option!`); } properties.push(data.propertyData); Reflect.set(data.target, symbolDtoProperties, properties); } function getRegisteredProperties(target) { const properties = []; const extendedClasses = Reflect.get(target, symbolDtoExtended); if (extendedClasses) { for (const extendedClass of extendedClasses) { properties.push(...getRegisteredProperties(extendedClass)); } } properties.push(...Reflect.get(target, symbolDtoProperties)); return properties; } function registerExtendedPropertyOf(data) { const properties = getRegisteredProperties(data.target); // Iterate over all properties of the target class and check if there are any collisions for (const targetToExtend of data.targetsToExtend) { const propertiesToExtend = getRegisteredProperties(targetToExtend); for (const propertyToExtend of propertiesToExtend) { if (properties.some(v => checkCollisionsOf(v, propertyToExtend))) { throw new Error(`Class ${data.target.name} extending error: Property with key "${propertyToExtend.key}" collide with another property and cannot be overridden!` + ` Please check if the property is already defined in the class or in the extended classes, as well as the "mapFrom" option!`); } } } // Iterate over all properties of the extended classes and check if there are any collisions for (const targetToExtend of data.targetsToExtend) { const propertiesToExtend = getRegisteredProperties(targetToExtend); for (const targetToExtendToCheck of data.targetsToExtend) { const propertiesToExtendToCheck = getRegisteredProperties(targetToExtendToCheck); // Skip the same class if (targetToExtend === targetToExtendToCheck) { continue; } for (const propertyToExtend of propertiesToExtend) { if (propertiesToExtendToCheck.some(v => checkCollisionsOf(v, propertyToExtend))) { throw new Error(`Class ${data.target.name} extending error: Property with key "${propertyToExtend.key}" collide with another property and cannot be overridden!` + ` Please check if the property is already defined in the class or in the extended classes, as well as the "mapFrom" option!`); } } } } Reflect.set(data.target, symbolDtoExtended, data.targetsToExtend); } function checkCollisionsOf(property_1, property_2) { return (property_1.key === property_2.mapFrom || property_1.mapFrom === property_2.key || property_1.key === property_2.key || (property_1.mapFrom && property_1.mapFrom === property_2.key)); } class ParseChainBase { setNextChain(nextChain) { this.nextChain = nextChain; return nextChain; } parse(data) { return this._parse(data, data => { if (this.nextChain) { return this.nextChain.parse(data); } return undefined; }); } } class ParseChainDefaultValue extends ParseChainBase { constructor() { super(); } _parse(data, next) { const { propertyDataToParse, dto } = data; const { key, defaultValue } = propertyDataToParse; if (dto[key] === undefined) { dto[key] = defaultValue; } return next(data); } } class ParseChainOptionalValue extends ParseChainBase { _parse(data, next) { const { propertyDataToParse, dto } = data; const { key, isOptional } = propertyDataToParse; // skip this chain if the value is optional and not present if (isOptional && dto[key] === undefined) { return; } return next(data); } } class ParseChainRequiredCheck extends ParseChainBase { _parse(data, next) { const { propertyDataToParse, dto } = data; const { key } = propertyDataToParse; // throw an error if the value is required and not present if (dto[key] === undefined) { return new PropertyParsingError({ propertyKey: key, causedBy: new ParsingError(`Property is required!`), }); } return next(data); } } class ParseChainParseValue extends ParseChainBase { _parse(data, next) { const { propertyDataToParse, dto } = data; const { key, parser, useDefaultValueOnParseError, defaultValue } = propertyDataToParse; try { dto[key] = parser.parse(dto[key]); } catch (error) { delete dto[key]; // Go to next chain if use default value on parse error is enabled if (useDefaultValueOnParseError && defaultValue !== undefined) { dto[key] = defaultValue; return next(data); } return new PropertyParsingError({ propertyKey: key, causedBy: error, }); } return next(data); } } /** * This is facade for the chain of responsibility pattern. * Defines the order of the chain and starts the chain. */ class ParseChainFacade { constructor() { this._chain = new ParseChainDefaultValue(); this._chain .setNextChain(new ParseChainOptionalValue()) .setNextChain(new ParseChainRequiredCheck()) .setNextChain(new ParseChainParseValue()); } parse(data) { return this._chain.parse(data); } } const parseChainFacade = new ParseChainFacade(); // eslint-disable-next-line function parseObject(_class, toParse) { const properties = getRegisteredProperties(_class); const parsingErrors = []; const dtoClass = new _class(); const parsedObject = dtoClass; for (const property of properties) { parsedObject[property.key] = toParse[property.mapFrom ?? property.key]; const error = parseChainFacade.parse({ propertyDataToParse: property, dto: parsedObject, }); if (error) { parsingErrors.push(error); } } if (parsingErrors.length > 0) { throw new ParsingError(parsingErrors.map(error => error.message).join('\n')); } return parsedObject; } function generateUuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } class PropertyParserAbstract { constructor() { this._uuid = generateUuid(); } get uuid() { return this._uuid; } } class PropertyParserFloating extends PropertyParserAbstract { parse(value) { if (Number.isNaN(value)) { throw new ParsingError(`The value is NaN`); } if (typeof value === 'number') { return value; } throw new ParsingError(`Unsupported type ${typeof value}`); } } class PropertyParserInteger extends PropertyParserAbstract { parse(value) { if (Number.isNaN(value)) { throw new ParsingError(`The value is NaN`); } const errorMessage = `The value of "${value}" is not an Integer`; if (typeof value === 'number') { const _parsedValue = this.checkInteger(value); if (!_parsedValue) throw new ParsingError(errorMessage); return _parsedValue; } throw new ParsingError(`Unsupported type ${typeof value}`); } checkInteger(value) { return parseInt(value.toFixed(0)) === value ? value : null; } } class PropertyParserString extends PropertyParserAbstract { parse(value) { if (typeof value !== 'string') { throw new ParsingError(`Value is not a string!`); } return value; } } /** * Decorator class for the number property parser. * Parses string to number before processing the value by the decorated class. */ class PropertyParserStringToNumberDecorator extends PropertyParserAbstract { constructor(_propertyParser) { super(); this._propertyParser = _propertyParser; } parse(value) { if (typeof value === 'string') { const _parsedValue = parseFloat(value.replace(',', '.')); return this._propertyParser.parse(_parsedValue); } return this._propertyParser.parse(value); } } class PropertyParserBoolean extends PropertyParserAbstract { parse(value) { if (typeof value === 'boolean') { return value; } throw new ParsingError(`Unsupported type ${typeof value}`); } } class PropertyParserStringToBooleanDecorator extends PropertyParserAbstract { constructor(parser) { super(); this.parser = parser; } parse(value) { if (typeof value === 'string') { if (value === 'true') { return true; } if (value === 'false') { return false; } } return this.parser.parse(value); } } class PropertyParserArray extends PropertyParserAbstract { constructor(parser) { super(); this.parser = parser; } parse(value) { if (!Array.isArray(value)) { throw new ParsingError(`Value is not an array! Value: ${value}`); } return value.map(item => this.parser.parse(item)); } } class PropertyParserEnum extends PropertyParserAbstract { constructor(enumType) { super(); this.enumType = enumType; } parse(value) { if (typeof value !== 'string' && typeof value !== 'number') { throw new ParsingError(`Value is not a string or number! Value: ${value}`); } for (const key in this.enumType) { if (this.enumType[key] === value) { return this.enumType[key]; } } throw new ParsingError(`Value is not in the enum! Value: ${value}`); } } class PropertyParserStringRegexDecorator extends PropertyParserAbstract { constructor(regex, parser) { super(); this.regex = regex; this.parser = parser; } parse(value) { const parsedValue = this.parser.parse(value); if (!this.regex.test(parsedValue)) { throw new ParsingError(`Value "${parsedValue}" does not match regex ${this.regex}`); } return parsedValue; } } class PropertyParserDto extends PropertyParserAbstract { constructor(_dtoClass) { super(); this._dtoClass = _dtoClass; } parse(value) { return parseObject(this._dtoClass, value); } } class PropertyParserDate extends PropertyParserAbstract { parse(value) { if (value instanceof Date) { return value; } if (typeof value !== 'string') { throw new ParsingError(`Value is not a string! Cannot parse ${value} to Date!`); } const _parsedDate = new Date(value); if (isNaN(_parsedDate.getTime()) || _parsedDate.toString() === 'Invalid Date') { throw new ParsingError(`Value ${value} is not a Date!`); } return _parsedDate; } } class PropertyParserRangeDecorator extends PropertyParserAbstract { constructor(data) { super(); this._range = data.range; this._parser = data.parser; this._errorMessage = data.errorMessage || `Value is not in range ${this._range.min} - ${this._range.max}`; } parse(value) { const parsedValue = this._parser.parse(value); if (this.checkIfInRange(parsedValue, this._range.min, this._range.max)) { return parsedValue; } throw new ParsingError(this._errorMessage); } _checkIfMoreThan(value, min) { return min === undefined || this.checkIfMoreThan(value, min); } _checkIfLessThan(value, max) { return max === undefined || this.checkIfLessThan(value, max); } checkIfInRange(value, min, max) { return (this._checkIfMoreThan(value, min) && this._checkIfLessThan(value, max)); } } function defineErrorMessageNumberRange(range) { let message = ''; if (range.min) { message = `Number should be more than ${range.min}`; } if (range.max) { message = `Number should be less than ${range.max}`; } if (range.min && range.max) { message = `Number should be in range of ${range.min} - ${range.max}`; } return message; } function defineErrorMessageStringRange(range) { let message = ''; if (range.min) { message = `String length should be more than ${range.min}`; } if (range.max) { message = `String length should be less than ${range.max}`; } if (range.min && range.max) { message = `String length should be in range of ${range.min} - ${range.max}`; } return message; } function defineErrorMessageArrayRange(range) { let message = ''; if (range.min) { message = `Array length should be more than ${range.min}`; } if (range.max) { message = `Array length should be less than ${range.max}`; } if (range.min && range.max) { message = `Array length should be in range of ${range.min} - ${range.max}`; } return message; } class PropertyParserNumberRangeDecorator extends PropertyParserRangeDecorator { constructor(data) { super({ errorMessage: defineErrorMessageNumberRange(data.range), ...data, }); } checkIfMoreThan(value, min) { return value >= min; } checkIfLessThan(value, max) { return value <= max; } } class PropertyParserStringRangeDecorator extends PropertyParserRangeDecorator { constructor(data) { super({ errorMessage: defineErrorMessageStringRange(data.range), ...data, }); } checkIfMoreThan(value, min) { return value.length >= min; } checkIfLessThan(value, max) { return value.length <= max; } } class PropertyParserArrayRangeDecorator extends PropertyParserRangeDecorator { constructor(data) { super({ errorMessage: defineErrorMessageArrayRange(data.range), ...data, }); } checkIfMoreThan(value, min) { return value.length >= min; } checkIfLessThan(value, max) { return value.length <= max; } } class ParsersCache { constructor() { this.cache = new Map(); } get(key, factory) { if (!this.cache.has(key)) { this.cache.set(key, factory()); } return this.cache.get(key); } } class PropertyParsers { /** * Returns a cached parser or creates a new one. * Can be used to cache parsers by key. * @param key - unique key for the parser. * @param factory - factory function to create a new parser. */ static CACHE_PARSER(key, factory) { return this.cache.get(key, factory); } static get STRING() { return this.cache.get('string', () => new PropertyParserString()); } static STRING_IN_RANGE(range) { return this.cache.get(`stringInRange${JSON.stringify(range)}`, () => new PropertyParserStringRangeDecorator({ range, parser: this.STRING, })); } static STRING_REGEX(regex) { return this.cache.get(`stringRegex${regex.toString()}`, () => new PropertyParserStringRegexDecorator(regex, this.STRING)); } static get NUMBER() { return this.cache.get('number', () => new PropertyParserFloating()); } static NUMBER_IN_RANGE(range) { return this.cache.get(`numberInRange${JSON.stringify(range)}`, () => new PropertyParserNumberRangeDecorator({ range, parser: this.NUMBER, })); } static get STRING_TO_NUMBER() { return this.cache.get('stringToNumber', () => new PropertyParserStringToNumberDecorator(this.NUMBER)); } static STRING_TO_NUMBER_IN_RANGE(range) { return this.cache.get(`stringToNumberInRange${JSON.stringify(range)}`, () => new PropertyParserStringToNumberDecorator(this.NUMBER_IN_RANGE(range))); } static get INTEGER() { return this.cache.get('integer', () => new PropertyParserInteger()); } static INTEGER_IN_RANGE(range) { return this.cache.get(`integerInRange${JSON.stringify(range)}`, () => new PropertyParserNumberRangeDecorator({ range, parser: this.INTEGER, })); } static get STRING_TO_INTEGER() { return this.cache.get('stringToInteger', () => new PropertyParserStringToNumberDecorator(this.INTEGER)); } static STRING_TO_INTEGER_IN_RANGE(range) { return this.cache.get(`stringToIntegerInRange${JSON.stringify(range)}`, () => new PropertyParserStringToNumberDecorator(this.INTEGER_IN_RANGE(range))); } static get BOOLEAN() { return this.cache.get('boolean', () => new PropertyParserBoolean()); } static get STRING_TO_BOOLEAN() { return this.cache.get('stringToBoolean', () => new PropertyParserStringToBooleanDecorator(this.BOOLEAN)); } static get DATE() { return this.cache.get('date', () => new PropertyParserDate()); } static ARRAY(parser) { return this.cache.get(parser.uuid ?? generateUuid(), () => new PropertyParserArray(parser)); } static ARRAY_IN_RANGE(parser, range) { return this.cache.get(`arrayInRange${parser.uuid}${JSON.stringify(range)}`, () => new PropertyParserArrayRangeDecorator({ range, parser: this.ARRAY(parser), })); } static ENUM(enumType) { const key = JSON.stringify(enumType); return this.cache.get(key, () => new PropertyParserEnum(enumType)); } static DTO(dtoClass) { return this.cache.get(dtoClass.name, () => new PropertyParserDto(dtoClass)); } } PropertyParsers.cache = new ParsersCache(); function CustomProperty(data) { // eslint-disable-next-line return function (target, propertyKey) { registerProperty({ target: target.constructor, propertyData: { key: propertyKey, ...data, }, }); }; } function StringProperty(data) { return CustomProperty({ parser: PropertyParsers.STRING_IN_RANGE({ min: data?.minLength, max: data?.maxLength, }), ...data, }); } function NumberProperty(data) { const range = { min: data?.minValue, max: data?.maxValue, }; return CustomProperty({ parser: data?.strictDataTypes ? PropertyParsers.NUMBER_IN_RANGE(range) : PropertyParsers.STRING_TO_NUMBER_IN_RANGE(range), ...data, }); } function IntegerProperty(data) { const range = { min: data?.minValue, max: data?.maxValue, }; return CustomProperty({ parser: data?.strictDataTypes ? PropertyParsers.INTEGER_IN_RANGE(range) : PropertyParsers.STRING_TO_INTEGER_IN_RANGE(range), ...data, }); } /** * Decorator to define a property that is a DTO. * @param _dtoClass The DTO class to use to parse the value. * @param data Additional data for the property. * @constructor */ function DtoProperty(_dtoClass, data) { return CustomProperty({ parser: PropertyParsers.DTO(_dtoClass), ...data, }); } const _stringToBooleanParser = new PropertyParserStringToBooleanDecorator(PropertyParsers.BOOLEAN); function BooleanProperty(data) { return CustomProperty({ parser: data?.strictDataTypes ? PropertyParsers.BOOLEAN : _stringToBooleanParser, ...data, }); } function ArrayProperty(ofPrimitive, data) { let parser; const range = { min: data?.minLength, max: data?.maxLength, }; if (ofPrimitive) { switch (ofPrimitive) { case 'string': parser = PropertyParsers.ARRAY_IN_RANGE(PropertyParsers.STRING_IN_RANGE({ min: data?.stringsLength?.minLength, max: data?.stringsLength?.maxLength, }), range); break; case 'number': { const numbersRange = { min: data?.numbersRange?.minValue, max: data?.numbersRange?.maxValue, }; parser = PropertyParsers.ARRAY_IN_RANGE((data?.strictDataTypes ? PropertyParsers.NUMBER_IN_RANGE(numbersRange) : PropertyParsers.STRING_TO_NUMBER_IN_RANGE(numbersRange)), range); break; } case 'boolean': parser = PropertyParsers.ARRAY_IN_RANGE((data?.strictDataTypes ? PropertyParsers.BOOLEAN : PropertyParsers.STRING_TO_BOOLEAN), range); break; } } if (!parser) { throw new Error(`Unsupported primitive type: ${ofPrimitive}`); } return CustomProperty({ parser, ...data, }); } function ArrayDtoProperty(ofDto, data) { return CustomProperty({ parser: PropertyParsers.ARRAY_IN_RANGE(PropertyParsers.DTO(ofDto), { min: data?.minLength, max: data?.maxLength, }), ...data, }); } function EnumProperty(enumType, data) { return CustomProperty({ parser: PropertyParsers.ENUM(enumType), ...data, }); } function RegexProperty(regex, data) { return CustomProperty({ parser: PropertyParsers.STRING_REGEX(regex), ...data, }); } /** * @deprecated - If you want to extend a class, use the ´extends´ keyword. * @param data - The data to extend the class. * @constructor */ function DtoClass(data) { return (target) => { registerExtendedPropertyOf({ target, targetsToExtend: data.extends, }); }; } export { ArrayDtoProperty, ArrayProperty, BooleanProperty, CustomProperty, DtoClass, DtoProperty, EnumProperty, IntegerProperty, NumberProperty, ParsingError, PropertyParsers, PropertyParsingError, RegexProperty, StringProperty, parseObject };