refactor: organise API around resources (#798)

This commit is contained in:
Carlos Valente
2024-03-02 23:08:08 +01:00
committed by GitHub
parent d87ba12b2e
commit 088927bbbb
104 changed files with 1741 additions and 1718 deletions
@@ -0,0 +1,206 @@
import { DeepPartial, MessageState, SimpleDirection, SimplePlayback } from 'ontime-types';
// skipcq: JS-C1003 - we like the API
import * as assert from '../utils/assert.js';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
import { messageService } from '../services/message-service/MessageService.js';
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js';
import { extraTimerService } from '../services/extra-timer-service/ExtraTimerService.js';
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
import { parse, updateEvent } from './integration.utils.js';
export type ChangeOptions = {
eventId: string;
property: string;
value: unknown;
};
export function dispatchFromAdapter(
type: string,
args: {
payload: unknown;
},
_source?: 'osc' | 'ws' | 'http',
) {
const payload = args.payload;
const action = type.toLowerCase();
const handler = actionHandlers[action];
if (handler) {
return handler(payload);
} else {
throw new Error(`Unhandled message ${type}`);
}
}
type ActionHandler = (payload: unknown) => { payload: unknown };
const actionHandlers: Record<string, ActionHandler> = {
/* General */
version: () => ({ payload: ONTIME_VERSION }),
poll: () => ({
payload: eventStore.poll(),
}),
change: (payload) => {
// WS: {type: 'change', payload: { eventId, property, value } }
const { eventId, property, value } = payload as ChangeOptions;
const { parsedPayload, parsedProperty } = parse(property, value);
const updatedEvent = updateEvent(eventId, parsedProperty, parsedPayload);
return { payload: updatedEvent };
},
/* Message Service */
message: (payload) => {
assert.isObject(payload);
const patch: DeepPartial<MessageState> = {
timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
public: 'public' in payload ? validateMessage(payload.public) : undefined,
lower: 'lower' in payload ? validateMessage(payload.lower) : undefined,
external: 'external' in payload ? validateMessage(payload.external) : undefined,
};
const newMessage = messageService.patch(patch);
return { payload: newMessage };
},
/* Playback */
start: (payload) => {
if (payload && typeof payload === 'object') {
if ('next' in payload) {
runtimeService.startNext();
return { payload: 'start' };
}
if ('index' in payload) {
const eventIndex = numberOrError(payload.index);
if (eventIndex <= 0) {
throw new Error(`Event index out of range ${eventIndex}`);
}
// Indexes in frontend are 1 based
const success = runtimeService.startByIndex(eventIndex - 1);
if (!success) {
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
}
return { payload: 'success' };
}
if ('id' in payload) {
assert.isString(payload);
runtimeService.startById(payload);
return { payload: 'success' };
}
if ('cue' in payload) {
assert.isString(payload);
runtimeService.startByCue(payload);
return { payload: 'success' };
}
}
runtimeService.start();
return { payload: 'start' };
},
pause: () => {
runtimeService.pause();
return { payload: 'success' };
},
stop: () => {
runtimeService.stop();
return { payload: 'success' };
},
reload: () => {
runtimeService.reload();
return { payload: 'success' };
},
roll: () => {
runtimeService.roll();
return { payload: 'success' };
},
load: (payload) => {
if (payload && typeof payload === 'object') {
if ('index' in payload) {
const eventIndex = numberOrError(payload.index);
if (eventIndex <= 0) {
throw new Error(`Event index out of range ${eventIndex}`);
}
// Indexes in frontend are 1 based
runtimeService.loadByIndex(eventIndex - 1);
return { payload: 'success' };
}
if ('id' in payload) {
assert.isDefined(payload.id);
runtimeService.loadById(payload.id.toString().toLowerCase());
return { payload: 'success' };
}
if ('cue' in payload) {
assert.isString(payload.cue);
runtimeService.loadByCue(payload.cue);
return { payload: 'success' };
}
if ('next' in payload) {
runtimeService.loadNext();
return { payload: 'success' };
}
if ('previous' in payload) {
runtimeService.loadPrevious();
return { payload: 'success' };
}
}
throw new Error('No load method provided');
},
addtime: (payload) => {
const time = numberOrError(payload);
if (time === 0) {
return { payload: 'success' };
}
runtimeService.addTime(time * 1000);
return { payload: 'success' };
},
/* Extra timers */
extratimer: (payload) => {
if (payload && typeof payload === 'string') {
if (payload === SimplePlayback.Start) {
const reply = extraTimerService.start();
return { payload: reply };
}
if (payload === SimplePlayback.Pause) {
const reply = extraTimerService.pause();
return { payload: reply };
}
if (payload === SimplePlayback.Stop) {
const reply = extraTimerService.stop();
return { payload: reply };
}
}
if (payload && typeof payload === 'object') {
if ('settime' in payload) {
const time = numberOrError(payload.settime);
const reply = extraTimerService.setTime(time);
return { payload: reply };
}
if ('direction' in payload) {
if (payload.direction === SimpleDirection.CountUp || payload.direction === SimpleDirection.CountDown) {
const reply = extraTimerService.setDirection(payload.direction);
return { payload: reply };
} else {
throw new Error('Invalid direction payload');
}
}
}
throw new Error('Invalid extra-timer payload');
},
};
/**
* Returns a value of type number, converting if necessary
* Otherwise throws
* @param value
* @returns number
* @throws
*/
function numberOrError(value: unknown) {
const converted = Number(value);
if (isNaN(converted)) {
throw new Error('Payload is not a valid number');
}
return converted;
}
@@ -0,0 +1,65 @@
/**
* API Router
* User to handle all requests which affect runtime
* It is a mirror implementation of OSC and Websocket Adapters
*
*/
import { ErrorResponse, LogOrigin, RuntimeStore } from 'ontime-types';
import express, { type Request, type Response } from 'express';
import { logger } from '../classes/Logger.js';
import { objectFromPath } from '../adapters/utils/parse.js';
import { dispatchFromAdapter } from './integration.controller.js';
import { unpackError } from 'ontime-utils';
import { eventStore } from '../stores/EventStore.js';
export const integrationRouter = express.Router();
const helloMessage = 'You have reached Ontime API server';
integrationRouter.get('/', (_req: Request, res: Response<{ message: string }>) => {
res.status(200).json({ message: helloMessage });
});
/**
* All calls are sent to the dispatcher
*/
integrationRouter.get('/*', (req: Request, res: Response) => {
let action = req.path.substring(1);
if (!action) {
return res.status(400).json({ error: 'No action found' });
}
try {
const actionArray = action.split('/');
const params = { payload: req.query as object } as { payload: object | null };
if (actionArray.length > 1) {
action = actionArray.shift() || '';
params.payload = objectFromPath(actionArray, params.payload);
}
const reply = dispatchFromAdapter(action, params, 'http');
res.status(202).json(reply);
} catch (error) {
const errorMessage = unpackError(error);
logger.error(LogOrigin.Rx, `HTTP IN: ${errorMessage}`);
res.status(500).send({ message: errorMessage });
}
});
integrationRouter.get('/poll', (_req: Request, res: Response<Partial<RuntimeStore> | ErrorResponse>) => {
try {
const state = eventStore.poll();
res.status(200).send(state);
} catch (error) {
const message = unpackError(error);
res.status(500).send({
message: `Could not get sync data: ${message}`,
});
}
});
@@ -0,0 +1,60 @@
import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js';
// TODO: handle custom fields
const whitelistedPayload = {
title: coerceString,
note: coerceString,
cue: coerceString,
duration: coerceNumber,
isPublic: coerceBoolean,
skip: coerceBoolean,
colour: coerceColour,
};
export function parse(property: string, value: unknown) {
if (!isKeyOfType(property, whitelistedPayload)) {
throw new Error(`Property ${property} not permitted`);
}
const parserFn = whitelistedPayload[property];
return { parsedProperty: property, parsedPayload: parserFn(value) };
}
/**
* Updates a property of the event with the given id
* @param {string} eventId
* @param {keyof OntimeEvent} propertyName
* @param {OntimeEvent[typeof propertyName]} newValue
*/
export function updateEvent(
eventId: string,
propertyName: keyof OntimeEvent,
newValue: OntimeEvent[typeof propertyName],
) {
const event = getEventWithId(eventId);
if (!event) {
throw new Error(`Event with ID ${eventId} not found`);
}
if (!isOntimeEvent(event)) {
throw new Error('Can only update events');
}
const propertiesToUpdate = { [propertyName]: newValue };
// Handles the special case for duration
// needs to be converted to milliseconds
if (propertyName === 'duration') {
propertiesToUpdate.duration = (newValue as number) * 1000;
propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration;
}
const newEvent = editEvent({ id: eventId, ...propertiesToUpdate });
return newEvent;
}