signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
352 lines • 14.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseTimeRangeParams = exports.splitPathExpression = exports.parseResolution = exports.HistoryApiHttpRegistry = void 0;
const history_1 = require("@signalk/server-api/history");
const polyfill_1 = require("@js-temporal/polyfill");
const debug_1 = require("../../debug");
const __1 = require("../");
const debug = (0, debug_1.createDebug)('signalk-server:api:history');
const HISTORY_API_PATH = `/signalk/v2/api/history`;
class HistoryApiHttpRegistry {
app;
historyProviders = new Map();
defaultProviderId;
proxy;
constructor(app) {
this.app = app;
this.proxy = {
getValues: (query) => {
return this.defaultProvider().getValues(query);
},
getContexts: (query) => {
return this.defaultProvider().getContexts(query);
},
getPaths: (query) => {
return this.defaultProvider().getPaths(query);
}
};
app.getHistoryApi = (providerId) => {
if (providerId !== undefined) {
const provider = this.historyProviders.get(providerId);
return provider
? Promise.resolve(provider)
: Promise.reject(new Error(`History api provider '${providerId}' not found`));
}
return this.defaultProviderId &&
this.historyProviders.has(this.defaultProviderId)
? Promise.resolve(this.proxy)
: Promise.reject(new Error('No history api provider configured'));
};
}
registerHistoryApiProvider(pluginId, provider) {
if (!(0, history_1.isHistoryProvider)(provider)) {
throw new Error('Invalid history api provider');
}
if (!this.historyProviders.has(pluginId)) {
this.historyProviders.set(pluginId, provider);
}
if (this.historyProviders.size === 1) {
this.defaultProviderId = pluginId;
}
debug(`Registered history api provider ${pluginId},`, `total=${this.historyProviders.size},`, `default=${this.defaultProviderId}`);
}
unregisterHistoryApiProvider(pluginId) {
if (!pluginId || !this.historyProviders.has(pluginId)) {
return;
}
this.historyProviders.delete(pluginId);
if (pluginId === this.defaultProviderId) {
this.defaultProviderId = this.historyProviders.keys().next().value;
}
debug(`Unregistered history api provider ${pluginId},`, `total=${this.historyProviders.size},`, `default=${this.defaultProviderId}`);
}
start() {
// return list of history providers
this.app.get(`${HISTORY_API_PATH}/_providers`, async (req, res) => {
debug(`**route = ${req.method} ${req.path}`);
try {
const r = {};
this.historyProviders.forEach((_v, k) => {
r[k] = {
isDefault: k === this.defaultProviderId
};
});
res.status(200).json(r);
}
catch (err) {
res.status(400).json({
statusCode: 400,
state: 'FAILED',
message: err instanceof Error ? err.message : 'Unknown error'
});
}
});
// return default history provider identifier
this.app.get(`${HISTORY_API_PATH}/_providers/_default`, async (req, res) => {
debug(`**route = ${req.method} ${req.path}`);
try {
res.status(200).json({
id: this.defaultProviderId
});
}
catch (err) {
res.status(400).json({
statusCode: 400,
state: 'FAILED',
message: err instanceof Error ? err.message : 'Unknown error'
});
}
});
// change default history provider
this.app.post(`${HISTORY_API_PATH}/_providers/_default/:id`, async (req, res) => {
debug(`**route = ${req.method} ${req.path}`);
try {
if (!this.app.securityStrategy.shouldAllowPut(req, 'vessels.self', null, 'history')) {
res.status(403).json(__1.Responses.unauthorised);
return;
}
if (!req.params.id) {
throw new Error('Provider id not supplied!');
}
if (this.historyProviders.has(req.params.id)) {
this.defaultProviderId = req.params.id;
res.status(200).json({
statusCode: 200,
state: 'COMPLETED',
message: `Default provider set to ${req.params.id}.`
});
}
else {
throw new Error(`Provider ${req.params.id} not found!`);
}
}
catch (err) {
res.status(400).json({
statusCode: 400,
state: 'FAILED',
message: err instanceof Error ? err.message : 'Unknown error'
});
}
});
this.app.get(`${HISTORY_API_PATH}/values`, (req, res) => respondWith(() => this.useProvider(req), (provider) => {
return provider.getValues(parseValuesQuery(req.query));
}, res));
this.app.get(`${HISTORY_API_PATH}/contexts`, (req, res) => respondWith(() => this.useProvider(req), (provider) => {
const { timeRangeParams, errors } = (0, exports.parseTimeRangeParams)(req.query);
if (errors.length > 0) {
throw new Error(`Validation errors: ${errors.join(', ')}`);
}
debug.enabled && debug(JSON.stringify(timeRangeParams, null, 2));
return provider.getContexts(timeRangeParams);
}, res));
this.app.get(`${HISTORY_API_PATH}/paths`, (req, res) => respondWith(() => this.useProvider(req), (provider) => {
const { timeRangeParams, errors } = (0, exports.parseTimeRangeParams)(req.query);
if (errors.length > 0) {
throw new Error(`Validation errors: ${errors.join(', ')}`);
}
debug.enabled && debug(JSON.stringify(timeRangeParams, null, 2));
return provider.getPaths(timeRangeParams);
}, res));
}
defaultProvider() {
if (this.defaultProviderId &&
this.historyProviders.has(this.defaultProviderId)) {
return this.historyProviders.get(this.defaultProviderId);
}
throw new Error('No history api provider configured');
}
useProvider(req) {
if (req.query.provider) {
const provider = this.historyProviders.get(req.query.provider);
if (!provider) {
throw new Error(`Requested provider not found! (${req.query.provider})`);
}
return provider;
}
return this.defaultProviderId
? this.historyProviders.get(this.defaultProviderId)
: undefined;
}
}
exports.HistoryApiHttpRegistry = HistoryApiHttpRegistry;
async function respondWith(getProvider, handler, res) {
try {
const provider = getProvider();
if (!provider) {
return res
.status(501)
.json({ error: 'No history api provider configured' });
}
res.json(await handler(provider));
}
catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : 'Invalid request'
});
}
}
const parseValuesQuery = (query) => {
const { timeRangeParams, errors } = (0, exports.parseTimeRangeParams)(query);
const context = query.context;
let resolution;
try {
resolution = (0, exports.parseResolution)(query.resolution);
}
catch (error) {
errors.push(error instanceof Error ? error.message : 'Invalid resolution');
}
const paths = query.paths;
if (!paths) {
errors.push('paths parameter is required and must be a string');
}
if (errors.length > 0) {
throw new Error(`Validation errors: ${errors.join(', ')}`);
}
const pathExpressions = (query.paths || '')
.replace(/[^0-9a-z.,_:|]/gi, '')
.split(',');
const pathSpecs = pathExpressions.map(exports.splitPathExpression);
const parsed = {
...timeRangeParams,
context,
resolution,
pathSpecs
};
debug.enabled && debug(JSON.stringify(parsed, null, 2));
return parsed;
};
// Maps the single-letter unit suffix in a resolution time expression to seconds.
const RESOLUTION_UNIT_SECONDS = {
s: 1,
m: 60,
h: 3_600,
d: 86_400
};
// Matches resolution time expressions per the History API spec, e.g.
// '1s' -> 1, '15m' -> 900, '2h' -> 7200, '1d' -> 86400.
const RESOLUTION_TIME_EXPRESSION = /^(\d+)([smhd])$/;
// Parses the `resolution` query parameter into seconds.
// Accepts a number (already in seconds), a numeric string, or a time
// expression of the form `<integer><unit>` where unit is s|m|h|d.
// Returns undefined when the parameter is absent.
// Exported for unit testing.
const parseResolution = (value) => {
if (value === undefined || value === null || value === '')
return undefined;
if (typeof value === 'number')
return value;
if (typeof value !== 'string')
return undefined;
const trimmed = value.trim();
const match = RESOLUTION_TIME_EXPRESSION.exec(trimmed);
if (match) {
return Number(match[1]) * RESOLUTION_UNIT_SECONDS[match[2]];
}
const asNumber = Number(trimmed);
if (!Number.isNaN(asNumber))
return asNumber;
throw new Error(`resolution parameter must be a number of seconds or a time expression like '1s', '1m', '1h', '1d'`);
};
exports.parseResolution = parseResolution;
// Parses a path expression into a PathSpec.
// Input examples and what they parse into:
// 'navigation.speedOverGround' -> { path, aggregate: 'average', parameter: [] }
// 'navigation.speedOverGround:max' -> { path, aggregate: 'max', parameter: [] }
// 'navigation.speedOverGround:sma:5' -> { path, aggregate: 'sma', parameter: ['5'] }
// 'navigation.speedOverGround|n2k-on-ve.can0.115' -> { path, aggregate: 'average', parameter: [], sourceRef }
// 'navigation.speedOverGround:max|n2k-on-ve.can0.115' -> { path, aggregate: 'max', parameter: [], sourceRef }
// 'navigation.position' -> { path, aggregate: 'first', parameter: [] }
// 'navigation.position:last' -> { path, aggregate: 'last', parameter: [] }
//
// The `|` separator is used to specify a sourceRef after the path and aggregate.
//
// `navigation.position` is object-valued (lat/lon), so numeric aggregates
// like `average` are not meaningful. When the caller does not specify an
// aggregate, we default to `first` instead of the usual `average`. An
// explicit aggregate is always honored so callers can still ask for
// `last` or `middle_index` when that matches their intent.
const splitPathExpression = (pathExpression) => {
const pipeIdx = pathExpression.indexOf('|');
let sourceRef;
let expr;
if (pipeIdx >= 0) {
sourceRef = pathExpression.substring(pipeIdx + 1);
expr = pathExpression.substring(0, pipeIdx);
}
else {
expr = pathExpression;
}
const parts = expr.split(':');
const aggregateMethod = (parts[1] ||
(parts[0] === 'navigation.position'
? 'first'
: 'average'));
const parameters = parts.slice(2).filter((p) => p.length > 0);
const spec = {
path: parts[0],
aggregate: aggregateMethod,
parameter: parameters
};
if (sourceRef) {
spec.sourceRef = sourceRef;
}
return spec;
};
exports.splitPathExpression = splitPathExpression;
// Exported for unit testing.
const parseTimeRangeParams = (query) => {
const errors = [];
const fromStr = query.from;
let from;
if (fromStr) {
try {
from = polyfill_1.Temporal.Instant.from(fromStr);
}
catch (error) {
errors.push(`from parameter must be a valid ISO 8601 timestamp: ${error instanceof Error ? error.message : 'Invalid format'}`);
}
}
const durationStr = query.duration;
let duration;
if (durationStr) {
try {
duration = polyfill_1.Temporal.Duration.from(durationStr);
}
catch {
// Strict non-negative decimal integer only: rule out negatives, hex
// (0x10), exponential (9e2), surrounding whitespace, and fractional
// forms that Number() would otherwise silently accept.
if (/^\d+$/.test(durationStr)) {
duration = polyfill_1.Temporal.Duration.from({ seconds: Number(durationStr) });
}
else {
errors.push(`duration parameter must be an ISO 8601 duration string (e.g. 'PT15M') or an integer number of seconds`);
}
}
}
if (!from && !duration) {
errors.push('Either from or duration parameter is required at minimum');
}
const toStr = query.to;
let to;
if (toStr) {
try {
to = polyfill_1.Temporal.Instant.from(toStr);
}
catch (error) {
errors.push(`to parameter must be a valid ISO 8601 timestamp: ${error instanceof Error ? error.message : 'Invalid format'}`);
}
}
if (from && to && duration) {
errors.push('Cannot specify all of from, to, and duration together; choose either from+to or from+duration or to+duration');
}
if (from && to && polyfill_1.Temporal.Instant.compare(from, to) >= 0) {
errors.push('from parameter must be before to parameter');
}
if (errors.length > 0) {
throw new Error(`Validation errors: ${errors.join(', ')}`);
}
return { timeRangeParams: { from, to, duration }, errors };
};
exports.parseTimeRangeParams = parseTimeRangeParams;
//# sourceMappingURL=index.js.map