Integrations data (#770)

* feat: OSC settings

* feat: HTTP settings
This commit is contained in:
Carlos Valente
2024-02-11 21:25:23 +01:00
committed by GitHub
parent 5355e45b80
commit 53963a9ad7
52 changed files with 742 additions and 1620 deletions
+14 -14
View File
@@ -233,26 +233,26 @@ export const startOSCServer = async (overrideConfig?: { port: number }) => {
export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => {
checkStart(OntimeStartOrder.InitIO);
// if a config is not provided, we use the persisted one
const { osc, http } = config ?? DataProvider.getData();
if (!osc) {
return 'OSC Invalid configuration';
} else {
const { success, message } = oscIntegration.init(osc);
logger.info(LogOrigin.Tx, message);
if (success) {
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
if (!http) {
return 'HTTP Invalid configuration';
} else {
const { success, message } = httpIntegration.init(http);
logger.info(LogOrigin.Tx, message);
if (success) {
if (http) {
logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...');
try {
httpIntegration.init(http);
integrationService.register(httpIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`);
}
}
};
@@ -268,7 +268,7 @@ export const shutdown = async (exitCode = 0) => {
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 99 means it was the UI
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
}
@@ -59,11 +59,11 @@ export class DataProvider {
await this.persist();
}
static getOsc() {
static getOsc(): OSCSettings {
return data.osc;
}
static getHttp() {
static getHttp(): HttpSettings {
return data.http;
}
@@ -94,14 +94,16 @@ export class DataProvider {
await this.persist();
}
static async setOsc(newData: OSCSettings) {
static async setOsc(newData: OSCSettings): Promise<OSCSettings> {
data.osc = { ...newData };
await this.persist();
return data.osc;
}
static async setHttp(newData: HttpSettings) {
static async setHttp(newData: HttpSettings): Promise<HttpSettings> {
data.http = { ...newData };
await this.persist();
return data.http;
}
static getRundown() {
@@ -118,6 +120,7 @@ export class DataProvider {
data.settings = mergedData.settings;
data.viewSettings = mergedData.viewSettings;
data.osc = mergedData.osc;
data.http = mergedData.http;
data.aliases = mergedData.aliases;
data.userFields = mergedData.userFields;
data.rundown = mergedData.rundown;
@@ -6,7 +6,8 @@ import { DatabaseModel } from 'ontime-types';
* @param {object} newData
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
const { rundown, project, settings, viewSettings, aliases, userFields, osc, http } = newData || {};
return {
...existing,
rundown: rundown ?? existing.rundown,
@@ -18,21 +19,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
...existing.userFields,
...(userFields && Object.fromEntries(Object.entries(userFields).filter(([_, value]) => value !== null))),
},
osc: {
...existing.osc,
...osc,
subscriptions: {
...existing.osc?.subscriptions,
...(newData?.osc?.subscriptions || {}),
...(existing.osc?.subscriptions && newData?.osc?.subscriptions
? Object.keys(existing.osc.subscriptions).reduce((acc, key) => {
if (!(key in newData.osc.subscriptions)) {
acc[key] = existing.osc.subscriptions[key];
}
return acc;
}, {})
: {}),
},
},
osc: { ...existing.osc, ...osc },
http: { ...existing.http, ...http },
};
}
@@ -36,14 +36,11 @@ describe('safeMerge', () => {
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
},
} as DatabaseModel;
@@ -101,39 +98,32 @@ describe('safeMerge', () => {
const newData = {
osc: {
portIn: 7777,
subscriptions: {
onStart: [
{
id: 'unique',
message: 'new message',
enabled: true,
},
],
},
subscriptions: [
{
id: 'unique',
cycle: 'onStart',
message: 'new message',
enabled: true,
},
],
},
};
//@ts-expect-error -- testing partial merge
const mergedData = safeMerge(existing, newData);
expect(mergedData.osc).toEqual({
expect(mergedData.osc).toMatchObject({
portIn: 7777,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [
{
id: 'unique',
message: 'new message',
enabled: true,
},
],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
subscriptions: [
{
id: 'unique',
cycle: 'onStart',
message: 'new message',
enabled: true,
},
],
});
});
@@ -179,14 +169,7 @@ describe('safeMerge', () => {
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
subscriptions: [],
},
} as DatabaseModel;
+12 -48
View File
@@ -7,6 +7,7 @@ import type {
ProjectData,
ErrorResponse,
ProjectFileListResponse,
OSCSettings,
} from 'ontime-types';
import { ExcelImportOptions, deepmerge } from 'ontime-utils';
@@ -33,7 +34,6 @@ import { oscIntegration } from '../services/integration-service/OscIntegration.j
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
import { logger } from '../classes/Logger.js';
import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
import { integrationService } from '../services/integration-service/IntegrationService.js';
import { getProjectFiles } from '../utils/getFileListFromFolder.js';
import { configService } from '../services/ConfigService.js';
import { deleteFile } from '../utils/parserUtils.js';
@@ -41,6 +41,7 @@ import { validateProjectFiles } from './ontimeController.validate.js';
import { dbModel } from '../models/dataModel.js';
import { sheet } from '../utils/sheetsAuth.js';
import { removeFileExtension } from '../utils/removeFileExtension.js';
import type { OntimeError } from '../utils/backend.types.js';
import { ensureJsonExtension } from '../utils/ensureJsonExtension.js';
import { generateUniqueFileName } from '../utils/generateUniqueFilename.js';
@@ -318,47 +319,18 @@ export const getOSC = async (_req: Request, res: Response) => {
// Create controller for POST request to '/ontime/osc'
// Returns ACK message
export const postOSC = async (req: Request, res: Response) => {
export const postOSC = async (req: Request, res: Response<OSCSettings | OntimeError>) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
await DataProvider.setOsc(oscSettings);
integrationService.unregister(oscIntegration);
// TODO: this update could be more granular, checking that relevant data was changed
const { success, message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(oscIntegration);
}
res.send(oscSettings).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
};
export const postOscSubscriptions = async (req: Request, res: Response) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const subscriptions = req.body;
const oscSettings = DataProvider.getOsc();
oscSettings.subscriptions = subscriptions;
await DataProvider.setOsc(oscSettings);
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Tx, message);
res.send(oscSettings).status(200);
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
@@ -371,26 +343,18 @@ export const getHTTP = async (_req: Request, res: Response<HttpSettings>) => {
};
// Create controller for POST request to '/ontime/http'
export const postHTTP = async (req: Request, res: Response) => {
export const postHTTP = async (req: Request, res: Response<HttpSettings | OntimeError>) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const httpSettings = req.body;
await DataProvider.setHttp(httpSettings);
integrationService.unregister(httpIntegration);
// TODO: this update could be more granular, checking that relevant data was changed
const { success, message } = httpIntegration.init(httpSettings);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(httpIntegration);
}
res.send(httpSettings).status(200);
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
@@ -2,13 +2,8 @@ import { body, check, validationResult } from 'express-validator';
import { join } from 'path';
import { existsSync } from 'fs';
import { Request, Response, NextFunction } from 'express';
import {
validateHttpSubscriptionObject,
validateOscSubscriptionObject,
validateOscSubscriptionCycle,
} from '../utils/parserFunctions.js';
import { uploadsFolderPath } from '../setup.js';
import { sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/views
@@ -87,14 +82,16 @@ export const validateSettings = [
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('portIn').exists().isInt({ min: 1024, max: 65535 }),
body('portOut').exists().isInt({ min: 1024, max: 65535 }),
body('portIn').exists().isPort(),
body('portOut').exists().isPort(),
body('targetIP').exists().isIP(),
body('enabledIn').exists().isBoolean(),
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.isObject()
.custom((value) => validateOscSubscriptionObject(value)),
.exists()
.isArray()
.custom((value) => sanitiseOscSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
@@ -108,38 +105,9 @@ export const validateOSC = [
export const validateHTTP = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.isObject()
.custom((value) => validateHttpSubscriptionObject(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/osc-subscriptions
*/
export const validateOscSubscription = [
body('onLoad')
.exists()
.isArray()
.custom((value) => validateOscSubscriptionCycle(value)),
body('onStart')
.isArray()
.custom((value) => validateOscSubscriptionCycle(value)),
body('onPause')
.isArray()
.custom((value) => validateOscSubscriptionCycle(value)),
body('onStop')
.isArray()
.custom((value) => validateOscSubscriptionCycle(value)),
body('onUpdate')
.isArray()
.custom((value) => validateOscSubscriptionCycle(value)),
body('onFinish')
.isArray()
.custom((value) => validateOscSubscriptionCycle(value)),
.custom((value) => sanitiseHttpSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
+2 -16
View File
@@ -46,24 +46,10 @@ export const dbModel: DatabaseModel = {
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
subscriptions: [],
},
};
+1 -6
View File
@@ -14,7 +14,6 @@ import {
poll,
postAliases,
postOSC,
postOscSubscriptions,
postSettings,
postUserFields,
postViewSettings,
@@ -43,7 +42,6 @@ import {
validateUserFields,
viewValidator,
validateHTTP,
validateOscSubscription,
validateProjectDuplicate,
validateLoadProjectFile,
validateProjectRename,
@@ -104,9 +102,6 @@ router.get('/osc', getOSC);
// create route between controller and '/ontime/osc' endpoint
router.post('/osc', validateOSC, postOSC);
// create route between controller and '/ontime/osc-subscriptions' endpoint
router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions);
// create route between controller and '/ontime/http' endpoint
router.get('/http', getHTTP);
@@ -135,7 +130,7 @@ router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile);
router.post('/sheet/clientsecret', uploadFile, uploadClientSecret);
router.get('/sheet/clientsecret', uploadFile, getClientSecret);
// Google Sheet integration - Step 1
// Google Sheet integration - Step 2
router.get('/sheet/authentication/url', getAuthenticationUrl);
router.get('/sheet/authentication', getAuthentication);
@@ -1,23 +1,22 @@
import got from 'got';
import { HttpSettings, HttpSubscription, HttpSubscriptionOptions, LogOrigin } from 'ontime-types';
import { HttpSettings, HttpSubscription, LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { dbModel } from '../../models/dataModel.js';
import { logger } from '../../classes/Logger.js';
import { validateHttpSubscriptionObject } from '../../utils/parserFunctions.js';
type Action = TimerLifeCycleKey | string;
/**
* @description Class contains logic towards outgoing HTTP communications
* @class
*/
export class HttpIntegration implements IIntegration<HttpSubscriptionOptions> {
subscriptions: HttpSubscription;
export class HttpIntegration implements IIntegration<HttpSubscription> {
subscriptions: HttpSubscription[];
enabled: boolean;
constructor() {
this.subscriptions = dbModel.http.subscriptions;
this.subscriptions = [];
this.enabled = false;
}
/**
@@ -25,64 +24,40 @@ export class HttpIntegration implements IIntegration<HttpSubscriptionOptions> {
*/
init(config: HttpSettings) {
const { subscriptions, enabledOut } = config;
if (!enabledOut) {
return {
success: false,
message: 'HTTP output disabled',
};
}
this.initSubscriptions(subscriptions);
return {
success: true,
message: 'HTTP integration client ready',
};
this.enabled = enabledOut;
}
initSubscriptions(subscriptionOptions: HttpSubscription) {
if (validateHttpSubscriptionObject(subscriptionOptions)) {
this.subscriptions = { ...subscriptionOptions };
}
initSubscriptions(subscriptions: HttpSubscription[]) {
this.subscriptions = subscriptions;
}
dispatch(action: Action, state?: object) {
if (!action) {
return {
success: false,
message: 'HTTP called with no action',
};
dispatch(action: TimerLifeCycleKey, state?: object) {
// noop
if (!this.enabled || !action) {
return;
}
// check subscriptions for action
const eventSubscriptions = this.subscriptions?.[action] || [];
eventSubscriptions.forEach((sub) => {
const { enabled, message } = sub;
if (enabled && message) {
const parsedMessage = parseTemplateNested(message, state || {});
try {
const parsedUrl = new URL(parsedMessage);
this.emit(parsedUrl);
} catch (err) {
logger.error(LogOrigin.Tx, `HTTP Integration: ${err}`);
return {
success: false,
message: `${err}`,
};
}
for (let i = 0; i < this.subscriptions.length; i++) {
const { cycle, message, enabled } = this.subscriptions[i];
if (cycle !== action || !enabled || !message) {
continue;
}
});
const parsedMessage = parseTemplateNested(message, state || {});
try {
const parsedUrl = new URL(parsedMessage);
this.emit(parsedUrl);
} catch (error) {
logger.error(LogOrigin.Tx, `HTTP Integration: ${error}`);
}
}
}
async emit(path: URL) {
try {
await got.get(path, {
retry: { limit: 0 },
});
} catch (err) {
logger.error(LogOrigin.Tx, `HTTP integration: ${err}`);
}
await got.get(path, {
retry: { limit: 0 },
});
}
shutdown() {}
@@ -1,24 +1,11 @@
import { TimerLifeCycle, Subscription } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
export default interface IIntegration<T> {
subscriptions: Subscription<T>;
init: (config: unknown) => OperationReturn;
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
subscriptions: T[];
init: (config: unknown) => void;
dispatch: (action: TimerLifeCycleKey, state?: object) => void;
emit: (...args: unknown[]) => unknown;
shutdown: () => void;
}
// either went well, or explain what failed
type OperationReturn = ReturnOnSuccess | ReturnOnError;
type ReturnOnSuccess = {
success: true;
message?: string;
};
type ReturnOnError = {
success: false;
message: string;
};
@@ -1,5 +1,8 @@
import { LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { eventStore } from '../../stores/EventStore.js';
import { logger } from '../../classes/Logger.js';
class IntegrationService {
private integrations: IIntegration<unknown>[];
@@ -24,7 +27,7 @@ class IntegrationService {
}
shutdown() {
console.log('Shutdown integrations');
logger.info(LogOrigin.Tx, `Shutdown Integrations`);
this.integrations.forEach((integration) => {
integration.shutdown();
});
@@ -1,25 +1,28 @@
import { ArgumentType, Client, Message } from 'node-osc';
import { OSCSettings, OscSubscription, OscSubscriptionOptions } from 'ontime-types';
import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { isObject } from '../../utils/varUtils.js';
import { dbModel } from '../../models/dataModel.js';
import { validateOscSubscriptionObject } from '../../utils/parserFunctions.js';
type Action = TimerLifeCycleKey | string;
import { logger } from '../../classes/Logger.js';
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OscIntegration implements IIntegration<OscSubscriptionOptions> {
export class OscIntegration implements IIntegration<OscSubscription> {
protected oscClient: null | Client;
subscriptions: OscSubscription;
subscriptions: OscSubscription[];
targetIP: MaybeString;
portOut: MaybeNumber;
enabledOut: boolean;
constructor() {
this.oscClient = null;
this.subscriptions = dbModel.osc.subscriptions;
this.subscriptions = [];
this.targetIP = null;
this.portOut = null;
this.enabledOut = false;
}
/**
@@ -27,75 +30,58 @@ export class OscIntegration implements IIntegration<OscSubscriptionOptions> {
*/
init(config: OSCSettings) {
const { targetIP, portOut, subscriptions, enabledOut } = config;
if (!enabledOut) {
this.oscClient?.close();
return {
success: false,
message: 'OSC output disabled',
};
}
this.initSubscriptions(subscriptions);
// runtime validation
const validateType = typeof targetIP !== 'string' || typeof portOut !== 'number';
const validateNull = !targetIP || !portOut;
if (validateType || validateNull) {
return {
success: false,
message: 'Config options incorrect',
};
if (!enabledOut && this.enabledOut) {
this.targetIP = targetIP;
this.portOut = portOut;
this.enabledOut = enabledOut;
this.shutdown();
return;
}
if (this.oscClient && targetIP === this.targetIP && portOut === this.portOut) {
// nothing changed that would mean we need a new client
return;
}
this.targetIP = targetIP;
this.portOut = portOut;
this.enabledOut = enabledOut;
try {
// this allows re-calling the init function during runtime
this.oscClient?.close();
logger.info(LogOrigin.Tx, 'Initialising OSC integration...');
this.oscClient = new Client(targetIP, portOut);
return {
success: true,
message: `OSC integration client connected to ${targetIP}:${portOut}`,
};
} catch (error) {
this.oscClient = null;
return {
success: false,
message: `Failed initialising OSC Client: ${error}`,
};
throw new Error(`Failed initialising OSC client: ${error}`);
}
return `OSC integration client connected to ${targetIP}:${portOut}`;
}
initSubscriptions(subscriptionOptions: OscSubscription) {
if (validateOscSubscriptionObject(subscriptionOptions)) {
this.subscriptions = { ...subscriptionOptions };
}
initSubscriptions(subscriptions: OscSubscription[]) {
this.subscriptions = subscriptions;
}
dispatch(action: Action, state?: object) {
if (!this.oscClient) {
return {
success: false,
message: 'Client not initialised',
};
dispatch(action: TimerLifeCycleKey, state?: object) {
// noop
if (!this.oscClient || !action) {
return;
}
if (!action) {
return {
success: false,
message: 'OSC called with no action',
};
}
// check subscriptions for action
const eventSubscriptions = this.subscriptions?.[action] || [];
eventSubscriptions.forEach((sub) => {
const { enabled, message } = sub;
if (enabled && message) {
const parsedMessage = parseTemplateNested(message, state || {});
this.emit(parsedMessage);
for (let i = 0; i < this.subscriptions.length; i++) {
const { cycle, message, enabled } = this.subscriptions[i];
if (cycle !== action || !enabled || !message) {
continue;
}
});
const parsedMessage = parseTemplateNested(message, state || {});
try {
this.emit(parsedMessage);
} catch (error) {
logger.error(LogOrigin.Tx, `OSC Integration: ${error}`);
}
}
}
emit(path: string, payload?: ArgumentType) {
@@ -105,33 +91,18 @@ export class OscIntegration implements IIntegration<OscSubscriptionOptions> {
const message = new Message(path);
if (payload) {
try {
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
} catch (error) {
console.log('OSC ERROR', error, payload);
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
}
this.oscClient.send(message, (error) => {
if (error) {
return {
success: false,
message: `Error sending message: ${JSON.stringify(error)}`,
};
}
return {
success: true,
message: 'OSC Message sent',
};
});
this.oscClient.send(message);
}
shutdown() {
console.log('Shutting down OSC integration');
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
if (this.oscClient) {
this.oscClient?.close();
this.oscClient = null;
@@ -1,162 +1,72 @@
import { HttpSubscription, OscSubscription } from 'ontime-types';
import {
validateOscSubscriptionObject,
validateOscSubscriptionCycle,
validateHttpSubscriptionCycle,
validateHttpSubscriptionObject,
} from '../parserFunctions.js';
import { sanitiseOscSubscriptions, sanitiseHttpSubscriptions } from '../parserFunctions.js';
describe('validateOscSubscriptionCycle()', () => {
it('should return false when given an OscSubscription with an invalid property value', () => {
const invalidEntry = [{ message: 'test', enabled: 'not a boolean' }];
describe('sanitiseOscSubscriptions()', () => {
it('returns an empty array if not an array', () => {
expect(sanitiseOscSubscriptions(undefined)).toEqual([]);
// @ts-expect-error -- data is external, we check bad types
expect(sanitiseOscSubscriptions({})).toEqual([]);
expect(sanitiseOscSubscriptions(null)).toEqual([]);
});
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionCycle(invalidEntry);
expect(result).toBe(false);
it('returns an array of valid entries', () => {
const oscSubscriptions: OscSubscription[] = [
{ id: '1', cycle: 'onLoad', message: 'test', enabled: true },
{ id: '2', cycle: 'onStart', message: 'test', enabled: false },
{ id: '3', cycle: 'onPause', message: 'test', enabled: true },
{ id: '4', cycle: 'onStop', message: 'test', enabled: false },
{ id: '5', cycle: 'onUpdate', message: 'test', enabled: true },
{ id: '6', cycle: 'onFinish', message: 'test', enabled: false },
];
const sanitationResult = sanitiseOscSubscriptions(oscSubscriptions);
expect(sanitationResult).toStrictEqual(oscSubscriptions);
});
it('filters invalid entries', () => {
const oscSubscriptions = [
{ cycle: 'onLoad', message: 'test', enabled: true },
{ id: '2', cycle: 'unknown', message: 'test', enabled: false },
{ id: '3', message: 'test', enabled: true },
{ id: '4', cycle: 'onStop', enabled: false },
{ id: '5', cycle: 'onUpdate', message: 'test' },
{ id: '6', cycle: 'onFinish', message: 'test', enabled: 'true' },
];
const sanitationResult = sanitiseOscSubscriptions(oscSubscriptions as OscSubscription[]);
expect(sanitationResult.length).toBe(0);
});
});
describe('validateOscSubscriptionObject()', () => {
it('should return true when given a valid OscSubscription', () => {
const validSubscription: OscSubscription = {
onLoad: [{ message: 'test', enabled: true }],
onStart: [{ message: 'test', enabled: false }],
onPause: [{ message: 'test', enabled: true }],
onStop: [{ message: 'test', enabled: false }],
onUpdate: [{ message: 'test', enabled: true }],
onFinish: [{ message: 'test', enabled: false }],
};
const result = validateOscSubscriptionObject(validSubscription);
expect(result).toBe(true);
describe('sanitiseHttpSubscriptions()', () => {
it('returns an empty array if not an array', () => {
expect(sanitiseHttpSubscriptions(undefined)).toEqual([]);
// @ts-expect-error -- data is external, we check bad types
expect(sanitiseHttpSubscriptions({})).toEqual([]);
expect(sanitiseHttpSubscriptions(null)).toEqual([]);
});
it('should return false when given undefined', () => {
const result = validateOscSubscriptionObject(undefined);
expect(result).toBe(false);
it('returns an array of valid entries', () => {
const oscSubscriptions: OscSubscription[] = [
{ id: '1', cycle: 'onLoad', message: 'http://test', enabled: true },
{ id: '2', cycle: 'onStart', message: 'http://test', enabled: false },
{ id: '3', cycle: 'onPause', message: 'http://test', enabled: true },
{ id: '4', cycle: 'onStop', message: 'http://test', enabled: false },
{ id: '5', cycle: 'onUpdate', message: 'http://test', enabled: true },
{ id: '6', cycle: 'onFinish', message: 'http://test', enabled: false },
];
const sanitationResult = sanitiseHttpSubscriptions(oscSubscriptions);
expect(sanitationResult).toStrictEqual(oscSubscriptions);
});
it('should return false when given null', () => {
const result = validateOscSubscriptionObject(null);
expect(result).toBe(false);
});
it('should return false when given an empty object', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject({});
expect(result).toBe(false);
});
it('should return false when given an empty array', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject([]);
expect(result).toBe(false);
});
it('should return false when given an object that is not an OscSubscription', () => {
const invalidObject = { foo: 'bar' };
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject(invalidObject);
expect(result).toBe(false);
});
it('should return false when given an OscSubscription with a missing property', () => {
const invalidSubscription = {
onLoad: [{ message: 'test', enabled: true }],
onStart: [{ message: 'test', enabled: false }],
onPause: [{ message: 'test', enabled: true }],
// Missing onStop
onUpdate: [{ message: 'test', enabled: true }],
onFinish: [{ message: 'test', enabled: false }],
};
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject(invalidSubscription);
expect(result).toBe(false);
});
});
describe('validateHttpSubscriptionCycle()', () => {
it('should return false when given an HttpSubscription with an invalid property value', () => {
const invalidBoolean = [{ message: 'http://', enabled: 'not a boolean' }];
const invalidHttp = [{ message: 'test', enabled: true }];
const noFtp = [{ message: 'ftp://test', enabled: true }];
const noEmpty = [{ message: '', enabled: true }];
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
expect(validateHttpSubscriptionCycle(invalidBoolean)).toBe(false);
expect(validateHttpSubscriptionCycle(invalidHttp)).toBe(false);
expect(validateHttpSubscriptionCycle(noFtp)).toBe(false);
expect(validateHttpSubscriptionCycle(noEmpty)).toBe(false);
});
it('should return true when given an HttpSubscription matches definition', () => {
const validHttp = [{ message: 'http://', enabled: true }];
const invalidHttps = [{ message: 'https://', enabled: true }];
expect(validateHttpSubscriptionCycle(validHttp)).toBe(true);
expect(validateHttpSubscriptionCycle(invalidHttps)).toBe(false);
});
});
describe('validateHttpSubscriptionObject()', () => {
it('should return true when given a valid HttpSubscription', () => {
const validSubscription: HttpSubscription = {
onLoad: [{ message: 'http://', enabled: true }],
onStart: [{ message: 'http://', enabled: false }],
onPause: [{ message: 'http://', enabled: true }],
onStop: [{ message: 'http://', enabled: false }],
onUpdate: [{ message: 'http://', enabled: true }],
onFinish: [{ message: 'http://', enabled: false }],
};
const result = validateHttpSubscriptionObject(validSubscription);
expect(result).toBe(true);
});
it('should return false when given undefined', () => {
const result = validateHttpSubscriptionObject(undefined);
expect(result).toBe(false);
});
it('should return false when given null', () => {
const result = validateHttpSubscriptionObject(null);
expect(result).toBe(false);
});
it('should return false when given an empty object', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject({});
expect(result).toBe(false);
});
it('should return false when given an empty array', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateHttpSubscriptionObject([]);
expect(result).toBe(false);
});
it('should return false when given an object that is not an HttpSubscription', () => {
const invalidObject = { foo: 'bar' };
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateHttpSubscriptionObject(invalidObject);
expect(result).toBe(false);
});
it('should return false when given an HttpSubscription with a missing property', () => {
const invalidSubscription = {
onLoad: [{ message: 'http://', enabled: true }],
onStart: [{ message: 'http://', enabled: false }],
onPause: [{ message: 'http://', enabled: true }],
// Missing onStop
onUpdate: [{ message: 'http://', enabled: true }],
onFinish: [{ message: 'http://', enabled: false }],
};
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateHttpSubscriptionObject(invalidSubscription);
expect(result).toBe(false);
it('filters invalid entries', () => {
const oscSubscriptions = [
{ cycle: 'onLoad', message: 'http://test', enabled: true },
{ id: '2', cycle: 'unknown', message: 'http://test', enabled: false },
{ id: '3', message: 'http://test', enabled: true },
{ id: '4', cycle: 'onStop', enabled: false },
{ id: '5', cycle: 'onUpdate', message: 'http://test' },
{ id: '6', cycle: 'onFinish', message: 'ftp://test', enabled: 'true' },
];
const sanitationResult = sanitiseHttpSubscriptions(oscSubscriptions as HttpSubscription[]);
expect(sanitationResult.length).toBe(0);
});
});
+1
View File
@@ -0,0 +1 @@
export type OntimeError = { message: string };
+26 -76
View File
@@ -6,17 +6,15 @@ import {
OSCSettings,
ProjectData,
Settings,
TimerLifeCycle,
UserFields,
ViewSettings,
OscSubscription,
HttpSubscription,
OscSubscriptionOptions,
HttpSubscriptionOptions,
DatabaseModel,
isOntimeEvent,
isOntimeDelay,
isOntimeBlock,
isOntimeCycle,
HttpSubscription,
} from 'ontime-types';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
@@ -156,40 +154,18 @@ export const parseViewSettings = (data): ViewSettings => {
};
/**
* Parses and validates OSC subscription cycle options
* @param data
* Sanitises an OSC Subscriptions array
*/
export const validateOscSubscriptionCycle = (data: OscSubscriptionOptions[]): boolean => {
for (const subscriptionOption of data) {
if (typeof subscriptionOption.message !== 'string' || typeof subscriptionOption.enabled !== 'boolean') {
return false;
}
}
return true;
};
/**
* Parses and validates OSC subscription object
* @param data
*/
export const validateOscSubscriptionObject = (data: OscSubscription): boolean => {
if (!data) {
return false;
export function sanitiseOscSubscriptions(subscriptions?: OscSubscription[]): OscSubscription[] {
if (!Array.isArray(subscriptions)) {
return [];
}
const timerKeys = Object.keys(TimerLifeCycle);
for (const key of timerKeys) {
// must contains all keys and be an array
if (!(key in data) || !Array.isArray(data[key])) {
return false;
}
const isValid = validateOscSubscriptionCycle(data[key]);
if (!isValid) {
return false;
}
}
return true;
};
return subscriptions.filter(
({ id, cycle, message, enabled }) =>
typeof id === 'string' && isOntimeCycle(cycle) && typeof message === 'string' && typeof enabled === 'boolean',
);
}
/**
* Parse osc portion of an entry
@@ -198,58 +174,35 @@ export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
if ('osc' in data) {
console.log('Found OSC definition, importing...');
// TODO: this can be improved by only merging known keys
const loadedConfig = data.osc || {};
const validatedSubscriptions = validateOscSubscriptionObject(loadedConfig.subscriptions)
? loadedConfig.subscriptions
: dbModel.osc.subscriptions;
return {
portIn: loadedConfig.portIn ?? dbModel.osc.portIn,
portOut: loadedConfig.portOut ?? dbModel.osc.portOut,
targetIP: loadedConfig.targetIP ?? dbModel.osc.targetIP,
enabledIn: loadedConfig.enabledIn ?? dbModel.osc.enabledIn,
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
subscriptions: validatedSubscriptions,
subscriptions: sanitiseOscSubscriptions(loadedConfig.subscriptions),
};
}
};
/**
* Parses and validates HTTP subscription cycle options
* @param data
* Sanitises an HTTP Subscriptions array
*/
export const validateHttpSubscriptionCycle = (data: HttpSubscriptionOptions[]): boolean => {
for (const subscriptionOption of data) {
const isHttp = subscriptionOption.message?.startsWith('http://');
if (typeof subscriptionOption.message !== 'string' || !isHttp || typeof subscriptionOption.enabled !== 'boolean') {
return false;
}
export function sanitiseHttpSubscriptions(subscriptions?: HttpSubscription[]): HttpSubscription[] {
if (!Array.isArray(subscriptions)) {
return [];
}
return true;
};
/**
* Parses and validates HTTP subscription object
* @param data
*/
export const validateHttpSubscriptionObject = (data: HttpSubscription): boolean => {
if (!data) {
return false;
}
const timerKeys = Object.keys(TimerLifeCycle);
// must contains all keys and be an array
for (const key of timerKeys) {
if (!(key in data) || !Array.isArray(data[key])) {
return false;
}
const isValid = validateHttpSubscriptionCycle(data[key]);
if (!isValid) {
return false;
}
}
return true;
};
return subscriptions.filter(
({ id, cycle, message, enabled }) =>
typeof id === 'string' &&
isOntimeCycle(cycle) &&
typeof message === 'string' &&
message.startsWith('http://') &&
typeof enabled === 'boolean',
);
}
/**
* Parse Http portion of an entry
@@ -263,13 +216,10 @@ export const parseHttp = (data: { http?: Partial<HttpSettings> }): HttpSettings
// TODO: this can be improved by only merging known keys
const loadedConfig = data?.http || {};
const validatedSubscriptions = validateHttpSubscriptionObject(loadedConfig.subscriptions)
? loadedConfig.subscriptions
: dbModel.http.subscriptions;
return {
enabledOut: loadedConfig.enabledOut ?? dbModel.http.enabledOut,
subscriptions: validatedSubscriptions,
subscriptions: sanitiseHttpSubscriptions(loadedConfig.subscriptions),
};
}
};