diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a2342a80..3f844ee2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -44,6 +44,7 @@ - Add ParameterClient for external parameter access - Add structured error handling with class error hierarchy - Add ParameterWatcher for real-time parameter monitoring + - Enhance Message Validation - **[Martins Mozeiko](https://github.com/martins-mozeiko)** - QoS new/delete fix diff --git a/example/actions/README.md b/example/actions/README.md index dc4aa521..2bd3f122 100644 --- a/example/actions/README.md +++ b/example/actions/README.md @@ -60,6 +60,29 @@ The `action_client/` directory contains examples of nodes that send goals to act - Cleanup and shutdown handling - **Run Command**: `node action_client/action-client-cancel-example.js` +#### 3. Action Client Validation (`action-client-validation-example.js`) + +**Purpose**: Demonstrates goal validation features for action clients. + +- **Action Type**: `action_tutorials_interfaces/action/Fibonacci` +- **Action Name**: `fibonacci` +- **Functionality**: + - Schema introspection for action goal types + - Client-level validation with `validateGoals: true` option + - Per-goal validation override with `{ validate: true/false }` + - Strict mode validation for detecting unknown fields + - Reusable goal validators with `createMessageValidator()` + - Error handling with `MessageValidationError` +- **Features**: + - **Goal Validation**: Catch invalid goals before sending to action server + - **Schema Introspection**: Use `getMessageSchema()` to inspect goal structure + - **Dynamic Toggle**: Enable/disable validation with `setValidation()` + - **Detailed Errors**: Field-level validation issues with expected vs received types + - **Strict Mode**: Detect extra fields that don't belong in the goal + - **Reusable Validators**: Create validators for repeated goal validation +- **Run Command**: `node action_client/action-client-validation-example.js` +- **Note**: Standalone example - demonstrates validation errors without requiring a running action server + ### Action Server Examples The `action_server/` directory contains examples of nodes that provide action services: @@ -216,6 +239,8 @@ int32[] sequence - **Feedback Handling**: Processing incremental updates during execution - **Result Processing**: Handling final results and status - **Goal Cancellation**: Canceling active goals with `cancelGoal()` +- **Goal Validation**: Pre-send validation with `validateGoals` option and `MessageValidationError` +- **Schema Introspection**: Programmatic access to action goal schemas #### Action Server Concepts @@ -260,19 +285,16 @@ All examples use ES6 classes to encapsulate action functionality: ### Common Issues 1. **Action Server Not Available**: - - Ensure action server is running before starting client - Check that both use the same action name (`fibonacci`) - Verify action type matches (`test_msgs/action/Fibonacci`) 2. **Goal Not Accepted**: - - Check server's `goalCallback` return value - Verify goal message structure is correct - Ensure server is properly initialized 3. **Missing Feedback**: - - Confirm feedback callback is properly bound - Check server's `publishFeedback()` calls - Verify feedback message structure diff --git a/example/actions/action_client/action-client-validation-example.js b/example/actions/action_client/action-client-validation-example.js new file mode 100644 index 00000000..aa8a3f4a --- /dev/null +++ b/example/actions/action_client/action-client-validation-example.js @@ -0,0 +1,53 @@ +// 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 rclnodejs = require('../../../index.js'); + +async function main() { + await rclnodejs.init(); + const node = rclnodejs.createNode('action_client_validation_example_node'); + + const Fibonacci = rclnodejs.require( + 'action_tutorials_interfaces/action/Fibonacci' + ); + + const actionClient = new rclnodejs.ActionClient( + node, + 'action_tutorials_interfaces/action/Fibonacci', + 'fibonacci', + { validateGoals: true } + ); + + const validGoal = { order: 10 }; + console.log( + 'Valid goal:', + rclnodejs.validateMessage(validGoal, Fibonacci.Goal).valid + ); + + try { + await actionClient.sendGoal({ order: 'invalid' }); + } catch (error) { + if (error instanceof rclnodejs.MessageValidationError) { + console.log('Caught validation error:', error.issues[0].problem); + } + } + + actionClient.destroy(); + node.destroy(); + rclnodejs.shutdown(); +} + +main(); diff --git a/example/graph/README.md b/example/graph/README.md index 8ceb4fda..7ada5bdd 100644 --- a/example/graph/README.md +++ b/example/graph/README.md @@ -31,7 +31,6 @@ Graph introspection allows you to: This example creates a complete ROS 2 system with multiple nodes and then introspects the graph to display: 1. **Node Creation**: Creates several nodes with different communication patterns: - - `publisher_node` (namespace: `ns1`) - publishes to a topic - `subscriber_node` (namespace: `ns1`) - subscribes to a topic - `service_node` (namespace: `ns1`) - provides a service diff --git a/example/lifecycle/README.md b/example/lifecycle/README.md index 5e1be36d..628e78e9 100644 --- a/example/lifecycle/README.md +++ b/example/lifecycle/README.md @@ -355,19 +355,16 @@ Multiple lifecycle nodes can be coordinated using external lifecycle managers: ### Common Issues 1. **Publisher Not Publishing**: - - Ensure lifecycle publisher is activated in `onActivate()` - Check that node is in active state - Verify publisher is created in `onConfigure()` 2. **Timer Not Working**: - - Create timer in `onActivate()`, not `onConfigure()` - Cancel timer in `onDeactivate()` - Check timer interval format (BigInt nanoseconds) 3. **State Transition Failures**: - - Ensure callbacks return appropriate return codes - Check for exceptions in callback implementations - Verify resource cleanup in deactivate/shutdown diff --git a/example/rate/README.md b/example/rate/README.md index 31cd5ead..685e535e 100644 --- a/example/rate/README.md +++ b/example/rate/README.md @@ -332,19 +332,16 @@ while (running) { ### Common Issues 1. **Rate Drift**: - - Processing time exceeds rate period - Solution: Optimize processing or reduce rate frequency - Monitor actual vs. target frequency 2. **Missed Messages**: - - Rate limiting causes message drops - Expected behavior in the example (only every 200th message processed) - Use `ros2 topic echo` to see all messages 3. **High CPU Usage**: - - Rate loop running too fast for processing capacity - Solution: Reduce rate frequency or optimize processing - Monitor system resource usage diff --git a/example/services/README.md b/example/services/README.md index 52f337bf..cadca6bb 100644 --- a/example/services/README.md +++ b/example/services/README.md @@ -70,6 +70,28 @@ ROS 2 services provide a request-response communication pattern where clients se - **TypeScript Ready**: Full type safety with comprehensive TypeScript definitions - **Run Command**: `node example/services/client/async-client-example.js` +#### Service Client Validation (`client/client-validation-example.js`) + +**Purpose**: Demonstrates request validation features for service clients. + +- **Service Type**: `example_interfaces/srv/AddTwoInts` +- **Service Name**: `add_two_ints` +- **Functionality**: + - Schema introspection for service request types + - Client-level validation with `validateRequests: true` option + - Per-request validation override with `{ validate: true/false }` + - Strict mode validation for detecting unknown fields + - Async request validation with `sendRequestAsync()` + - Error handling with `MessageValidationError` +- **Features**: + - **Request Validation**: Catch invalid requests before sending to service + - **Schema Introspection**: Use `getMessageSchema()` to inspect request structure + - **Dynamic Toggle**: Enable/disable validation with `setValidation()` + - **Detailed Errors**: Field-level validation issues with expected vs received types + - **Strict Mode**: Detect extra fields that don't belong in the request +- **Run Command**: `node example/services/client/client-validation-example.js` +- **Note**: Standalone example - demonstrates validation errors without requiring a running service + **Key API Differences**: ```javascript @@ -333,6 +355,8 @@ This script automatically starts the service, tests the client, and cleans up. - **Error Handling**: Proper error handling with try/catch blocks and specific error types (async only) - **Timeout Management**: Built-in timeout support to prevent hanging requests (async only) - **Request Cancellation**: AbortController support for user-cancellable operations (async only) +- **Request Validation**: Pre-send validation with `validateRequests` option and `MessageValidationError` +- **Schema Introspection**: Programmatic access to service request/response schemas - **Resource Management**: Proper node shutdown and cleanup - **Data Analysis**: Processing and interpreting received data - **Visualization**: Converting data to human-readable formats diff --git a/example/services/client/client-validation-example.js b/example/services/client/client-validation-example.js new file mode 100644 index 00000000..3b6a45c9 --- /dev/null +++ b/example/services/client/client-validation-example.js @@ -0,0 +1,47 @@ +// 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 rclnodejs = require('../../../index.js'); + +async function main() { + await rclnodejs.init(); + const node = rclnodejs.createNode('client_validation_example_node'); + + const client = node.createClient( + 'example_interfaces/srv/AddTwoInts', + 'add_two_ints', + { validateRequests: true } + ); + + const validRequest = { a: BigInt(5), b: BigInt(10) }; + console.log( + 'Valid request:', + rclnodejs.validateMessage(validRequest, client.typeClass.Request).valid + ); + + try { + client.sendRequest({ a: 'invalid', b: BigInt(10) }, () => {}); + } catch (error) { + if (error instanceof rclnodejs.MessageValidationError) { + console.log('Caught validation error:', error.issues[0].problem); + } + } + + node.destroy(); + rclnodejs.shutdown(); +} + +main(); diff --git a/example/topics/README.md b/example/topics/README.md index 308b8b70..a60daf02 100644 --- a/example/topics/README.md +++ b/example/topics/README.md @@ -69,6 +69,27 @@ The `publisher/` directory contains examples of nodes that publish messages to t - **Run Command**: `node publisher/publisher-raw-message.js` - **Pair**: Works with `subscription-raw-message.js` +### 7. Publisher Validation (`publisher-validation-example.js`) + +**Purpose**: Demonstrates message validation features for publishers. + +- **Message Type**: `std_msgs/msg/String`, `geometry_msgs/msg/Twist` +- **Topics**: Various validation test topics +- **Functionality**: + - Schema introspection with `getMessageSchema()`, `getFieldNames()`, `getFieldType()` + - Publisher-level validation with `validateMessages: true` option + - Per-publish validation override with `{ validate: true/false }` + - Strict mode validation for unknown fields + - Nested message validation (Twist with Vector3) + - Reusable validators with `createMessageValidator()` + - Error handling with `MessageValidationError` +- **Features**: + - Catch invalid messages before publishing + - Dynamic validation toggle with `setValidation()` + - Detailed error reports with field-level issues +- **Run Command**: `node publisher/publisher-validation-example.js` +- **Note**: Standalone example - no subscriber required + ## Subscriber Examples The `subscriber/` directory contains examples of nodes that subscribe to topics: @@ -214,7 +235,8 @@ Several examples work together to demonstrate complete communication: - **Service Events**: Monitoring service interactions - **Multi-dimensional Arrays**: Complex data structures with layout information - **Message Serialization**: TypedArray handling and JSON-safe conversion for web applications -- **Validation**: Name and topic validation utilities +- **Name Validation**: Topic names, node names, and namespace validation utilities +- **Message Validation**: Schema introspection and pre-publish message validation with detailed error reporting ## Notes diff --git a/example/topics/publisher/publisher-validation-example.js b/example/topics/publisher/publisher-validation-example.js new file mode 100644 index 00000000..41265a6e --- /dev/null +++ b/example/topics/publisher/publisher-validation-example.js @@ -0,0 +1,39 @@ +// 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 rclnodejs = require('../../../index.js'); + +rclnodejs.init().then(() => { + const node = rclnodejs.createNode('publisher_validation_example_node'); + + const publisher = node.createPublisher('std_msgs/msg/String', 'topic', { + validateMessages: true, + }); + + publisher.publish({ data: 'Hello ROS' }); + console.log('Published valid message'); + + try { + publisher.publish({ data: 12345 }); + } catch (error) { + if (error instanceof rclnodejs.MessageValidationError) { + console.log('Caught validation error:', error.issues[0].problem); + } + } + + node.destroy(); + rclnodejs.shutdown(); +}); diff --git a/index.js b/index.js index 2f9200e8..c89b8b6b 100644 --- a/index.js +++ b/index.js @@ -62,6 +62,15 @@ const ParameterClient = require('./lib/parameter_client.js'); const errors = require('./lib/errors.js'); const ParameterWatcher = require('./lib/parameter_watcher.js'); const { spawn } = require('child_process'); +const { + ValidationProblem, + getMessageSchema, + getFieldNames, + getFieldType, + validateMessage, + assertValidMessage, + createMessageValidator, +} = require('./lib/message_validation.js'); /** * Get the version of the generator that was used for the currently present interfaces. @@ -581,6 +590,57 @@ let rcl = { */ toJSONString: toJSONString, + /** {@link ValidationProblem} - Enum of validation problem types */ + ValidationProblem: ValidationProblem, + + /** + * 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 + */ + getMessageSchema: getMessageSchema, + + /** + * Get field names for a message type + * @param {function|string|object} typeClass - Message type class or identifier + * @returns {string[]} Array of field names + */ + getFieldNames: getFieldNames, + + /** + * 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 + */ + getFieldType: getFieldType, + + /** + * 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 + * @returns {{valid: boolean, issues: Array}} Validation result + */ + validateMessage: validateMessage, + + /** + * 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 + * @throws {MessageValidationError} If validation fails + */ + assertValidMessage: assertValidMessage, + + /** + * 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 + */ + createMessageValidator: createMessageValidator, + // Error classes for structured error handling /** {@link RclNodeError} - Base error class for all rclnodejs errors */ RclNodeError: errors.RclNodeError, @@ -591,6 +651,8 @@ let rcl = { TypeValidationError: errors.TypeValidationError, /** {@link RangeValidationError} - Range/value validation error */ RangeValidationError: errors.RangeValidationError, + /** {@link MessageValidationError} - Message structure/type validation error */ + MessageValidationError: errors.MessageValidationError, /** {@link NameValidationError} - ROS name validation error */ NameValidationError: errors.NameValidationError, diff --git a/lib/action/client.js b/lib/action/client.js index 104d4f19..e8230202 100644 --- a/lib/action/client.js +++ b/lib/action/client.js @@ -28,6 +28,7 @@ const { ActionError, OperationError, } = require('../errors.js'); +const { assertValidMessage } = require('../message_validation.js'); /** * @class - ROS Action client. @@ -80,6 +81,12 @@ class ActionClient extends Entity { this._options.qos.statusSubQosProfile = this._options.qos.statusSubQosProfile || QoS.profileActionStatusDefault; + this._validateGoals = this._options.validateGoals || false; + this._validationOptions = this._options.validationOptions || { + strict: true, + checkTypes: true, + }; + let type = this.typeClass.type(); this._handle = rclnodejs.actionCreateClient( @@ -200,6 +207,28 @@ class ActionClient extends Entity { } } + /** + * Enable or disable goal validation for this action client + * @param {boolean} enabled - Whether to validate goals before sending + * @param {object} [options] - Validation options + * @param {boolean} [options.strict=true] - Throw on unknown fields + * @param {boolean} [options.checkTypes=true] - Validate field types + */ + setValidation(enabled, options = {}) { + this._validateGoals = enabled; + if (options && Object.keys(options).length > 0) { + this._validationOptions = { ...this._validationOptions, ...options }; + } + } + + /** + * Check if goal validation is enabled for this action client + * @returns {boolean} True if validation is enabled + */ + get validationEnabled() { + return this._validateGoals; + } + /** * Send a goal and wait for the goal ACK asynchronously. * @@ -209,9 +238,27 @@ class ActionClient extends Entity { * @param {object} goal - The goal request. * @param {function} feedbackCallback - Callback function for feedback associated with the goal. * @param {object} goalUuid - Universally unique identifier for the goal. If None, then a random UUID is generated. + * @param {object} [options] - Send options + * @param {boolean} [options.validate] - Override validateGoals setting for this call * @returns {Promise} - A Promise to a goal handle that resolves when the goal request has been accepted or rejected. + * @throws {MessageValidationError} If validation is enabled and goal is invalid */ - sendGoal(goal, feedbackCallback, goalUuid) { + sendGoal(goal, feedbackCallback, goalUuid, options = {}) { + const shouldValidate = + options.validate !== undefined ? options.validate : this._validateGoals; + + if (shouldValidate) { + const GoalType = this._typeClass.impl.SendGoalService.Request; + const goalInstance = new GoalType(); + if (goalInstance.goal && goalInstance.goal.constructor) { + assertValidMessage( + goal, + goalInstance.goal.constructor, + this._validationOptions + ); + } + } + let request = new this._typeClass.impl.SendGoalService.Request(); request['goal_id'] = goalUuid || ActionUuid.randomMessage(); request.goal = goal; diff --git a/lib/client.js b/lib/client.js index 56ee508c..048fdcbc 100644 --- a/lib/client.js +++ b/lib/client.js @@ -22,6 +22,7 @@ const { TimeoutError, AbortError, } = require('./errors.js'); +const { assertValidMessage } = require('./message_validation.js'); const debug = require('debug')('rclnodejs:client'); // Polyfill for AbortSignal.any() for Node.js <= 20.3.0 @@ -80,6 +81,33 @@ class Client extends Entity { this._nodeHandle = nodeHandle; this._serviceName = serviceName; this._sequenceNumberToCallbackMap = new Map(); + this._validateRequests = options.validateRequests || false; + this._validationOptions = options.validationOptions || { + strict: true, + checkTypes: true, + }; + } + + /** + * Enable or disable request validation for this client + * @param {boolean} enabled - Whether to validate requests before sending + * @param {object} [options] - Validation options + * @param {boolean} [options.strict=true] - Throw on unknown fields + * @param {boolean} [options.checkTypes=true] - Validate field types + */ + setValidation(enabled, options = {}) { + this._validateRequests = enabled; + if (options && Object.keys(options).length > 0) { + this._validationOptions = { ...this._validationOptions, ...options }; + } + } + + /** + * Check if request validation is enabled for this client + * @returns {boolean} True if validation is enabled + */ + get validationEnabled() { + return this._validateRequests; } /** @@ -96,10 +124,13 @@ class Client extends Entity { * Send the request and will be notified asynchronously if receiving the response. * @param {object} request - The request to be submitted. * @param {ResponseCallback} callback - Thc callback function for receiving the server response. + * @param {object} [options] - Send options + * @param {boolean} [options.validate] - Override validateRequests setting for this call * @return {undefined} + * @throws {MessageValidationError} If validation is enabled and request is invalid * @see {@link ResponseCallback} */ - sendRequest(request, callback) { + sendRequest(request, callback, options = {}) { if (typeof callback !== 'function') { throw new TypeValidationError('callback', callback, 'function', { entityType: 'service', @@ -107,6 +138,19 @@ class Client extends Entity { }); } + const shouldValidate = + options.validate !== undefined + ? options.validate + : this._validateRequests; + + if (shouldValidate && !(request instanceof this._typeClass.Request)) { + assertValidMessage( + request, + this._typeClass.Request, + this._validationOptions + ); + } + let requestToSend = request instanceof this._typeClass.Request ? request @@ -124,9 +168,11 @@ class Client extends Entity { * @param {object} [options] - Optional parameters for the request. * @param {number} [options.timeout] - Timeout in milliseconds for the request. * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @param {boolean} [options.validate] - Override validateRequests setting for this call * @return {Promise} Promise that resolves with the service response. * @throws {module:rclnodejs.TimeoutError} If the request times out (when options.timeout is exceeded). * @throws {module:rclnodejs.AbortError} If the request is manually aborted (via options.signal). + * @throws {module:rclnodejs.MessageValidationError} If validation is enabled and request is invalid. * @throws {Error} If the request fails for other reasons. */ sendRequestAsync(request, options = {}) { @@ -191,6 +237,19 @@ class Client extends Entity { } try { + const shouldValidate = + options.validate !== undefined + ? options.validate + : this._validateRequests; + + if (shouldValidate && !(request instanceof this._typeClass.Request)) { + assertValidMessage( + request, + this._typeClass.Request, + this._validationOptions + ); + } + let requestToSend = request instanceof this._typeClass.Request ? request diff --git a/lib/errors.js b/lib/errors.js index 5673af56..ae452926 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -162,6 +162,55 @@ class RangeValidationError extends ValidationError { } } +/** + * Message validation error for ROS message structure/type issues + * @class + * @extends ValidationError + */ +class MessageValidationError extends ValidationError { + /** + * @param {string} messageType - The ROS message type (e.g., 'std_msgs/msg/String') + * @param {Array} issues - Array of validation issues + * @param {string} issues[].field - Field path where issue occurred + * @param {string} issues[].problem - Problem type (UNKNOWN_FIELD, TYPE_MISMATCH, etc.) + * @param {string} [issues[].expected] - Expected type or value + * @param {any} [issues[].received] - Actual value received + * @param {object} [options] - Additional options + */ + constructor(messageType, issues, options = {}) { + const issuesSummary = issues + .map((i) => `${i.field}: ${i.problem}`) + .join(', '); + super(`Invalid message for '${messageType}': ${issuesSummary}`, { + code: 'MESSAGE_VALIDATION_ERROR', + entityType: 'message', + entityName: messageType, + details: { issues }, + ...options, + }); + this.messageType = messageType; + this.issues = issues; + } + + /** + * Get issues filtered by problem type + * @param {string} problemType - Problem type to filter by + * @returns {Array} Filtered issues + */ + getIssuesByType(problemType) { + return this.issues.filter((i) => i.problem === problemType); + } + + /** + * Check if a specific field has validation issues + * @param {string} fieldPath - Field path to check + * @returns {boolean} True if field has issues + */ + hasFieldIssue(fieldPath) { + return this.issues.some((i) => i.field === fieldPath); + } +} + /** * ROS name validation error (topics, nodes, services) * @class @@ -541,6 +590,7 @@ module.exports = { ValidationError, TypeValidationError, RangeValidationError, + MessageValidationError, NameValidationError, // Operation errors diff --git a/lib/message_validation.js b/lib/message_validation.js new file mode 100644 index 00000000..e2fb9248 --- /dev/null +++ b/lib/message_validation.js @@ -0,0 +1,533 @@ +// 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'); + +/** + * 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 + * @param {function} [loader] - Interface loader function + * @returns {function|null} The resolved type class or null + */ +function resolveTypeClass(typeClass, loader) { + if (typeof typeClass === 'function') { + return typeClass; + } + + if (loader) { + try { + return loader(typeClass); + } catch { + return null; + } + } + + 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 + * @param {function} [loader] - Interface loader function (required if typeClass is string/object) + * @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, loader) { + const resolved = resolveTypeClass(typeClass, loader); + 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 + * @param {function} [loader] - Interface loader function + * @returns {string[]} Array of field names + */ +function getFieldNames(typeClass, loader) { + const schema = getMessageSchema(typeClass, loader); + 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 + * @param {function} [loader] - Interface loader function + * @returns {object|null} Field type information or null if not found + */ +function getFieldType(typeClass, fieldName, loader) { + const schema = getMessageSchema(typeClass, loader); + 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) + * @param {function} [options.loader] - Interface loader function + * @returns {{valid: boolean, issues: Array}} Validation result + */ +function validateMessage(obj, typeClass, options = {}) { + const { + strict = false, + checkTypes = true, + checkRequired = false, + path = '', + loader, + } = options; + + const issues = []; + const resolved = resolveTypeClass(typeClass, loader); + + 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, loader), + { + strict, + checkTypes, + checkRequired, + path: `${fieldPath}[${i}]`, + loader, + } + ); + 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, + loader + ); + if (nestedTypeClass) { + const nestedResult = validateMessage(value, nestedTypeClass, { + strict, + checkTypes, + checkRequired, + path: fieldPath, + loader, + }); + 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 + * @param {function} [loader] - Interface loader function + * @returns {function|null} Nested type class or null + */ +function getNestedTypeClass(parentTypeClass, fieldName, loader) { + 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 && loader) { + 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, loader); + } + } + } + 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, options.loader); + 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 + * @param {function} [loader] - Interface loader function + * @returns {function} Validator function that takes (obj, options?) and returns validation result + */ +function createMessageValidator(typeClass, defaultOptions = {}, loader) { + const resolved = resolveTypeClass(typeClass, loader); + if (!resolved) { + throw new TypeValidationError( + 'typeClass', + typeClass, + 'valid message type class' + ); + } + + return function validator(obj, options = {}) { + return validateMessage(obj, resolved, { + ...defaultOptions, + ...options, + loader, + }); + }; +} + +module.exports = { + ValidationProblem, + + getMessageSchema, + getFieldNames, + getFieldType, + + validateMessage, + assertValidMessage, + createMessageValidator, + + getMessageTypeString, +}; diff --git a/lib/publisher.js b/lib/publisher.js index 543d46ec..e1b17a31 100644 --- a/lib/publisher.js +++ b/lib/publisher.js @@ -17,6 +17,7 @@ const rclnodejs = require('./native_loader.js'); const debug = require('debug')('rclnodejs:publisher'); const Entity = require('./entity.js'); +const { assertValidMessage } = require('./message_validation.js'); /** * @class - Class representing a Publisher in ROS @@ -27,6 +28,11 @@ class Publisher extends Entity { constructor(handle, typeClass, topic, options, node, eventCallbacks) { super(handle, typeClass, options); this._node = node; + this._validateMessages = options.validateMessages || false; + this._validationOptions = options.validationOptions || { + strict: true, + checkTypes: true, + }; if (node && eventCallbacks) { this._events = eventCallbacks.createEventHandlers(this.handle); node._events.push(...this._events); @@ -44,12 +50,24 @@ class Publisher extends Entity { * Publish a message * @param {object|Buffer} message - The message to be sent, could be kind of JavaScript message generated from .msg * or be a Buffer for a raw message. + * @param {object} [options] - Publish options + * @param {boolean} [options.validate] - Override validateMessages setting for this publish call * @return {undefined} + * @throws {MessageValidationError} If validation is enabled and message is invalid */ - publish(message) { + publish(message, options = {}) { if (message instanceof Buffer) { rclnodejs.publishRawMessage(this._handle, message); } else { + const shouldValidate = + options.validate !== undefined + ? options.validate + : this._validateMessages; + + if (shouldValidate && !(message instanceof this._typeClass)) { + assertValidMessage(message, this._typeClass, this._validationOptions); + } + // Enables call by plain object/number/string argument // e.g. publisher.publish(3.14); // publisher.publish('The quick brown fox...'); @@ -65,6 +83,29 @@ class Publisher extends Entity { debug(`Message of topic ${this.topic} has been published.`); } + /** + * Enable or disable message validation for this publisher + * @param {boolean} enabled - Whether to validate messages before publishing + * @param {object} [options] - Validation options + * @param {boolean} [options.strict=true] - Throw on unknown fields + * @param {boolean} [options.checkTypes=true] - Validate field types + * @param {boolean} [options.checkRequired=false] - Check for missing fields + */ + setValidation(enabled, options = {}) { + this._validateMessages = enabled; + if (options && Object.keys(options).length > 0) { + this._validationOptions = { ...this._validationOptions, ...options }; + } + } + + /** + * Check if message validation is enabled for this publisher + * @returns {boolean} True if validation is enabled + */ + get validationEnabled() { + return this._validateMessages; + } + static createPublisher(node, typeClass, topic, options, eventCallbacks) { let type = typeClass.type(); let handle = rclnodejs.createPublisher( diff --git a/rosidl_gen/message_translator.js b/rosidl_gen/message_translator.js index 9b2ff311..6add0575 100644 --- a/rosidl_gen/message_translator.js +++ b/rosidl_gen/message_translator.js @@ -58,66 +58,6 @@ function copyMsgObject(msg, obj) { } } -function verifyMessage(message, obj) { - if (message.constructor.isROSArray) { - // It's a ROS message array - // Note: there won't be any JavaScript array in message. - if (!Array.isArray(obj)) { - return false; - } - // TODO(Kenny): deal with TypedArray in the future - // TODO(Kenny): if the elements are objects, check the objects - } else { - // It's a ROS message - const def = message.constructor.ROSMessageDef; - let obj = {}; - for (let i in def.fields) { - const name = def.fields[i].name; - if (def.fields[i].type.isPrimitiveType) { - // check type/existence - switch (def.fields[i].type) { - case 'char': - case 'int16': - case 'int32': - case 'byte': - case 'uint16': - case 'uint32': - case 'float32': - case 'float64': - if (typeof obj[name] != 'number') { - return false; - } - break; - case 'int64': - case 'uint64': - if (typeof obj[name] != 'bigint') { - return false; - } - case 'bool': - if (typeof obj[name] != 'boolean') { - return false; - } - break; - case 'string': - if (typeof obj[name] != 'string') { - return false; - } - break; - } - } else if (!verifyMessage(message[name], obj[name])) { - // Proceed further on this member - return false; - } - } - } - return true; -} - -function verifyMessageStruct(MessageType, obj) { - const msg = new MessageType(); - return verifyMessage(msg, obj); -} - function toPlainObject(message, enableTypedArray = true) { if (!message) return undefined; @@ -186,7 +126,6 @@ function constructFromPlanObject(msg, obj) { } module.exports = { - verifyMessageStruct: verifyMessageStruct, toROSMessage: toROSMessage, toPlainObject: toPlainObject, constructFromPlanObject: constructFromPlanObject, diff --git a/src/macros.h b/src/macros.h index 846cfd15..ad809ca6 100644 --- a/src/macros.h +++ b/src/macros.h @@ -23,8 +23,7 @@ { \ if (lhs op rhs) { \ rcl_reset_error(); \ - Napi::Error::New(env, message) \ - .ThrowAsJavaScriptException(); \ + Napi::Error::New(env, message).ThrowAsJavaScriptException(); \ return env.Undefined(); \ } \ } @@ -33,8 +32,7 @@ { \ if (lhs op rhs) { \ rcl_reset_error(); \ - Napi::Error::New(env, message) \ - .ThrowAsJavaScriptException(); \ + Napi::Error::New(env, message).ThrowAsJavaScriptException(); \ } \ } diff --git a/test/test-message-validation.js b/test/test-message-validation.js new file mode 100644 index 00000000..e81df4ac --- /dev/null +++ b/test/test-message-validation.js @@ -0,0 +1,440 @@ +// 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 assert = require('assert'); +const rclnodejs = require('../index.js'); + +describe('Message Validation Tests', function () { + this.timeout(60 * 1000); + + before(async function () { + await rclnodejs.init(); + }); + + after(function () { + rclnodejs.shutdown(); + }); + + describe('ValidationProblem enum', function () { + it('should have all problem types defined', function () { + assert.strictEqual( + rclnodejs.ValidationProblem.UNKNOWN_FIELD, + 'UNKNOWN_FIELD' + ); + assert.strictEqual( + rclnodejs.ValidationProblem.TYPE_MISMATCH, + 'TYPE_MISMATCH' + ); + assert.strictEqual( + rclnodejs.ValidationProblem.MISSING_FIELD, + 'MISSING_FIELD' + ); + assert.strictEqual( + rclnodejs.ValidationProblem.ARRAY_LENGTH, + 'ARRAY_LENGTH' + ); + assert.strictEqual( + rclnodejs.ValidationProblem.OUT_OF_RANGE, + 'OUT_OF_RANGE' + ); + assert.strictEqual( + rclnodejs.ValidationProblem.NESTED_ERROR, + 'NESTED_ERROR' + ); + }); + }); + + describe('getMessageSchema', function () { + it('should return schema for std_msgs/msg/String', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const schema = rclnodejs.getMessageSchema(StringClass); + + assert.ok(schema); + assert.strictEqual(schema.messageType, 'std_msgs/msg/String'); + assert.ok(Array.isArray(schema.fields)); + assert.ok(schema.fields.length > 0); + + const dataField = schema.fields.find((f) => f.name === 'data'); + assert.ok(dataField); + assert.strictEqual(dataField.type.type, 'string'); + assert.strictEqual(dataField.type.isPrimitiveType, true); + }); + + it('should return schema for geometry_msgs/msg/Twist', function () { + const TwistClass = rclnodejs.require('geometry_msgs/msg/Twist'); + const schema = rclnodejs.getMessageSchema(TwistClass); + + assert.ok(schema); + assert.strictEqual(schema.messageType, 'geometry_msgs/msg/Twist'); + assert.ok(schema.fields.length === 2); + + const linearField = schema.fields.find((f) => f.name === 'linear'); + assert.ok(linearField); + assert.strictEqual(linearField.type.isPrimitiveType, false); + assert.strictEqual(linearField.type.type, 'Vector3'); + }); + + it('should return null for invalid type class', function () { + const schema = rclnodejs.getMessageSchema({}); + assert.strictEqual(schema, null); + }); + }); + + describe('getFieldNames', function () { + it('should return field names for std_msgs/msg/String', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const fields = rclnodejs.getFieldNames(StringClass); + + assert.ok(Array.isArray(fields)); + assert.ok(fields.includes('data')); + }); + + it('should return field names for geometry_msgs/msg/Twist', function () { + const TwistClass = rclnodejs.require('geometry_msgs/msg/Twist'); + const fields = rclnodejs.getFieldNames(TwistClass); + + assert.ok(Array.isArray(fields)); + assert.ok(fields.includes('linear')); + assert.ok(fields.includes('angular')); + }); + }); + + describe('getFieldType', function () { + it('should return field type for std_msgs/msg/String.data', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const fieldType = rclnodejs.getFieldType(StringClass, 'data'); + + assert.ok(fieldType); + assert.strictEqual(fieldType.type, 'string'); + assert.strictEqual(fieldType.isPrimitiveType, true); + }); + + it('should return null for unknown field', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const fieldType = rclnodejs.getFieldType(StringClass, 'unknown_field'); + + assert.strictEqual(fieldType, null); + }); + }); + + describe('validateMessage', function () { + it('should validate correct std_msgs/msg/String', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const result = rclnodejs.validateMessage({ data: 'hello' }, StringClass); + + assert.strictEqual(result.valid, true); + assert.strictEqual(result.issues.length, 0); + }); + + it('should validate primitive value for wrapper message', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const result = rclnodejs.validateMessage('hello', StringClass); + + assert.strictEqual(result.valid, true); + assert.strictEqual(result.issues.length, 0); + }); + + it('should detect type mismatch for std_msgs/msg/String', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const result = rclnodejs.validateMessage({ data: 123 }, StringClass); + + assert.strictEqual(result.valid, false); + assert.ok(result.issues.length > 0); + + const typeIssue = result.issues.find( + (i) => i.problem === 'TYPE_MISMATCH' + ); + assert.ok(typeIssue); + assert.strictEqual(typeIssue.field, 'data'); + assert.strictEqual(typeIssue.expected, 'string'); + assert.strictEqual(typeIssue.received, 'number'); + }); + + it('should detect unknown fields in strict mode', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const result = rclnodejs.validateMessage( + { data: 'hello', unknown_field: 'value' }, + StringClass, + { strict: true } + ); + + assert.strictEqual(result.valid, false); + const unknownIssue = result.issues.find( + (i) => i.problem === 'UNKNOWN_FIELD' + ); + assert.ok(unknownIssue); + assert.strictEqual(unknownIssue.field, 'unknown_field'); + }); + + it('should allow unknown fields in non-strict mode', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const result = rclnodejs.validateMessage( + { data: 'hello', unknown_field: 'value' }, + StringClass, + { strict: false } + ); + + assert.strictEqual(result.valid, true); + }); + + it('should validate nested messages', function () { + const TwistClass = rclnodejs.require('geometry_msgs/msg/Twist'); + const result = rclnodejs.validateMessage( + { + linear: { x: 1.0, y: 2.0, z: 3.0 }, + angular: { x: 0.0, y: 0.0, z: 0.5 }, + }, + TwistClass + ); + + assert.strictEqual(result.valid, true); + }); + + it('should detect type mismatch in nested messages', function () { + const TwistClass = rclnodejs.require('geometry_msgs/msg/Twist'); + const result = rclnodejs.validateMessage( + { + linear: { x: 'not a number', y: 2.0, z: 3.0 }, + angular: { x: 0.0, y: 0.0, z: 0.5 }, + }, + TwistClass + ); + + assert.strictEqual(result.valid, false); + const typeIssue = result.issues.find( + (i) => i.problem === 'TYPE_MISMATCH' + ); + assert.ok(typeIssue); + assert.ok(typeIssue.field.includes('linear')); + }); + + it('should handle null input', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const result = rclnodejs.validateMessage(null, StringClass); + + assert.strictEqual(result.valid, false); + }); + + it('should handle undefined input', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const result = rclnodejs.validateMessage(undefined, StringClass); + + assert.strictEqual(result.valid, false); + }); + }); + + describe('assertValidMessage', function () { + it('should not throw for valid message', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + + assert.doesNotThrow(() => { + rclnodejs.assertValidMessage({ data: 'hello' }, StringClass); + }); + }); + + it('should throw MessageValidationError for invalid message', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + + assert.throws( + () => { + rclnodejs.assertValidMessage({ data: 123 }, StringClass); + }, + (error) => { + assert.ok(error instanceof rclnodejs.MessageValidationError); + assert.strictEqual(error.messageType, 'std_msgs/msg/String'); + assert.ok(Array.isArray(error.issues)); + assert.ok(error.issues.length > 0); + return true; + } + ); + }); + + it('should throw for unknown fields in strict mode', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + + assert.throws(() => { + rclnodejs.assertValidMessage( + { data: 'hello', unknown: 'field' }, + StringClass, + { strict: true } + ); + }, rclnodejs.MessageValidationError); + }); + }); + + describe('createMessageValidator', function () { + it('should create a reusable validator', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const validator = rclnodejs.createMessageValidator(StringClass, { + strict: true, + }); + + assert.ok(typeof validator === 'function'); + + const validResult = validator({ data: 'hello' }); + assert.strictEqual(validResult.valid, true); + + const invalidResult = validator({ data: 'hello', extra: 'field' }); + assert.strictEqual(invalidResult.valid, false); + }); + + it('should allow options override', function () { + const StringClass = rclnodejs.require('std_msgs/msg/String'); + const validator = rclnodejs.createMessageValidator(StringClass, { + strict: true, + }); + + const result = validator( + { data: 'hello', extra: 'field' }, + { strict: false } + ); + assert.strictEqual(result.valid, true); + }); + }); + + describe('MessageValidationError', function () { + it('should create error with issues', function () { + const issues = [ + { + field: 'data', + problem: 'TYPE_MISMATCH', + expected: 'string', + received: 'number', + }, + ]; + const error = new rclnodejs.MessageValidationError( + 'std_msgs/msg/String', + issues + ); + + assert.ok(error instanceof rclnodejs.ValidationError); + assert.ok(error instanceof rclnodejs.MessageValidationError); + assert.strictEqual(error.messageType, 'std_msgs/msg/String'); + assert.deepStrictEqual(error.issues, issues); + }); + + it('should filter issues by type', function () { + const issues = [ + { + field: 'data', + problem: 'TYPE_MISMATCH', + expected: 'string', + received: 'number', + }, + { field: 'unknown', problem: 'UNKNOWN_FIELD' }, + ]; + const error = new rclnodejs.MessageValidationError( + 'test/msg/Test', + issues + ); + + const typeIssues = error.getIssuesByType('TYPE_MISMATCH'); + assert.strictEqual(typeIssues.length, 1); + assert.strictEqual(typeIssues[0].field, 'data'); + + const unknownIssues = error.getIssuesByType('UNKNOWN_FIELD'); + assert.strictEqual(unknownIssues.length, 1); + assert.strictEqual(unknownIssues[0].field, 'unknown'); + }); + + it('should check for field issues', function () { + const issues = [{ field: 'data', problem: 'TYPE_MISMATCH' }]; + const error = new rclnodejs.MessageValidationError( + 'test/msg/Test', + issues + ); + + assert.strictEqual(error.hasFieldIssue('data'), true); + assert.strictEqual(error.hasFieldIssue('other'), false); + }); + }); + + describe('Publisher validation integration', function () { + let node; + let testCounter = 0; + + beforeEach(function () { + testCounter++; + node = new rclnodejs.Node( + `test_validation_node_${testCounter}_${Date.now()}` + ); + }); + + afterEach(function () { + node.destroy(); + }); + + it('should publish without validation by default', function () { + const publisher = node.createPublisher( + 'std_msgs/msg/String', + 'test_topic' + ); + assert.strictEqual(publisher.validationEnabled, false); + + assert.doesNotThrow(() => { + publisher.publish({ data: 'valid string' }); + }); + }); + + it('should validate when enabled via options', function () { + const publisher = node.createPublisher( + 'std_msgs/msg/String', + 'test_topic', + { + validateMessages: true, + } + ); + + assert.strictEqual(publisher.validationEnabled, true); + + assert.doesNotThrow(() => { + publisher.publish({ data: 'valid string' }); + }); + + assert.throws(() => { + publisher.publish({ data: 123 }); + }, rclnodejs.MessageValidationError); + }); + + it('should validate with per-publish override', function () { + const publisher = node.createPublisher( + 'std_msgs/msg/String', + 'test_topic' + ); + + assert.throws(() => { + publisher.publish({ data: 123 }, { validate: true }); + }, rclnodejs.MessageValidationError); + + assert.doesNotThrow(() => { + publisher.publish({ data: 'valid string' }); + }); + }); + + it('should toggle validation with setValidation', function () { + const publisher = node.createPublisher( + 'std_msgs/msg/String', + 'test_topic' + ); + + publisher.setValidation(true); + assert.strictEqual(publisher.validationEnabled, true); + + publisher.setValidation(false); + assert.strictEqual(publisher.validationEnabled, false); + }); + }); +}); diff --git a/types/action_client.d.ts b/types/action_client.d.ts index 494df840..3735153a 100644 --- a/types/action_client.d.ts +++ b/types/action_client.d.ts @@ -115,6 +115,14 @@ declare module 'rclnodejs' { statusSubQosProfile?: QoS | QoS.ProfileRef; } + /** + * Options for sending a goal + */ + interface SendGoalOptions { + /** Override validateGoals setting for this call */ + validate?: boolean; + } + /** * ROS Action client. */ @@ -131,9 +139,24 @@ declare module 'rclnodejs' { node: Node, typeClass: T, actionName: string, - options?: Options + options?: Options & { + validateGoals?: boolean; + validationOptions?: MessageValidationOptions; + } ); + /** + * Whether goal validation is enabled for this action client. + */ + readonly validationEnabled: boolean; + + /** + * Enable or disable goal validation for this action client + * @param enabled - Whether to validate goals before sending + * @param options - Validation options + */ + setValidation(enabled: boolean, options?: MessageValidationOptions): void; + /** * Send a goal and wait for the goal ACK asynchronously. * @@ -143,12 +166,15 @@ declare module 'rclnodejs' { * @param goal - The goal request. * @param feedbackCallback - Callback function for feedback associated with the goal. * @param goalUuid - Universally unique identifier for the goal. If None, then a random UUID is generated. + * @param options - Send options (e.g., { validate: true }) * @returns A Promise to a goal handle that resolves when the goal request has been accepted or rejected. + * @throws MessageValidationError if validation is enabled and goal is invalid */ sendGoal( goal: ActionGoal, feedbackCallback?: (feedbackMessage: ActionFeedback) => void, - goalUuid?: unique_identifier_msgs.msg.UUID + goalUuid?: unique_identifier_msgs.msg.UUID, + options?: SendGoalOptions ): Promise>; /** diff --git a/types/client.d.ts b/types/client.d.ts index 12828fca..aacf461b 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -1,17 +1,40 @@ declare module 'rclnodejs' { + /** + * Options for sending a request + */ + interface SendRequestOptions { + /** Override validateRequests setting for this call */ + validate?: boolean; + } + /** * A ROS service client. */ interface Client> extends Entity { + /** + * Whether request validation is enabled for this client. + */ + readonly validationEnabled: boolean; + + /** + * Enable or disable request validation for this client + * @param enabled - Whether to validate requests before sending + * @param options - Validation options + */ + setValidation(enabled: boolean, options?: MessageValidationOptions): void; + /** * Make a service request and wait for to be notified asynchronously through a callback. * * @param request - Request to be submitted. * @param callback - Callback for receiving the server response. + * @param options - Send options (e.g., { validate: true }) + * @throws MessageValidationError if validation is enabled and request is invalid */ sendRequest( request: ServiceRequestMessage, - callback: Client.ResponseCallback + callback: Client.ResponseCallback, + options?: SendRequestOptions ): void; /** @@ -22,6 +45,7 @@ declare module 'rclnodejs' { * @returns Promise that resolves with the service response. * @throws TimeoutError if the request times out (when options.timeout is exceeded). * @throws AbortError if the request is manually aborted (via options.signal). + * @throws MessageValidationError if validation is enabled and request is invalid. * @throws Error if the request fails for other reasons. */ sendRequestAsync( @@ -109,6 +133,11 @@ declare module 'rclnodejs' { * will abort the request. */ signal?: AbortSignal; + + /** + * Override validateRequests setting for this call + */ + validate?: boolean; } } } diff --git a/types/errors.d.ts b/types/errors.d.ts index f0529890..a9de2737 100644 --- a/types/errors.d.ts +++ b/types/errors.d.ts @@ -149,6 +149,55 @@ declare module 'rclnodejs' { ); } + /** + * A single validation issue found during message validation + */ + export interface MessageValidationIssue { + /** Field path where issue occurred (e.g., 'linear.x' or 'data') */ + field: string; + /** Problem type (UNKNOWN_FIELD, TYPE_MISMATCH, etc.) */ + problem: string; + /** Expected type or value */ + expected?: string; + /** Actual value received */ + received?: any; + } + + /** + * Message validation error for ROS message structure/type issues + */ + export class MessageValidationError extends ValidationError { + /** The ROS message type (e.g., 'std_msgs/msg/String') */ + messageType: string; + /** Array of validation issues found */ + issues: MessageValidationIssue[]; + + /** + * @param messageType - The ROS message type + * @param issues - Array of validation issues + * @param options - Additional options + */ + constructor( + messageType: string, + issues: MessageValidationIssue[], + options?: RclNodeErrorOptions + ); + + /** + * Get issues filtered by problem type + * @param problemType - Problem type to filter by + * @returns Filtered issues + */ + getIssuesByType(problemType: string): MessageValidationIssue[]; + + /** + * Check if a specific field has validation issues + * @param fieldPath - Field path to check + * @returns True if field has issues + */ + hasFieldIssue(fieldPath: string): boolean; + } + /** * ROS name validation error (topics, nodes, services) */ diff --git a/types/index.d.ts b/types/index.d.ts index 3ff38bee..356803f5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,5 @@ /// +/// import { ChildProcess } from 'child_process'; diff --git a/types/message_validation.d.ts b/types/message_validation.d.ts new file mode 100644 index 00000000..0db80a3f --- /dev/null +++ b/types/message_validation.d.ts @@ -0,0 +1,197 @@ +// 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. + +declare module 'rclnodejs' { + /** + * Validation issue problem types + */ + export const ValidationProblem: { + /** Field exists in object but not in message schema */ + readonly UNKNOWN_FIELD: 'UNKNOWN_FIELD'; + /** Field type doesn't match expected type */ + readonly TYPE_MISMATCH: 'TYPE_MISMATCH'; + /** Required field is missing */ + readonly MISSING_FIELD: 'MISSING_FIELD'; + /** Array length constraint violated */ + readonly ARRAY_LENGTH: 'ARRAY_LENGTH'; + /** Value is out of valid range */ + readonly OUT_OF_RANGE: 'OUT_OF_RANGE'; + /** Nested message validation failed */ + readonly NESTED_ERROR: 'NESTED_ERROR'; + }; + + /** + * Field type information from message schema + */ + export interface MessageFieldType { + /** The type name (e.g., 'string', 'float64', 'Vector3') */ + type: string; + /** Whether this is a primitive ROS type */ + isPrimitiveType: boolean; + /** Whether this field is an array */ + isArray: boolean; + /** For fixed-size arrays, the required size */ + arraySize?: number; + /** Whether this is a fixed-size array */ + isFixedSizeArray: boolean; + /** Whether this has an upper bound (bounded sequence) */ + isUpperBound: boolean; + /** Whether this is a dynamic array */ + isDynamicArray: boolean; + /** Package name for non-primitive types */ + pkgName?: string; + /** String upper bound for bounded strings */ + stringUpperBound?: number; + } + + /** + * Field definition from message schema + */ + export interface MessageField { + /** Field name */ + name: string; + /** Field type information */ + type: MessageFieldType; + /** Default value if any */ + default_value?: any; + } + + /** + * Constant definition from message schema + */ + export interface MessageConstant { + /** Constant name */ + name: string; + /** Constant type */ + type: string; + /** Constant value */ + value: any; + } + + /** + * Message schema definition + */ + export interface MessageSchema { + /** Array of field definitions */ + fields: MessageField[]; + /** Array of constant definitions */ + constants: MessageConstant[]; + /** Full message type string (e.g., 'std_msgs/msg/String') */ + messageType: string; + /** Base type information */ + baseType?: { + pkgName: string; + type: string; + isPrimitiveType: boolean; + }; + } + + /** + * Options for message validation + */ + export interface MessageValidationOptions { + /** If true, unknown fields cause validation failure (default: false) */ + strict?: boolean; + /** If true, validate field types (default: true) */ + checkTypes?: boolean; + /** If true, check for missing fields (default: false) */ + checkRequired?: boolean; + /** Interface loader function (internal use) */ + loader?: (name: string) => any; + } + + /** + * Result of message validation + */ + export interface MessageValidationResult { + /** Whether the message is valid */ + valid: boolean; + /** Array of validation issues found */ + issues: MessageValidationIssue[]; + } + + /** + * Get the schema definition for a message type + * @param typeClass - Message type class or identifier + * @param loader - Interface loader function (optional) + * @returns Schema definition with fields and constants, or null if not found + */ + export function getMessageSchema( + typeClass: TypeClass, + loader?: (name: string) => any + ): MessageSchema | null; + + /** + * Get field names for a message type + * @param typeClass - Message type class or identifier + * @param loader - Interface loader function (optional) + * @returns Array of field names + */ + export function getFieldNames( + typeClass: TypeClass, + loader?: (name: string) => any + ): string[]; + + /** + * Get type information for a specific field + * @param typeClass - Message type class or identifier + * @param fieldName - Name of the field + * @param loader - Interface loader function (optional) + * @returns Field type information or null if not found + */ + export function getFieldType( + typeClass: TypeClass, + fieldName: string, + loader?: (name: string) => any + ): MessageFieldType | null; + + /** + * Validate a message object against its schema + * @param obj - Plain object to validate + * @param typeClass - Message type class or identifier + * @param options - Validation options + * @returns Validation result with valid flag and issues array + */ + export function validateMessage( + obj: any, + typeClass: TypeClass, + options?: MessageValidationOptions + ): MessageValidationResult; + + /** + * Validate a message and throw if invalid + * @param obj - Plain object to validate + * @param typeClass - Message type class or identifier + * @param options - Validation options + * @throws MessageValidationError if validation fails + */ + export function assertValidMessage( + obj: any, + typeClass: TypeClass, + options?: MessageValidationOptions + ): void; + + /** + * Create a validator function for a specific message type + * @param typeClass - Message type class or identifier + * @param defaultOptions - Default validation options + * @param loader - Interface loader function (optional) + * @returns Validator function that takes (obj, options?) and returns validation result + */ + export function createMessageValidator( + typeClass: TypeClass, + defaultOptions?: MessageValidationOptions, + loader?: (name: string) => any + ): (obj: any, options?: MessageValidationOptions) => MessageValidationResult; +} diff --git a/types/publisher.d.ts b/types/publisher.d.ts index b80861d3..37408f42 100644 --- a/types/publisher.d.ts +++ b/types/publisher.d.ts @@ -1,4 +1,12 @@ declare module 'rclnodejs' { + /** + * Options for publishing a message + */ + interface PublishOptions { + /** Override validateMessages setting for this publish call */ + validate?: boolean; + } + /** * A ROS Publisher that publishes messages on a topic. */ @@ -8,12 +16,27 @@ declare module 'rclnodejs' { * Topic on which messages are published. */ readonly topic: string; + + /** + * Whether message validation is enabled for this publisher. + */ + readonly validationEnabled: boolean; + /** * Publish a message * * @param message - The message to be sent. + * @param options - Publish options (e.g., { validate: true }) + * @throws MessageValidationError if validation is enabled and message is invalid + */ + publish(message: MessageType | Buffer, options?: PublishOptions): void; + + /** + * Enable or disable message validation for this publisher + * @param enabled - Whether to validate messages before publishing + * @param options - Validation options */ - publish(message: MessageType | Buffer): void; + setValidation(enabled: boolean, options?: MessageValidationOptions): void; /** * Get the number of subscriptions to this publisher.