Source: schema.js

// Licensed under the MIT License
// https://github.com/craigahobbs/schema-markdown-js/blob/main/LICENSE

/** @module lib/schema */

import {typeModel} from './typeModel.js';
import {validateTypeModelErrors} from './schemaUtil.js';


/**
 * Get a user type's referenced type model
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
 * @param {string} typeName - The type name
 * @param {Object} [referencedTypes=null] - Optional map of referenced user type name to user type model
 * @returns {Object} The referenced [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
 */
export function getReferencedTypes(types, typeName, referencedTypes = {}) {
    return getReferencedTypesHelper(types, {'user': typeName}, referencedTypes);
}


function getReferencedTypesHelper(types, type, referencedTypes) {
    // Array?
    if ('array' in type) {
        const {array} = type;
        getReferencedTypesHelper(types, array.type, referencedTypes);

    // Dict?
    } else if ('dict' in type) {
        const {dict} = type;
        getReferencedTypesHelper(types, dict.type, referencedTypes);
        if ('keyType' in dict) {
            getReferencedTypesHelper(types, dict.keyType, referencedTypes);
        }

    // User type?
    } else if ('user' in type) {
        const typeName = type.user;

        // Already encountered?
        if (!(typeName in referencedTypes)) {
            const userType = types[typeName];
            referencedTypes[typeName] = userType;

            // Struct?
            if ('struct' in userType) {
                const {struct} = userType;
                if ('bases' in struct) {
                    for (const base of struct.bases) {
                        getReferencedTypesHelper(types, {'user': base}, referencedTypes);
                    }
                }
                for (const member of getStructMembers(types, struct)) {
                    getReferencedTypesHelper(types, member.type, referencedTypes);
                }

            // Enum?
            } else if ('enum' in userType) {
                const enum_ = userType.enum;
                if ('bases' in enum_) {
                    for (const base of enum_.bases) {
                        getReferencedTypesHelper(types, {'user': base}, referencedTypes);
                    }
                }

            // Typedef?
            } else if ('typedef' in userType) {
                const {typedef} = userType;
                getReferencedTypesHelper(types, typedef.type, referencedTypes);

            // Action?
            } else if ('action' in userType) {
                const {action} = userType;
                if ('path' in action) {
                    getReferencedTypesHelper(types, {'user': action.path}, referencedTypes);
                }
                if ('query' in action) {
                    getReferencedTypesHelper(types, {'user': action.query}, referencedTypes);
                }
                if ('input' in action) {
                    getReferencedTypesHelper(types, {'user': action.input}, referencedTypes);
                }
                if ('output' in action) {
                    getReferencedTypesHelper(types, {'user': action.output}, referencedTypes);
                }
                if ('errors' in action) {
                    getReferencedTypesHelper(types, {'user': action.errors}, referencedTypes);
                }
            }
        }
    }

    return referencedTypes;
}


/**
 * Schema Markdown type model validation error
 *
 * @extends {Error}
 * @property {?string} memberFqn - The fully qualified member name
 */
export class ValidationError extends Error {
    /**
     * Schema Markdown type model validation error constructor
     *
     * @param {string} message - The validation error message
     * @param {string} [memberFqn=null] - The fully-qualified member name
     */
    constructor(message, memberFqn = null) {
        super(message);
        this.name = this.constructor.name;
        this.memberFqn = memberFqn;
    }
}


/**
 * Type-validate a value using a user type model. Container values are duplicated since some member types are
 * transformed during validation.
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
 * @param {string} typeName - The type name
 * @param {Object} value - The value object to validate
 * @param {?string} [memberFqn=null] - The fully-qualified member name
 * @returns {Object} The validated, transformed value object
 * @throws [ValidationError]{@link module:lib/schema.ValidationError}
 */
export function validateType(types, typeName, value, memberFqn = null) {
    if (!(typeName in types)) {
        throw new ValidationError(`Unknown type '${typeName}'`);
    }
    return validateTypeHelper(types, {'user': typeName}, value, memberFqn);
}


// Regular expressions used by validateTypeHelper
const rDate = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
const rDatetime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/;


function validateTypeHelper(types, type, value, memberFqn) {
    let valueNew = value;

    // Built-in type?
    if ('builtin' in type) {
        const {builtin} = type;

        // string or uuid?
        if (builtin === 'string' || builtin === 'uuid') {
            // Not a string?
            if (typeof value !== 'string') {
                throwMemberError(type, value, memberFqn);
            }

            // Not a valid UUID?
            if (builtin === 'uuid' && !value.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i)) {
                throwMemberError(type, value, memberFqn);
            }

        // int or float?
        } else if (builtin === 'int' || builtin === 'float') {
            // Convert string?
            if (typeof value === 'string') {
                if (isNaN(value)) {
                    throwMemberError(type, value, memberFqn);
                }
                valueNew = parseFloat(value);

            // Not a number?
            } else if (typeof value !== 'number') {
                throwMemberError(type, value, memberFqn);
            }

            // Non-int number?
            if (builtin === 'int' && Math.trunc(valueNew) !== valueNew) {
                throwMemberError(type, value, memberFqn);
            }

        // bool?
        } else if (builtin === 'bool') {
            // Convert string?
            if (typeof value === 'string') {
                if (value === 'true') {
                    valueNew = true;
                } else if (value === 'false') {
                    valueNew = false;
                } else {
                    throwMemberError(type, value, memberFqn);
                }

            // Not a bool?
            } else if (typeof value !== 'boolean') {
                throwMemberError(type, value, memberFqn);
            }

        // date or datetime?
        } else if (builtin === 'date' || builtin === 'datetime') {
            // Convert string?
            if (typeof value === 'string') {
                // Valid date format?
                const mDate = value.match(rDate);
                if (mDate !== null) {
                    const year = Number.parseInt(mDate.groups.year, 10);
                    const month = Number.parseInt(mDate.groups.month, 10);
                    const day = Number.parseInt(mDate.groups.day, 10);
                    valueNew = new Date(year, month - 1, day);
                } else if (rDatetime.test(value)) {
                    valueNew = new Date(value);
                } else {
                    throwMemberError(type, value, memberFqn);
                }

            // Not a date?
            } else if (!(value instanceof Date)) {
                throwMemberError(type, value, memberFqn);
            }

            // For date type, clear hours, minutes, seconds, and milliseconds
            if (builtin === 'date') {
                valueNew = new Date(valueNew.getFullYear(), valueNew.getMonth(), valueNew.getDate());
            }
        }

    // array?
    } else if ('array' in type) {
        // Valid value type?
        const {array} = type;
        const arrayType = array.type;
        const arrayAttr = 'attr' in array ? array.attr : null;
        if (value === '') {
            valueNew = [];
        } else if (!Array.isArray(value)) {
            throwMemberError(type, value, memberFqn);
        }

        // Validate the list contents
        const valueCopy = [];
        const arrayValueNullable = arrayAttr !== null && 'nullable' in arrayAttr && arrayAttr.nullable;
        for (let ixArrayValue = 0; ixArrayValue < valueNew.length; ixArrayValue++) {
            const memberFqnValue = memberFqn !== null ? `${memberFqn}.${ixArrayValue}` : `${ixArrayValue}`;
            let arrayValue = valueNew[ixArrayValue];
            if (arrayValueNullable && (arrayValue === null || arrayValue === 'null')) {
                arrayValue = null;
            } else {
                arrayValue = validateTypeHelper(types, arrayType, arrayValue, memberFqnValue);
                validateAttr(arrayType, arrayAttr, arrayValue, memberFqnValue);
            }
            valueCopy.push(arrayValue);
        }

        // Return the validated, transformed copy
        valueNew = valueCopy;

    // dict?
    } else if ('dict' in type) {
        // Valid value type?
        const {dict} = type;
        const dictType = dict.type;
        const dictAttr = 'attr' in dict ? dict.attr : null;
        const dictKeyType = 'keyType' in dict ? dict.keyType : {'builtin': 'string'};
        const dictKeyAttr = 'keyAttr' in dict ? dict.keyAttr : null;
        if (value === '') {
            valueNew = {};
        } else if (value === null || typeof value !== 'object') {
            throwMemberError(type, value, memberFqn);
        }

        // Validate the dict key/value pairs
        const valueCopy = valueNew instanceof Map ? new Map() : {};
        const dictKeyNullable = dictKeyAttr !== null && 'nullable' in dictKeyAttr && dictKeyAttr.nullable;
        const dictValueNullable = dictAttr !== null && 'nullable' in dictAttr && dictAttr.nullable;
        for (let [dictKey, dictValue] of (valueNew instanceof Map ? valueNew.entries() : Object.entries(valueNew))) {
            const memberFqnKey = memberFqn !== null ? `${memberFqn}.${dictKey}` : `${dictKey}`;

            // Validate the key
            if (dictKeyNullable && (dictKey === null || dictKey === 'null')) {
                dictKey = null;
            } else {
                dictKey = validateTypeHelper(types, dictKeyType, dictKey, memberFqn);
                validateAttr(dictKeyType, dictKeyAttr, dictKey, memberFqn);
            }

            // Validate the value
            if (dictValueNullable && (dictValue === null || dictValue === 'null')) {
                dictValue = null;
            } else {
                dictValue = validateTypeHelper(types, dictType, dictValue, memberFqnKey);
                validateAttr(dictType, dictAttr, dictValue, memberFqnKey);
            }

            // Copy the key/value
            if (valueCopy instanceof Map) {
                valueCopy.set(dictKey, dictValue);
            } else {
                valueCopy[dictKey] = dictValue;
            }
        }

        // Return the validated, transformed copy
        valueNew = valueCopy;

    // User type?
    } else if ('user' in type) {
        const userType = types[type.user];

        // action?
        if ('action' in userType) {
            throwMemberError(type, value, memberFqn);
        }

        // typedef?
        if ('typedef' in userType) {
            const {typedef} = userType;
            const typedefAttr = 'attr' in typedef ? typedef.attr : null;

            // Validate the value
            const valueNullable = typedefAttr !== null && 'nullable' in typedefAttr && typedefAttr.nullable;
            if (valueNullable && (value === null || value === 'null')) {
                valueNew = null;
            } else {
                valueNew = validateTypeHelper(types, typedef.type, value, memberFqn);
                validateAttr(type, typedefAttr, valueNew, memberFqn);
            }

        // enum?
        } else if ('enum' in userType) {
            const enum_ = userType.enum;

            // Not a valid enum value?
            if (!getEnumValues(types, enum_).some((enumValue) => value === enumValue.name)) {
                throwMemberError(type, value, memberFqn);
            }

        // struct?
        } else if ('struct' in userType) {
            const {struct} = userType;

            // Valid value type?
            if (value === '') {
                valueNew = {};
            } else if (value === null || typeof value !== 'object') {
                throwMemberError({'user': struct.name}, value, memberFqn);
            }

            // Valid union?
            const isUnion = 'union' in struct ? struct.union : false;
            if (isUnion) {
                if (Object.keys(value).length !== 1) {
                    throwMemberError({'user': struct.name}, value, memberFqn);
                }
            }

            // Validate the struct members
            const valueCopy = valueNew instanceof Map ? new Map() : {};
            for (const member of getStructMembers(types, struct)) {
                const memberName = member.name;
                const memberFqnMember = memberFqn !== null ? `${memberFqn}.${memberName}` : `${memberName}`;
                const memberOptional = 'optional' in member && member.optional;
                const memberNullable = 'attr' in member && 'nullable' in member.attr && member.attr.nullable;

                // Missing non-optional member?
                if (!(valueNew instanceof Map ? valueNew.has(memberName) : memberName in valueNew)) {
                    if (!memberOptional && !isUnion) {
                        throw new ValidationError(`Required member '${memberFqnMember}' missing`);
                    }
                } else {
                    // Validate the member value
                    let memberValue = valueNew instanceof Map ? valueNew.get(memberName) : valueNew[memberName];
                    if (memberNullable && (memberValue === null || memberValue === 'null')) {
                        memberValue = null;
                    } else {
                        memberValue = validateTypeHelper(types, member.type, memberValue, memberFqnMember);
                        validateAttr(member.type, 'attr' in member ? member.attr : null, memberValue, memberFqnMember);
                    }

                    // Copy the validated member
                    if (valueCopy instanceof Map) {
                        valueCopy.set(memberName, memberValue);
                    } else {
                        valueCopy[memberName] = memberValue;
                    }
                }
            }

            // Any unknown members?
            const valueCopyKeys = valueCopy instanceof Map ? Array.from(valueCopy.keys()) : Object.keys(valueCopy);
            const valueNewKeys = valueNew instanceof Map ? Array.from(valueNew.keys()) : Object.keys(valueNew);
            if (valueCopyKeys.length !== valueNewKeys.length) {
                const memberSet = new Set(getStructMembers(types, struct).map((member) => member.name));
                const [unknownKey] = valueNewKeys.filter((key) => !memberSet.has(key));
                const unknownFqn = memberFqn !== null ? `${memberFqn}.${unknownKey}` : `${unknownKey}`;
                throw new ValidationError(`Unknown member '${unknownFqn.slice(0, 100)}'`);
            }

            // Return the validated, transformed copy
            valueNew = valueCopy;
        }
    }

    return valueNew;
}


function throwMemberError(type, value, memberFqn, attr = null) {
    const memberPart = memberFqn !== null ? ` for member '${memberFqn}'` : '';
    const typeName = 'builtin' in type ? type.builtin : ('array' in type ? 'array' : ('dict' in type ? 'dict' : type.user));
    const attrPart = attr !== null ? ` [${attr}]` : '';
    const valueStr = `${JSON.stringify(value)}`;
    const msg = `Invalid value ${valueStr.slice(0, 100)} (type '${typeof value}')${memberPart}, expected type '${typeName}'${attrPart}`;
    throw new ValidationError(msg, memberFqn);
}


function validateAttr(type, attr, value, memberFqn) {
    if (attr !== null) {
        const length = Array.isArray(value) || typeof value === 'string' ? value.length
            : (typeof value === 'object' ? Object.keys(value).length : null);

        if ('eq' in attr && !(value === attr.eq)) {
            throwMemberError(type, value, memberFqn, `== ${attr.eq}`);
        }
        if ('lt' in attr && !(value < attr.lt)) {
            throwMemberError(type, value, memberFqn, `< ${attr.lt}`);
        }
        if ('lte' in attr && !(value <= attr.lte)) {
            throwMemberError(type, value, memberFqn, `<= ${attr.lte}`);
        }
        if ('gt' in attr && !(value > attr.gt)) {
            throwMemberError(type, value, memberFqn, `> ${attr.gt}`);
        }
        if ('gte' in attr && !(value >= attr.gte)) {
            throwMemberError(type, value, memberFqn, `>= ${attr.gte}`);
        }
        if ('lenEq' in attr && !(length === attr.lenEq)) {
            throwMemberError(type, value, memberFqn, `len == ${attr.lenEq}`);
        }
        if ('lenLT' in attr && !(length < attr.lenLT)) {
            throwMemberError(type, value, memberFqn, `len < ${attr.lenLT}`);
        }
        if ('lenLTE' in attr && !(length <= attr.lenLTE)) {
            throwMemberError(type, value, memberFqn, `len <= ${attr.lenLTE}`);
        }
        if ('lenGT' in attr && !(length > attr.lenGT)) {
            throwMemberError(type, value, memberFqn, `len > ${attr.lenGT}`);
        }
        if ('lenGTE' in attr && !(length >= attr.lenGTE)) {
            throwMemberError(type, value, memberFqn, `len >= ${attr.lenGTE}`);
        }
    }
}


/**
 * Get the struct's members (inherited members first)
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
 * @param {Object} struct - The [struct model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Struct'}
 * @returns {Array<Object>} The array of
 *     [struct member models]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='StructMember'}
 */
export function getStructMembers(types, struct) {
    // No base structs?
    if (!('bases' in struct)) {
        return 'members' in struct ? struct.members : [];
    }

    // Get base struct members
    const members = [];
    for (const base of struct.bases) {
        let baseUserType = types[base];
        while ('typedef' in baseUserType) {
            baseUserType = types[baseUserType.typedef.type.user];
        }
        members.push(...getStructMembers(types, baseUserType.struct));
    }

    // Add struct members
    if ('members' in struct) {
        members.push(...struct.members);
    }

    return members;
}


/**
 * Get the enum's values (inherited values first)
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
 * @param {Object} enum_ - The [enum model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Enum'}
 * @returns {Array<Object>} The array of
 *     [enum value models]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='EnumValue'}
 */
export function getEnumValues(types, enum_) {
    // No base enums?
    if (!('bases' in enum_)) {
        return 'values' in enum_ ? enum_.values : [];
    }

    // Get base enum values
    const values = [];
    for (const base of enum_.bases) {
        let baseUserType = types[base];
        while ('typedef' in baseUserType) {
            baseUserType = types[baseUserType.typedef.type.user];
        }
        values.push(...getEnumValues(types, baseUserType.enum));
    }

    // Add enum values
    if ('values' in enum_) {
        values.push(...enum_.values);
    }

    return values;
}


/**
 * Validate a type model's types object
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
 * @returns {Object} The validated [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
 * @throws [ValidationError]{@link module:lib/schema.ValidationError}
 */
export function validateTypeModel(types) {
    // Validate with the type model
    const validatedTypes = validateType(typeModel, 'Types', types);

    // Do additional type model validation
    const errors = validateTypeModelErrors(validatedTypes);
    if (errors.length) {
        throw new ValidationError(errors.map(([,, message]) => message).join('\n'));
    }

    return validatedTypes;
}