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

RobotWebTools / rclnodejs / 30249040388

27 Jul 2026 08:13AM UTC coverage: 91.103%. Remained the same
30249040388

Pull #1565

github

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

2174 of 2562 branches covered (84.86%)

Branch coverage included in aggregate %.

347 of 374 new or added lines in 2 files covered. (92.78%)

17404 of 18928 relevant lines covered (91.95%)

218.95 hits per line

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

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

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

4✔
34
import interfaceLoader from './interface_loader.js';
4✔
35
import { getMessageSchema } from './message_validation.js';
4✔
36

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

52✔
76
  if (fieldType.isPrimitiveType) {
52✔
77
    return primitiveToJsonSchema(fieldType);
42✔
78
  }
42✔
79

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

4✔
89
const INT64_TYPES = new Set(['int64', 'uint64']);
4✔
90

4✔
91
function primitiveToJsonSchema(fieldType) {
49✔
92
  const { type, stringUpperBound } = fieldType;
49✔
93

49✔
94
  if (type === 'bool') return { type: 'boolean' };
49✔
95
  if (INT64_TYPES.has(type)) {
49✔
96
    // See the docstring above — deliberately string, not integer. uint64
20✔
97
    // gets its own (unsigned-only) pattern/format so the schema doesn't
20✔
98
    // claim negative values are valid input for an unsigned field.
20✔
99
    const unsigned = type === 'uint64';
20✔
100
    return {
20✔
101
      type: 'string',
20✔
102
      format: unsigned ? 'uint64' : 'int64',
20✔
103
      pattern: unsigned ? '^[0-9]+n$' : '^-?[0-9]+n$',
20✔
104
      description: `ROS 2 ${type}, transmitted as a BigInt-string (e.g. "42n") for precision-safety.`,
20✔
105
    };
20✔
106
  }
20✔
107
  if (
28✔
108
    [
28✔
109
      'int8',
28✔
110
      'uint8',
28✔
111
      'int16',
28✔
112
      'uint16',
28✔
113
      'int32',
28✔
114
      'uint32',
28✔
115
      'byte',
28✔
116
      'char',
28✔
117
    ].includes(type)
28✔
118
  ) {
46✔
119
    return { type: 'integer' };
1✔
120
  }
1✔
121
  if (['float32', 'float64'].includes(type)) {
46✔
122
    return { type: 'number' };
16✔
123
  }
16✔
124
  if (type === 'string' || type === 'wstring') {
49!
125
    const schema = { type: 'string' };
11✔
126
    if (stringUpperBound != null && stringUpperBound > 0) {
11✔
127
      schema.maxLength = stringUpperBound;
1✔
128
    }
1✔
129
    return schema;
11✔
130
  }
11✔
NEW
131
  // Unknown/unmapped primitive — fall back to permissive rather than
×
NEW
132
  // silently wrong.
×
NEW
133
  return {};
×
134
}
49✔
135

4✔
136
/**
4✔
137
 * Resolve a ROS message type name into a JSON Schema object (properties per
4✔
138
 * field), registering it into `components` under `componentName` so nested
4✔
139
 * `$ref`s can point at it. No-op if already registered/in-progress (cycle
4✔
140
 * guard).
4✔
141
 */
4✔
142
function registerComponent(typeName, componentName, components, seen) {
10✔
143
  if (components.has(componentName) || seen.has(componentName)) return;
10✔
144
  seen.add(componentName);
5✔
145

5✔
146
  let typeClass;
5✔
147
  try {
5✔
148
    typeClass = interfaceLoader.loadInterface(typeName);
5✔
149
  } catch {
10!
NEW
150
    components.set(componentName, {
×
NEW
151
      type: 'object',
×
NEW
152
      description: `Could not resolve ${typeName}`,
×
NEW
153
    });
×
NEW
154
    return;
×
NEW
155
  }
×
156

5✔
157
  const schema = getMessageSchema(typeClass);
5✔
158
  components.set(
5✔
159
    componentName,
5✔
160
    messageSchemaToJsonSchema(schema, components, seen)
5✔
161
  );
5✔
162
}
10✔
163

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

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

4✔
209
/**
4✔
210
 * Build a full OpenAPI 3.1 document from a capability registry snapshot
4✔
211
 * (`CapabilityRegistry.list()`'s shape: `{call, publish, subscribe}`, each a
4✔
212
 * `{name: typeName}` map).
4✔
213
 *
4✔
214
 * @param {{call: object, publish: object, subscribe: object}} capabilities
4✔
215
 * @param {object} [options]
4✔
216
 * @param {string} [options.title]
4✔
217
 * @param {string} [options.version]
4✔
218
 * @param {string} [options.basePath] - default '/capability'
4✔
219
 * @param {Array<{url: string, description?: string}>} [options.servers] -
4✔
220
 *   where the runtime's HTTP transport actually listens. Omitting this is
4✔
221
 *   rarely what you want: OpenAPI defaults `servers` to `[{url: '/'}]`, so
4✔
222
 *   a client resolves every path against whatever origin served the
4✔
223
 *   document. When the document is served by a *static* file server (the
4✔
224
 *   demo's `static.mjs` on :8080) but the runtime listens elsewhere
4✔
225
 *   (:9001), Swagger UI's "Try it out" would POST to the static server and
4✔
226
 *   get its 404 back. The CLI fills this in from the `http` config.
4✔
227
 * @returns {object} an OpenAPI 3.1 document (plain object; caller decides
4✔
228
 *   JSON vs. YAML serialization)
4✔
229
 */
4✔
230
function buildOpenApiDocument(capabilities, options = {}) {
10✔
231
  const {
10✔
232
    title = 'rclnodejs/web capability API',
10✔
233
    version = '0.0.0',
10✔
234
    basePath = '/capability',
10✔
235
    servers = [],
10✔
236
  } = options;
10✔
237

10✔
238
  const components = new Map();
10✔
239
  const seen = new Set();
10✔
240
  const paths = {};
10✔
241

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

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

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

10✔
325
  return {
10✔
326
    openapi: '3.1.0',
10✔
327
    info: { title, version },
10✔
328
    ...(servers.length ? { servers } : {}),
10✔
329
    paths,
10✔
330
    components: { schemas: Object.fromEntries(components) },
10✔
331
  };
10✔
332
}
10✔
333

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

4✔
352
function sanitizeName(name) {
20✔
353
  return name.replace(/^\//, '').replace(/[^a-zA-Z0-9_]/g, '_');
20✔
354
}
20✔
355

4✔
356
export {
4✔
357
  buildOpenApiDocument,
4✔
358
  rosFieldTypeToJsonSchema,
4✔
359
  messageSchemaToJsonSchema,
4✔
360
  primitiveToJsonSchema,
4✔
361
};
4✔
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