diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index 5d1545311..ba8a69ebc 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -27,9 +27,28 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const argv = process.argv.slice(2); +// 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: 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); +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); @@ -116,8 +135,6 @@ const argv = process.argv.slice(2); 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 + @@ -167,6 +184,73 @@ const argv = process.argv.slice(2); } })(); +/** + * Handle the `openapi` subcommand: build a bare `CapabilityRegistry` from + * `expose` 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 { + // No `version` option here — see lib/openapi.js's docstring. + const document = buildOpenApiDocument(registry.list(), { + title: cfg.node, + 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); + } +} + +/** + * Derive OpenAPI `servers` from the config's HTTP transport. + * + * 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 HTTP + * is disabled + */ +function openApiServers(cfg) { + if (!cfg.http || cfg.http.port == null) return []; + const host = displayHost(cfg.http.host || cfg.host); + return [ + { + url: `http://${host}:${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..b48192fd3 --- /dev/null +++ b/lib/openapi.js @@ -0,0 +1,384 @@ +// 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. + * + * 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. + * + * Reuses core's message introspection (`message_validation.js`'s + * `getMessageSchema()`) rather than re-parsing rosidl ASTs, and resolves + * 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. + */ + +import interfaceLoader from './interface_loader.js'; +import { getMessageSchema } from './message_validation.js'; + +/** + * Map one ROS field-type descriptor (from `getMessageSchema()`) to a + * JSON Schema fragment. + * + * 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 + * @param {Map} components - accumulator for nested-message + * component schemas, keyed by component name + * @returns {object} a JSON Schema fragment + */ +function rosFieldTypeToJsonSchema(fieldType, components) { + if (fieldType.isArray) { + const itemSchema = rosFieldTypeToJsonSchema( + { ...fieldType, isArray: false }, + components + ); + 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); + 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)) { + // 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.`, + }; + } + 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. + * + * 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) { + if (components.has(componentName)) return; + + 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)); +} + +/** + * 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) { + const properties = {}; + const required = []; + for (const field of schema.fields || []) { + if (field.name.startsWith('_')) continue; + properties[field.name] = rosFieldTypeToJsonSchema(field.type, components); + 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) { + 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) { + return { type: 'object', description: `Could not resolve ${typeName}` }; + } + return messageSchemaToJsonSchema(schema, components); +} + +/** + * Build a full OpenAPI 3.1 document from a capability registry snapshot + * (`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.basePath] - default '/capability' + * @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' } = 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 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), + }, + }, + }, + responses: { + 200: { + description: 'ROS 2 service response', + content: { + 'application/json': { + schema: topLevelSchema(typeName, 'Response', components), + }, + }, + }, + 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), + }, + }, + }, + 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), + }, + }, + }, + 404: subscribeNotExposedResponse(), + }, + }, + }; + } + + return { + openapi: '3.1.0', + info: { title, version: '0.0.0' }, + 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' }, + }, + }, + }, + }, + }; +} + +/** + * 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, '_'); +} + +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..633720bf9 --- /dev/null +++ b/test/test-openapi.js @@ -0,0 +1,296 @@ +// 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 + +// 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. + +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('close', (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')); + // 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'); + assert.strictEqual(unsignedSchema.format, 'uint64'); + 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 () { + 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' }); + assert.strictEqual(doc.openapi, '3.1.0'); + assert.strictEqual(doc.info.title, 'test API'); + assert.strictEqual(doc.info.version, '0.0.0'); + }); + + 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("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; + 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('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 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', + '--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('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); + 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/); + }); + }); +});