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

RobotWebTools / rclnodejs / 30322190118

28 Jul 2026 02:06AM UTC coverage: 91.116% (+0.01%) from 91.103%
30322190118

Pull #1565

github

web-flow
Merge bb906d366 into 62e89b67c
Pull Request #1565: [Web runtime] Add OpenAPI 3.1 export from the capability registry

2182 of 2572 branches covered (84.84%)

Branch coverage included in aggregate %.

391 of 418 new or added lines in 2 files covered. (93.54%)

17448 of 18972 relevant lines covered (91.97%)

218.68 hits per line

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

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

8✔
15
/**
8✔
16
 * OpenAPI 3.1 export for the Web Runtime capability registry.
8✔
17
 *
8✔
18
 * Turns `CapabilityRegistry.list()` into a documented, introspectable
8✔
19
 * Web API.
8✔
20
 *
8✔
21
 * CLI-only: not re-exported from `lib/runtime/index.js`, so it isn't part
8✔
22
 * of the published `rclnodejs/web/server` API (`dist/server.js` bundles
8✔
23
 * only `lib/runtime/index.js`). Importing this file directly is
8✔
24
 * unsupported — go through the `rclnodejs-web openapi` subcommand.
8✔
25
 *
8✔
26
 * Deliberately reuses the message introspection that already ships in
8✔
27
 * rclnodejs core (`lib/message_validation.js`'s `getMessageSchema()`)
8✔
28
 * instead of re-parsing rosidl ASTs. Resolving a
8✔
29
 * ROS type name is a pure local file lookup (`interface_loader` reads the
8✔
30
 * already-generated `.js` message classes) and never calls a live
8✔
31
 * `rclnodejs.init()`, so `buildOpenApiDocument()` can run standalone against
8✔
32
 * just a `web.json` config, without any transport or ROS graph. It still
8✔
33
 * needs ROS 2 sourced, though: `require()`-ing a generated message class
8✔
34
 * loads rclnodejs's native addon as a side effect, and without a sourced
8✔
35
 * environment the native loader may fail to match a prebuild and fall back
8✔
36
 * to a source rebuild.
8✔
37
 */
8✔
38

8✔
39
import interfaceLoader from './interface_loader.js';
8✔
40
import { getMessageSchema } from './message_validation.js';
8✔
41

8✔
42
/**
8✔
43
 * Map one ROS field-type descriptor (the shape produced by
8✔
44
 * `getMessageSchema()`, ultimately from the rosidl-generated
8✔
45
 * `ROSMessageDef`) to a JSON Schema fragment.
8✔
46
 *
8✔
47
 * 64-bit integers are the one deliberate deviation from the "obvious"
8✔
48
 * mapping: OpenAPI's `format: int64` is a documentation-only annotation on
8✔
49
 * top of `type: integer`, and JSON numbers cannot safely carry 64-bit
8✔
50
 * precision in JS (or most JSON parsers). rclnodejs's own wire convention
8✔
51
 * already represents `int64`/`uint64` as `"<digits>n"` strings (see
8✔
52
 * `PRIMITIVE_TYPE_MAP` in message_validation.js mapping them to `'bigint'`),
8✔
53
 * so the schema follows that convention rather than the nominal
8✔
54
 * `type: integer, format: int64` pairing.
8✔
55
 *
8✔
56
 * @param {object} fieldType - a field's `type` descriptor from
8✔
57
 *   `getMessageSchema(...).fields[i].type`
8✔
58
 * @param {Map<string,object>} components - accumulator for referenced
8✔
59
 *   nested-message component schemas, keyed by component name
8✔
60
 *   (`<pkg>__msg__<Type>` or `<pkg>__srv__<Type>_Request`/`_Response`)
8✔
61
 * @param {Set<string>} seen - cycle guard for recursive `$ref` resolution
8✔
62
 * @returns {object} a JSON Schema fragment
8✔
63
 */
8✔
64
function rosFieldTypeToJsonSchema(fieldType, components, seen) {
74✔
65
  if (fieldType.isArray) {
74!
NEW
66
    const itemSchema = rosFieldTypeToJsonSchema(
×
NEW
67
      { ...fieldType, isArray: false },
×
NEW
68
      components,
×
NEW
69
      seen
×
NEW
70
    );
×
NEW
71
    const arraySchema = { type: 'array', items: itemSchema };
×
NEW
72
    if (fieldType.isFixedSizeArray && fieldType.arraySize != null) {
×
NEW
73
      arraySchema.minItems = fieldType.arraySize;
×
NEW
74
      arraySchema.maxItems = fieldType.arraySize;
×
NEW
75
    } else if (fieldType.isUpperBound && fieldType.arraySize != null) {
×
NEW
76
      arraySchema.maxItems = fieldType.arraySize;
×
NEW
77
    }
×
NEW
78
    return arraySchema;
×
NEW
79
  }
×
80

74✔
81
  if (fieldType.isPrimitiveType) {
74✔
82
    return primitiveToJsonSchema(fieldType);
60✔
83
  }
60✔
84

14✔
85
  // Nested message type — register as a component and return a $ref so
14✔
86
  // repeated uses of the same type (e.g. geometry_msgs/msg/Pose across many
14✔
87
  // capabilities) share one schema instead of being inlined N times.
14✔
88
  const componentName = `${fieldType.pkgName}__msg__${fieldType.type}`;
14✔
89
  const typeName = `${fieldType.pkgName}/msg/${fieldType.type}`;
14✔
90
  registerComponent(typeName, componentName, components, seen);
14✔
91
  return { $ref: `#/components/schemas/${componentName}` };
14✔
92
}
74✔
93

8✔
94
const INT64_TYPES = new Set(['int64', 'uint64']);
8✔
95

8✔
96
function primitiveToJsonSchema(fieldType) {
67✔
97
  const { type, stringUpperBound } = fieldType;
67✔
98

67✔
99
  if (type === 'bool') return { type: 'boolean' };
67✔
100
  if (INT64_TYPES.has(type)) {
67✔
101
    // Deliberately string, not integer (see docstring above). uint64 gets
29✔
102
    // an unsigned-only pattern so the schema can't claim negatives are valid.
29✔
103
    const unsigned = type === 'uint64';
29✔
104
    return {
29✔
105
      type: 'string',
29✔
106
      format: unsigned ? 'uint64' : 'int64',
29✔
107
      pattern: unsigned ? '^[0-9]+n$' : '^-?[0-9]+n$',
29✔
108
      example: '42n',
29✔
109
      description: `ROS 2 ${type}, transmitted as a BigInt-string (e.g. "42n") for precision-safety.`,
29✔
110
    };
29✔
111
  }
29✔
112
  if (
37✔
113
    [
37✔
114
      'int8',
37✔
115
      'uint8',
37✔
116
      'int16',
37✔
117
      'uint16',
37✔
118
      'int32',
37✔
119
      'uint32',
37✔
120
      'byte',
37✔
121
      'char',
37✔
122
    ].includes(type)
37✔
123
  ) {
61✔
124
    return { type: 'integer' };
1✔
125
  }
1✔
126
  if (['float32', 'float64'].includes(type)) {
61✔
127
    return { type: 'number' };
22✔
128
  }
22✔
129
  if (type === 'string' || type === 'wstring') {
67!
130
    const schema = { type: 'string' };
14✔
131
    if (stringUpperBound != null && stringUpperBound > 0) {
14✔
132
      schema.maxLength = stringUpperBound;
1✔
133
    }
1✔
134
    return schema;
14✔
135
  }
14✔
NEW
136
  // Unknown/unmapped primitive — fall back to permissive rather than
×
NEW
137
  // silently wrong.
×
NEW
138
  return {};
×
139
}
67✔
140

8✔
141
/**
8✔
142
 * Resolve a ROS message type name into a JSON Schema object (properties per
8✔
143
 * field), registering it into `components` under `componentName` so nested
8✔
144
 * `$ref`s can point at it. No-op if already registered/in-progress (cycle
8✔
145
 * guard).
8✔
146
 */
8✔
147
function registerComponent(typeName, componentName, components, seen) {
14✔
148
  if (components.has(componentName) || seen.has(componentName)) return;
14✔
149
  seen.add(componentName);
7✔
150

7✔
151
  let typeClass;
7✔
152
  try {
7✔
153
    typeClass = interfaceLoader.loadInterface(typeName);
7✔
154
  } catch {
14!
NEW
155
    components.set(componentName, {
×
NEW
156
      type: 'object',
×
NEW
157
      description: `Could not resolve ${typeName}`,
×
NEW
158
    });
×
NEW
159
    return;
×
NEW
160
  }
×
161

7✔
162
  const schema = getMessageSchema(typeClass);
7✔
163
  components.set(
7✔
164
    componentName,
7✔
165
    messageSchemaToJsonSchema(schema, components, seen)
7✔
166
  );
7✔
167
}
14✔
168

8✔
169
/**
8✔
170
 * Convert a `getMessageSchema()`-shaped object into a JSON Schema Object
8✔
171
 * (`{type: 'object', properties: {...}}`), recursively registering any
8✔
172
 * nested message types into `components`.
8✔
173
 */
8✔
174
function messageSchemaToJsonSchema(schema, components, seen) {
44✔
175
  const properties = {};
44✔
176
  const required = [];
44✔
177
  for (const field of schema.fields || []) {
44!
178
    if (field.name.startsWith('_')) continue;
74!
179
    properties[field.name] = rosFieldTypeToJsonSchema(
74✔
180
      field.type,
74✔
181
      components,
74✔
182
      seen
74✔
183
    );
74✔
184
    required.push(field.name);
74✔
185
  }
74✔
186
  const jsonSchema = { type: 'object', properties };
44✔
187
  if (required.length) jsonSchema.required = required;
44✔
188
  if (schema.messageType) jsonSchema['x-ros-type'] = schema.messageType;
44✔
189
  return jsonSchema;
44✔
190
}
44✔
191

8✔
192
/**
8✔
193
 * Resolve a top-level capability type (message for publish/subscribe,
8✔
194
 * service Request/Response for call) to a JSON Schema, without registering
8✔
195
 * it as a component itself (the top-level request/response body is inlined
8✔
196
 * in the operation, only *nested* types become `$ref`d components — this
8✔
197
 * matches typical OpenAPI style for RPC-shaped APIs).
8✔
198
 */
8✔
199
function topLevelSchema(typeName, subType, components, seen) {
37✔
200
  let typeClass;
37✔
201
  try {
37✔
202
    typeClass = interfaceLoader.loadInterface(typeName);
37✔
203
  } catch {
37!
NEW
204
    return { type: 'object', description: `Could not resolve ${typeName}` };
×
NEW
205
  }
×
206
  const resolved = subType ? typeClass[subType] : typeClass;
37✔
207
  const schema = getMessageSchema(resolved);
37✔
208
  if (!schema) {
37!
NEW
209
    return { type: 'object', description: `Could not resolve ${typeName}` };
×
NEW
210
  }
×
211
  return messageSchemaToJsonSchema(schema, components, seen);
37✔
212
}
37✔
213

8✔
214
/**
8✔
215
 * Build a full OpenAPI 3.1 document from a capability registry snapshot
8✔
216
 * (`CapabilityRegistry.list()`'s shape: `{call, publish, subscribe}`, each a
8✔
217
 * `{name: typeName}` map).
8✔
218
 *
8✔
219
 * No `servers` option: it's pure top-level metadata this function never
8✔
220
 * reads while building `paths`, so callers (e.g. the CLI's
8✔
221
 * `openApiServers()`) attach it to the returned document directly instead.
8✔
222
 *
8✔
223
 * No `version` option either: `info.version` describes the caller's API,
8✔
224
 * not the rclnodejs release that generated the document, and there's no
8✔
225
 * source for the former today — so it's a fixed `'0.0.0'` placeholder.
8✔
226
 *
8✔
227
 * @param {{call: object, publish: object, subscribe: object}} capabilities
8✔
228
 * @param {object} [options]
8✔
229
 * @param {string} [options.title]
8✔
230
 * @param {string} [options.basePath] - default '/capability'
8✔
231
 * @returns {object} an OpenAPI 3.1 document (plain object; caller decides
8✔
232
 *   JSON vs. YAML serialization)
8✔
233
 */
8✔
234
function buildOpenApiDocument(capabilities, options = {}) {
14✔
235
  const { title = 'rclnodejs/web capability API' } = options;
14✔
236
  // Match HttpTransport's normalisation so a trailing-slash basePath
14✔
237
  // can't produce a route the runtime doesn't actually serve.
14✔
238
  const basePath = _normaliseBasePath(options.basePath);
14✔
239

14✔
240
  const components = new Map();
14✔
241
  const seen = new Set();
14✔
242
  const paths = {};
14✔
243

14✔
244
  for (const [name, typeName] of Object.entries(capabilities.call || {})) {
14!
245
    const route = `${basePath}/call${name}`;
9✔
246
    paths[route] = {
9✔
247
      post: {
9✔
248
        summary: `Call ROS 2 service ${name}`,
9✔
249
        operationId: `call_${sanitizeName(name)}`,
9✔
250
        'x-ros-capability': { kind: 'call', name, type: typeName },
9✔
251
        requestBody: {
9✔
252
          required: true,
9✔
253
          content: {
9✔
254
            'application/json': {
9✔
255
              schema: topLevelSchema(typeName, 'Request', components, seen),
9✔
256
            },
9✔
257
          },
9✔
258
        },
9✔
259
        responses: {
9✔
260
          200: {
9✔
261
            description: 'ROS 2 service response',
9✔
262
            content: {
9✔
263
              'application/json': {
9✔
264
                schema: topLevelSchema(typeName, 'Response', components, seen),
9✔
265
              },
9✔
266
            },
9✔
267
          },
9✔
268
          404: notExposedResponse(),
9✔
269
        },
9✔
270
      },
9✔
271
    };
9✔
272
  }
9✔
273

14✔
274
  for (const [name, typeName] of Object.entries(capabilities.publish || {})) {
14!
275
    const route = `${basePath}/publish${name}`;
12✔
276
    paths[route] = {
12✔
277
      post: {
12✔
278
        summary: `Publish to ROS 2 topic ${name}`,
12✔
279
        operationId: `publish_${sanitizeName(name)}`,
12✔
280
        'x-ros-capability': { kind: 'publish', name, type: typeName },
12✔
281
        requestBody: {
12✔
282
          required: true,
12✔
283
          content: {
12✔
284
            'application/json': {
12✔
285
              schema: topLevelSchema(typeName, null, components, seen),
12✔
286
            },
12✔
287
          },
12✔
288
        },
12✔
289
        responses: {
12✔
290
          204: { description: 'Published, no content' },
12✔
291
          404: notExposedResponse(),
12✔
292
        },
12✔
293
      },
12✔
294
    };
12✔
295
  }
12✔
296

14✔
297
  for (const [name, typeName] of Object.entries(capabilities.subscribe || {})) {
14!
298
    const route = `${basePath}/subscribe${name}`;
7✔
299
    paths[route] = {
7✔
300
      get: {
7✔
301
        summary: `Subscribe to ROS 2 topic ${name} via Server-Sent Events`,
7✔
302
        operationId: `subscribe_${sanitizeName(name)}`,
7✔
303
        'x-ros-capability': { kind: 'subscribe', name, type: typeName },
7✔
304
        description:
7✔
305
          'Requires the HTTP transport to be started with `sse: true` ' +
7✔
306
          '(`--http-sse` on the CLI). Shipped in rclnodejs 2.1.1. ' +
7✔
307
          '**Not testable via "Try it out"**: this response is an ' +
7✔
308
          'unbounded stream that never completes, and API explorers ' +
7✔
309
          '(e.g. Swagger UI) wait for the response body to finish before ' +
7✔
310
          'displaying it, so the request will appear to hang forever. Use ' +
7✔
311
          '`curl -N` or a browser `EventSource` instead.',
7✔
312
        responses: {
7✔
313
          200: {
7✔
314
            description: `Server-Sent Events stream of ${typeName} messages`,
7✔
315
            content: {
7✔
316
              'text/event-stream': {
7✔
317
                schema: topLevelSchema(typeName, null, components, seen),
7✔
318
              },
7✔
319
            },
7✔
320
          },
7✔
321
          404: subscribeNotExposedResponse(),
7✔
322
        },
7✔
323
      },
7✔
324
    };
7✔
325
  }
7✔
326

14✔
327
  return {
14✔
328
    openapi: '3.1.0',
14✔
329
    info: { title, version: '0.0.0' },
14✔
330
    paths,
14✔
331
    components: { schemas: Object.fromEntries(components) },
14✔
332
  };
14✔
333
}
14✔
334

8✔
335
function notExposedResponse() {
21✔
336
  return {
21✔
337
    description: 'Capability not exposed',
21✔
338
    content: {
21✔
339
      'application/json': {
21✔
340
        schema: {
21✔
341
          type: 'object',
21✔
342
          properties: {
21✔
343
            ok: { type: 'boolean', const: false },
21✔
344
            error: { type: 'string' },
21✔
345
            code: { type: 'string', const: 'not_exposed' },
21✔
346
          },
21✔
347
        },
21✔
348
      },
21✔
349
    },
21✔
350
  };
21✔
351
}
21✔
352

8✔
353
/**
8✔
354
 * 404 for `subscribe`: unlike `call`/`publish`, it has two causes —
8✔
355
 * `unsupported_kind` when `sse` is off (the default), `not_exposed` when
8✔
356
 * `sse` is on but the capability isn't registered — so `code` lists both
8✔
357
 * instead of a single `const`.
8✔
358
 */
8✔
359
function subscribeNotExposedResponse() {
7✔
360
  return {
7✔
361
    description:
7✔
362
      'Capability not exposed, or subscribe over HTTP is disabled ' +
7✔
363
      '(`sse: false`, the default)',
7✔
364
    content: {
7✔
365
      'application/json': {
7✔
366
        schema: {
7✔
367
          type: 'object',
7✔
368
          properties: {
7✔
369
            ok: { type: 'boolean', const: false },
7✔
370
            error: { type: 'string' },
7✔
371
            code: {
7✔
372
              type: 'string',
7✔
373
              enum: ['not_exposed', 'unsupported_kind'],
7✔
374
            },
7✔
375
          },
7✔
376
        },
7✔
377
      },
7✔
378
    },
7✔
379
  };
7✔
380
}
7✔
381

8✔
382
/**
8✔
383
 * Normalise `basePath` like `HttpTransport` does (single leading slash, no
8✔
384
 * trailing slash). Duplicated, not imported, to keep this file free of any
8✔
385
 * `lib/runtime/` dependency.
8✔
386
 */
8✔
387
function _normaliseBasePath(value) {
14✔
388
  if (value === undefined || value === null || value === '') {
14✔
389
    return '/capability';
6✔
390
  }
6✔
391
  let p = String(value).replace(/\/+$/, '');
8✔
392
  if (!p.startsWith('/')) p = '/' + p;
14!
393
  return p || '/capability';
14!
394
}
14✔
395

8✔
396
function sanitizeName(name) {
28✔
397
  return name.replace(/^\//, '').replace(/[^a-zA-Z0-9_]/g, '_');
28✔
398
}
28✔
399

8✔
400
export {
8✔
401
  buildOpenApiDocument,
8✔
402
  rosFieldTypeToJsonSchema,
8✔
403
  messageSchemaToJsonSchema,
8✔
404
  primitiveToJsonSchema,
8✔
405
};
8✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc