• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

RobotWebTools / rclnodejs / 19957465401

05 Dec 2025 08:39AM UTC coverage: 80.404% (-2.3%) from 82.751%
19957465401

Pull #1341

github

web-flow
Merge e3710d3b9 into faaa2d4b5
Pull Request #1341: Enhance Message Validation

1212 of 1681 branches covered (72.1%)

Branch coverage included in aggregate %.

115 of 186 new or added lines in 5 files covered. (61.83%)

13 existing lines in 3 files now uncovered.

2612 of 3075 relevant lines covered (84.94%)

460.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

53.25
/lib/message_validation.js
1
// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved.
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//     http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14

15
'use strict';
16

17
const { MessageValidationError, TypeValidationError } = require('./errors.js');
26✔
18

19
/**
20
 * Validation issue problem types
21
 * @enum {string}
22
 */
23
const ValidationProblem = {
26✔
24
  /** Field exists in object but not in message schema */
25
  UNKNOWN_FIELD: 'UNKNOWN_FIELD',
26
  /** Field type doesn't match expected type */
27
  TYPE_MISMATCH: 'TYPE_MISMATCH',
28
  /** Required field is missing */
29
  MISSING_FIELD: 'MISSING_FIELD',
30
  /** Array length constraint violated */
31
  ARRAY_LENGTH: 'ARRAY_LENGTH',
32
  /** Value is out of valid range */
33
  OUT_OF_RANGE: 'OUT_OF_RANGE',
34
  /** Nested message validation failed */
35
  NESTED_ERROR: 'NESTED_ERROR',
36
};
37

38
/**
39
 * Map ROS primitive types to JavaScript types
40
 */
41
const PRIMITIVE_TYPE_MAP = {
26✔
42
  bool: 'boolean',
43
  int8: 'number',
44
  uint8: 'number',
45
  int16: 'number',
46
  uint16: 'number',
47
  int32: 'number',
48
  uint32: 'number',
49
  int64: 'bigint',
50
  uint64: 'bigint',
51
  float32: 'number',
52
  float64: 'number',
53
  char: 'number',
54
  byte: 'number',
55
  string: 'string',
56
  wstring: 'string',
57
};
58

59
/**
60
 * Check if value is a TypedArray
61
 * @param {any} value - Value to check
62
 * @returns {boolean} True if TypedArray
63
 */
64
function isTypedArray(value) {
NEW
65
  return ArrayBuffer.isView(value) && !(value instanceof DataView);
×
66
}
67

68
/**
69
 * Get the JavaScript type string for a value
70
 * @param {any} value - Value to get type of
71
 * @returns {string} Type description
72
 */
73
function getValueType(value) {
NEW
74
  if (value === null) return 'null';
×
NEW
75
  if (value === undefined) return 'undefined';
×
NEW
76
  if (Array.isArray(value)) return 'array';
×
NEW
77
  if (isTypedArray(value)) return 'TypedArray';
×
NEW
78
  return typeof value;
×
79
}
80

81
/**
82
 * Resolve a type class from various input formats
83
 * @param {string|object|function} typeClass - Type identifier
84
 * @param {function} [loader] - Interface loader function
85
 * @returns {function|null} The resolved type class or null
86
 */
87
function resolveTypeClass(typeClass, loader) {
88
  if (typeof typeClass === 'function') {
57✔
89
    return typeClass;
56✔
90
  }
91

92
  if (loader) {
1!
NEW
93
    try {
×
NEW
94
      return loader(typeClass);
×
95
    } catch {
NEW
96
      return null;
×
97
    }
98
  }
99

100
  return null;
1✔
101
}
102

103
/**
104
 * Get message type string from type class
105
 * @param {function} typeClass - Message type class
106
 * @returns {string} Message type string (e.g., 'std_msgs/msg/String')
107
 */
108
function getMessageTypeString(typeClass) {
109
  if (typeof typeClass.type === 'function') {
32!
110
    const t = typeClass.type();
32✔
111
    return `${t.pkgName}/${t.subFolder}/${t.interfaceName}`;
32✔
112
  }
NEW
113
  return 'unknown';
×
114
}
115

116
/**
117
 * Get the schema definition for a message type
118
 * @param {function|string|object} typeClass - Message type class or identifier
119
 * @param {function} [loader] - Interface loader function (required if typeClass is string/object)
120
 * @returns {object|null} Schema definition with fields and constants, or null if not found
121
 * @example
122
 * const schema = getMessageSchema(StringClass);
123
 * // Returns: {
124
 * //   fields: [{name: 'data', type: {type: 'string', isPrimitiveType: true, ...}}],
125
 * //   constants: [],
126
 * //   messageType: 'std_msgs/msg/String'
127
 * // }
128
 */
129
function getMessageSchema(typeClass, loader) {
130
  const resolved = resolveTypeClass(typeClass, loader);
29✔
131
  if (!resolved || !resolved.ROSMessageDef) {
29✔
132
    return null;
1✔
133
  }
134

135
  const def = resolved.ROSMessageDef;
28✔
136
  return {
28✔
137
    fields: def.fields || [],
28!
138
    constants: def.constants || [],
28!
139
    messageType: getMessageTypeString(resolved),
140
    baseType: def.baseType,
141
  };
142
}
143

144
/**
145
 * Get field names for a message type
146
 * @param {function|string|object} typeClass - Message type class or identifier
147
 * @param {function} [loader] - Interface loader function
148
 * @returns {string[]} Array of field names
149
 */
150
function getFieldNames(typeClass, loader) {
151
  const schema = getMessageSchema(typeClass, loader);
2✔
152
  if (!schema) return [];
2!
153
  return schema.fields.map((f) => f.name);
3✔
154
}
155

156
/**
157
 * Get type information for a specific field
158
 * @param {function|string|object} typeClass - Message type class or identifier
159
 * @param {string} fieldName - Name of the field
160
 * @param {function} [loader] - Interface loader function
161
 * @returns {object|null} Field type information or null if not found
162
 */
163
function getFieldType(typeClass, fieldName, loader) {
164
  const schema = getMessageSchema(typeClass, loader);
2✔
165
  if (!schema) return null;
2!
166

167
  const field = schema.fields.find((f) => f.name === fieldName);
2✔
168
  return field ? field.type : null;
2✔
169
}
170

171
/**
172
 * Validate a primitive value against its expected type
173
 * @param {any} value - Value to validate
174
 * @param {object} fieldType - Field type definition
175
 * @returns {object|null} Validation issue or null if valid
176
 */
177
function validatePrimitiveValue(value, fieldType) {
178
  const expectedJsType = PRIMITIVE_TYPE_MAP[fieldType.type];
26✔
179
  const actualType = typeof value;
26✔
180

181
  if (!expectedJsType) {
26!
NEW
182
    return null; // Unknown primitive type, skip validation
×
183
  }
184

185
  // Allow number for bigint fields (will be converted)
186
  if (expectedJsType === 'bigint' && actualType === 'number') {
26!
NEW
187
    return null;
×
188
  }
189

190
  if (actualType !== expectedJsType) {
26✔
191
    return {
5✔
192
      problem: ValidationProblem.TYPE_MISMATCH,
193
      expected: expectedJsType,
194
      received: actualType,
195
    };
196
  }
197

198
  return null;
21✔
199
}
200

201
/**
202
 * Validate array constraints
203
 * @param {any} value - Array value to validate
204
 * @param {object} fieldType - Field type definition
205
 * @returns {object|null} Validation issue or null if valid
206
 */
207
function validateArrayConstraints(value, fieldType) {
NEW
208
  if (!Array.isArray(value) && !isTypedArray(value)) {
×
NEW
209
    return {
×
210
      problem: ValidationProblem.TYPE_MISMATCH,
211
      expected: 'array',
212
      received: getValueType(value),
213
    };
214
  }
215

NEW
216
  const length = value.length;
×
217

218
  // Fixed size array
NEW
219
  if (fieldType.isFixedSizeArray && length !== fieldType.arraySize) {
×
NEW
220
    return {
×
221
      problem: ValidationProblem.ARRAY_LENGTH,
222
      expected: `exactly ${fieldType.arraySize} elements`,
223
      received: `${length} elements`,
224
    };
225
  }
226

227
  // Upper bound array
NEW
228
  if (fieldType.isUpperBound && length > fieldType.arraySize) {
×
NEW
229
    return {
×
230
      problem: ValidationProblem.ARRAY_LENGTH,
231
      expected: `at most ${fieldType.arraySize} elements`,
232
      received: `${length} elements`,
233
    };
234
  }
235

NEW
236
  return null;
×
237
}
238

239
/**
240
 * Validate a message object against its schema
241
 * @param {object} obj - Plain object to validate
242
 * @param {function|string|object} typeClass - Message type class or identifier
243
 * @param {object} [options] - Validation options
244
 * @param {boolean} [options.strict=false] - If true, unknown fields cause validation failure
245
 * @param {boolean} [options.checkTypes=true] - If true, validate field types
246
 * @param {boolean} [options.checkRequired=false] - If true, check for missing fields
247
 * @param {string} [options.path=''] - Current path for nested validation (internal use)
248
 * @param {function} [options.loader] - Interface loader function
249
 * @returns {{valid: boolean, issues: Array<object>}} Validation result
250
 */
251
function validateMessage(obj, typeClass, options = {}) {
7✔
252
  const {
253
    strict = false,
9✔
254
    checkTypes = true,
15✔
255
    checkRequired = false,
18✔
256
    path = '',
18✔
257
    loader,
258
  } = options;
22✔
259

260
  const issues = [];
22✔
261
  const resolved = resolveTypeClass(typeClass, loader);
22✔
262

263
  if (!resolved) {
22!
NEW
264
    issues.push({
×
265
      field: path || '(root)',
×
266
      problem: 'INVALID_TYPE_CLASS',
267
      expected: 'valid message type class',
268
      received: typeof typeClass,
269
    });
NEW
270
    return { valid: false, issues };
×
271
  }
272

273
  const schema = getMessageSchema(resolved);
22✔
274
  if (!schema) {
22!
NEW
275
    issues.push({
×
276
      field: path || '(root)',
×
277
      problem: 'NO_SCHEMA',
278
      expected: 'message with ROSMessageDef',
279
      received: 'class without schema',
280
    });
NEW
281
    return { valid: false, issues };
×
282
  }
283

284
  if (obj === null || obj === undefined) {
22✔
285
    issues.push({
2✔
286
      field: path || '(root)',
4✔
287
      problem: ValidationProblem.TYPE_MISMATCH,
288
      expected: 'object',
289
      received: String(obj),
290
    });
291
    return { valid: false, issues };
2✔
292
  }
293

294
  const type = typeof obj;
20✔
295
  if (
20✔
296
    type === 'string' ||
77✔
297
    type === 'number' ||
298
    type === 'boolean' ||
299
    type === 'bigint'
300
  ) {
301
    if (schema.fields.length === 1 && schema.fields[0].name === 'data') {
1!
302
      const fieldType = schema.fields[0].type;
1✔
303
      if (checkTypes && fieldType.isPrimitiveType) {
1!
304
        const typeIssue = validatePrimitiveValue(obj, fieldType);
1✔
305
        if (typeIssue) {
1!
NEW
306
          issues.push({
×
307
            field: path ? `${path}.data` : 'data',
×
308
            ...typeIssue,
309
          });
310
        }
311
      }
312
      return { valid: issues.length === 0, issues };
1✔
313
    }
314
  }
315

316
  if (type !== 'object') {
19!
NEW
317
    issues.push({
×
318
      field: path || '(root)',
×
319
      problem: ValidationProblem.TYPE_MISMATCH,
320
      expected: 'object',
321
      received: type,
322
    });
NEW
323
    return { valid: false, issues };
×
324
  }
325

326
  const fieldNames = new Set(schema.fields.map((f) => f.name));
29✔
327
  const objKeys = Object.keys(obj);
19✔
328

329
  if (strict) {
19✔
330
    for (const key of objKeys) {
7✔
331
      if (!fieldNames.has(key)) {
10✔
332
        issues.push({
3✔
333
          field: path ? `${path}.${key}` : key,
3!
334
          problem: ValidationProblem.UNKNOWN_FIELD,
335
        });
336
      }
337
    }
338
  }
339

340
  for (const field of schema.fields) {
19✔
341
    const fieldPath = path ? `${path}.${field.name}` : field.name;
29✔
342
    const value = obj[field.name];
29✔
343
    const fieldType = field.type;
29✔
344

345
    if (field.name.startsWith('_')) continue;
29!
346

347
    if (value === undefined) {
29!
NEW
348
      if (checkRequired) {
×
NEW
349
        issues.push({
×
350
          field: fieldPath,
351
          problem: ValidationProblem.MISSING_FIELD,
352
          expected: fieldType.type,
353
        });
354
      }
NEW
355
      continue;
×
356
    }
357

358
    if (fieldType.isArray) {
29!
NEW
359
      const arrayIssue = validateArrayConstraints(value, fieldType);
×
NEW
360
      if (arrayIssue) {
×
NEW
361
        issues.push({ field: fieldPath, ...arrayIssue });
×
NEW
362
        continue;
×
363
      }
364

NEW
365
      if (checkTypes && Array.isArray(value) && value.length > 0) {
×
NEW
366
        if (fieldType.isPrimitiveType) {
×
NEW
367
          for (let i = 0; i < value.length; i++) {
×
NEW
368
            const elemIssue = validatePrimitiveValue(value[i], fieldType);
×
NEW
369
            if (elemIssue) {
×
NEW
370
              issues.push({
×
371
                field: `${fieldPath}[${i}]`,
372
                ...elemIssue,
373
              });
374
            }
375
          }
376
        } else {
NEW
377
          for (let i = 0; i < value.length; i++) {
×
NEW
378
            const nestedResult = validateMessage(
×
379
              value[i],
380
              getNestedTypeClass(resolved, field.name, loader),
381
              {
382
                strict,
383
                checkTypes,
384
                checkRequired,
385
                path: `${fieldPath}[${i}]`,
386
                loader,
387
              }
388
            );
NEW
389
            if (!nestedResult.valid) {
×
NEW
390
              issues.push(...nestedResult.issues);
×
391
            }
392
          }
393
        }
394
      }
395
    } else if (fieldType.isPrimitiveType) {
29✔
396
      if (checkTypes) {
25!
397
        const typeIssue = validatePrimitiveValue(value, fieldType);
25✔
398
        if (typeIssue) {
25✔
399
          issues.push({ field: fieldPath, ...typeIssue });
5✔
400
        }
401
      }
402
    } else {
403
      if (value !== null && typeof value === 'object') {
4!
404
        const nestedTypeClass = getNestedTypeClass(
4✔
405
          resolved,
406
          field.name,
407
          loader
408
        );
409
        if (nestedTypeClass) {
4!
410
          const nestedResult = validateMessage(value, nestedTypeClass, {
4✔
411
            strict,
412
            checkTypes,
413
            checkRequired,
414
            path: fieldPath,
415
            loader,
416
          });
417
          if (!nestedResult.valid) {
4✔
418
            issues.push(...nestedResult.issues);
1✔
419
          }
420
        }
NEW
421
      } else if (checkTypes && value !== null) {
×
NEW
422
        issues.push({
×
423
          field: fieldPath,
424
          problem: ValidationProblem.TYPE_MISMATCH,
425
          expected: 'object',
426
          received: getValueType(value),
427
        });
428
      }
429
    }
430
  }
431

432
  return { valid: issues.length === 0, issues };
19✔
433
}
434

435
/**
436
 * Get the type class for a nested field
437
 * @param {function} parentTypeClass - Parent message type class
438
 * @param {string} fieldName - Field name
439
 * @param {function} [loader] - Interface loader function
440
 * @returns {function|null} Nested type class or null
441
 */
442
function getNestedTypeClass(parentTypeClass, fieldName, loader) {
443
  try {
4✔
444
    const instance = new parentTypeClass();
4✔
445
    const fieldValue = instance[fieldName];
4✔
446

447
    if (
4!
448
      fieldValue &&
12✔
449
      fieldValue.constructor &&
450
      fieldValue.constructor.ROSMessageDef
451
    ) {
452
      return fieldValue.constructor;
4✔
453
    }
454

NEW
455
    if (
×
456
      fieldValue &&
×
457
      fieldValue.classType &&
458
      fieldValue.classType.elementType
459
    ) {
NEW
460
      return fieldValue.classType.elementType;
×
461
    }
462
  } catch {
NEW
463
    const schema = getMessageSchema(parentTypeClass);
×
NEW
464
    if (schema && loader) {
×
NEW
465
      const field = schema.fields.find((f) => f.name === fieldName);
×
NEW
466
      if (field && !field.type.isPrimitiveType) {
×
NEW
467
        const typeName = `${field.type.pkgName}/msg/${field.type.type}`;
×
NEW
468
        return resolveTypeClass(typeName, loader);
×
469
      }
470
    }
471
  }
NEW
472
  return null;
×
473
}
474

475
/**
476
 * Validate a message and throw if invalid
477
 * @param {object} obj - Plain object to validate
478
 * @param {function|string|object} typeClass - Message type class or identifier
479
 * @param {object} [options] - Validation options (same as validateMessage)
480
 * @throws {MessageValidationError} If validation fails
481
 * @returns {void}
482
 */
483
function assertValidMessage(obj, typeClass, options = {}) {
2✔
484
  const result = validateMessage(obj, typeClass, options);
6✔
485

486
  if (!result.valid) {
6✔
487
    const resolved = resolveTypeClass(typeClass, options.loader);
4✔
488
    const messageType = resolved
4!
489
      ? getMessageTypeString(resolved)
490
      : String(typeClass);
491
    throw new MessageValidationError(messageType, result.issues);
4✔
492
  }
493
}
494

495
/**
496
 * Create a validator function for a specific message type
497
 * @param {function|string|object} typeClass - Message type class or identifier
498
 * @param {object} [defaultOptions] - Default validation options
499
 * @param {function} [loader] - Interface loader function
500
 * @returns {function} Validator function that takes (obj, options?) and returns validation result
501
 */
502
function createMessageValidator(typeClass, defaultOptions = {}, loader) {
×
503
  const resolved = resolveTypeClass(typeClass, loader);
2✔
504
  if (!resolved) {
2!
NEW
505
    throw new TypeValidationError(
×
506
      'typeClass',
507
      typeClass,
508
      'valid message type class'
509
    );
510
  }
511

512
  return function validator(obj, options = {}) {
2✔
513
    return validateMessage(obj, resolved, {
3✔
514
      ...defaultOptions,
515
      ...options,
516
      loader,
517
    });
518
  };
519
}
520

521
module.exports = {
26✔
522
  ValidationProblem,
523

524
  getMessageSchema,
525
  getFieldNames,
526
  getFieldType,
527

528
  validateMessage,
529
  assertValidMessage,
530
  createMessageValidator,
531

532
  getMessageTypeString,
533
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc