From 55ae618bc82d610d18ed03e9eda8a27528b6461e Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Mon, 27 Jul 2026 14:20:36 +0800 Subject: [PATCH 1/8] Support OpenAPI --- bin/rclnodejs-web.js | 97 ++++++++++- lib/openapi.js | 353 ++++++++++++++++++++++++++++++++++++++ lib/runtime/cli-config.js | 13 ++ test/test-openapi.js | 239 ++++++++++++++++++++++++++ 4 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 lib/openapi.js create mode 100644 test/test-openapi.js diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index 5d1545311..3ccf03c18 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -27,9 +27,31 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const argv = process.argv.slice(2); +// `openapi` is a one-shot metadata subcommand (prototype for +// docs/AGENT_API_DESIGN_2.2.0.md): it resolves `expose` capabilities to +// their ROS message/service schemas and prints a document to stdout, without +// starting any transport or connecting to a live ROS graph (`rclnodejs.init()` +// is never called). That said, resolving a message type still `require()`s +// its generated `.js` class, which loads rclnodejs's native addon as a side +// effect (same as any other rclnodejs usage) — so source your ROS 2 +// environment first. Without it, the native loader may not find a matching +// prebuild and can fall back to rebuilding from source, which is slow and, +// in the worst case, wipes `generated/` (see the incident note in +// docs/AGENT_API_DESIGN_2.2.0.md §5). +const SUBCOMMANDS = new Set(['openapi']); +let subcommand; +let argv = process.argv.slice(2); +if (SUBCOMMANDS.has(argv[0])) { + subcommand = argv[0]; + argv = argv.slice(1); +} (async function main() { + if (subcommand) { + await runOpenApiSubcommand(argv); + return; + } + let parsed; try { parsed = parseArgv(argv); @@ -167,6 +189,79 @@ const argv = process.argv.slice(2); } })(); +/** + * Handle the `openapi` one-shot subcommand: parse argv + config the same way + * the server start path does, build a bare `CapabilityRegistry` from + * `expose` (no Node, no transports, no `rclnodejs.init()`), and print the + * resulting OpenAPI document to stdout as JSON. + * + * @param {string[]} rest - argv with the subcommand keyword already removed + */ +async function runOpenApiSubcommand(rest) { + let parsed; + try { + parsed = parseArgv(rest); + } catch (e) { + fail(e); + } + if (parsed.help) { + process.stdout.write(HELP); + process.exit(0); + } + + let cfg; + try { + cfg = mergeConfig(loadConfigFile(parsed.configPath), parsed.partial); + validateConfig(cfg, 'options'); + } catch (e) { + fail(e); + } + + const { CapabilityRegistry } = await import('../lib/runtime/index.js'); + const { buildOpenApiDocument } = await import('../lib/openapi.js'); + const registry = new CapabilityRegistry(); + registry.expose(cfg.expose); + + try { + const document = buildOpenApiDocument(registry.list(), { + title: cfg.node, + basePath: cfg.http.basePath || '/capability', + servers: openApiServers(cfg), + }); + process.stdout.write(JSON.stringify(document, null, 2) + '\n'); + } catch (e) { + fail(e); + } +} + +/** + * Derive OpenAPI `servers` from the resolved config's HTTP transport. + * + * Without this the document has no `servers`, which OpenAPI defines as + * `[{url: '/'}]` — i.e. "resolve paths against whatever origin served this + * document". That is wrong here: the document is a static file, typically + * served by a different origin than the runtime (the demo serves it from + * `static.mjs` on :8080 while the runtime's HTTP transport listens on + * :9001), so Swagger UI's "Try it out" would hit the static server. + * + * Uses `localhost` rather than the configured bind host on purpose: the + * default `::` is a wildcard bind address, not a URL an HTTP client can + * dial. Downstream deployments behind a proxy should rewrite `servers`. + * + * @param {object} cfg - fully-resolved config from `mergeConfig` + * @returns {Array<{url: string, description: string}>} empty when the HTTP + * transport is disabled — WS-only runtimes have no HTTP origin to point at + */ +function openApiServers(cfg) { + if (!cfg.http || cfg.http.port == null) return []; + return [ + { + url: `http://localhost:${cfg.http.port}`, + description: 'rclnodejs/web HTTP transport', + }, + ]; +} + function fail(err) { if (err && err.cli) { process.stderr.write(`error: ${err.message}\n\n${HELP}`); diff --git a/lib/openapi.js b/lib/openapi.js new file mode 100644 index 000000000..1ca2aa325 --- /dev/null +++ b/lib/openapi.js @@ -0,0 +1,353 @@ +// Copyright (c) 2026 RobotWebTools Contributors. 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. + +/** + * OpenAPI 3.1 export for the Web Runtime capability registry. + * + * Turns `CapabilityRegistry.list()` into a documented, introspectable + * Web API. + * + * Deliberately reuses the message introspection that already ships in + * rclnodejs core (`lib/message_validation.js`'s `getMessageSchema()`) + * instead of re-parsing rosidl ASTs. Resolving a + * ROS type name is a pure local file lookup (`interface_loader` reads the + * already-generated `.js` message classes) and never calls a live + * `rclnodejs.init()`, so `buildOpenApiDocument()` can run standalone against + * just a `web.json` config, without any transport or ROS graph. It still + * needs ROS 2 sourced, though: `require()`-ing a generated message class + * loads rclnodejs's native addon as a side effect, and without a sourced + * environment the native loader may fail to match a prebuild and fall back + * to a source rebuild. + */ + +import interfaceLoader from './interface_loader.js'; +import { getMessageSchema } from './message_validation.js'; + +/** + * Map one ROS field-type descriptor (the shape produced by + * `getMessageSchema()`, ultimately from the rosidl-generated + * `ROSMessageDef`) to a JSON Schema fragment. + * + * 64-bit integers are the one deliberate deviation from the "obvious" + * mapping: OpenAPI's `format: int64` is a documentation-only annotation on + * top of `type: integer`, and JSON numbers cannot safely carry 64-bit + * precision in JS (or most JSON parsers). rclnodejs's own wire convention + * already represents `int64`/`uint64` as `"n"` strings (see + * `PRIMITIVE_TYPE_MAP` in message_validation.js mapping them to `'bigint'`), + * so the schema follows that convention rather than the nominal + * `type: integer, format: int64` pairing. + * + * @param {object} fieldType - a field's `type` descriptor from + * `getMessageSchema(...).fields[i].type` + * @param {Map} components - accumulator for referenced + * nested-message component schemas, keyed by component name + * (`__msg__` or `__srv___Request`/`_Response`) + * @param {Set} seen - cycle guard for recursive `$ref` resolution + * @returns {object} a JSON Schema fragment + */ +function rosFieldTypeToJsonSchema(fieldType, components, seen) { + if (fieldType.isArray) { + const itemSchema = rosFieldTypeToJsonSchema( + { ...fieldType, isArray: false }, + components, + seen + ); + const arraySchema = { type: 'array', items: itemSchema }; + if (fieldType.isFixedSizeArray && fieldType.arraySize != null) { + arraySchema.minItems = fieldType.arraySize; + arraySchema.maxItems = fieldType.arraySize; + } else if (fieldType.isUpperBound && fieldType.arraySize != null) { + arraySchema.maxItems = fieldType.arraySize; + } + return arraySchema; + } + + if (fieldType.isPrimitiveType) { + return primitiveToJsonSchema(fieldType); + } + + // Nested message type — register as a component and return a $ref so + // repeated uses of the same type (e.g. geometry_msgs/msg/Pose across many + // capabilities) share one schema instead of being inlined N times. + const componentName = `${fieldType.pkgName}__msg__${fieldType.type}`; + const typeName = `${fieldType.pkgName}/msg/${fieldType.type}`; + registerComponent(typeName, componentName, components, seen); + return { $ref: `#/components/schemas/${componentName}` }; +} + +const INT64_TYPES = new Set(['int64', 'uint64']); + +function primitiveToJsonSchema(fieldType) { + const { type, stringUpperBound } = fieldType; + + if (type === 'bool') return { type: 'boolean' }; + if (INT64_TYPES.has(type)) { + // See the docstring above — deliberately string, not integer. + return { + type: 'string', + format: 'int64', + pattern: '^-?[0-9]+n$', + description: `ROS 2 ${type}, transmitted as a BigInt-string (e.g. "42n") for precision-safety.`, + }; + } + if ( + [ + 'int8', + 'uint8', + 'int16', + 'uint16', + 'int32', + 'uint32', + 'byte', + 'char', + ].includes(type) + ) { + return { type: 'integer' }; + } + if (['float32', 'float64'].includes(type)) { + return { type: 'number' }; + } + if (type === 'string' || type === 'wstring') { + const schema = { type: 'string' }; + if (stringUpperBound != null && stringUpperBound > 0) { + schema.maxLength = stringUpperBound; + } + return schema; + } + // Unknown/unmapped primitive — fall back to permissive rather than + // silently wrong. + return {}; +} + +/** + * Resolve a ROS message type name into a JSON Schema object (properties per + * field), registering it into `components` under `componentName` so nested + * `$ref`s can point at it. No-op if already registered/in-progress (cycle + * guard). + */ +function registerComponent(typeName, componentName, components, seen) { + if (components.has(componentName) || seen.has(componentName)) return; + seen.add(componentName); + + let typeClass; + try { + typeClass = interfaceLoader.loadInterface(typeName); + } catch { + components.set(componentName, { + type: 'object', + description: `Could not resolve ${typeName}`, + }); + return; + } + + const schema = getMessageSchema(typeClass); + components.set( + componentName, + messageSchemaToJsonSchema(schema, components, seen) + ); +} + +/** + * Convert a `getMessageSchema()`-shaped object into a JSON Schema Object + * (`{type: 'object', properties: {...}}`), recursively registering any + * nested message types into `components`. + */ +function messageSchemaToJsonSchema(schema, components, seen) { + const properties = {}; + const required = []; + for (const field of schema.fields || []) { + if (field.name.startsWith('_')) continue; + properties[field.name] = rosFieldTypeToJsonSchema( + field.type, + components, + seen + ); + required.push(field.name); + } + const jsonSchema = { type: 'object', properties }; + if (required.length) jsonSchema.required = required; + if (schema.messageType) jsonSchema['x-ros-type'] = schema.messageType; + return jsonSchema; +} + +/** + * Resolve a top-level capability type (message for publish/subscribe, + * service Request/Response for call) to a JSON Schema, without registering + * it as a component itself (the top-level request/response body is inlined + * in the operation, only *nested* types become `$ref`d components — this + * matches typical OpenAPI style for RPC-shaped APIs). + */ +function topLevelSchema(typeName, subType, components, seen) { + const typeClass = interfaceLoader.loadInterface(typeName); + const resolved = subType ? typeClass[subType] : typeClass; + const schema = getMessageSchema(resolved); + if (!schema) { + return { type: 'object', description: `Could not resolve ${typeName}` }; + } + return messageSchemaToJsonSchema(schema, components, seen); +} + +/** + * Build a full OpenAPI 3.1 document from a capability registry snapshot + * (`CapabilityRegistry.list()`'s shape: `{call, publish, subscribe}`, each a + * `{name: typeName}` map). + * + * @param {{call: object, publish: object, subscribe: object}} capabilities + * @param {object} [options] + * @param {string} [options.title] + * @param {string} [options.version] + * @param {string} [options.basePath] - default '/capability' + * @param {Array<{url: string, description?: string}>} [options.servers] - + * where the runtime's HTTP transport actually listens. Omitting this is + * rarely what you want: OpenAPI defaults `servers` to `[{url: '/'}]`, so + * a client resolves every path against whatever origin served the + * document. When the document is served by a *static* file server (the + * demo's `static.mjs` on :8080) but the runtime listens elsewhere + * (:9001), Swagger UI's "Try it out" would POST to the static server and + * get its 404 back. The CLI fills this in from the `http` config. + * @returns {object} an OpenAPI 3.1 document (plain object; caller decides + * JSON vs. YAML serialization) + */ +function buildOpenApiDocument(capabilities, options = {}) { + const { + title = 'rclnodejs/web capability API', + version = '0.0.0', + basePath = '/capability', + servers = [], + } = options; + + const components = new Map(); + const seen = new Set(); + const paths = {}; + + for (const [name, typeName] of Object.entries(capabilities.call || {})) { + const route = `${basePath}/call${name}`; + paths[route] = { + post: { + summary: `Call ROS 2 service ${name}`, + operationId: `call_${sanitizeName(name)}`, + 'x-ros-capability': { kind: 'call', name, type: typeName }, + requestBody: { + required: true, + content: { + 'application/json': { + schema: topLevelSchema(typeName, 'Request', components, seen), + }, + }, + }, + responses: { + 200: { + description: 'ROS 2 service response', + content: { + 'application/json': { + schema: topLevelSchema(typeName, 'Response', components, seen), + }, + }, + }, + 404: notExposedResponse(), + }, + }, + }; + } + + for (const [name, typeName] of Object.entries(capabilities.publish || {})) { + const route = `${basePath}/publish${name}`; + paths[route] = { + post: { + summary: `Publish to ROS 2 topic ${name}`, + operationId: `publish_${sanitizeName(name)}`, + 'x-ros-capability': { kind: 'publish', name, type: typeName }, + requestBody: { + required: true, + content: { + 'application/json': { + schema: topLevelSchema(typeName, null, components, seen), + }, + }, + }, + responses: { + 204: { description: 'Published, no content' }, + 404: notExposedResponse(), + }, + }, + }; + } + + for (const [name, typeName] of Object.entries(capabilities.subscribe || {})) { + const route = `${basePath}/subscribe${name}`; + paths[route] = { + get: { + summary: `Subscribe to ROS 2 topic ${name} via Server-Sent Events`, + operationId: `subscribe_${sanitizeName(name)}`, + 'x-ros-capability': { kind: 'subscribe', name, type: typeName }, + description: + 'Requires the HTTP transport to be started with `sse: true` ' + + '(`--http-sse` on the CLI). Shipped in rclnodejs 2.1.1. ' + + '**Not testable via "Try it out"**: this response is an ' + + 'unbounded stream that never completes, and API explorers ' + + '(e.g. Swagger UI) wait for the response body to finish before ' + + 'displaying it, so the request will appear to hang forever. Use ' + + '`curl -N` or a browser `EventSource` instead.', + responses: { + 200: { + description: `Server-Sent Events stream of ${typeName} messages`, + content: { + 'text/event-stream': { + schema: topLevelSchema(typeName, null, components, seen), + }, + }, + }, + 404: notExposedResponse(), + }, + }, + }; + } + + return { + openapi: '3.1.0', + info: { title, version }, + ...(servers.length ? { servers } : {}), + paths, + components: { schemas: Object.fromEntries(components) }, + }; +} + +function notExposedResponse() { + return { + description: 'Capability not exposed', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean', const: false }, + error: { type: 'string' }, + code: { type: 'string', const: 'not_exposed' }, + }, + }, + }, + }, + }; +} + +function sanitizeName(name) { + return name.replace(/^\//, '').replace(/[^a-zA-Z0-9_]/g, '_'); +} + +export { + buildOpenApiDocument, + rosFieldTypeToJsonSchema, + messageSchemaToJsonSchema, + primitiveToJsonSchema, +}; diff --git a/lib/runtime/cli-config.js b/lib/runtime/cli-config.js index 03a0356ce..abb9eaa15 100644 --- a/lib/runtime/cli-config.js +++ b/lib/runtime/cli-config.js @@ -321,6 +321,7 @@ class CliError extends Error { } const HELP = `Usage: rclnodejs-web [config.json] [options] + rclnodejs-web openapi [config.json] [options] rclnodejs/web — typed Web SDK and capability runtime for ROS 2. @@ -328,6 +329,13 @@ const HELP = `Usage: rclnodejs-web [config.json] [options] topics and services to browsers. Speaks both WebSocket (always on) and HTTP (opt-in via --http-port). + The 'openapi' subcommand prints an OpenAPI 3.1 document to + stdout instead of starting the runtime and never calls + rclnodejs.init() — but still source your ROS 2 environment first, + since resolving a message type loads rclnodejs's native addon as a + side effect. Only the same 'expose' config is needed otherwise: + rclnodejs-web openapi web.json > openapi.json + WebSocket transport (always on): -p, --port WS listen port (default 9000) --host WS bind host (default ::, dual-stack) @@ -366,6 +374,11 @@ Examples: rclnodejs-web --port 9000 --http-port 9001 --http-sse --http-cors '*' \\ --subscribe /scan=sensor_msgs/msg/LaserScan + # Export an OpenAPI 3.1 document instead of starting the runtime + # (same --call/--publish/--subscribe or config file): + rclnodejs-web openapi \\ + --call /add_two_ints=example_interfaces/srv/AddTwoInts > openapi.json + # Or all from a JSON config: rclnodejs-web web.json diff --git a/test/test-openapi.js b/test/test-openapi.js new file mode 100644 index 000000000..8361b5bcb --- /dev/null +++ b/test/test-openapi.js @@ -0,0 +1,239 @@ +// Copyright (c) 2026 RobotWebTools Contributors. 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 + +// Prototype tests for docs/AGENT_API_DESIGN_2.2.0.md: the OpenAPI / +// agent-tool-schema export built on top of the capability registry. +// These are pure metadata transforms — no ROS 2 init, no transports, no +// network — so they run fast and need no `rclnodejs.init()`. + +import assert from 'assert'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { buildOpenApiDocument, primitiveToJsonSchema } from '../lib/openapi.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = path.join(__dirname, '..', 'bin', 'rclnodejs-web.js'); + +function runCli(args) { + return new Promise((resolve, reject) => { + const p = spawn(process.execPath, [CLI_PATH, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + p.stdout.on('data', (d) => (stdout += d.toString())); + p.stderr.on('data', (d) => (stderr += d.toString())); + p.on('exit', (code) => resolve({ code, stdout, stderr })); + p.on('error', reject); + }); +} + +describe('lib/openapi.js', function () { + describe('primitiveToJsonSchema', function () { + it('maps bool to boolean', function () { + assert.deepStrictEqual(primitiveToJsonSchema({ type: 'bool' }), { + type: 'boolean', + }); + }); + + it('maps int32 to a plain integer', function () { + assert.deepStrictEqual(primitiveToJsonSchema({ type: 'int32' }), { + type: 'integer', + }); + }); + + it('maps float64 to number', function () { + assert.deepStrictEqual(primitiveToJsonSchema({ type: 'float64' }), { + type: 'number', + }); + }); + + it('maps int64/uint64 to a BigInt-string, not a bare integer', function () { + const schema = primitiveToJsonSchema({ type: 'int64' }); + assert.strictEqual(schema.type, 'string'); + assert.strictEqual(schema.format, 'int64'); + const re = new RegExp(schema.pattern); + assert.ok(re.test('42n')); + assert.ok(re.test('-7n')); + assert.ok(!re.test('42')); + + const unsignedSchema = primitiveToJsonSchema({ type: 'uint64' }); + assert.strictEqual(unsignedSchema.type, 'string'); + }); + + it('maps bounded strings to maxLength', function () { + const schema = primitiveToJsonSchema({ + type: 'string', + stringUpperBound: 32, + }); + assert.strictEqual(schema.type, 'string'); + assert.strictEqual(schema.maxLength, 32); + }); + + it('leaves unbounded strings without maxLength', function () { + const schema = primitiveToJsonSchema({ type: 'string' }); + assert.strictEqual(schema.type, 'string'); + assert.strictEqual(schema.maxLength, undefined); + }); + }); + + describe('buildOpenApiDocument', function () { + const capabilities = { + call: { '/add_two_ints': 'example_interfaces/srv/AddTwoInts' }, + publish: { '/chatter': 'std_msgs/msg/String' }, + subscribe: { '/cmd_vel': 'geometry_msgs/msg/Twist' }, + }; + + it('produces a valid-looking OpenAPI 3.1 document shell', function () { + const doc = buildOpenApiDocument(capabilities, { + title: 'test API', + version: '1.2.3', + }); + assert.strictEqual(doc.openapi, '3.1.0'); + assert.strictEqual(doc.info.title, 'test API'); + assert.strictEqual(doc.info.version, '1.2.3'); + }); + + it('documents a call capability as POST /capability/call/', function () { + const doc = buildOpenApiDocument(capabilities); + const op = doc.paths['/capability/call/add_two_ints'].post; + assert.strictEqual(op.operationId, 'call_add_two_ints'); + assert.strictEqual( + op['x-ros-capability'].type, + 'example_interfaces/srv/AddTwoInts' + ); + const reqSchema = op.requestBody.content['application/json'].schema; + assert.deepStrictEqual(Object.keys(reqSchema.properties).sort(), [ + 'a', + 'b', + ]); + const resSchema = op.responses['200'].content['application/json'].schema; + assert.deepStrictEqual(Object.keys(resSchema.properties), ['sum']); + // AddTwoInts fields are int64 — must use the BigInt-string schema. + assert.strictEqual(reqSchema.properties.a.type, 'string'); + assert.strictEqual(reqSchema.properties.a.format, 'int64'); + }); + + it('documents a publish capability as POST /capability/publish/ returning 204', function () { + const doc = buildOpenApiDocument(capabilities); + const op = doc.paths['/capability/publish/chatter'].post; + assert.strictEqual(op.operationId, 'publish_chatter'); + assert.ok(op.responses['204']); + const schema = op.requestBody.content['application/json'].schema; + assert.deepStrictEqual(Object.keys(schema.properties), ['data']); + }); + + it('documents a subscribe capability as GET /capability/subscribe/ over SSE', function () { + const doc = buildOpenApiDocument(capabilities); + const op = doc.paths['/capability/subscribe/cmd_vel'].get; + assert.strictEqual(op.operationId, 'subscribe_cmd_vel'); + const sseContent = op.responses['200'].content['text/event-stream']; + assert.ok(sseContent, 'expected a text/event-stream response'); + }); + + it('de-duplicates nested message types into one shared $ref component', function () { + const doc = buildOpenApiDocument(capabilities); + const op = doc.paths['/capability/subscribe/cmd_vel'].get; + const schema = op.responses['200'].content['text/event-stream'].schema; + // Twist has `linear` and `angular`, both Vector3 — both should point + // at the same component instead of being inlined twice. + assert.strictEqual( + schema.properties.linear.$ref, + schema.properties.angular.$ref + ); + const refName = schema.properties.linear.$ref.split('/').pop(); + assert.ok( + doc.components.schemas[refName], + 'Vector3 component should be registered' + ); + assert.deepStrictEqual( + Object.keys(doc.components.schemas[refName].properties).sort(), + ['x', 'y', 'z'] + ); + }); + }); + + describe('servers', function () { + const capabilities = { + call: {}, + publish: { '/chatter': 'std_msgs/msg/String' }, + subscribe: {}, + }; + + it('omits servers by default', function () { + const doc = buildOpenApiDocument(capabilities); + assert.ok(!('servers' in doc)); + }); + + it('emits servers when given, so clients target the runtime origin', function () { + // Without this, OpenAPI's default `servers` is [{url: '/'}] — every + // path resolves against whatever origin served the document. When a + // static file server hosts openapi.json but the runtime listens + // elsewhere, "Try it out" hits the static server and 404s. + const doc = buildOpenApiDocument(capabilities, { + servers: [{ url: 'http://localhost:9001', description: 'http' }], + }); + assert.deepStrictEqual(doc.servers, [ + { url: 'http://localhost:9001', description: 'http' }, + ]); + }); + }); + + describe('CLI subcommand (bin/rclnodejs-web.js openapi)', function () { + this.timeout(10000); + + it('openapi prints a document from --call/--publish flags, no ROS init needed', async function () { + const { code, stdout } = await runCli([ + 'openapi', + '--call', + '/add_two_ints=example_interfaces/srv/AddTwoInts', + ]); + assert.strictEqual(code, 0); + const doc = JSON.parse(stdout); + assert.strictEqual(doc.openapi, '3.1.0'); + assert.ok(doc.paths['/capability/call/add_two_ints']); + }); + + it('openapi omits servers when the HTTP transport is off', async function () { + const { code, stdout } = await runCli([ + 'openapi', + '--publish', + '/chatter=std_msgs/msg/String', + ]); + assert.strictEqual(code, 0); + assert.ok(!('servers' in JSON.parse(stdout))); + }); + + it('openapi points servers at the HTTP transport port when enabled', async function () { + const { code, stdout } = await runCli([ + 'openapi', + '--publish', + '/chatter=std_msgs/msg/String', + '--http-port', + '9001', + ]); + assert.strictEqual(code, 0); + const doc = JSON.parse(stdout); + assert.strictEqual(doc.servers.length, 1); + assert.strictEqual(doc.servers[0].url, 'http://localhost:9001'); + }); + + it('rejects an unknown flag the same way the server-start path does', async function () { + const { code, stderr } = await runCli(['openapi', '--nope']); + assert.notStrictEqual(code, 0); + assert.match(stderr, /unknown flag/); + }); + + it('--help documents the subcommand', async function () { + const { code, stdout } = await runCli(['--help']); + assert.strictEqual(code, 0); + assert.match(stdout, /rclnodejs-web openapi/); + }); + }); +}); From 0ae3143a62041ebf32b9ec2b5969be0a45225c2f Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Mon, 27 Jul 2026 15:06:49 +0800 Subject: [PATCH 2/8] Address comments --- lib/openapi.js | 16 ++++++++++++---- test/test-openapi.js | 6 +++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index 1ca2aa325..62dfe345f 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -93,11 +93,14 @@ function primitiveToJsonSchema(fieldType) { if (type === 'bool') return { type: 'boolean' }; if (INT64_TYPES.has(type)) { - // See the docstring above — deliberately string, not integer. + // See the docstring above — deliberately string, not integer. uint64 + // gets its own (unsigned-only) pattern/format so the schema doesn't + // claim negative values are valid input for an unsigned field. + const unsigned = type === 'uint64'; return { type: 'string', - format: 'int64', - pattern: '^-?[0-9]+n$', + format: unsigned ? 'uint64' : 'int64', + pattern: unsigned ? '^[0-9]+n$' : '^-?[0-9]+n$', description: `ROS 2 ${type}, transmitted as a BigInt-string (e.g. "42n") for precision-safety.`, }; } @@ -189,7 +192,12 @@ function messageSchemaToJsonSchema(schema, components, seen) { * matches typical OpenAPI style for RPC-shaped APIs). */ function topLevelSchema(typeName, subType, components, seen) { - const typeClass = interfaceLoader.loadInterface(typeName); + let typeClass; + try { + typeClass = interfaceLoader.loadInterface(typeName); + } catch { + return { type: 'object', description: `Could not resolve ${typeName}` }; + } const resolved = subType ? typeClass[subType] : typeClass; const schema = getMessageSchema(resolved); if (!schema) { diff --git a/test/test-openapi.js b/test/test-openapi.js index 8361b5bcb..9ce761ffb 100644 --- a/test/test-openapi.js +++ b/test/test-openapi.js @@ -29,7 +29,7 @@ function runCli(args) { let stderr = ''; p.stdout.on('data', (d) => (stdout += d.toString())); p.stderr.on('data', (d) => (stderr += d.toString())); - p.on('exit', (code) => resolve({ code, stdout, stderr })); + p.on('close', (code) => resolve({ code, stdout, stderr })); p.on('error', reject); }); } @@ -65,6 +65,10 @@ describe('lib/openapi.js', function () { const unsignedSchema = primitiveToJsonSchema({ type: 'uint64' }); assert.strictEqual(unsignedSchema.type, 'string'); + assert.strictEqual(unsignedSchema.format, 'uint64'); + const unsignedRe = new RegExp(unsignedSchema.pattern); + assert.ok(unsignedRe.test('42n')); + assert.ok(!unsignedRe.test('-7n')); }); it('maps bounded strings to maxLength', function () { From 37810cd6472d56c2fb9dac2f8944377b5b45164c Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Mon, 27 Jul 2026 16:12:57 +0800 Subject: [PATCH 3/8] Address comments --- bin/rclnodejs-web.js | 36 +++++++++++------------------------- test/test-openapi.js | 8 ++++---- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index 3ccf03c18..8c1cc604f 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -27,17 +27,10 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// `openapi` is a one-shot metadata subcommand (prototype for -// docs/AGENT_API_DESIGN_2.2.0.md): it resolves `expose` capabilities to -// their ROS message/service schemas and prints a document to stdout, without -// starting any transport or connecting to a live ROS graph (`rclnodejs.init()` -// is never called). That said, resolving a message type still `require()`s -// its generated `.js` class, which loads rclnodejs's native addon as a side -// effect (same as any other rclnodejs usage) — so source your ROS 2 -// environment first. Without it, the native loader may not find a matching -// prebuild and can fall back to rebuilding from source, which is slow and, -// in the worst case, wipes `generated/` (see the incident note in -// docs/AGENT_API_DESIGN_2.2.0.md §5). +// `openapi` is a one-shot subcommand: it prints an OpenAPI document to +// stdout without starting any transport or calling `rclnodejs.init()`. +// Still needs ROS 2 sourced, though — resolving a message type loads +// rclnodejs's native addon as a side effect, same as any other usage. const SUBCOMMANDS = new Set(['openapi']); let subcommand; let argv = process.argv.slice(2); @@ -190,9 +183,8 @@ if (SUBCOMMANDS.has(argv[0])) { })(); /** - * Handle the `openapi` one-shot subcommand: parse argv + config the same way - * the server start path does, build a bare `CapabilityRegistry` from - * `expose` (no Node, no transports, no `rclnodejs.init()`), and print the + * Handle the `openapi` subcommand: build a bare `CapabilityRegistry` from + * `expose` (no Node, no transports, no `rclnodejs.init()`) and print the * resulting OpenAPI document to stdout as JSON. * * @param {string[]} rest - argv with the subcommand keyword already removed @@ -237,20 +229,14 @@ async function runOpenApiSubcommand(rest) { /** * Derive OpenAPI `servers` from the resolved config's HTTP transport. * - * Without this the document has no `servers`, which OpenAPI defines as - * `[{url: '/'}]` — i.e. "resolve paths against whatever origin served this - * document". That is wrong here: the document is a static file, typically - * served by a different origin than the runtime (the demo serves it from - * `static.mjs` on :8080 while the runtime's HTTP transport listens on - * :9001), so Swagger UI's "Try it out" would hit the static server. - * - * Uses `localhost` rather than the configured bind host on purpose: the - * default `::` is a wildcard bind address, not a URL an HTTP client can - * dial. Downstream deployments behind a proxy should rewrite `servers`. + * Without this, OpenAPI defaults `servers` to `[{url: '/'}]` — the origin + * that served the document, not the runtime. That's wrong for a static + * `openapi.json` served from elsewhere (e.g. Swagger UI's "Try it out" + * would hit the wrong server), so fill it in from the config instead. * * @param {object} cfg - fully-resolved config from `mergeConfig` * @returns {Array<{url: string, description: string}>} empty when the HTTP - * transport is disabled — WS-only runtimes have no HTTP origin to point at + * transport is disabled */ function openApiServers(cfg) { if (!cfg.http || cfg.http.port == null) return []; diff --git a/test/test-openapi.js b/test/test-openapi.js index 9ce761ffb..2a960d13a 100644 --- a/test/test-openapi.js +++ b/test/test-openapi.js @@ -6,10 +6,10 @@ // // http://www.apache.org/licenses/LICENSE-2.0 -// Prototype tests for docs/AGENT_API_DESIGN_2.2.0.md: the OpenAPI / -// agent-tool-schema export built on top of the capability registry. -// These are pure metadata transforms — no ROS 2 init, no transports, no -// network — so they run fast and need no `rclnodejs.init()`. +// Tests for lib/openapi.js: the OpenAPI export built on top of the +// capability registry. These are pure metadata transforms — no ROS 2 +// init, no transports, no network — so they run fast and need no +// `rclnodejs.init()`. import assert from 'assert'; import path from 'node:path'; From 8d14c544c6e9c44591af65d664420ed6b5238038 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Mon, 27 Jul 2026 17:54:47 +0800 Subject: [PATCH 4/8] Address comments --- bin/rclnodejs-web.js | 17 +++++--- lib/openapi.js | 39 +++++++++-------- test/test-openapi.js | 99 ++++++++++++++++++++++++++++++-------------- 3 files changed, 99 insertions(+), 56 deletions(-) diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index 8c1cc604f..6e768e6b0 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -27,6 +27,11 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// A wildcard bind address (dual-stack default, or IPv4-any) isn't a URL an +// HTTP client can dial, so display/advertise it as `localhost` instead. +// Any other configured host (a real hostname/IP) passes through unchanged. +const displayHost = (h) => (['0.0.0.0', '::'].includes(h) ? 'localhost' : h); + // `openapi` is a one-shot subcommand: it prints an OpenAPI document to // stdout without starting any transport or calling `rclnodejs.init()`. // Still needs ROS 2 sourced, though — resolving a message type loads @@ -131,8 +136,6 @@ if (SUBCOMMANDS.has(argv[0])) { const httpTransport = httpEnabled ? runtime.transports[1] : null; if (!parsed.quiet) { - const displayHost = (h) => - ['0.0.0.0', '::'].includes(h) ? 'localhost' : h; const list = runtime.registry.list(); const totals = Object.keys(list.call).length + @@ -215,11 +218,14 @@ async function runOpenApiSubcommand(rest) { registry.expose(cfg.expose); try { + // No `version` option: `info.version` describes the caller's API, not + // rclnodejs's own release — see lib/openapi.js's docstring. const document = buildOpenApiDocument(registry.list(), { title: cfg.node, - basePath: cfg.http.basePath || '/capability', - servers: openApiServers(cfg), + basePath: cfg.http.basePath || cfg.path, }); + const servers = openApiServers(cfg); + if (servers.length) document.servers = servers; process.stdout.write(JSON.stringify(document, null, 2) + '\n'); } catch (e) { fail(e); @@ -240,9 +246,10 @@ async function runOpenApiSubcommand(rest) { */ function openApiServers(cfg) { if (!cfg.http || cfg.http.port == null) return []; + const host = displayHost(cfg.http.host || cfg.host); return [ { - url: `http://localhost:${cfg.http.port}`, + url: `http://${host}:${cfg.http.port}`, description: 'rclnodejs/web HTTP transport', }, ]; diff --git a/lib/openapi.js b/lib/openapi.js index 62dfe345f..f10706887 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -18,6 +18,11 @@ * Turns `CapabilityRegistry.list()` into a documented, introspectable * Web API. * + * CLI-only: not re-exported from `lib/runtime/index.js`, so it isn't part + * of the published `rclnodejs/web/server` API (`dist/server.js` bundles + * only `lib/runtime/index.js`). Importing this file directly is + * unsupported — go through the `rclnodejs-web openapi` subcommand. + * * Deliberately reuses the message introspection that already ships in * rclnodejs core (`lib/message_validation.js`'s `getMessageSchema()`) * instead of re-parsing rosidl ASTs. Resolving a @@ -93,14 +98,14 @@ function primitiveToJsonSchema(fieldType) { if (type === 'bool') return { type: 'boolean' }; if (INT64_TYPES.has(type)) { - // See the docstring above — deliberately string, not integer. uint64 - // gets its own (unsigned-only) pattern/format so the schema doesn't - // claim negative values are valid input for an unsigned field. + // Deliberately string, not integer (see docstring above). uint64 gets + // an unsigned-only pattern so the schema can't claim negatives are valid. const unsigned = type === 'uint64'; return { type: 'string', format: unsigned ? 'uint64' : 'int64', pattern: unsigned ? '^[0-9]+n$' : '^-?[0-9]+n$', + example: '42n', description: `ROS 2 ${type}, transmitted as a BigInt-string (e.g. "42n") for precision-safety.`, }; } @@ -211,29 +216,24 @@ function topLevelSchema(typeName, subType, components, seen) { * (`CapabilityRegistry.list()`'s shape: `{call, publish, subscribe}`, each a * `{name: typeName}` map). * + * No `servers` option: it's pure top-level metadata this function never + * reads while building `paths`, so callers (e.g. the CLI's + * `openApiServers()`) attach it to the returned document directly instead. + * + * No `version` option either: `info.version` describes the caller's API, + * not the rclnodejs release that generated the document, and there's no + * source for the former today — so it's a fixed `'0.0.0'` placeholder. + * * @param {{call: object, publish: object, subscribe: object}} capabilities * @param {object} [options] * @param {string} [options.title] - * @param {string} [options.version] * @param {string} [options.basePath] - default '/capability' - * @param {Array<{url: string, description?: string}>} [options.servers] - - * where the runtime's HTTP transport actually listens. Omitting this is - * rarely what you want: OpenAPI defaults `servers` to `[{url: '/'}]`, so - * a client resolves every path against whatever origin served the - * document. When the document is served by a *static* file server (the - * demo's `static.mjs` on :8080) but the runtime listens elsewhere - * (:9001), Swagger UI's "Try it out" would POST to the static server and - * get its 404 back. The CLI fills this in from the `http` config. * @returns {object} an OpenAPI 3.1 document (plain object; caller decides * JSON vs. YAML serialization) */ function buildOpenApiDocument(capabilities, options = {}) { - const { - title = 'rclnodejs/web capability API', - version = '0.0.0', - basePath = '/capability', - servers = [], - } = options; + const { title = 'rclnodejs/web capability API', basePath = '/capability' } = + options; const components = new Map(); const seen = new Set(); @@ -324,8 +324,7 @@ function buildOpenApiDocument(capabilities, options = {}) { return { openapi: '3.1.0', - info: { title, version }, - ...(servers.length ? { servers } : {}), + info: { title, version: '0.0.0' }, paths, components: { schemas: Object.fromEntries(components) }, }; diff --git a/test/test-openapi.js b/test/test-openapi.js index 2a960d13a..09817d788 100644 --- a/test/test-openapi.js +++ b/test/test-openapi.js @@ -62,6 +62,10 @@ describe('lib/openapi.js', function () { assert.ok(re.test('42n')); assert.ok(re.test('-7n')); assert.ok(!re.test('42')); + // Explicit example so API explorers (e.g. Swagger UI) show a sane + // value instead of synthesizing an arbitrarily long digit run to + // satisfy the unbounded `[0-9]+` pattern. + assert.ok(re.test(schema.example)); const unsignedSchema = primitiveToJsonSchema({ type: 'uint64' }); assert.strictEqual(unsignedSchema.type, 'string'); @@ -69,6 +73,7 @@ describe('lib/openapi.js', function () { const unsignedRe = new RegExp(unsignedSchema.pattern); assert.ok(unsignedRe.test('42n')); assert.ok(!unsignedRe.test('-7n')); + assert.ok(unsignedRe.test(unsignedSchema.example)); }); it('maps bounded strings to maxLength', function () { @@ -95,13 +100,10 @@ describe('lib/openapi.js', function () { }; it('produces a valid-looking OpenAPI 3.1 document shell', function () { - const doc = buildOpenApiDocument(capabilities, { - title: 'test API', - version: '1.2.3', - }); + const doc = buildOpenApiDocument(capabilities, { title: 'test API' }); assert.strictEqual(doc.openapi, '3.1.0'); assert.strictEqual(doc.info.title, 'test API'); - assert.strictEqual(doc.info.version, '1.2.3'); + assert.strictEqual(doc.info.version, '0.0.0'); }); it('documents a call capability as POST /capability/call/', function () { @@ -163,32 +165,6 @@ describe('lib/openapi.js', function () { }); }); - describe('servers', function () { - const capabilities = { - call: {}, - publish: { '/chatter': 'std_msgs/msg/String' }, - subscribe: {}, - }; - - it('omits servers by default', function () { - const doc = buildOpenApiDocument(capabilities); - assert.ok(!('servers' in doc)); - }); - - it('emits servers when given, so clients target the runtime origin', function () { - // Without this, OpenAPI's default `servers` is [{url: '/'}] — every - // path resolves against whatever origin served the document. When a - // static file server hosts openapi.json but the runtime listens - // elsewhere, "Try it out" hits the static server and 404s. - const doc = buildOpenApiDocument(capabilities, { - servers: [{ url: 'http://localhost:9001', description: 'http' }], - }); - assert.deepStrictEqual(doc.servers, [ - { url: 'http://localhost:9001', description: 'http' }, - ]); - }); - }); - describe('CLI subcommand (bin/rclnodejs-web.js openapi)', function () { this.timeout(10000); @@ -204,6 +180,23 @@ describe('lib/openapi.js', function () { assert.ok(doc.paths['/capability/call/add_two_ints']); }); + it('openapi routes use --path when http.basePath is not set, matching the server transport', async function () { + // Must match HttpTransport's own fallback (cfg.path, not a hardcoded + // '/capability') or the document describes routes the server doesn't + // actually serve. + const { code, stdout } = await runCli([ + 'openapi', + '--path', + '/api', + '--call', + '/add_two_ints=example_interfaces/srv/AddTwoInts', + ]); + assert.strictEqual(code, 0); + const doc = JSON.parse(stdout); + assert.ok(doc.paths['/api/call/add_two_ints']); + assert.ok(!doc.paths['/capability/call/add_two_ints']); + }); + it('openapi omits servers when the HTTP transport is off', async function () { const { code, stdout } = await runCli([ 'openapi', @@ -228,6 +221,50 @@ describe('lib/openapi.js', function () { assert.strictEqual(doc.servers[0].url, 'http://localhost:9001'); }); + it('openapi uses a configured --http-host verbatim, not a hardcoded localhost', async function () { + const { code, stdout } = await runCli([ + 'openapi', + '--publish', + '/chatter=std_msgs/msg/String', + '--http-port', + '9001', + '--http-host', + 'api.example.com', + ]); + assert.strictEqual(code, 0); + const doc = JSON.parse(stdout); + assert.strictEqual(doc.servers[0].url, 'http://api.example.com:9001'); + }); + + it('openapi displays a wildcard --http-host as localhost, matching the startup banner', async function () { + const { code, stdout } = await runCli([ + 'openapi', + '--publish', + '/chatter=std_msgs/msg/String', + '--http-port', + '9001', + '--http-host', + '0.0.0.0', + ]); + assert.strictEqual(code, 0); + const doc = JSON.parse(stdout); + assert.strictEqual(doc.servers[0].url, 'http://localhost:9001'); + }); + + it("openapi leaves info.version at the '0.0.0' placeholder", async function () { + // info.version describes the user's API, not the tool that generated + // it, and buildOpenApiDocument() has no option for it (see its + // docstring) — so it's always this fixed placeholder. + const { code, stdout } = await runCli([ + 'openapi', + '--publish', + '/chatter=std_msgs/msg/String', + ]); + assert.strictEqual(code, 0); + const doc = JSON.parse(stdout); + assert.strictEqual(doc.info.version, '0.0.0'); + }); + it('rejects an unknown flag the same way the server-start path does', async function () { const { code, stderr } = await runCli(['openapi', '--nope']); assert.notStrictEqual(code, 0); From bb906d366b4e9583c1c90e801651dd08376d8628 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Tue, 28 Jul 2026 10:06:43 +0800 Subject: [PATCH 5/8] Address comments --- lib/openapi.js | 51 +++++++++++++++++++++++++++++++++++++++++--- test/test-openapi.js | 17 +++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index f10706887..47fb46824 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -232,8 +232,10 @@ function topLevelSchema(typeName, subType, components, seen) { * JSON vs. YAML serialization) */ function buildOpenApiDocument(capabilities, options = {}) { - const { title = 'rclnodejs/web capability API', basePath = '/capability' } = - options; + const { title = 'rclnodejs/web capability API' } = options; + // Match HttpTransport's normalisation so a trailing-slash basePath + // can't produce a route the runtime doesn't actually serve. + const basePath = _normaliseBasePath(options.basePath); const components = new Map(); const seen = new Set(); @@ -316,7 +318,7 @@ function buildOpenApiDocument(capabilities, options = {}) { }, }, }, - 404: notExposedResponse(), + 404: subscribeNotExposedResponse(), }, }, }; @@ -348,6 +350,49 @@ function notExposedResponse() { }; } +/** + * 404 for `subscribe`: unlike `call`/`publish`, it has two causes — + * `unsupported_kind` when `sse` is off (the default), `not_exposed` when + * `sse` is on but the capability isn't registered — so `code` lists both + * instead of a single `const`. + */ +function subscribeNotExposedResponse() { + return { + description: + 'Capability not exposed, or subscribe over HTTP is disabled ' + + '(`sse: false`, the default)', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean', const: false }, + error: { type: 'string' }, + code: { + type: 'string', + enum: ['not_exposed', 'unsupported_kind'], + }, + }, + }, + }, + }, + }; +} + +/** + * Normalise `basePath` like `HttpTransport` does (single leading slash, no + * trailing slash). Duplicated, not imported, to keep this file free of any + * `lib/runtime/` dependency. + */ +function _normaliseBasePath(value) { + if (value === undefined || value === null || value === '') { + return '/capability'; + } + let p = String(value).replace(/\/+$/, ''); + if (!p.startsWith('/')) p = '/' + p; + return p || '/capability'; +} + function sanitizeName(name) { return name.replace(/^\//, '').replace(/[^a-zA-Z0-9_]/g, '_'); } diff --git a/test/test-openapi.js b/test/test-openapi.js index 09817d788..b3bb35f8c 100644 --- a/test/test-openapi.js +++ b/test/test-openapi.js @@ -143,6 +143,23 @@ describe('lib/openapi.js', function () { assert.ok(sseContent, 'expected a text/event-stream response'); }); + it("subscribe's 404 documents both unsupported_kind (sse off, the default) and not_exposed", function () { + const doc = buildOpenApiDocument(capabilities); + const op = doc.paths['/capability/subscribe/cmd_vel'].get; + const codeSchema = + op.responses['404'].content['application/json'].schema.properties.code; + assert.deepStrictEqual(codeSchema.enum, [ + 'not_exposed', + 'unsupported_kind', + ]); + }); + + it('normalizes a trailing-slash basePath, matching HttpTransport, instead of emitting a double slash', function () { + const doc = buildOpenApiDocument(capabilities, { basePath: '/api/' }); + assert.ok(doc.paths['/api/call/add_two_ints']); + assert.ok(!('/api//call/add_two_ints' in doc.paths)); + }); + it('de-duplicates nested message types into one shared $ref component', function () { const doc = buildOpenApiDocument(capabilities); const op = doc.paths['/capability/subscribe/cmd_vel'].get; From 8da7fe449db571f7645415ae90f9ba648f3e6fa2 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Tue, 28 Jul 2026 15:39:05 +0800 Subject: [PATCH 6/8] Simplify comments --- bin/rclnodejs-web.js | 26 +++++++++----------- lib/openapi.js | 57 +++++++++++++++++--------------------------- 2 files changed, 33 insertions(+), 50 deletions(-) diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index 6e768e6b0..ba8a69ebc 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -32,10 +32,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Any other configured host (a real hostname/IP) passes through unchanged. const displayHost = (h) => (['0.0.0.0', '::'].includes(h) ? 'localhost' : h); -// `openapi` is a one-shot subcommand: it prints an OpenAPI document to -// stdout without starting any transport or calling `rclnodejs.init()`. -// Still needs ROS 2 sourced, though — resolving a message type loads -// rclnodejs's native addon as a side effect, same as any other usage. +// `openapi` is a one-shot subcommand: prints an OpenAPI document to +// stdout, no transport or `rclnodejs.init()` — but still needs ROS 2 +// sourced, since resolving a message type loads the native addon. const SUBCOMMANDS = new Set(['openapi']); let subcommand; let argv = process.argv.slice(2); @@ -187,8 +186,7 @@ if (SUBCOMMANDS.has(argv[0])) { /** * Handle the `openapi` subcommand: build a bare `CapabilityRegistry` from - * `expose` (no Node, no transports, no `rclnodejs.init()`) and print the - * resulting OpenAPI document to stdout as JSON. + * `expose` and print the resulting OpenAPI document to stdout as JSON. * * @param {string[]} rest - argv with the subcommand keyword already removed */ @@ -218,8 +216,7 @@ async function runOpenApiSubcommand(rest) { registry.expose(cfg.expose); try { - // No `version` option: `info.version` describes the caller's API, not - // rclnodejs's own release — see lib/openapi.js's docstring. + // No `version` option here — see lib/openapi.js's docstring. const document = buildOpenApiDocument(registry.list(), { title: cfg.node, basePath: cfg.http.basePath || cfg.path, @@ -233,16 +230,15 @@ async function runOpenApiSubcommand(rest) { } /** - * Derive OpenAPI `servers` from the resolved config's HTTP transport. + * Derive OpenAPI `servers` from the config's HTTP transport. * - * Without this, OpenAPI defaults `servers` to `[{url: '/'}]` — the origin - * that served the document, not the runtime. That's wrong for a static - * `openapi.json` served from elsewhere (e.g. Swagger UI's "Try it out" - * would hit the wrong server), so fill it in from the config instead. + * Without this, OpenAPI defaults `servers` to `[{url: '/'}]` — the + * document's own origin, not the runtime's, which is wrong once + * `openapi.json` is served from elsewhere (e.g. Swagger UI's "Try it out"). * * @param {object} cfg - fully-resolved config from `mergeConfig` - * @returns {Array<{url: string, description: string}>} empty when the HTTP - * transport is disabled + * @returns {Array<{url: string, description: string}>} empty when HTTP + * is disabled */ function openApiServers(cfg) { if (!cfg.http || cfg.http.port == null) return []; diff --git a/lib/openapi.js b/lib/openapi.js index 47fb46824..24c81dccd 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -13,51 +13,38 @@ // limitations under the License. /** - * OpenAPI 3.1 export for the Web Runtime capability registry. + * OpenAPI 3.1 export for the Web Runtime capability registry: turns + * `CapabilityRegistry.list()` into a documented, introspectable Web API. * - * Turns `CapabilityRegistry.list()` into a documented, introspectable - * Web API. + * CLI-only — not re-exported from `lib/runtime/index.js`, so it isn't + * part of the published `rclnodejs/web/server` API. Go through the + * `rclnodejs-web openapi` subcommand instead of importing this directly. * - * CLI-only: not re-exported from `lib/runtime/index.js`, so it isn't part - * of the published `rclnodejs/web/server` API (`dist/server.js` bundles - * only `lib/runtime/index.js`). Importing this file directly is - * unsupported — go through the `rclnodejs-web openapi` subcommand. - * - * Deliberately reuses the message introspection that already ships in - * rclnodejs core (`lib/message_validation.js`'s `getMessageSchema()`) - * instead of re-parsing rosidl ASTs. Resolving a - * ROS type name is a pure local file lookup (`interface_loader` reads the - * already-generated `.js` message classes) and never calls a live - * `rclnodejs.init()`, so `buildOpenApiDocument()` can run standalone against - * just a `web.json` config, without any transport or ROS graph. It still - * needs ROS 2 sourced, though: `require()`-ing a generated message class - * loads rclnodejs's native addon as a side effect, and without a sourced - * environment the native loader may fail to match a prebuild and fall back - * to a source rebuild. + * Reuses core's message introspection (`message_validation.js`'s + * `getMessageSchema()`) rather than re-parsing rosidl ASTs, and resolves + * types via `interface_loader` without ever calling `rclnodejs.init()` — + * so this runs standalone against just a `web.json` config. Still needs + * ROS 2 sourced, though: resolving a message type loads rclnodejs's + * native addon as a side effect, and without it the loader may fall back + * to a slow source rebuild. */ import interfaceLoader from './interface_loader.js'; import { getMessageSchema } from './message_validation.js'; /** - * Map one ROS field-type descriptor (the shape produced by - * `getMessageSchema()`, ultimately from the rosidl-generated - * `ROSMessageDef`) to a JSON Schema fragment. + * Map one ROS field-type descriptor (from `getMessageSchema()`) to a + * JSON Schema fragment. * - * 64-bit integers are the one deliberate deviation from the "obvious" - * mapping: OpenAPI's `format: int64` is a documentation-only annotation on - * top of `type: integer`, and JSON numbers cannot safely carry 64-bit - * precision in JS (or most JSON parsers). rclnodejs's own wire convention - * already represents `int64`/`uint64` as `"n"` strings (see - * `PRIMITIVE_TYPE_MAP` in message_validation.js mapping them to `'bigint'`), - * so the schema follows that convention rather than the nominal - * `type: integer, format: int64` pairing. + * One deliberate deviation: 64-bit integers map to `type: string`, not + * `type: integer` — OpenAPI's `format: int64` is documentation-only, and + * JSON numbers can't safely hold 64-bit precision. rclnodejs's own wire + * convention already sends `int64`/`uint64` as `"n"` strings, so + * the schema follows that instead of the nominal `integer`/`int64` pairing. * - * @param {object} fieldType - a field's `type` descriptor from - * `getMessageSchema(...).fields[i].type` - * @param {Map} components - accumulator for referenced - * nested-message component schemas, keyed by component name - * (`__msg__` or `__srv___Request`/`_Response`) + * @param {object} fieldType - a field's `type` descriptor + * @param {Map} components - accumulator for nested-message + * component schemas, keyed by component name * @param {Set} seen - cycle guard for recursive `$ref` resolution * @returns {object} a JSON Schema fragment */ From c8acbbe398caa722ce87906219813c92741affb2 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Tue, 28 Jul 2026 17:15:02 +0800 Subject: [PATCH 7/8] Simplify comments --- lib/openapi.js | 4 ++-- test/test-openapi.js | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index 24c81dccd..967a347b8 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -22,8 +22,8 @@ * * Reuses core's message introspection (`message_validation.js`'s * `getMessageSchema()`) rather than re-parsing rosidl ASTs, and resolves - * types via `interface_loader` without ever calling `rclnodejs.init()` — - * so this runs standalone against just a `web.json` config. Still needs + * types via `interface_loader` — so this runs standalone against just a + * `web.json` config, without any transport or ROS graph. Still needs * ROS 2 sourced, though: resolving a message type loads rclnodejs's * native addon as a side effect, and without it the loader may fall back * to a slow source rebuild. diff --git a/test/test-openapi.js b/test/test-openapi.js index b3bb35f8c..633720bf9 100644 --- a/test/test-openapi.js +++ b/test/test-openapi.js @@ -8,8 +8,7 @@ // Tests for lib/openapi.js: the OpenAPI export built on top of the // capability registry. These are pure metadata transforms — no ROS 2 -// init, no transports, no network — so they run fast and need no -// `rclnodejs.init()`. +// init, no transports, no network — so they run fast. import assert from 'assert'; import path from 'node:path'; From b088d38095ce21947d66d7b14b615e7cc2ad15ca Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Tue, 28 Jul 2026 17:44:54 +0800 Subject: [PATCH 8/8] Simplify comments --- lib/openapi.js | 46 +++++++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index 967a347b8..b48192fd3 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -45,15 +45,13 @@ import { getMessageSchema } from './message_validation.js'; * @param {object} fieldType - a field's `type` descriptor * @param {Map} components - accumulator for nested-message * component schemas, keyed by component name - * @param {Set} seen - cycle guard for recursive `$ref` resolution * @returns {object} a JSON Schema fragment */ -function rosFieldTypeToJsonSchema(fieldType, components, seen) { +function rosFieldTypeToJsonSchema(fieldType, components) { if (fieldType.isArray) { const itemSchema = rosFieldTypeToJsonSchema( { ...fieldType, isArray: false }, - components, - seen + components ); const arraySchema = { type: 'array', items: itemSchema }; if (fieldType.isFixedSizeArray && fieldType.arraySize != null) { @@ -74,7 +72,7 @@ function rosFieldTypeToJsonSchema(fieldType, components, seen) { // capabilities) share one schema instead of being inlined N times. const componentName = `${fieldType.pkgName}__msg__${fieldType.type}`; const typeName = `${fieldType.pkgName}/msg/${fieldType.type}`; - registerComponent(typeName, componentName, components, seen); + registerComponent(typeName, componentName, components); return { $ref: `#/components/schemas/${componentName}` }; } @@ -128,12 +126,14 @@ function primitiveToJsonSchema(fieldType) { /** * Resolve a ROS message type name into a JSON Schema object (properties per * field), registering it into `components` under `componentName` so nested - * `$ref`s can point at it. No-op if already registered/in-progress (cycle - * guard). + * `$ref`s can point at it. No-op if already registered. + * + * No cycle guard: a message value type can't structurally reference itself + * (directly or indirectly) — rosidl generates fixed-size value types, and a + * self-referential one would need infinite size, so it can't compile. */ -function registerComponent(typeName, componentName, components, seen) { - if (components.has(componentName) || seen.has(componentName)) return; - seen.add(componentName); +function registerComponent(typeName, componentName, components) { + if (components.has(componentName)) return; let typeClass; try { @@ -147,10 +147,7 @@ function registerComponent(typeName, componentName, components, seen) { } const schema = getMessageSchema(typeClass); - components.set( - componentName, - messageSchemaToJsonSchema(schema, components, seen) - ); + components.set(componentName, messageSchemaToJsonSchema(schema, components)); } /** @@ -158,16 +155,12 @@ function registerComponent(typeName, componentName, components, seen) { * (`{type: 'object', properties: {...}}`), recursively registering any * nested message types into `components`. */ -function messageSchemaToJsonSchema(schema, components, seen) { +function messageSchemaToJsonSchema(schema, components) { const properties = {}; const required = []; for (const field of schema.fields || []) { if (field.name.startsWith('_')) continue; - properties[field.name] = rosFieldTypeToJsonSchema( - field.type, - components, - seen - ); + properties[field.name] = rosFieldTypeToJsonSchema(field.type, components); required.push(field.name); } const jsonSchema = { type: 'object', properties }; @@ -183,7 +176,7 @@ function messageSchemaToJsonSchema(schema, components, seen) { * in the operation, only *nested* types become `$ref`d components — this * matches typical OpenAPI style for RPC-shaped APIs). */ -function topLevelSchema(typeName, subType, components, seen) { +function topLevelSchema(typeName, subType, components) { let typeClass; try { typeClass = interfaceLoader.loadInterface(typeName); @@ -195,7 +188,7 @@ function topLevelSchema(typeName, subType, components, seen) { if (!schema) { return { type: 'object', description: `Could not resolve ${typeName}` }; } - return messageSchemaToJsonSchema(schema, components, seen); + return messageSchemaToJsonSchema(schema, components); } /** @@ -225,7 +218,6 @@ function buildOpenApiDocument(capabilities, options = {}) { const basePath = _normaliseBasePath(options.basePath); const components = new Map(); - const seen = new Set(); const paths = {}; for (const [name, typeName] of Object.entries(capabilities.call || {})) { @@ -239,7 +231,7 @@ function buildOpenApiDocument(capabilities, options = {}) { required: true, content: { 'application/json': { - schema: topLevelSchema(typeName, 'Request', components, seen), + schema: topLevelSchema(typeName, 'Request', components), }, }, }, @@ -248,7 +240,7 @@ function buildOpenApiDocument(capabilities, options = {}) { description: 'ROS 2 service response', content: { 'application/json': { - schema: topLevelSchema(typeName, 'Response', components, seen), + schema: topLevelSchema(typeName, 'Response', components), }, }, }, @@ -269,7 +261,7 @@ function buildOpenApiDocument(capabilities, options = {}) { required: true, content: { 'application/json': { - schema: topLevelSchema(typeName, null, components, seen), + schema: topLevelSchema(typeName, null, components), }, }, }, @@ -301,7 +293,7 @@ function buildOpenApiDocument(capabilities, options = {}) { description: `Server-Sent Events stream of ${typeName} messages`, content: { 'text/event-stream': { - schema: topLevelSchema(typeName, null, components, seen), + schema: topLevelSchema(typeName, null, components), }, }, },