signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
343 lines (334 loc) • 15.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.mountSwaggerUi = mountSwaggerUi;
/* eslint-disable @typescript-eslint/no-explicit-any */
const path_1 = __importDefault(require("path"));
const marked_1 = require("marked");
const swagger_ui_express_1 = __importDefault(require("swagger-ui-express"));
const constants_1 = require("../constants");
const openApi_1 = require("./course/openApi");
const openApi_2 = require("./notifications/openApi");
const openApi_3 = require("./resources/openApi");
const openApi_4 = require("./autopilot/openApi");
const openApi_5 = require("./security/openApi");
const openApi_6 = require("./discovery/openApi");
const openApi_7 = require("./weather/openApi");
const openApi_8 = require("./apps/openApi");
const openApi_9 = require("./history/openApi");
const openApi_10 = require("./radar/openApi");
const asyncApi_1 = require("./course/asyncApi");
const asyncApi_2 = require("./autopilot/asyncApi");
const asyncApi_3 = require("./notifications/asyncApi");
const asyncApi_4 = require("./radar/asyncApi");
const asyncApi_5 = require("./resources/asyncApi");
const apiDocs = [
openApi_6.discoveryApiRecord,
openApi_8.appsApiRecord,
openApi_4.autopilotApiRecord,
openApi_1.courseApiRecord,
openApi_2.notificationsApiRecord,
openApi_3.resourcesApiRecord,
openApi_7.weatherApiRecord,
openApi_5.securityApiRecord,
openApi_9.historyApiRecord,
openApi_10.radarApiRecord
].reduce((acc, apiRecord) => {
acc[apiRecord.name] = apiRecord;
return acc;
}, {});
function mountSwaggerUi(app, path) {
const allApiNames = () => Object.keys(apiDocs).concat(app.getPluginOpenApiRecords().map(({ name }) => name));
// custom middleware to re-setup swaggerUI, because plugins have
// not been loaded when this is called early in server startup sequence
app.use(path, (req, res, next) => {
swagger_ui_express_1.default.setup(undefined, {
explorer: true,
swaggerOptions: {
urls: allApiNames().map((name) => ({
name,
url: `${constants_1.SERVERROUTESPREFIX}/openapi/${name}`
}))
}
});
next();
});
app.use(path, swagger_ui_express_1.default.serve, swagger_ui_express_1.default.setup(undefined, {
explorer: true,
swaggerOptions: {
urls: Object.keys(apiDocs).map((name) => ({
name,
url: `${constants_1.SERVERROUTESPREFIX}/openapi/${name}`
}))
}
}));
const apiDefinitionHandler = (req, res) => {
let apiRecord;
if (req.params.api) {
apiRecord = apiDocs[req.params.api];
}
else if (req.params.pluginId) {
apiRecord = app.getPluginOpenApi(req.params.pluginId);
}
const apiDoc = apiRecord?.apiDoc;
const apiPath = apiRecord?.path;
if (apiDoc && apiPath !== undefined) {
;
apiDoc.servers = apiDoc.servers ?? [
{
url: `${apiPath}`
}
];
res.json(apiDoc);
}
else {
res.status(404);
res.json('Not found');
}
};
app.get(`${constants_1.SERVERROUTESPREFIX}/openapi/plugins/:pluginId`, apiDefinitionHandler);
app.get(`${constants_1.SERVERROUTESPREFIX}/openapi/:api`, apiDefinitionHandler);
const asyncApiDocs = {
course: {
name: 'course',
title: 'Course API',
doc: asyncApi_1.courseAsyncApiDoc
},
autopilot: {
name: 'autopilot',
title: 'Autopilot API',
doc: asyncApi_2.autopilotAsyncApiDoc
},
notifications: {
name: 'notifications',
title: 'Notifications API',
doc: asyncApi_3.notificationsAsyncApiDoc
},
radar: {
name: 'radar',
title: 'Radar API',
doc: asyncApi_4.radarAsyncApiDoc
},
resources: {
name: 'resources',
title: 'Resources API',
doc: asyncApi_5.resourcesAsyncApiDoc
}
};
// List available AsyncAPI docs
app.get(`${constants_1.SERVERROUTESPREFIX}/asyncapi`, (_req, res) => {
res.json(Object.values(asyncApiDocs).map(({ name, title }) => ({
name,
title,
jsonUrl: `${constants_1.SERVERROUTESPREFIX}/asyncapi/${name}`,
docsUrl: `${constants_1.SERVERROUTESPREFIX}/asyncapi/docs`
})));
});
// Serve marked.js from node_modules
app.get('/skServer/vendor/marked.umd.js', (_req, res) => {
res.sendFile(path_1.default.join(path_1.default.dirname(require.resolve('marked/package.json')), 'lib', 'marked.umd.js'));
});
// Serve unified AsyncAPI viewer with dropdown
// MUST be registered before /asyncapi/:api to avoid matching "docs" as :api
const asyncApiUrls = Object.values(asyncApiDocs).map(({ name, title }) => ({
name: title,
url: `${constants_1.SERVERROUTESPREFIX}/asyncapi/${name}`
}));
app.get(`${constants_1.SERVERROUTESPREFIX}/asyncapi/docs`, (_req, res) => {
const specsJson = JSON.stringify(asyncApiUrls.map((u) => ({
title: u.name,
url: u.url
})));
res.send(asyncApiViewerHtml(specsJson));
});
// Serve individual AsyncAPI JSON documents
app.get(`${constants_1.SERVERROUTESPREFIX}/asyncapi/:api`, (req, res) => {
const record = asyncApiDocs[req.params.api];
if (record) {
const cleaned = JSON.parse(JSON.stringify(record.doc, (key, value) => key === '$id' ? undefined : value));
resolveRefs(cleaned, cleaned);
if (cleaned.info?.description) {
cleaned.info.descriptionHtml = (0, marked_1.marked)(cleaned.info.description);
}
res.json(cleaned);
}
else {
res.status(404).json('Not found');
}
});
}
function resolveRefs(obj, root) {
if (!obj || typeof obj !== 'object')
return;
for (const key of Object.keys(obj)) {
if (key === '$ref' &&
typeof obj[key] === 'string' &&
obj[key].startsWith('#/')) {
const path = obj[key].slice(2).split('/');
let target = root;
for (const p of path) {
target = target?.[p];
}
if (target && typeof target === 'object') {
delete obj.$ref;
Object.assign(obj, JSON.parse(JSON.stringify(target)));
}
}
else {
resolveRefs(obj[key], root);
}
}
}
function asyncApiViewerHtml(specsJson) {
return `
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Signal K AsyncAPI Documentation</title>
<script src="/skServer/vendor/marked.umd.js"><\/script>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1e1e1e; color: #d4d4d4; }
#nav { display: flex; align-items: center; gap: 12px; padding: 10px 20px; background: #252526; color: #cccccc; border-bottom: 1px solid #3c3c3c; }
#nav label { font-size: 14px; font-weight: 500; }
#nav select { padding: 4px 8px; border-radius: 4px; border: 1px solid #3c3c3c; background: #3c3c3c; color: #cccccc; font-size: 14px; }
#nav select:focus { outline: none; border-color: #007acc; }
.container { max-width: 960px; margin: 0 auto; padding: 20px; height: calc(100vh - 45px); overflow-y: auto; }
h1 { color: #e0e0e0; font-size: 22px; margin: 0 0 4px 0; }
h2 { color: #569cd6; font-size: 18px; margin: 28px 0 12px 0; border-bottom: 1px solid #3c3c3c; padding-bottom: 6px; }
h3 { color: #9cdcfe; font-size: 15px; margin: 16px 0 8px 0; }
.version { color: #858585; font-size: 13px; margin-bottom: 16px; }
.description { line-height: 1.6; margin-bottom: 20px; }
.description code { background: #2d2d2d; color: #ce9178; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.description pre { background: #1a1a1a; border: 1px solid #3c3c3c; border-radius: 4px; padding: 12px; overflow-x: auto; }
.description pre code { background: none; padding: 0; }
a { color: #3794ff; text-decoration: none; }
a:hover { color: #4daafc; text-decoration: underline; }
.card { background: #252526; border: 1px solid #3c3c3c; border-radius: 6px; padding: 16px; margin-bottom: 12px; }
.card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.badge-ws { background: #2b5797; color: #9cdcfe; }
.badge-receive { background: #264f36; color: #6a9955; }
.summary { color: #b0b0b0; font-size: 13px; margin-bottom: 8px; }
.channel-addr { font-family: monospace; color: #dcdcaa; font-size: 14px; }
.msg-name { font-family: monospace; color: #ce9178; font-size: 13px; }
.prop-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
.prop-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid #3c3c3c; color: #858585; font-weight: 500; }
.prop-table td { padding: 6px 8px; border-bottom: 1px solid #2d2d2d; }
.type-tag { color: #4ec9b0; font-family: monospace; font-size: 12px; }
dl.server-info { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 8px 0 0 0; font-size: 13px; }
dl.server-info dt { color: #858585; }
dl.server-info dd { margin: 0; }
.schema-block { background: #1a1a1a; border: 1px solid #3c3c3c; border-radius: 4px; padding: 12px; font-family: monospace; font-size: 12px; white-space: pre-wrap; overflow-x: auto; margin-top: 8px; }
#loading { text-align: center; padding: 60px; color: #858585; }
</style>
</head>
<body>
<div id="nav">
<label for="spec-select">API:</label>
<select id="spec-select"></select>
</div>
<div class="container" id="content"><div id="loading">Loading...</div></div>
<script>
var SPECS = ${specsJson};
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function md(s) { return typeof marked !== 'undefined' ? marked.parse(s) : '<p>' + esc(s) + '</p>'; }
function schemaToString(schema, indent) {
indent = indent || 0;
if (!schema) return 'any';
var pad = ' '.repeat(indent);
if (schema.anyOf) return schema.anyOf.map(function(s) { return schemaToString(s, indent); }).join(' | ');
if (schema.const !== undefined) return JSON.stringify(schema.const);
if (schema.type === 'object' && schema.properties) {
var lines = ['{'];
Object.keys(schema.properties).forEach(function(k) {
lines.push(pad + ' ' + k + ': ' + schemaToString(schema.properties[k], indent + 1));
});
lines.push(pad + '}');
return lines.join('\\n');
}
if (schema.type === 'array' && schema.items) return schemaToString(schema.items, indent) + '[]';
if (schema.type) return schema.type;
return 'any';
}
function renderDoc(doc) {
var html = '<h1>' + esc(doc.info.title) + '</h1>';
html += '<div class="version">v' + esc(doc.info.version) + ' — AsyncAPI ' + esc(doc.asyncapi) + '</div>';
if (doc.info.description) {
html += '<div class="description">' + md(doc.info.description) + '</div>';
}
if (doc.servers) {
html += '<h2>Servers</h2>';
Object.keys(doc.servers).forEach(function(sname) {
var srv = doc.servers[sname];
html += '<div class="card"><div class="card-header"><span class="badge badge-ws">' + esc(srv.protocol) + '</span><strong>' + esc(sname) + '</strong></div>';
html += '<dl class="server-info">';
html += '<div><dt>Host</dt><dd>' + esc(srv.host) + '</dd></div>';
if (srv.pathname) html += '<div><dt>Path</dt><dd>' + esc(srv.pathname) + '</dd></div>';
if (srv.description) html += '<div><dt>Description</dt><dd>' + esc(srv.description) + '</dd></div>';
html += '</dl></div>';
});
}
if (doc.channels) {
html += '<h2>Channels</h2>';
Object.keys(doc.channels).forEach(function(cname) {
var ch = doc.channels[cname];
html += '<div class="card">';
html += '<div class="card-header"><span class="channel-addr">' + esc(ch.address || cname) + '</span></div>';
if (ch.description) html += '<div class="summary">' + esc(ch.description) + '</div>';
if (ch.messages) {
html += '<h3>Messages</h3>';
Object.keys(ch.messages).forEach(function(mname) {
var msg = ch.messages[mname];
html += '<div style="margin-bottom:12px">';
html += '<div class="msg-name">' + esc(msg.name || mname) + '</div>';
if (msg.title) html += '<div><strong>' + esc(msg.title) + '</strong></div>';
if (msg.summary) html += '<div class="summary">' + esc(msg.summary) + '</div>';
if (msg.payload) {
html += '<div class="schema-block">' + esc(schemaToString(msg.payload)) + '</div>';
}
html += '</div>';
});
}
html += '</div>';
});
}
if (doc.operations) {
html += '<h2>Operations</h2>';
Object.keys(doc.operations).forEach(function(oname) {
var op = doc.operations[oname];
html += '<div class="card">';
html += '<div class="card-header"><span class="badge badge-receive">' + esc(op.action) + '</span><strong>' + esc(oname) + '</strong></div>';
if (op.summary) html += '<div class="summary">' + esc(op.summary) + '</div>';
if (op.description) html += '<div class="summary">' + esc(op.description) + '</div>';
html += '</div>';
});
}
return html;
}
var select = document.getElementById('spec-select');
SPECS.forEach(function(s, i) {
var opt = document.createElement('option');
opt.value = i;
opt.textContent = s.title;
select.appendChild(opt);
});
function loadSpec(idx) {
var spec = SPECS[idx];
document.getElementById('content').innerHTML = '<div id="loading">Loading...</div>';
fetch(spec.url).then(function(r) { return r.json(); }).then(function(doc) {
document.getElementById('content').innerHTML = renderDoc(doc);
}).catch(function(err) {
document.getElementById('content').innerHTML = '<p style="color:#f48771">Error: ' + esc(err.message) + '</p>';
});
}
select.addEventListener('change', function() { loadSpec(parseInt(this.value)); });
loadSpec(0);
<\/script>
</body>
</html>`;
}
//# sourceMappingURL=swagger.js.map