// 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 `"<digits>n"` strings, so
* the schema follows that instead of the nominal `integer`/`int64` pairing.
*
* @param {object} fieldType - a field's `type` descriptor
* @param {Map<string,object>} components - accumulator for nested-message
* component schemas, keyed by component name
* @returns {object} a JSON Schema fragment
*/
function rosFieldTypeToJsonSchema(fieldType, components) {
if (fieldType.isArray) {
// Recurse for the element schema; ROS has no array-of-array, so
// clearing `isArray` always lands in a branch below.
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,
};