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

RobotWebTools / rclnodejs / 30788554446

03 Aug 2026 05:55AM UTC coverage: 91.103%. Remained the same
30788554446

push

github

web-flow
[Web runtime] Add OpenAPI 3.1 export from the capability registry (#1565)

- Add `lib/openapi.js`: maps `CapabilityRegistry.list()` and rosidl introspection into an OpenAPI 3.1 document via `buildOpenApiDocument(capabilities, {title, basePath})`. CLI-only, not part of the `rclnodejs/web/server` API; lives in `lib/`, not `lib/runtime/`, since it has no runtime/transport dependency.
- JSON Schema mapper: `int64`/`uint64` as BigInt-strings with distinct signed/unsigned patterns, both using a shared `"42n"` example (avoids API explorers synthesizing an arbitrarily long digit string from the unbounded pattern); bounded/fixed-size arrays; de-duplicated `$ref` components for nested types; unresolvable types degrade to a placeholder instead of aborting the document.
- Add the `rclnodejs-web openapi [config.json]` CLI subcommand: resolves `expose` capabilities to schemas and prints the document to stdout without starting any transport or calling `rclnodejs.init()`.
- Derive `servers` and route `basePath` from the resolved HTTP config, matching the server transport's own fallback and its wildcard-to-`localhost` host display, so generated documents always describe the routes the runtime actually serves and honor a configured `--http-host`.
- Document all three capability kinds, including `GET /capability/subscribe/<name>` as `text/event-stream` — noted as not testable via "Try it out" in API explorers, since the response never completes.
- Extend `--help` usage text with the new subcommand.
- Add `test/test-openapi.js` covering the schema mapper, document assembly, and CLI behavior.

Fix: #1564

2180 of 2570 branches covered (84.82%)

Branch coverage included in aggregate %.

371 of 399 new or added lines in 2 files covered. (92.98%)

17428 of 18953 relevant lines covered (91.95%)

219.05 hits per line

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

90.72
/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: turns
8✔
17
 * `CapabilityRegistry.list()` into a documented, introspectable Web API.
8✔
18
 *
8✔
19
 * CLI-only — not re-exported from `lib/runtime/index.js`, so it isn't
8✔
20
 * part of the published `rclnodejs/web/server` API. Go through the
8✔
21
 * `rclnodejs-web openapi` subcommand instead of importing this directly.
8✔
22
 *
8✔
23
 * Reuses core's message introspection (`message_validation.js`'s
8✔
24
 * `getMessageSchema()`) rather than re-parsing rosidl ASTs, and resolves
8✔
25
 * types via `interface_loader` — so this runs standalone against just a
8✔
26
 * `web.json` config, without any transport or ROS graph. Still needs
8✔
27
 * ROS 2 sourced, though: resolving a message type loads rclnodejs's
8✔
28
 * native addon as a side effect, and without it the loader may fall back
8✔
29
 * to a slow source rebuild.
8✔
30
 */
8✔
31

8✔
32
import interfaceLoader from './interface_loader.js';
8✔
33
import { getMessageSchema } from './message_validation.js';
8✔
34

8✔
35
/**
8✔
36
 * Map one ROS field-type descriptor (from `getMessageSchema()`) to a
8✔
37
 * JSON Schema fragment.
8✔
38
 *
8✔
39
 * One deliberate deviation: 64-bit integers map to `type: string`, not
8✔
40
 * `type: integer` — OpenAPI's `format: int64` is documentation-only, and
8✔
41
 * JSON numbers can't safely hold 64-bit precision. rclnodejs's own wire
8✔
42
 * convention already sends `int64`/`uint64` as `"<digits>n"` strings, so
8✔
43
 * the schema follows that instead of the nominal `integer`/`int64` pairing.
8✔
44
 *
8✔
45
 * @param {object} fieldType - a field's `type` descriptor
8✔
46
 * @param {Map<string,object>} components - accumulator for nested-message
8✔
47
 *   component schemas, keyed by component name
8✔
48
 * @returns {object} a JSON Schema fragment
8✔
49
 */
8✔
50
function rosFieldTypeToJsonSchema(fieldType, components) {
74✔
51
  if (fieldType.isArray) {
74!
NEW
52
    // Recurse for the element schema; ROS has no array-of-array, so
×
NEW
53
    // clearing `isArray` always lands in a branch below.
×
NEW
54
    const itemSchema = rosFieldTypeToJsonSchema(
×
NEW
55
      { ...fieldType, isArray: false },
×
NEW
56
      components
×
NEW
57
    );
×
NEW
58
    const arraySchema = { type: 'array', items: itemSchema };
×
NEW
59
    if (fieldType.isFixedSizeArray && fieldType.arraySize != null) {
×
NEW
60
      arraySchema.minItems = fieldType.arraySize;
×
NEW
61
      arraySchema.maxItems = fieldType.arraySize;
×
NEW
62
    } else if (fieldType.isUpperBound && fieldType.arraySize != null) {
×
NEW
63
      arraySchema.maxItems = fieldType.arraySize;
×
NEW
64
    }
×
NEW
65
    return arraySchema;
×
NEW
66
  }
×
67

74✔
68
  if (fieldType.isPrimitiveType) {
74✔
69
    return primitiveToJsonSchema(fieldType);
60✔
70
  }
60✔
71

14✔
72
  // Nested message type — register as a component and return a $ref so
14✔
73
  // repeated uses of the same type (e.g. geometry_msgs/msg/Pose across many
14✔
74
  // capabilities) share one schema instead of being inlined N times.
14✔
75
  const componentName = `${fieldType.pkgName}__msg__${fieldType.type}`;
14✔
76
  const typeName = `${fieldType.pkgName}/msg/${fieldType.type}`;
14✔
77
  registerComponent(typeName, componentName, components);
14✔
78
  return { $ref: `#/components/schemas/${componentName}` };
14✔
79
}
74✔
80

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

8✔
83
function primitiveToJsonSchema(fieldType) {
67✔
84
  const { type, stringUpperBound } = fieldType;
67✔
85

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

8✔
128
/**
8✔
129
 * Resolve a ROS message type name into a JSON Schema object (properties per
8✔
130
 * field), registering it into `components` under `componentName` so nested
8✔
131
 * `$ref`s can point at it. No-op if already registered.
8✔
132
 *
8✔
133
 * No cycle guard: a message value type can't structurally reference itself
8✔
134
 * (directly or indirectly) — rosidl generates fixed-size value types, and a
8✔
135
 * self-referential one would need infinite size, so it can't compile.
8✔
136
 */
8✔
137
function registerComponent(typeName, componentName, components) {
14✔
138
  if (components.has(componentName)) return;
14✔
139

7✔
140
  let typeClass;
7✔
141
  try {
7✔
142
    typeClass = interfaceLoader.loadInterface(typeName);
7✔
143
  } catch {
14!
NEW
144
    components.set(componentName, {
×
NEW
145
      type: 'object',
×
NEW
146
      description: `Could not resolve ${typeName}`,
×
NEW
147
    });
×
NEW
148
    return;
×
NEW
149
  }
×
150

7✔
151
  const schema = getMessageSchema(typeClass);
7✔
152
  components.set(componentName, messageSchemaToJsonSchema(schema, components));
7✔
153
}
14✔
154

8✔
155
/**
8✔
156
 * Convert a `getMessageSchema()`-shaped object into a JSON Schema Object
8✔
157
 * (`{type: 'object', properties: {...}}`), recursively registering any
8✔
158
 * nested message types into `components`.
8✔
159
 */
8✔
160
function messageSchemaToJsonSchema(schema, components) {
44✔
161
  const properties = {};
44✔
162
  const required = [];
44✔
163
  for (const field of schema.fields || []) {
44!
164
    if (field.name.startsWith('_')) continue;
74!
165
    properties[field.name] = rosFieldTypeToJsonSchema(field.type, components);
74✔
166
    required.push(field.name);
74✔
167
  }
74✔
168
  const jsonSchema = { type: 'object', properties };
44✔
169
  if (required.length) jsonSchema.required = required;
44✔
170
  if (schema.messageType) jsonSchema['x-ros-type'] = schema.messageType;
44✔
171
  return jsonSchema;
44✔
172
}
44✔
173

8✔
174
/**
8✔
175
 * Resolve a top-level capability type (message for publish/subscribe,
8✔
176
 * service Request/Response for call) to a JSON Schema, without registering
8✔
177
 * it as a component itself (the top-level request/response body is inlined
8✔
178
 * in the operation, only *nested* types become `$ref`d components — this
8✔
179
 * matches typical OpenAPI style for RPC-shaped APIs).
8✔
180
 */
8✔
181
function topLevelSchema(typeName, subType, components) {
37✔
182
  let typeClass;
37✔
183
  try {
37✔
184
    typeClass = interfaceLoader.loadInterface(typeName);
37✔
185
  } catch {
37!
NEW
186
    return { type: 'object', description: `Could not resolve ${typeName}` };
×
NEW
187
  }
×
188
  const resolved = subType ? typeClass[subType] : typeClass;
37✔
189
  const schema = getMessageSchema(resolved);
37✔
190
  if (!schema) {
37!
NEW
191
    return { type: 'object', description: `Could not resolve ${typeName}` };
×
NEW
192
  }
×
193
  return messageSchemaToJsonSchema(schema, components);
37✔
194
}
37✔
195

8✔
196
/**
8✔
197
 * Build a full OpenAPI 3.1 document from a capability registry snapshot
8✔
198
 * (`CapabilityRegistry.list()`'s shape: `{call, publish, subscribe}`, each a
8✔
199
 * `{name: typeName}` map).
8✔
200
 *
8✔
201
 * No `servers` option: it's pure top-level metadata this function never
8✔
202
 * reads while building `paths`, so callers (e.g. the CLI's
8✔
203
 * `openApiServers()`) attach it to the returned document directly instead.
8✔
204
 *
8✔
205
 * No `version` option either: `info.version` describes the caller's API,
8✔
206
 * not the rclnodejs release that generated the document, and there's no
8✔
207
 * source for the former today — so it's a fixed `'0.0.0'` placeholder.
8✔
208
 *
8✔
209
 * @param {{call: object, publish: object, subscribe: object}} capabilities
8✔
210
 * @param {object} [options]
8✔
211
 * @param {string} [options.title]
8✔
212
 * @param {string} [options.basePath] - default '/capability'
8✔
213
 * @returns {object} an OpenAPI 3.1 document (plain object; caller decides
8✔
214
 *   JSON vs. YAML serialization)
8✔
215
 */
8✔
216
function buildOpenApiDocument(capabilities, options = {}) {
14✔
217
  const { title = 'rclnodejs/web capability API' } = options;
14✔
218
  // Match HttpTransport's normalisation so a trailing-slash basePath
14✔
219
  // can't produce a route the runtime doesn't actually serve.
14✔
220
  const basePath = _normaliseBasePath(options.basePath);
14✔
221

14✔
222
  const components = new Map();
14✔
223
  const paths = {};
14✔
224

14✔
225
  for (const [name, typeName] of Object.entries(capabilities.call || {})) {
14!
226
    const route = `${basePath}/call${name}`;
9✔
227
    paths[route] = {
9✔
228
      post: {
9✔
229
        summary: `Call ROS 2 service ${name}`,
9✔
230
        operationId: `call_${sanitizeName(name)}`,
9✔
231
        'x-ros-capability': { kind: 'call', name, type: typeName },
9✔
232
        requestBody: {
9✔
233
          required: true,
9✔
234
          content: {
9✔
235
            'application/json': {
9✔
236
              schema: topLevelSchema(typeName, 'Request', components),
9✔
237
            },
9✔
238
          },
9✔
239
        },
9✔
240
        responses: {
9✔
241
          200: {
9✔
242
            description: 'ROS 2 service response',
9✔
243
            content: {
9✔
244
              'application/json': {
9✔
245
                schema: topLevelSchema(typeName, 'Response', components),
9✔
246
              },
9✔
247
            },
9✔
248
          },
9✔
249
          404: notExposedResponse(),
9✔
250
        },
9✔
251
      },
9✔
252
    };
9✔
253
  }
9✔
254

14✔
255
  for (const [name, typeName] of Object.entries(capabilities.publish || {})) {
14!
256
    const route = `${basePath}/publish${name}`;
12✔
257
    paths[route] = {
12✔
258
      post: {
12✔
259
        summary: `Publish to ROS 2 topic ${name}`,
12✔
260
        operationId: `publish_${sanitizeName(name)}`,
12✔
261
        'x-ros-capability': { kind: 'publish', name, type: typeName },
12✔
262
        requestBody: {
12✔
263
          required: true,
12✔
264
          content: {
12✔
265
            'application/json': {
12✔
266
              schema: topLevelSchema(typeName, null, components),
12✔
267
            },
12✔
268
          },
12✔
269
        },
12✔
270
        responses: {
12✔
271
          204: { description: 'Published, no content' },
12✔
272
          404: notExposedResponse(),
12✔
273
        },
12✔
274
      },
12✔
275
    };
12✔
276
  }
12✔
277

14✔
278
  for (const [name, typeName] of Object.entries(capabilities.subscribe || {})) {
14!
279
    const route = `${basePath}/subscribe${name}`;
7✔
280
    paths[route] = {
7✔
281
      get: {
7✔
282
        summary: `Subscribe to ROS 2 topic ${name} via Server-Sent Events`,
7✔
283
        operationId: `subscribe_${sanitizeName(name)}`,
7✔
284
        'x-ros-capability': { kind: 'subscribe', name, type: typeName },
7✔
285
        description:
7✔
286
          'Requires the HTTP transport to be started with `sse: true` ' +
7✔
287
          '(`--http-sse` on the CLI). Shipped in rclnodejs 2.1.1. ' +
7✔
288
          '**Not testable via "Try it out"**: this response is an ' +
7✔
289
          'unbounded stream that never completes, and API explorers ' +
7✔
290
          '(e.g. Swagger UI) wait for the response body to finish before ' +
7✔
291
          'displaying it, so the request will appear to hang forever. Use ' +
7✔
292
          '`curl -N` or a browser `EventSource` instead.',
7✔
293
        responses: {
7✔
294
          200: {
7✔
295
            description: `Server-Sent Events stream of ${typeName} messages`,
7✔
296
            content: {
7✔
297
              'text/event-stream': {
7✔
298
                schema: topLevelSchema(typeName, null, components),
7✔
299
              },
7✔
300
            },
7✔
301
          },
7✔
302
          404: subscribeNotExposedResponse(),
7✔
303
        },
7✔
304
      },
7✔
305
    };
7✔
306
  }
7✔
307

14✔
308
  return {
14✔
309
    openapi: '3.1.0',
14✔
310
    info: { title, version: '0.0.0' },
14✔
311
    paths,
14✔
312
    components: { schemas: Object.fromEntries(components) },
14✔
313
  };
14✔
314
}
14✔
315

8✔
316
function notExposedResponse() {
21✔
317
  return {
21✔
318
    description: 'Capability not exposed',
21✔
319
    content: {
21✔
320
      'application/json': {
21✔
321
        schema: {
21✔
322
          type: 'object',
21✔
323
          properties: {
21✔
324
            ok: { type: 'boolean', const: false },
21✔
325
            error: { type: 'string' },
21✔
326
            code: { type: 'string', const: 'not_exposed' },
21✔
327
          },
21✔
328
        },
21✔
329
      },
21✔
330
    },
21✔
331
  };
21✔
332
}
21✔
333

8✔
334
/**
8✔
335
 * 404 for `subscribe`: unlike `call`/`publish`, it has two causes —
8✔
336
 * `unsupported_kind` when `sse` is off (the default), `not_exposed` when
8✔
337
 * `sse` is on but the capability isn't registered — so `code` lists both
8✔
338
 * instead of a single `const`.
8✔
339
 */
8✔
340
function subscribeNotExposedResponse() {
7✔
341
  return {
7✔
342
    description:
7✔
343
      'Capability not exposed, or subscribe over HTTP is disabled ' +
7✔
344
      '(`sse: false`, the default)',
7✔
345
    content: {
7✔
346
      'application/json': {
7✔
347
        schema: {
7✔
348
          type: 'object',
7✔
349
          properties: {
7✔
350
            ok: { type: 'boolean', const: false },
7✔
351
            error: { type: 'string' },
7✔
352
            code: {
7✔
353
              type: 'string',
7✔
354
              enum: ['not_exposed', 'unsupported_kind'],
7✔
355
            },
7✔
356
          },
7✔
357
        },
7✔
358
      },
7✔
359
    },
7✔
360
  };
7✔
361
}
7✔
362

8✔
363
/**
8✔
364
 * Normalise `basePath` like `HttpTransport` does (single leading slash, no
8✔
365
 * trailing slash). Duplicated, not imported, to keep this file free of any
8✔
366
 * `lib/runtime/` dependency.
8✔
367
 */
8✔
368
function _normaliseBasePath(value) {
14✔
369
  if (value === undefined || value === null || value === '') {
14✔
370
    return '/capability';
6✔
371
  }
6✔
372
  let p = String(value).replace(/\/+$/, '');
8✔
373
  if (!p.startsWith('/')) p = '/' + p;
14!
374
  return p || '/capability';
14!
375
}
14✔
376

8✔
377
function sanitizeName(name) {
28✔
378
  return name.replace(/^\//, '').replace(/[^a-zA-Z0-9_]/g, '_');
28✔
379
}
28✔
380

8✔
381
export {
8✔
382
  buildOpenApiDocument,
8✔
383
  rosFieldTypeToJsonSchema,
8✔
384
  messageSchemaToJsonSchema,
8✔
385
  primitiveToJsonSchema,
8✔
386
};
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