UNPKG

bookish-potato-dto

Version:

A TypeScript DTO (Data Transfer Object) parsing and validation library. Define a schema once — get runtime validation and a fully inferred TypeScript type for free.

894 lines (860 loc) 30 kB
/** * Attaches the three modifier methods to any field descriptor. * Single source of truth — no per-field repetition needed. */ function withModifiers(descriptor) { const { options, ...rest } = descriptor; return { ...descriptor, optional: (() => ({ ...rest, options: { ...options, isOptional: true }, })), nullable: (() => ({ ...rest, options: { ...options, isNullable: true }, })), optionalAndNullable: (() => ({ ...rest, options: { ...options, isOptional: true, isNullable: true }, })), }; } // ---- Field builders ---- const field = { /** A string field. Replaces @StringProperty. */ string(options) { return withModifiers({ kind: 'string', options: options ?? {} }); }, /** A floating-point number field. Replaces @NumberProperty. */ number(options) { return withModifiers({ kind: 'number', options: options ?? {} }); }, /** An integer field. Replaces @IntegerProperty. */ integer(options) { return withModifiers({ kind: 'integer', options: options ?? {} }); }, /** A boolean field. Replaces @BooleanProperty. */ boolean(options) { return withModifiers({ kind: 'boolean', options: options ?? {} }); }, /** An enum field. Replaces @EnumProperty. */ enum(enumObj, options) { return withModifiers({ kind: 'enum', options: options ?? {}, enumObj }); }, /** A Date field. Replaces @DateProperty. */ date(options) { return withModifiers({ kind: 'date', options: options ?? {} }); }, /** A regex-validated string field. Replaces @RegexProperty. */ regex(pattern, options) { return withModifiers({ kind: 'regex', options: options ?? {}, pattern }); }, /** An array of primitives. Replaces @ArrayProperty. */ array(itemType, options) { return withModifiers({ kind: 'array', options: options ?? {}, itemType }); }, /** An array of nested DTOs. Replaces @ArrayDtoProperty. */ arrayDto(dtoDefinition, options) { return withModifiers({ kind: 'arrayDto', options: (options ?? {}), dtoDefinition, }); }, /** A nested DTO field. Replaces @DtoProperty. */ dto(dtoDefinition, options) { return withModifiers({ kind: 'dto', options: (options ?? {}), dtoDefinition, }); }, /** A field with a fully custom parser. Replaces @CustomProperty. */ custom(options) { return withModifiers({ kind: 'custom', options, parser: options.parser }); }, }; 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); }); } /** * Creates a DtoDefinition from a schema map of field descriptors. * The returned object's `.fields` property can be spread to compose or extend DTOs. * * @example * const PersonDto = defineDto({ * name: field.string(), * age: field.integer(), * }); * * // Extend via spread: * const EmployeeDto = defineDto({ * ...PersonDto.fields, * position: field.string(), * }); */ function defineDto(schema) { return { _uuid: generateUuid(), fields: schema, }; } /** * Generates an OpenAPI schema for a given DTO definition. * It also returns all referenced DTOs to help build the 'components/schemas' section. */ function generateOpenApi(dto, options = {}) { const refs = {}; const processedUuids = new Set(); function getName(d) { return options.nameResolver?.(d) ?? d._uuid; } function transformDto(d) { if (processedUuids.has(d._uuid)) { return { $ref: `#/components/schemas/${getName(d)}` }; } processedUuids.add(d._uuid); const properties = {}; const required = []; for (const [key, descriptor] of Object.entries(d.fields)) { properties[key] = transformField(descriptor); if (!descriptor.options.isOptional) { required.push(key); } } refs[getName(d)] = { type: 'object', properties, ...(required.length > 0 ? { required } : {}), }; return { $ref: `#/components/schemas/${getName(d)}` }; } function transformField(descriptor) { const { kind, options: fieldOptions } = descriptor; let inferred = {}; switch (kind) { case 'string': { const opt = fieldOptions; inferred = { type: 'string', ...(opt.minLength === undefined ? {} : { minLength: opt.minLength }), ...(opt.maxLength === undefined ? {} : { maxLength: opt.maxLength }), }; break; } case 'number': case 'integer': { const opt = fieldOptions; inferred = { type: kind === 'integer' ? 'integer' : 'number', ...(opt.minValue === undefined ? {} : { minimum: opt.minValue }), ...(opt.maxValue === undefined ? {} : { maximum: opt.maxValue }), }; break; } case 'boolean': inferred = { type: 'boolean' }; break; case 'date': inferred = { type: 'string', format: 'date-time' }; break; case 'regex': { const d = descriptor; inferred = { type: 'string', pattern: d.pattern.source }; break; } case 'enum': { const d = descriptor; inferred = { type: typeof Object.values(d.enumObj)[0] === 'number' ? 'number' : 'string', enum: Object.values(d.enumObj), }; break; } case 'array': { const d = descriptor; const opt = fieldOptions; inferred = { type: 'array', items: mapPrimitiveToOpenApi(d.itemType, opt), ...(opt.minLength === undefined ? {} : { minItems: opt.minLength }), ...(opt.maxLength === undefined ? {} : { maxItems: opt.maxLength }), }; break; } case 'dto': return { ...transformDto(descriptor.dtoDefinition), ...fieldOptions.openApi, }; case 'arrayDto': { const d = descriptor; inferred = { type: 'array', items: transformDto(d.dtoDefinition), ...(d.options.minLength === undefined ? {} : { minItems: d.options.minLength }), ...(d.options.maxLength === undefined ? {} : { maxItems: d.options.maxLength }), }; break; } case 'custom': inferred = { type: 'object', description: 'Custom parsed field' }; break; } // Common metadata const common = { ...(fieldOptions.isNullable ? { nullable: true } : {}), ...(fieldOptions.defaultValue === undefined ? {} : { default: fieldOptions.defaultValue }), }; // Ranking: Manual Overrides > Common/Inferred return { ...inferred, ...common, ...fieldOptions.openApi, }; } const rootSchema = transformDto(dto); return { schema: rootSchema, refs, }; } function mapPrimitiveToOpenApi(kind, options) { switch (kind) { case 'string': return { type: 'string', ...(options.stringsLength?.minLength === undefined ? {} : { minLength: options.stringsLength.minLength }), ...(options.stringsLength?.maxLength === undefined ? {} : { maxLength: options.stringsLength.maxLength }), }; case 'number': return { type: 'number', ...(options.numbersRange?.minValue === undefined ? {} : { minimum: options.numbersRange.minValue }), ...(options.numbersRange?.maxValue === undefined ? {} : { maximum: options.numbersRange.maxValue }), }; case 'boolean': return { type: 'boolean' }; default: return {}; } } 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 = data.customMessage || `Property "${data.propertyKey}" parsing error: ${data.causedBy.message}`; } } 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 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; } } let _fn; function getDtoParseFn() { return _fn; } function setDtoParseFn(fn) { _fn = fn; } 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(`array:${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)); } /** * Returns a cached PropertyParser that delegates to schemaParseObject. * Uses the DtoDefinition's stable _uuid as the cache key. * Prefix avoids collision with PropertyParsers.ARRAY which also caches by parser.uuid. */ static DTO_DEFINITION(dto) { const cacheKey = `dto-def:${dto._uuid}`; return this.cache.get(cacheKey, () => ({ uuid: cacheKey, parse: (value) => { const fn = getDtoParseFn(); if (!fn) { throw new Error('[bookish-potato-dto] schema parse function not yet registered. ' + 'Ensure "bookish-potato-dto" is imported before parsing nested DTOs.'); } return fn(dto, value); }, })); } } PropertyParsers.cache = new ParsersCache(); /** * Resolves a runtime PropertyParser from a FieldDescriptor. * Reuses PropertyParsers cache for all types. */ function resolveParser(descriptor) { switch (descriptor.kind) { case 'string': { const { minLength, maxLength } = descriptor.options; return PropertyParsers.STRING_IN_RANGE({ min: minLength, max: maxLength }); } case 'number': { const { strictDataTypes, minValue, maxValue } = descriptor.options; const range = { min: minValue, max: maxValue }; return (strictDataTypes ? PropertyParsers.NUMBER_IN_RANGE(range) : PropertyParsers.STRING_TO_NUMBER_IN_RANGE(range)); } case 'integer': { const { strictDataTypes, minValue, maxValue } = descriptor.options; const range = { min: minValue, max: maxValue }; return (strictDataTypes ? PropertyParsers.INTEGER_IN_RANGE(range) : PropertyParsers.STRING_TO_INTEGER_IN_RANGE(range)); } case 'boolean': return (descriptor.options.strictDataTypes ? PropertyParsers.BOOLEAN : PropertyParsers.STRING_TO_BOOLEAN); case 'enum': return PropertyParsers.ENUM(descriptor.enumObj); case 'date': return PropertyParsers.DATE; case 'regex': return PropertyParsers.STRING_REGEX(descriptor.pattern); case 'array': { const { strictDataTypes, minLength, maxLength, stringsLength, numbersRange } = descriptor.options; const arrayRange = { min: minLength, max: maxLength }; switch (descriptor.itemType) { case 'string': return PropertyParsers.ARRAY_IN_RANGE(PropertyParsers.STRING_IN_RANGE({ min: stringsLength?.minLength, max: stringsLength?.maxLength }), arrayRange); case 'number': { const nr = { min: numbersRange?.minValue, max: numbersRange?.maxValue }; return PropertyParsers.ARRAY_IN_RANGE(strictDataTypes ? PropertyParsers.NUMBER_IN_RANGE(nr) : PropertyParsers.STRING_TO_NUMBER_IN_RANGE(nr), arrayRange); } case 'boolean': return PropertyParsers.ARRAY_IN_RANGE(strictDataTypes ? PropertyParsers.BOOLEAN : PropertyParsers.STRING_TO_BOOLEAN, arrayRange); default: throw new Error('field.array: unsupported itemType'); } } case 'dto': return PropertyParsers.DTO_DEFINITION(descriptor.dtoDefinition); case 'arrayDto': { const { minLength, maxLength } = descriptor.options; const itemParser = PropertyParsers.DTO_DEFINITION(descriptor.dtoDefinition); return PropertyParsers.ARRAY_IN_RANGE(itemParser, { min: minLength, max: maxLength }); } case 'custom': return descriptor.parser; } } 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, isNullable, } = propertyDataToParse; if (dto[key] === null) { if (isNullable) { return next(data); } return new PropertyParsingError({ propertyKey: key, causedBy: new Error(`Property cannot be null!`), }); } try { dto[key] = parser.parse(dto[key]); } catch (error) { const value = dto[key]; 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, customMessage: data.propertyDataToParse.parsingErrorMessage?.(key, value, 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(); /** * parseObject implementation for DtoDefinition. * Reuses the existing chain-of-responsibility parse engine. * Called by the public parseObject() overload in src/parser/parser.ts. */ function schemaParseObject(dto, toParse) { if (!toParse || typeof toParse !== 'object' || Array.isArray(toParse)) { throw new ParsingError('Provided value to parse is not a valid object'); } const rawObj = toParse; const parsedObject = {}; const parsingErrors = []; for (const [fieldKey, descriptor] of Object.entries(dto.fields)) { // Resolve mapFrom before the chain runs — the chain only uses the field key const sourceKey = descriptor.options.mapFrom ?? fieldKey; parsedObject[fieldKey] = rawObj[sourceKey]; const opts = descriptor.options; const propertyDataToParse = { key: fieldKey, parser: resolveParser(descriptor), defaultValue: opts.defaultValue, isOptional: opts.isOptional, isNullable: opts.isNullable, useDefaultValueOnParseError: opts.useDefaultValueOnParseError, mapFrom: opts.mapFrom, parsingErrorMessage: opts.parsingErrorMessage, }; const error = parseChainFacade.parse({ propertyDataToParse, dto: parsedObject, }); if (error) { parsingErrors.push(error); } } if (parsingErrors.length > 0) { throw new ParsingError(parsingErrors.map(e => e.message).join('\n')); } return parsedObject; } // Self-register when this module first loads. // Uses the dependency-free _dto-parse-registry to avoid circular import issues. setDtoParseFn(schemaParseObject); /** * Parses a plain object using a schema-first DtoDefinition. * Returns a fully typed plain object — no class instantiation. */ function parseObject(dto, toParse) { return schemaParseObject(dto, toParse); } export { ParsingError, PropertyParsingError, defineDto, field, generateOpenApi, parseObject };