UNPKG

class-validator-jsonschema

Version:

Convert class-validator-decorated classes into JSON schema

327 lines (291 loc) 9.17 kB
// tslint:disable:no-submodule-imports ban-types import * as cv from 'class-validator' import { ValidationMetadata } from 'class-validator/types/metadata/ValidationMetadata' import _groupBy from 'lodash.groupby' import _merge from 'lodash.merge' import type { ReferenceObject, SchemaObject } from 'openapi3-ts' import { getMetadataSchema } from './decorators' import { defaultConverters } from './defaultConverters' import { defaultOptions, IOptions } from './options' import { ExposeMetadata } from 'class-transformer' export { JSONSchema } from './decorators' type IStorage = { validationMetadatas: Map<Function | string, ValidationMetadata[]> } & Omit<cv.MetadataStorage, 'validationMetadatas'> /** * Convert class-validator metadata into JSON Schema definitions. */ export function validationMetadatasToSchemas( userOptions?: Partial<IOptions> ): Record<string, SchemaObject> { const options: IOptions = { ...defaultOptions, ...userOptions, } const metadatas = getMetadatasFromStorage( options.classValidatorMetadataStorage ) return validationMetadataArrayToSchemas(metadatas, userOptions) } /** * Convert an array of class-validator metadata into JSON Schema definitions. */ export function validationMetadataArrayToSchemas( metadatas: ValidationMetadata[], userOptions?: Partial<IOptions> ): Record<string, SchemaObject> { const options: IOptions = { ...defaultOptions, ...userOptions, } const schemas: { [key: string]: SchemaObject } = {} Object.entries( _groupBy( metadatas, ({ target }) => target[options.schemaNameField as keyof typeof target] ?? (target as Function).name ) ).forEach(([key, ownMetas]) => { const target = ownMetas[0].target as Function const metas = ownMetas .concat(getInheritedMetadatas(target, metadatas)) .filter( (propMeta) => !( isExcluded(propMeta, options) || isExcluded({ ...propMeta, target }, options) ) ) .map((propMeta) => { /** * Retrieves all properties that have the Expose decorator from class-transformer * and remaps the property names to the names exposed by the Expose decorator. */ const exposeMetadata = userOptions?.classTransformerMetadataStorage?.getExposedMetadatas( propMeta.target as any ) const ctMetaForField = exposeMetadata?.find( (meta: ExposeMetadata) => meta.propertyName === propMeta.propertyName ) if (ctMetaForField?.options.name) { propMeta.propertyName = ctMetaForField.options.name } return propMeta }) const properties: { [name: string]: ReferenceObject | SchemaObject } = {} Object.entries(_groupBy(metas, 'propertyName')).forEach( ([propName, propMetas]) => { const schema = applyConverters(propMetas, options) properties[propName] = applyDecorators( schema, target, options, propName ) } ) const definitionSchema: SchemaObject = { properties, type: 'object', } const required = getRequiredPropNames(target, metas, options) if (required.length > 0) { definitionSchema.required = required } schemas[key] = applyDecorators( definitionSchema, target, options, target.name ) as SchemaObject }) return schemas } /** * Search for the JSON Schema definition from child class up to the * top parent class until empty function name is found. */ function getTargetConstructorSchema( schemas: Record<string, SchemaObject>, targetConstructor: Function ): SchemaObject { if (!targetConstructor.name) { return {} } else if (schemas[targetConstructor.name]) { return schemas[targetConstructor.name] } else { return getTargetConstructorSchema( schemas, Object.getPrototypeOf(targetConstructor) ) } } /** * Generate JSON Schema definitions from the target object constructor. */ export function targetConstructorToSchema( targetConstructor: Function, userOptions?: Partial<IOptions> ): SchemaObject { const options: IOptions = { ...defaultOptions, ...userOptions, } const storage = options.classValidatorMetadataStorage let metadatas = storage.getTargetValidationMetadatas( targetConstructor, '', true, false ) metadatas = populateMetadatasWithConstraints(storage, metadatas) const schemas = validationMetadataArrayToSchemas(metadatas, userOptions) return getTargetConstructorSchema(schemas, targetConstructor) } /** * Return `storage.validationMetadatas` populated with `constraintMetadatas`. */ function getMetadatasFromStorage( storage: cv.MetadataStorage ): ValidationMetadata[] { const metadatas: ValidationMetadata[] = [] for (const value of (storage as unknown as IStorage).validationMetadatas) { metadatas.push(...populateMetadatasWithConstraints(storage, value[1])) } return metadatas } function populateMetadatasWithConstraints( storage: cv.MetadataStorage, metadatas: ValidationMetadata[] ): ValidationMetadata[] { return metadatas.map((meta) => { if (meta.constraintCls) { const constraint = storage.getTargetValidatorConstraints( meta.constraintCls ) if (constraint.length > 0) { return { ...meta, type: constraint[0].name } } } return { ...meta } }) } /** * Return target class' inherited validation metadatas, with original metadatas * given precedence over inherited ones in case of duplicates. * * Adapted from `class-validator` source. * * @param target Target child class. * @param metadatas All class-validator metadata objects. */ function getInheritedMetadatas( target: Function, metadatas: ValidationMetadata[] ) { return metadatas.filter( (d) => d.target instanceof Function && target.prototype instanceof d.target && !metadatas.find( (m) => m.propertyName === d.propertyName && m.target === target && m.type === d.type ) ) } /** * Convert a property's class-validator metadata into a JSON Schema property. */ function applyConverters( propertyMetadatas: ValidationMetadata[], options: IOptions ): SchemaObject { const converters = { ...defaultConverters, ...options.additionalConverters } const convert = (meta: ValidationMetadata) => { const typeMeta = options.classTransformerMetadataStorage?.findTypeMetadata( meta.target as Function, meta.propertyName ) const isMap = typeMeta && typeMeta.reflectedType && new typeMeta.reflectedType() instanceof Map const converter = converters[meta.type] || converters[cv.ValidationTypes.CUSTOM_VALIDATION] const items = typeof converter === 'function' ? converter(meta, options) : converter if (meta.each && isMap) { return { additionalProperties: { ...items, }, type: 'object', } } return meta.each ? { items, type: 'array' } : items } return _merge({}, ...propertyMetadatas.map(convert)) } /** Check whether property is excluded with class-transformer `@Exclude` decorator. */ function isExcluded( propertyMetadata: ValidationMetadata, options: IOptions ): boolean { return !!options.classTransformerMetadataStorage?.findExcludeMetadata( propertyMetadata.target as Function, propertyMetadata.propertyName ) } /** * Given a JSON Schema object, supplement it with additional schema properties * defined by target object's @JSONSchema decorator. */ function applyDecorators( schema: SchemaObject, target: Function, options: IOptions, propertyName: string ): ReferenceObject | SchemaObject { const additionalSchema = getMetadataSchema(target.prototype, propertyName) return typeof additionalSchema === 'function' ? additionalSchema(schema, options) : _merge({}, schema, additionalSchema) } /** * Get the required property names of a validated class. * @param target Validation target class. * @param metadatas Validation metadata objects of the validated class. * @param options Global class-validator options. */ function getRequiredPropNames( target: Function, metadatas: ValidationMetadata[], options: IOptions ) { function isDefined(metas: ValidationMetadata[]) { return ( metas && metas.some(({ type }) => type === cv.ValidationTypes.IS_DEFINED) ) } function isOptional(metas: ValidationMetadata[]) { return ( metas && metas.some(({ type }) => [cv.ValidationTypes.CONDITIONAL_VALIDATION, cv.IS_EMPTY].includes(type) ) ) } return Object.entries(_groupBy(metadatas, (m) => m.propertyName)) .filter(([_, metas]) => { const own = metas.filter((m) => m.target === target) const inherited = metas.filter((m) => m.target !== target) return options.skipMissingProperties ? isDefined(own) || (!isOptional(own) && isDefined(inherited)) : !(isOptional(own) || isOptional(inherited)) }) .map(([name]) => name) }