// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved.
//
// 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 { MessageValidationError, TypeValidationError } = require('./errors.js');
const interfaceLoader = require('./interface_loader.js');
/**
* Validation issue problem types
* @enum {string}
*/
const ValidationProblem = {
/** Field exists in object but not in message schema */
UNKNOWN_FIELD: 'UNKNOWN_FIELD',
/** Field type doesn't match expected type */
TYPE_MISMATCH: 'TYPE_MISMATCH',
/** Required field is missing */
MISSING_FIELD: 'MISSING_FIELD',
/** Array length constraint violated */
ARRAY_LENGTH: 'ARRAY_LENGTH',
/** Value is out of valid range */
OUT_OF_RANGE: 'OUT_OF_RANGE',
/** Nested message validation failed */
NESTED_ERROR: 'NESTED_ERROR',
};
/**
* Map ROS primitive types to JavaScript types
*/
const PRIMITIVE_TYPE_MAP = {
bool: 'boolean',
int8: 'number',
uint8: 'number',
int16: 'number',
uint16: 'number',
int32: 'number',
uint32: 'number',
int64: 'bigint',
uint64: 'bigint',
float32: 'number',
float64: 'number',
char: 'number',
byte: 'number',
string: 'string',
wstring: 'string',
};
/**
* Check if value is a TypedArray
* @param {any} value - Value to check
* @returns {boolean} True if TypedArray
*/
function isTypedArray(value) {
return ArrayBuffer.isView(value) && !(value instanceof DataView);
}
/**
* Get the JavaScript type string for a value
* @param {any} value - Value to get type of
* @returns {string} Type description
*/
function getValueType(value) {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (Array.isArray(value)) return 'array';
if (isTypedArray(value)) return 'TypedArray';
return typeof value;
}
/**
* Resolve a type class from various input formats
* @param {string|object|function} typeClass - Type identifier
* @returns {function|null} The resolved type class or null
*/
function resolveTypeClass(typeClass) {
if (typeof typeClass === 'function') {
return typeClass;
}
try {
return interfaceLoader.loadInterface(typeClass);
} catch {
return null;
}
}
/**
* Get message type string from type class
* @param {function} typeClass - Message type class
* @returns {string} Message type string (e.g., 'std_msgs/msg/String')
*/
function getMessageTypeString(typeClass) {
if (typeof typeClass.type === 'function') {
const t = typeClass.type();
return `${t.pkgName}/${t.subFolder}/${t.interfaceName}`;
}
return 'unknown';
}
/**
* Get the schema definition for a message type
* @param {function|string|object} typeClass - Message type class or identifier
* @returns {object|null} Schema definition with fields and constants, or null if not found
* @example
* const schema = getMessageSchema(StringClass);
* // Returns: {
* // fields: [{name: 'data', type: {type: 'string', isPrimitiveType: true, ...}}],
* // constants: [],
* // messageType: 'std_msgs/msg/String'
* // }
*/
function getMessageSchema(typeClass) {
const resolved = resolveTypeClass(typeClass);
if (!resolved || !resolved.ROSMessageDef) {
return null;
}
const def = resolved.ROSMessageDef;
return {
fields: def.fields || [],
constants: def.constants || [],
messageType: getMessageTypeString(resolved),
baseType: def.baseType,
};
}
/**
* Get field names for a message type
* @param {function|string|object} typeClass - Message type class or identifier
* @returns {string[]} Array of field names
*/
function getFieldNames(typeClass) {
const schema = getMessageSchema(typeClass);
if (!schema) return [];
return schema.fields.map((f) => f.name);
}
/**
* Get type information for a specific field
* @param {function|string|object} typeClass - Message type class or identifier
* @param {string} fieldName - Name of the field
* @returns {object|null} Field type information or null if not found
*/
function getFieldType(typeClass, fieldName) {
const schema = getMessageSchema(typeClass);
if (!schema) return null;
const field = schema.fields.find((f) => f.name === fieldName);
return field ? field.type : null;
}
/**
* Validate a primitive value against its expected type
* @param {any} value - Value to validate
* @param {object} fieldType - Field type definition
* @returns {object|null} Validation issue or null if valid
*/
function validatePrimitiveValue(value, fieldType) {
const expectedJsType = PRIMITIVE_TYPE_MAP[fieldType.type];
const actualType = typeof value;
if (!expectedJsType) {
return null; // Unknown primitive type, skip validation
}
// Allow number for bigint fields (will be converted)
if (expectedJsType === 'bigint' && actualType === 'number') {
return null;
}
if (actualType !== expectedJsType) {
return {
problem: ValidationProblem.TYPE_MISMATCH,
expected: expectedJsType,
received: actualType,
};
}
return null;
}
/**
* Validate array constraints
* @param {any} value - Array value to validate
* @param {object} fieldType - Field type definition
* @returns {object|null} Validation issue or null if valid
*/
function validateArrayConstraints(value, fieldType) {
if (!Array.isArray(value) && !isTypedArray(value)) {
return {
problem: ValidationProblem.TYPE_MISMATCH,
expected: 'array',
received: getValueType(value),
};
}
const length = value.length;
// Fixed size array
if (fieldType.isFixedSizeArray && length !== fieldType.arraySize) {
return {
problem: ValidationProblem.ARRAY_LENGTH,
expected: `exactly ${fieldType.arraySize} elements`,
received: `${length} elements`,
};
}
// Upper bound array
if (fieldType.isUpperBound && length > fieldType.arraySize) {
return {
problem: ValidationProblem.ARRAY_LENGTH,
expected: `at most ${fieldType.arraySize} elements`,
received: `${length} elements`,
};
}
return null;
}
/**
* Validate a message object against its schema
* @param {object} obj - Plain object to validate
* @param {function|string|object} typeClass - Message type class or identifier
* @param {object} [options] - Validation options
* @param {boolean} [options.strict=false] - If true, unknown fields cause validation failure
* @param {boolean} [options.checkTypes=true] - If true, validate field types
* @param {boolean} [options.checkRequired=false] - If true, check for missing fields
* @param {string} [options.path=''] - Current path for nested validation (internal use)
* @returns {{valid: boolean, issues: Array<object>}} Validation result
*/
function validateMessage(obj, typeClass, options = {}) {
const {
strict = false,
checkTypes = true,
checkRequired = false,
path = '',
} = options;
const issues = [];
const resolved = resolveTypeClass(typeClass);
if (!resolved) {
issues.push({
field: path || '(root)',
problem: 'INVALID_TYPE_CLASS',
expected: 'valid message type class',
received: typeof typeClass,
});
return { valid: false, issues };
}
const schema = getMessageSchema(resolved);
if (!schema) {
issues.push({
field: path || '(root)',
problem: 'NO_SCHEMA',
expected: 'message with ROSMessageDef',
received: 'class without schema',
});
return { valid: false, issues };
}
if (obj === null || obj === undefined) {
issues.push({
field: path || '(root)',
problem: ValidationProblem.TYPE_MISMATCH,
expected: 'object',
received: String(obj),
});
return { valid: false, issues };
}
const type = typeof obj;
if (
type === 'string' ||
type === 'number' ||
type === 'boolean' ||
type === 'bigint'
) {
if (schema.fields.length === 1 && schema.fields[0].name === 'data') {
const fieldType = schema.fields[0].type;
if (checkTypes && fieldType.isPrimitiveType) {
const typeIssue = validatePrimitiveValue(obj, fieldType);
if (typeIssue) {
issues.push({
field: path ? `${path}.data` : 'data',
...typeIssue,
});
}
}
return { valid: issues.length === 0, issues };
}
}
if (type !== 'object') {
issues.push({
field: path || '(root)',
problem: ValidationProblem.TYPE_MISMATCH,
expected: 'object',
received: type,
});
return { valid: false, issues };
}
const fieldNames = new Set(schema.fields.map((f) => f.name));
const objKeys = Object.keys(obj);
if (strict) {
for (const key of objKeys) {
if (!fieldNames.has(key)) {
issues.push({
field: path ? `${path}.${key}` : key,
problem: ValidationProblem.UNKNOWN_FIELD,
});
}
}
}
for (const field of schema.fields) {
const fieldPath = path ? `${path}.${field.name}` : field.name;
const value = obj[field.name];
const fieldType = field.type;
if (field.name.startsWith('_')) continue;
if (value === undefined) {
if (checkRequired) {
issues.push({
field: fieldPath,
problem: ValidationProblem.MISSING_FIELD,
expected: fieldType.type,
});
}
continue;
}
if (fieldType.isArray) {
const arrayIssue = validateArrayConstraints(value, fieldType);
if (arrayIssue) {
issues.push({ field: fieldPath, ...arrayIssue });
continue;
}
if (checkTypes && Array.isArray(value) && value.length > 0) {
if (fieldType.isPrimitiveType) {
for (let i = 0; i < value.length; i++) {
const elemIssue = validatePrimitiveValue(value[i], fieldType);
if (elemIssue) {
issues.push({
field: `${fieldPath}[${i}]`,
...elemIssue,
});
}
}
} else {
for (let i = 0; i < value.length; i++) {
const nestedResult = validateMessage(
value[i],
getNestedTypeClass(resolved, field.name),
{
strict,
checkTypes,
checkRequired,
path: `${fieldPath}[${i}]`,
}
);
if (!nestedResult.valid) {
issues.push(...nestedResult.issues);
}
}
}
}
} else if (fieldType.isPrimitiveType) {
if (checkTypes) {
const typeIssue = validatePrimitiveValue(value, fieldType);
if (typeIssue) {
issues.push({ field: fieldPath, ...typeIssue });
}
}
} else {
if (value !== null && typeof value === 'object') {
const nestedTypeClass = getNestedTypeClass(resolved, field.name);
if (nestedTypeClass) {
const nestedResult = validateMessage(value, nestedTypeClass, {
strict,
checkTypes,
checkRequired,
path: fieldPath,
});
if (!nestedResult.valid) {
issues.push(...nestedResult.issues);
}
}
} else if (checkTypes && value !== null) {
issues.push({
field: fieldPath,
problem: ValidationProblem.TYPE_MISMATCH,
expected: 'object',
received: getValueType(value),
});
}
}
}
return { valid: issues.length === 0, issues };
}
/**
* Get the type class for a nested field
* @param {function} parentTypeClass - Parent message type class
* @param {string} fieldName - Field name
* @returns {function|null} Nested type class or null
*/
function getNestedTypeClass(parentTypeClass, fieldName) {
try {
const instance = new parentTypeClass();
const fieldValue = instance[fieldName];
if (
fieldValue &&
fieldValue.constructor &&
fieldValue.constructor.ROSMessageDef
) {
return fieldValue.constructor;
}
if (
fieldValue &&
fieldValue.classType &&
fieldValue.classType.elementType
) {
return fieldValue.classType.elementType;
}
} catch {
const schema = getMessageSchema(parentTypeClass);
if (schema) {
const field = schema.fields.find((f) => f.name === fieldName);
if (field && !field.type.isPrimitiveType) {
const typeName = `${field.type.pkgName}/msg/${field.type.type}`;
return resolveTypeClass(typeName);
}
}
}
return null;
}
/**
* Validate a message and throw if invalid
* @param {object} obj - Plain object to validate
* @param {function|string|object} typeClass - Message type class or identifier
* @param {object} [options] - Validation options (same as validateMessage)
* @throws {MessageValidationError} If validation fails
* @returns {void}
*/
function assertValidMessage(obj, typeClass, options = {}) {
const result = validateMessage(obj, typeClass, options);
if (!result.valid) {
const resolved = resolveTypeClass(typeClass);
const messageType = resolved
? getMessageTypeString(resolved)
: String(typeClass);
throw new MessageValidationError(messageType, result.issues);
}
}
/**
* Create a validator function for a specific message type
* @param {function|string|object} typeClass - Message type class or identifier
* @param {object} [defaultOptions] - Default validation options
* @returns {function} Validator function that takes (obj, options?) and returns validation result
*/
function createMessageValidator(typeClass, defaultOptions = {}) {
const resolved = resolveTypeClass(typeClass);
if (!resolved) {
throw new TypeValidationError(
'typeClass',
typeClass,
'valid message type class'
);
}
return function validator(obj, options = {}) {
return validateMessage(obj, resolved, {
...defaultOptions,
...options,
});
};
}
module.exports = {
ValidationProblem,
getMessageSchema,
getFieldNames,
getFieldType,
validateMessage,
assertValidMessage,
createMessageValidator,
getMessageTypeString,
};