@accordproject/concerto-core
Version:
Core Implementation for the Concerto Modeling Language
555 lines (497 loc) • 20.5 kB
JavaScript
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const Util = require('@accordproject/concerto-util').NullUtil;
const ModelUtil = require('../modelutil');
const ValidationException = require('./validationexception');
const Globalize = require('../globalize');
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
/**
* <p>
* Validates a Resource or Field against the models defined in the ModelManager.
* This class is used with the Visitor pattern and visits the class declarations
* (etc) for the model, checking that the data in a Resource / Field is consistent
* with the model.
* </p>
* The parameters for the visit method must contain the following properties:
* <ul>
* <li> 'stack' - the TypedStack of objects being processed. It should
* start as [Resource] or [Field]</li>
* <li> 'rootResourceIdentifier' - the identifier of the resource being validated </li>
* <li> 'modelManager' - the ModelManager instance to use for type checking</li>
* </ul>
* @private
* @class
* @memberof module:concerto-core
*/
class ObjectValidator {
/**
* ResourceValidator constructor
* @param {*} concerto - the Concerto instance used for validation
* @param {Object} [options] - the optional validation options.
* @param {boolean} [options.validate] - validate the structure of the Resource
* with its model prior to serialization (default to true)
* @param {boolean} [options.convertResourcesToRelationships] - Convert resources that
* are specified for relationship fields into relationships, false by default.
* @param {boolean} [options.permitResourcesForRelationships] - Permit resources in the
*/
constructor(concerto, options) {
this.options = options || {};
this.concerto = concerto;
if(!this.concerto) {
throw new Error('Missing concerto instance');
}
}
/**
* Visitor design pattern.
*
* @param {Object} thing - the object being visited
* @param {Object} parameters - the parameter
* @return {Object} the result of visiting or null
* @private
*/
visit(thing, parameters) {
if (thing.isEnum?.()) {
return this.visitEnumDeclaration(thing, parameters);
} else if (thing.isClassDeclaration?.()) {
return this.visitClassDeclaration(thing, parameters);
} else if (thing.isRelationship?.()) {
return this.visitRelationshipDeclaration(thing, parameters);
} else if (thing.isTypeScalar?.()) {
return this.visitField(thing.getScalarField(), parameters);
} else if (thing.isField?.()) {
return this.visitField(thing, parameters);
}
}
/**
* Visitor design pattern
*
* @param {EnumDeclaration} enumDeclaration - the object being visited
* @param {Object} parameters - the parameter
* @return {Object} the result of visiting or null
* @private
*/
visitEnumDeclaration(enumDeclaration, parameters) {
const obj = parameters.stack.pop();
// now check that obj is one of the enum values
const properties = enumDeclaration.getProperties();
let found = false;
for(let n=0; n < properties.length; n++) {
const property = properties[n];
if(property.getName() === obj) {
found = true;
}
}
if(!found) {
ObjectValidator.reportInvalidEnumValue( parameters.rootResourceIdentifier, enumDeclaration, obj );
}
return null;
}
/**
* Visitor design pattern
* @param {ClassDeclaration} classDeclaration - the object being visited
* @param {Object} parameters - the parameter
* @return {Object} the result of visiting or null
* @private
*/
visitClassDeclaration(classDeclaration, parameters) {
const obj = parameters.stack.pop();
if(this.concerto.isIdentifiable(obj)) {
parameters.rootResourceIdentifier = this.concerto.getFullyQualifiedIdentifier(obj);
}
const toBeAssignedClassDeclaration = this.concerto.getModelManager().getType(obj.$class);
const toBeAssignedClassDecName = toBeAssignedClassDeclaration.getFullyQualifiedName();
// is the type we are assigning to abstract?
// the only way this can happen is if the type is non-abstract
// and then gets redeclared as abstract
if(toBeAssignedClassDeclaration.isAbstract()) {
ObjectValidator.reportAbstractClass(toBeAssignedClassDeclaration);
}
// are there extra fields in the object?
let props = Object.getOwnPropertyNames(obj);
for (let n = 0; n < props.length; n++) {
let propName = props[n];
if(!this.isSystemProperty(propName)) {
const field = toBeAssignedClassDeclaration.getProperty(propName);
if (!field) {
if(this.concerto.isIdentifiable(obj)) {
ObjectValidator.reportUndeclaredField(this.concerto.getIdentifier(obj), propName, toBeAssignedClassDecName);
}
else {
ObjectValidator.reportUndeclaredField(parameters.currentIdentifier, propName, toBeAssignedClassDecName);
}
}
}
}
if (this.concerto.isIdentifiable( obj )) {
const id = this.concerto.getIdentifier(obj);
// prevent empty identifiers
if(!id || id.trim().length === 0) {
ObjectValidator.reportEmptyIdentifier(parameters.rootResourceIdentifier);
}
parameters.currentIdentifier = this.concerto.getFullyQualifiedIdentifier(obj);
}
// now validate each property
const properties = toBeAssignedClassDeclaration.getProperties();
for(let n=0; n < properties.length; n++) {
const property = properties[n];
const value = obj[property.getName()];
if(!Util.isNull(value)) {
parameters.stack.push(value);
property.accept(this,parameters);
}
else {
// Should allow systems properties like $identifier
if(!property.isOptional() && !this.isSystemProperty(property.getName())) {
ObjectValidator.reportMissingRequiredProperty( parameters.rootResourceIdentifier, property);
}
}
}
return null;
}
/**
* Returns true if the property is a system property.
* System properties are not declared in the model.
* @param {String} propertyName - the name of the property
* @return {Boolean} true if the property is a system property
* @private
*/
isSystemProperty(propertyName) {
return propertyName.charAt(0) === '$';
}
/**
* Visitor design pattern
* @param {Field} field - the object being visited
* @param {Object} parameters - the parameter
* @return {Object} the result of visiting or null
* @private
*/
visitField(field, parameters) {
const obj = parameters.stack.pop();
let dataType = typeof(obj);
let propName = field.getName();
if (dataType === 'symbol') {
ObjectValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field, this.concerto);
}
if(field.isTypeEnum()) {
this.checkEnum(obj, field,parameters);
}
else {
if(field.isArray()) {
this.checkArray(obj, field,parameters);
}
else {
this.checkItem(obj, field,parameters);
}
}
return null;
}
/**
* Check a Field that is declared as an Array.
* @param {Object} obj - the object being validated
* @param {Field} field - the object being visited
* @param {Object} parameters - the parameter
* @private
*/
checkEnum(obj,field,parameters) {
if(field.isArray() && !(obj instanceof Array)) {
ObjectValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, field.getName(), obj, field, this.concerto);
}
const enumDeclaration = field.getParent().getModelFile().getType(field.getType());
if(field.isArray()) {
for(let n=0; n < obj.length; n++) {
const item = obj[n];
parameters.stack.push(item);
enumDeclaration.accept(this, parameters);
}
}
else {
const item = obj;
parameters.stack.push(item);
enumDeclaration.accept(this, parameters);
}
}
/**
* Check a Field that is declared as an Array.
* @param {Object} obj - the object being validated
* @param {Field} field - the object being visited
* @param {Object} parameters - the parameter
* @private
*/
checkArray(obj,field,parameters) {
if(!(obj instanceof Array)) {
ObjectValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, field.getName(), obj, field, this.concerto);
}
for(let n=0; n < obj.length; n++) {
const item = obj[n];
this.checkItem(item, field, parameters);
}
}
/**
* Check a single (non-array) field.
* @param {Object} obj - the object being validated
* @param {Field} field - the object being visited
* @param {Object} parameters - the parameter
* @private
*/
checkItem(obj,field, parameters) {
let dataType = typeof obj;
let propName = field.getName();
if (dataType === 'symbol') {
ObjectValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field, this.concerto);
}
if(field.isPrimitive()) {
let invalid = false;
switch(field.getType()) {
case 'String':
if(dataType !== 'string') {
invalid = true;
}
break;
case 'Double':
case 'Long':
case 'Integer':
if(dataType !== 'number') {
invalid = true;
}
break;
case 'Boolean':
if(dataType !== 'boolean') {
invalid = true;
}
break;
case 'DateTime':
if(typeof obj !== 'string' || !dayjs.utc(obj).isValid()) {
invalid = true;
}
break;
}
if (invalid) {
ObjectValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field, this.concerto);
}
else {
if(field.getValidator() !== null) {
field.getValidator().validate(parameters.currentIdentifier, obj);
}
}
}
else {
// a field that points to a transaction, asset, participant...
let classDeclaration = this.concerto.getModelManager().getType(field.getFullyQualifiedTypeName());
try {
classDeclaration = this.concerto.getModelManager().getType(obj.$class);
} catch (err) {
ObjectValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field, this.concerto);
}
// is it compatible?
if(!ModelUtil.isAssignableTo(classDeclaration.getModelFile(), classDeclaration.getFullyQualifiedName(), field)) {
ObjectValidator.reportInvalidFieldAssignment(parameters.rootResourceIdentifier, propName, obj, field);
}
// recurse
parameters.stack.push(obj);
classDeclaration.accept(this, parameters);
}
}
/**
* Visitor design pattern
* @param {RelationshipDeclaration} relationshipDeclaration - the object being visited
* @param {Object} parameters - the parameter
* @return {Object} the result of visiting or null
* @private
*/
visitRelationshipDeclaration(relationshipDeclaration, parameters) {
const obj = parameters.stack.pop();
if(relationshipDeclaration.isArray()) {
if(!(obj instanceof Array)) {
ObjectValidator.reportInvalidFieldAssignment(parameters.rootResourceIdentifier, relationshipDeclaration.getName(), obj, relationshipDeclaration);
}
for(let n=0; n < obj.length; n++) {
const item = obj[n];
this.checkRelationship(parameters, relationshipDeclaration, item);
}
}
else {
this.checkRelationship(parameters, relationshipDeclaration, obj);
}
return null;
}
/**
* Check a single relationship
* @param {Object} parameters - the parameter
* @param {*} relationshipDeclaration - the object being visited
* @param {Object} obj - the object being validated
* @private
*/
checkRelationship(parameters, relationshipDeclaration, obj) {
if(this.concerto.isRelationship( obj )) {
// All good..
} else if (this.concerto.isIdentifiable(obj) && (this.options.convertResourcesToRelationships || this.options.permitResourcesForRelationships)) {
// All good.. Again
} else {
ObjectValidator.reportNotRelationshipViolation(parameters.rootResourceIdentifier, relationshipDeclaration, obj);
}
const relationshipType = this.concerto.isRelationship(obj) ? this.concerto.fromURI(obj).typeDeclaration
: this.concerto.getTypeDeclaration(obj);
if(!relationshipType.getIdentifierFieldName()) {
throw new Error('Relationship can only be to identified types.');
}
if(!ModelUtil.isAssignableTo(relationshipType.getModelFile(), relationshipType.getFullyQualifiedName(), relationshipDeclaration)) {
ObjectValidator.reportInvalidFieldAssignment(parameters.rootResourceIdentifier, relationshipDeclaration.getName(), obj, relationshipDeclaration);
}
}
/**
* Throw a new error for a model violation.
* @param {string} id - the identifier of this instance.
* @param {string} propName - the name of the field.
* @param {*} value - the value of the field.
* @param {Field} field - the field
* @param {*} concerto - the concerto instance
* @throws {ValidationException} the exception
* @private
*/
static reportFieldTypeViolation(id, propName, value, field, concerto) {
let isArray = field.isArray() ? '[]' : '';
let typeOfValue = typeof value;
if( concerto.isObject(value) && concerto.isIdentifiable(value)) {
typeOfValue = concerto.getType(value);
value = concerto.getIdentifier(value);
}
else {
if(value) {
try {
value = JSON.stringify(value);
}
catch(err) {
value = value.toString();
}
}
}
let formatter = Globalize.messageFormatter('resourcevalidator-fieldtypeviolation');
throw new ValidationException(formatter({
resourceId: id,
propertyName: propName,
fieldType: field.getType() + isArray,
value: value,
typeOfValue: typeOfValue
}));
}
/**
* Throw a new error for a model violation.
* @param {string} id - the identifier of this instance.
* @param {RelationshipDeclaration} relationshipDeclaration - the declaration of the classs
* @param {Object} value - the value of the field.
* @private
*/
static reportNotRelationshipViolation(id, relationshipDeclaration, value) {
let formatter = Globalize.messageFormatter('resourcevalidator-notrelationship');
throw new ValidationException(formatter({
resourceId: id,
classFQN: relationshipDeclaration.getFullyQualifiedTypeName(),
invalidValue: value.toString()
}));
}
/**
* Throw a new error for a missing, but required field.
* @param {string} id - the identifier of this instance.
* @param {Field} field - the field/
* @private
*/
static reportMissingRequiredProperty(id, field) {
let formatter = Globalize.messageFormatter('resourcevalidator-missingrequiredproperty');
throw new ValidationException(formatter({
resourceId: id,
fieldName: field.getName()
}));
}
/**
* Throw a new error for a missing, but required field.
* @param {string} id - the identifier of this instance.
* @param {Field} field - the field/
* @private
*/
static reportEmptyIdentifier(id) {
let formatter = Globalize.messageFormatter('resourcevalidator-emptyidentifier');
throw new ValidationException(formatter({
resourceId: id
}));
}
/**
* Throw a new error for a missing, but required field.
* @param {string} id - the identifier of this instance.
* @param {Field} field - the field
* @param {string} obj - the object value
* @private
*/
static reportInvalidEnumValue(id, field, obj) {
let formatter = Globalize.messageFormatter('resourcevalidator-invalidenumvalue');
throw new ValidationException(formatter({
resourceId: id,
value: obj,
fieldName: field.getName()
}));
}
/**
* Throw a validation exception for an abstract class
* @param {ClassDeclaration} classDeclaration - the class declaration
* @throws {ValidationException} the validation exception
* @private
*/
static reportAbstractClass(classDeclaration) {
let formatter = Globalize.messageFormatter('resourcevalidator-abstractclass');
throw new ValidationException(formatter({
className: classDeclaration.getFullyQualifiedName(),
}));
}
/**
* Throw a validation exception for an abstract class
* @param {string} resourceId - the id of the resouce being validated
* @param {string} propertyName - the name of the property that is not declared
* @param {string} fullyQualifiedTypeName - the fully qualified type being validated
* @throws {ValidationException} the validation exception
* @private
*/
static reportUndeclaredField(resourceId, propertyName, fullyQualifiedTypeName ) {
let formatter = Globalize.messageFormatter('resourcevalidator-undeclaredfield');
throw new ValidationException(formatter({
resourceId: resourceId,
propertyName: propertyName,
fullyQualifiedTypeName: fullyQualifiedTypeName
}));
}
/**
* Throw a validation exception for an invalid field assignment
* @param {string} resourceId - the id of the resouce being validated
* @param {string} propName - the name of the property that is being assigned
* @param {*} obj - the Field
* @param {Field} field - the Field
* @throws {ValidationException} the validation exception
* @private
*/
static reportInvalidFieldAssignment(resourceId, propName, obj, field) {
let formatter = Globalize.messageFormatter('resourcevalidator-invalidfieldassignment');
let typeName = field.getFullyQualifiedTypeName();
if(field.isArray()) {
typeName += '[]';
}
throw new ValidationException(formatter({
resourceId: resourceId,
propertyName: propName,
objectType: obj.$class,
fieldType: typeName
}));
}
}
module.exports = ObjectValidator;