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
JavaScript
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 };