import { IModelType, IModelTypeItem, IModelTypeConstraint, IModelTypeConstrainable, IModelTypeConstraintFactory, IModelTypeRegistry, IModelTypeComposite, IModelTypeCompositeBuilder } from "./model.api" import { ModelTypeRegistry } from "./model.registry"; import { ModelConstraints, ModelTypeConstrainable, ModelTypeConstraintPossibleValues, } from "./model.base"; import { ModelTypeNumber, ModelTypeConstraintLess, ModelTypeConstraintLessEqual, ModelTypeConstraintMore, ModelTypeConstraintMoreEqual, ModelTypeConstraintInteger, ModelTypeConstraintMultipleOf } from "./model.number" import { ModelTypeString, ModelTypeConstraintLength, ModelTypeConstraintRegex, ModelTypeConstraintInvalidRegex } from "./model.string" import { ModelTypeConstraintBefore, ModelTypeConstraintAfter, ModelTypeConstraintOlder, ModelTypeConstraintYounger } from "./model.date" import { ModelTypeBool } from "./model.bool" import { ModelTypeArray } from "./model.array" import { ModelTypeAny, ModelTypeObject, ModelTypeConstraintCompareProperties, ModelTypeConstraintEqualProperties, ModelTypeConstraintConditionalValue, ModelTypePropertyConstraint, ModelTypeConstraintOneOf } from "./model.object" import { invertedRE } from './regex-util'; import { JsonReferenceProcessor } from "@hn3000/json-ref" import * as fetch from "isomorphic-fetch"; export interface IConstraintFactory { [k:string]: (o:any) => IModelTypeConstraint; } export interface IConstraintFactories { numbers: IConstraintFactory; strings: IConstraintFactory; dates: IConstraintFactory; booleans: IConstraintFactory; objects: IConstraintFactory; universal: IConstraintFactory; } export interface IModelSchemaParserDefaults { numbers?: { minimum?: number; maximum?: number; minimumExclusive?: boolean; maximumExclusive?: boolean; multipleOf?: number; }; strings?: { minLength?: number; maxLength?: number; pattern?: String | RegExp; }; dates?: { minimum?: Date; maximum?: Date; } booleans?: { }; objects?: { }; universal?: { }; } function parseAge(o:any) { let result = o.age ? o.age : o.years ? o.years+'y' : '0y'; return result; } var constraintFactoriesDefault:IConstraintFactories = { numbers: { /* unnecessary: available via minimum / maximum less(o:any) { return new ModelTypeConstraintLess(o.value); }, more(o:any) { return new ModelTypeConstraintMore(o.value); }, lessEqual(o:any) { return new ModelTypeConstraintLessEqual(o.value); }, moreEqual(o:any) { return new ModelTypeConstraintMoreEqual(o.value); } */ }, strings: { maxAge(o:any) { return new ModelTypeConstraintYounger(parseAge(o)); }, minAge(o:any) { return new ModelTypeConstraintOlder(parseAge(o)); }, before(o:any) { return new ModelTypeConstraintBefore(o.date); }, after(o:any) { return new ModelTypeConstraintAfter(o.date); } }, dates: { maxAge(o:any) { return new ModelTypeConstraintYounger(parseAge(o)); }, minAge(o:any) { return new ModelTypeConstraintOlder(parseAge(o)); }, before(o:any) { return new ModelTypeConstraintBefore(o.date); }, after(o:any) { return new ModelTypeConstraintAfter(o.date); } }, booleans: { }, objects: { maxAge(o:any) { return new ModelTypePropertyConstraint(o.property, new ModelTypeConstraintYounger(parseAge(o))); }, minAge(o:any) { return new ModelTypePropertyConstraint(o.property, new ModelTypeConstraintOlder(parseAge(o))); }, before(o:any) { return new ModelTypePropertyConstraint(o.property, new ModelTypeConstraintBefore(o.date)); }, after(o:any) { return new ModelTypePropertyConstraint(o.property, new ModelTypeConstraintAfter(o.date)); }, equalProperties(o:any) { return new ModelTypeConstraintEqualProperties(o); }, compareProperties(o:any) { return new ModelTypeConstraintCompareProperties(o); }, requiredIf(o:any) { return new ModelTypeConstraintConditionalValue({ condition: o.condition, clearOtherwise: o.clearOtherwise, properties: o.properties }); }, requiredIfAll(o:any) { return new ModelTypeConstraintConditionalValue({ condition: o.condition||o.conditions, clearOtherwise: o.clearOtherwise, properties: o.properties }); }, valueIf(o:any) { return new ModelTypeConstraintConditionalValue({ condition: o.condition, clearOtherwise: false, properties: o.valueProperty, possibleValue: o.possibleValue }); }, valueIfAll(o:any) { return new ModelTypeConstraintConditionalValue({ condition: o.condition||o.conditions, clearOtherwise: false, properties: o.valueProperty, possibleValue: o.possibleValue }); } }, universal: { possibleValue(o:any) { return new ModelTypeConstraintPossibleValues(o); }, possibleValues(o:any) { return new ModelTypeConstraintPossibleValues(o); }, } }; /* tbh, I really don't know which ones of these are *correct* -- you can thank the openapi guys for this */ const flavorProps = [ 'flavor', 'flavour', 'x-flavour', 'x-flavor', 'x-Flavour', 'x-Flavor', ]; export class ModelSchemaParser implements IModelTypeRegistry { constructor(constraintFactory?:IModelTypeConstraintFactory, defaultValues?: IModelSchemaParserDefaults) { this._constraintFactory = constraintFactory || {}; this._registry = new ModelTypeRegistry(); this._defaults = defaultValues ?? {}; } addSchemaFromURL(url:string):Promise { this._ensureRefProcessor(); var p = this._refProcessor.expandRef(url); return p.then((schema:any) => { return this.addSchemaObject(url, schema); }); } /** * Parses a schema object and adds the resulting model type to the internal * registry. * * @param name of the type * @param schemaObject schema definition / description of the type * @param defaults can be used to override defaults */ addSchemaObject(name:string, schemaObject:any, defaults?: IModelSchemaParserDefaults):IModelType { const nameOrId = name || schemaObject.id; var type = this.parseSchemaObject(schemaObject, nameOrId); //console.log(`parsed type for name ${name}: ${type.name} / ${type.kind}`); type && this._registry.addType(type); return type; } parseSchemaObject(schemaObject:any, nameOrId?:string):IModelType { var schemaType = schemaObject['type']; var result:IModelTypeConstrainable = null; switch (schemaType) { case 'object': result = this.parseSchemaObjectTypeObject(schemaObject, nameOrId); break; case 'array': result = this.parseSchemaObjectTypeArray(schemaObject, nameOrId); break; case 'string': result = this.parseSchemaObjectTypeString(schemaObject, nameOrId); break; case 'number': result = this.parseSchemaObjectTypeNumber(schemaObject, nameOrId); break; case 'integer': result = this.parseSchemaObjectTypeNumber(schemaObject, nameOrId, new ModelTypeConstraintInteger()); break; case 'boolean': result = this.parseSchemaObjectTypeBoolean(schemaObject, nameOrId); break; case 'bool': console.log("warning: non-standard type 'bool' found in schema"); result = this.parseSchemaObjectTypeBoolean(schemaObject, nameOrId); break; default: result = this.parseSchemaObjectUntyped(schemaObject, nameOrId); //console.log(`don't know how to handle type ${schemaType} in`, schemaObject); break; } if (result != null) { const flavours = flavorProps.filter(x => x in schemaObject); if (flavours.length) { if (flavours.length > 1) { const flavoursArr = flavours.reduce((r,x) => (r.push(`${x}=${schemaObject[x]}`), r), []); console.debug(`found multiple flavours (${flavoursArr.join(';')}), using ${flavours[0]}`); } const flavour = schemaObject[flavours[0]]; result.propSet('flavor', flavour); result.propSet('flavour', flavour); } result.propSet("schema", schemaObject); } return result; } parseSchemaConstraintEnum(schemaObject:any) { var e = schemaObject['enum']; if (Array.isArray(e)) { return new ModelTypeConstraintPossibleValues(e); } return null; } parseSchemaObjectTypeString(schemaObject:any, name?: string) { const sources = [schemaObject, this._defaults.strings]; const minLen = findFirst(sources, 'minLength'); const maxLen = findFirst(sources, 'maxLength'); const pattern = findFirst(sources, 'pattern'); //const format = findFirst(sources, 'format'); var constraints = this._parseConstraints(schemaObject, [ constraintFactoriesDefault.strings, constraintFactoriesDefault.universal ]); if (minLen != undefined || maxLen != undefined) { constraints = constraints.add(new ModelTypeConstraintLength(minLen, maxLen)); } if (pattern != undefined) { let ire = invertedRE(pattern); if (ire) { constraints = constraints.add(new ModelTypeConstraintInvalidRegex(`(${ire})`)); } else { constraints = constraints.add(new ModelTypeConstraintRegex(pattern, '')); } } let enumConstraint = this.parseSchemaConstraintEnum(schemaObject); if (null != enumConstraint) { constraints = constraints.add(enumConstraint); } return new ModelTypeString(name, constraints); } parseSchemaObjectTypeNumber(schemaObject:any, name?: string, ...constraintArgs:IModelTypeConstraint[]) { //console.log('parsing number object', schemaObject); const sources = [schemaObject, this._defaults.numbers]; const minimum = findFirst(sources, 'minimum'); const maximum = findFirst(sources, 'maximum'); const minOut = findFirst(sources, 'minimumExclusive'); const maxOut = findFirst(sources, 'maximumExclusive'); const multipleOf = findFirst(sources, 'multipleOf'); const constraints = [ ...constraintArgs ]; if (typeof(minimum) === "number") { if (minOut) { constraints.push(new ModelTypeConstraintMore(minimum)); } else { constraints.push(new ModelTypeConstraintMoreEqual(minimum)); } } if (typeof(maximum) === "number") { if (maxOut) { constraints.push(new ModelTypeConstraintLess(maximum)); } else { constraints.push(new ModelTypeConstraintLessEqual(maximum)); } } if (typeof(multipleOf) === "number") { constraints.push(new ModelTypeConstraintMultipleOf(multipleOf)); } let enumConstraint = this.parseSchemaConstraintEnum(schemaObject); if (null != enumConstraint) { constraints.push(enumConstraint); } //console.log(constraints, typeof(minimum), typeof(maximum)); return new ModelTypeNumber(name, new ModelConstraints(constraints)); } parseSchemaObjectTypeBoolean(schemaObject:any, name?: string) { let constraints:ModelConstraints = null; let enumConstraint = this.parseSchemaConstraintEnum(schemaObject); if (null != enumConstraint) { constraints = new ModelConstraints([enumConstraint]); } return new ModelTypeBool(name, constraints); } parseSchemaObjectTypeObject(schemaObject:any, name?:string): IModelTypeConstrainable { let id = name || schemaObject.id || anonymousId(); let constraints = this._parseConstraints(schemaObject, [constraintFactoriesDefault.objects,constraintFactoriesDefault.universal]); let type:IModelTypeCompositeBuilder; let props = schemaObject['properties']; let keys = props && Object.keys(props); let dependencies = schemaObject['dependencies']; if (null != dependencies) { let deps = Object.keys(dependencies); for (let d of deps) { if (Array.isArray(dependencies[d])) { let dependentProperties = dependencies[d] as string[]; constraints = constraints.add(new ModelTypeConstraintConditionalValue({ condition: { property: d as string, invert: true, value: null as string }, properties: dependentProperties, clearOtherwise: false })); } } } type = new ModelTypeObject(id, null, constraints); let required:string[] = schemaObject['required'] || []; if (props) { for (var key of keys) { let isRequired = (-1 != required.indexOf(key)); type.addItem(key, this.parseSchemaObject(props[key], key), isRequired); } } let allOf = schemaObject['allOf']; if (allOf && Array.isArray(allOf)) { var index = 0; for (var inner of allOf) { let innerType = this.parseSchemaObjectTypeObject(inner, `${name}/allOf[${index}]`); if ((innerType as IModelTypeComposite).items) { type = type.extend(innerType as IModelTypeComposite); } ++index; } } let oneOf = schemaObject['oneOf']; if (oneOf && Array.isArray(oneOf)) { var index = 0; const alternatives: IModelType[] = []; for (var inner of oneOf) { let innerType = this.parseSchemaObjectTypeObject(inner, `${name}/oneOf[${index}]`); //console.log(`oneOf: found alternative ${innerType.name} / ${innerType.kind}`); alternatives.push(innerType); ++index; } type.addConstraint(new ModelTypeConstraintOneOf(alternatives)); } required.forEach((req) => { const entry = type.findItem(req); if (null != entry) { entry.required = true; } else { type.addItem(req, new ModelTypeAny(req), true); } }); if (0 == type.items.length) { return new ModelTypeAny(id, null, constraints); } return type; } parseSchemaObjectTypeArray(schemaObject:any, name?:string) { var elementType:IModelType = null; if (Array.isArray(schemaObject.items)) { console.warn('metamodel unhandled schema construct: array items property is array'); } else if (null != schemaObject.items) { elementType = this.parseSchemaObject(schemaObject.items); } if (null == elementType) { console.warn('metamodel found untyped array'); elementType = new ModelTypeAny("any"); } var type = new ModelTypeArray(elementType, name); return type; } parseSchemaObjectUntyped(schemaObject:any, name?:string):IModelTypeConstrainable { if (schemaObject.properties || schemaObject.allOf) { return this.parseSchemaObjectTypeObject(schemaObject, name); } if (null != schemaObject.type) { console.log(`no implementation for schema type ${schemaObject.type} (${name}) in ${JSON.stringify(schemaObject)}`); } return new ModelTypeAny(name); } _parseConstraints( schemaObject:any, factories:IConstraintFactory[] ):ModelConstraints { var constraints = schemaObject.constraints as any[]; if (constraints && Array.isArray(constraints)) { var cc = constraints.map((c:any) => { var factory:(o:any) => IModelTypeConstraint; factory = this._constraintFactory[c.constraint]; if (!factory) { factory = findFirst(factories, c.constraint); } if (!factory) { console.log("unrecognized constraint", c.constraint, c); } return factory && factory(c); }).filter((x) => x != null); return new ModelConstraints(cc); } return new ModelConstraints([]); } type(name:string) { return this._registry.type(name); } itemType(name:string) { return this._registry.itemType(name); } addType(type:IModelType) { this._registry.addType(type); } getRegisteredNames() { return this._registry.getRegisteredNames(); } private _ensureRefProcessor() { if (!this._refProcessor) { this._refProcessor = new JsonReferenceProcessor(fetchFetcher); } } private _constraintFactory:IModelTypeConstraintFactory; private _registry:ModelTypeRegistry; private _refProcessor:JsonReferenceProcessor; private _defaults:IModelSchemaParserDefaults; } function anonymousId(prefix?:string) { var suffix = (Math.floor(Math.random()*10e6)+(Date.now()*10e6)).toString(36); return (prefix || 'anon') + suffix; } function fetchFetcher(url:string):Promise { var p = fetch(url); return p.then(function (r:any) { if (r.status < 300) { var x = r.text(); return x; } return null; }); } function findFirst(tt:{[k:string]:T}[], name:string): T|undefined { for (const t of tt) { if (t && undefined !== t[name]) return t[name]; } return undefined; }