UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

352 lines 14.4 kB
"use strict"; 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