nmos-ledger
Version:
NMOS discovery and registration APIs
553 lines (495 loc) • 19.2 kB
JavaScript
/* Copyright 2016 Streampunk Media Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// QueryAPI implementation
var express = require('express');
var immutable = require('seamless-immutable');
var NodeStore = require('./NodeStore.js');
var mdns = require('mdns-js');
var bodyparser = require('body-parser');
var uuid = require('uuid');
var WebSocketServer = require('ws').Server;
var os = require('os');
var url = require('url');
const EventEmitter = require('events');
var util = require('util');
var pathEnum = ["/nodes", "/devices", "/sources", "/flows", "/senders", "/receivers"];
var firstExtNetIf = require('./Util.js').getFirstExternalNetworkInterface().address;
function QueryAPI (port, storeFn, serviceName, pri, modifyEvents, iface) {
EventEmitter.call(this);
var app = express();
var server = null;
var wss = null;
var mdnsService = null;
var webSockets = {};
var wsFilter = {};
var instanceUUID = uuid.v4();
if (!pri || Number(pri) !== pri || pri % 1 !== 0) pri = 100;
if (!serviceName || typeof serviceName !== 'string') serviceName = 'ledger_query';
var api = this;
if (modifyEvents && typeof modifyEvents === 'object' &&
modifyEvents.on && typeof modifyEvents.on === 'function') { // Pass it on
modifyEvents.on('modify', function (ev) {
api.emit('modify', ev);
});
};
if (!iface) iface = '0.0.0.0';
if (iface !== '0.0.0.0') firstExtNetIf = iface;
function setPagingHeaders(res, total, pageOf, pages, size) {
if (pageOf) res.set('X-Streampunk-Ledger-PageOf', pageOf.toString());
if (size) res.set('X-Streampunk-Ledger-Size', size.toString());
if (pages) res.set('X-Streampunk-Ledger-Pages', pages.toString());
if (total) res.set('X-Streampunk-Ledger-Total', total.toString());
return res;
}
/**
* Returns the port that this Query API is configured to use.
* @return {Number} Port for this node API.
*/
this.getPort = function () {
return port;
}
/**
* Initialise the Query APIs routing table.
* @return {NodeAPI} Returns this object with the routing table initialised and
* ready to {@link NodeAPI#start}.
*/
this.init = function() {
app.use(function(req, res, next) {
// TODO enhance this to better supports CORS
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, PUT, POST, HEAD, OPTIONS, DELETE");
res.header("Access-Control-Allow-Headers", "Content-Type, Accept");
res.header("Access-Control-Max-Age", "3600");
if (req.method == 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
});
app.use(bodyparser.json());
app.get('/', function (req, res) {
res.json(['x-nmos/']);
});
app.get('/x-nmos/', function (req, res) {
res.json(['query/']);
});
app.get('/x-nmos/query/', function (req, res) {
res.json([ "v1.0/" ]);
});
var qapi = express();
// Mount all other methods at this base path
app.use('/x-nmos/query/v1.0/', qapi);
qapi.get('/', function (req, res) {
res.json([
"subscriptions/",
"flows/",
"sources/",
"nodes/",
"devices/",
"senders/",
"receivers/"
]);
});
// List nodes
qapi.get('/nodes', function (req, res, next) {
storeFn().getNodes(req.query,
function (err, nodes, total, pageOf, pages, size) {
if (err) next(err);
else setPagingHeaders(res, total, pageOf, pages, size).json(nodes);
});
});
// Get single node
qapi.get('/nodes/:id', function (req, res, next) {
storeFn().getNode(req.params.id, function (err, node) {
if (err) next(err);
else res.json(node);
});
});
// List devices
qapi.get('/devices/', function (req, res, next) {
storeFn().getDevices(req.query,
function (err, devices, total, pageOf, pages, size) {
if (err) next(err);
else setPagingHeaders(res, total, pageOf, pages, size).json(devices);
});
});
// Get a single device
qapi.get('/devices/:id', function (req, res, next) {
storeFn().getDevice(req.params.id, function (err, device) {
if (err) next(err);
else res.json(device);
});
});
// List sources
qapi.get('/sources/', function (req, res, next) {
storeFn().getSources(req.query,
function(err, sources, total, pageOf, pages, size) {
if (err) next(err);
else setPagingHeaders(res, total, pageOf, pages, size).json(sources);
});
});
// Get a single source
qapi.get('/sources/:id', function (req, res, next) {
storeFn().getSource(req.params.id, function (err, source) {
if (err) next(err);
else res.json(source);
});
});
// List flows
qapi.get('/flows/', function (req, res, next) {
storeFn().getFlows(req.query,
function (err, flows, total, pageOf, pages, size) {
if (err) next(err);
else setPagingHeaders(res, total, pageOf, pages, size).json(flows);
});
});
// Get a single flow
qapi.get('/flows/:id', function (req, res, next) {
storeFn().getFlow(req.params.id, function (err, flow) {
if (err) next(err);
else res.json(flow);
});
});
// List senders
qapi.get('/senders/', function (req, res, next) {
storeFn().getSenders(req.query,
function(err, senders, pageOf, size, page, total) {
if (err) next(err);
else setPagingHeaders(res, total, pageOf, page, size).json(senders);
});
});
// Get a single sender
qapi.get('/senders/:id', function (req, res, next) {
storeFn().getSender(req.params.id, function (err, sender) {
if (err) next(err);
else res.json(sender);
});
});
// List receivers
qapi.get('/receivers/', function (req, res, next) {
storeFn().getReceivers(req.query,
function(err, receivers, total, pageOf, pages, size) {
if (err) next(err);
else setPagingHeaders(res, total, pageOf, pages, size).json(receivers);
});
});
// Get a single receiver
qapi.get('/receivers/:id', function (req, res, next) {
storeFn().getReceiver(req.params.id, function(err, receiver) {
if (err) next(err);
else res.json(receiver);
});
});
qapi.post('/subscriptions', function (req, res, next) {
var sub = req.body;
if (!sub.max_update_rate_ms)
return next(NodeStore.prototype.statusError(400,
"Subscription must have a 'max_update_rate_ms' property."));
if (typeof sub.persist === 'undefined')
return next(NodeStore.prototype.statusError(400,
"Subscription must have a 'persist' property."));
if (!sub.resource_path)
return next(NodeStore.prototype.statusError(400,
"Subscription must have a 'resource_path' property."));
if (!sub.params)
return next(NodeStore.prototype.statusError(400,
"Subscription must have a 'params' property, although this may be an " +
"empty object."));
if (typeof sub.max_update_rate_ms !== 'number' ||
sub.max_update_rate_ms < 0)
return next(NodeStore.prototype.statusError(400,
"Subscription parameter 'max_update_rate_ms' should be a number and " +
"greater than or equal to 0."));
sub.max_update_rate_ms = sub.max_update_rate_ms | 0;
if (typeof sub.persist !== 'boolean')
return next(NodeStore.prototype.statusError(400,
"Subscription parameter 'persist' must be a boolean."));
if (typeof sub.resource_path !== 'string' ||
pathEnum.indexOf(sub.resource_path) === -1)
return next(NodeStore.prototype.statusError(400,
"Subscription parameter 'resource_path' must be one of " +
pathEnum.toString() + "."));
if (typeof sub.params !== 'object')
return next(NodeStore.prototype.statusError(400,
"Subscription parameter 'params' must be an object."));
sub.id = uuid.v4();
sub.ws_href = `ws://${firstExtNetIf}:${server.address().port}/` +
`ws/?uid=${sub.id}`;
if (webSockets[sub.id]) {
webSockets[sub.id] = sub;
res.status(200).json(sub);
} else {
console.log("Creating subscription.", sub.ws_href);
webSockets[sub.id] = sub;
res.status(201).json(sub);
};
});
qapi.get('/subscriptions', function (req, res, next) {
res.json(Object.keys(webSockets).map(function (k) {
return webSockets[k];
}));
});
qapi.get('/subscriptions/:id', function (req, res, next) {
if (!webSockets[req.params.id])
return next(NodeStore.prototype.statusError(404,
`A web socket subscription with id '${req.params.id}' is now known ` +
`to this query API.`));
res.json(webSockets[req.params.id]);
});
qapi.delete('/subscriptions/:id', function (req, res, next) {
if (!webSockets[req.params.id])
return next(NodeStore.prototype.statusError(404,
`On delete, a web socket subscription with id '${req.params.id}' is now known ` +
`to this query API.`));
if (webSockets[req.params.id].persist === false) {
return next(NodeStore.prototype.statusError(403,
`A delete request is made against a non-persistent subscription with ` +
`id '${req.params.id}' that is ` +
`managed by the Query API and cannot be deleted.`));
}
delete webSockets[req.params.id];
res.status(204).end();
});
app.use(function (err, req, res, next) {
if (err.status) {
res.status(err.status).json({
code: err.status,
error: (err.message) ? err.message : 'Internal server error. No message available.',
debug: (err.stack) ? err.stack : 'No stack available.'
});
} else {
res.status(500).json({
code: 500,
error: (err.message) ? err.message : 'Internal server error. No message available.',
debug: (err.stack) ? err.stack : 'No stack available.'
})
}
});
app.use(function (req, res, next) {
res.status(404).json({
code : 404,
error : `Could not find the requested resource '${req.path}'.`,
debug : req.path
});
});
return this;
}
/**
* Start the Query API server. If the port is already in use, the server
* will be closed.
* @param {QueryAPI~trackStatus=} cb Optional callback to track API starting
* or errors.
* @return {QueryAPI} This object with an asynchronous request
* to start the server.
*/
this.start = function (cb) {
server = app.listen(port, iface, function (e) {
var host = server.address().address;
var port = server.address().port;
if (e) {
if (e.code == 'EADDRINUSE') {
console.log('Address http://%s:%s already in use.', host, port);
server.close();
};
if (cb) cb(e);
} else {
console.log('Streampunk media ledger query service running at http://%s:%s',
host, port);
if (cb) cb();
};
});
wss = new WebSocketServer({ server : server });
wss.on('connection', connectWS.bind(this));
wss.on('error', console.error.bind(null, 'Websocket Error:'));
this.startMDNS();
return this;
}
this.startMDNS = function startMDNS() {
// mdns.excludeInterface('0.0.0.0');
if (serviceName === 'none') return; // For REST service acceptance testing
mdnsService = mdns.createAdvertisement(mdns.tcp('nmos-query'), port, {
name : serviceName,
txt : {
pri : pri
}
});
mdnsService.start();
process.on('SIGINT', function () {
if (mdnsService) {
mdnsService.stop();
console.log('Stopping ledger query service MDNS.');
}
setTimeout(function onTimeout() {
process.exit();
}, 1000);
});
}
/**
* Stop the server running the Query API.
* @param {QueryAPI~trackStatus=} cb Optional callback that tracks when the
* server is stopped.
* @return {QueryAPI} This object with an asynchronous request
* to stop the server.
*/
this.stop = function(cb) {
var error = '';
if (server) {
server.close(function () {
this.stopMDNS(cb);
server = null;
}.bind(this));
} else {
this.stopMDNS(function (e) {
if (e) cb(new Error(e.message +
' Server is not set for this Query API and so cannot be stopped.'));
else
cb(new Error('Server is not set for this Query API and so cannot be stopped.'));
server = null;
}.bind(this));
}
wss.close();
return this;
}
this.stopMDNS = function (cb) {
if (serviceName === 'none') return cb(); // For REST service acceptance testing
if (mdnsService) {
mdnsService.stop();
mdnsService.networking.stop();
mdnsService = null;
if (cb) cb();
} else {
if (cb) cb(new Error('MDNS advertisement is not set for this Query API and so cannot be stopped.'));
}
return this;
}
// Check the validity of a port
function validPort(port) {
return port &&
Number(port) === port &&
port % 1 === 0 &&
port > 0;
}
function connectWS (ws) {
var reqUrl = url.parse(ws.upgradeReq.url, true);
if (!reqUrl.query.uid || !webSockets[reqUrl.query.uid]) {
return ws.close(1008, JSON.stringify({
code : 404,
error : `Subscription with identifier '${reqUrl.query.uid}' could not be found.`
}));
}
var sub = webSockets[reqUrl.query.uid];
ws.ledgerID = uuid.v4();
if (wsFilter[sub.resource_path]) {
wsFilter[sub.resource_path].push({ sub : sub, socket : ws });
} else {
wsFilter[sub.resource_path] = [ { sub : sub, socket : ws } ];
}
ws.on('close', function () {
wsFilter[sub.resource_path] = wsFilter[sub.resource_path].filter(function (x) {
return x.socket.ledgerID !== ws.ledgerID;
});
if (!sub.persist && !wsFilter[sub.resource_path].find(function (x) {
return x.sub.id === sub.id;
})) {
delete webSockets[sub.id];
};
});
var method = `get${sub.resource_path[1].toUpperCase()}${sub.resource_path.slice(2)}`;
storeFn()[method]("",
function (err, resources, total, pageOf, pages, size) {
if (err) return console.error('Failed to read resources on websocket connection.');
var tsBase = Date.now();
var ts = `${tsBase / 1000|0}:${tsBase % 1000 * 1000000}`;
var g = {
grain_type : "event",
source_id : instanceUUID,
flow_id : sub.id,
origin_timestamp : ts,
sync_timestamp : ts,
creation_timestamp : ts,
rate : { numerator: 0, denominator: 1 },
duration : { numerator: 0, denominator: 1 },
grain : {
type : "urn:x-nmos:format:data.event",
topic : `${sub.resourcePath}/`,
data : resources.map(function (ro) {
return { path : ro.id, pre : ro, post : ro };
})
}
};
ws.send(JSON.stringify(g), { mask : false});
ws.lastTime = tsBase;
});
};
this.on('modify', function (ev) {
var subs = wsFilter[ev.topic.slice(0, -1)];
if (subs) {
subs.forEach(function (subWs) {
if (!Object.keys(subWs.sub.params).every(function (k) {
var preMatch = (ev.data[0].pre && ev.data[0].pre[k]) === subWs.sub.params[k];
var postMatch = (ev.data[0].post && ev.data[0].post[k]) === subWs.sub.params[k];
return preMatch || postMatch;
})) return;
var tsBase = Date.now();
var ts = `${tsBase / 1000|0}:${tsBase % 1000 * 1000000}`;
var g = {
grain_type : "event",
source_id : instanceUUID,
flow_id : subWs.sub.id,
origin_timestamp : ts,
sync_timestamp : ts,
creation_timestamp : ts,
rate : { numerator: 0, denominator: 1 },
duration : { numerator: 0, denominator: 1 },
grain : {
type : "urn:x-nmos:format:data.event",
topic : ev.topic,
data : ev.data
}
};
if (subWs.builder) {
subWs.builder.grain.data.push(g.grain.data[0]);
g.grain.data = subWs.builder.grain.data;
subWs.builder = g;
} else {
subWs.builder = g;
}
if (subWs.lastTime &&
tsBase - subWs.lastTime < subWs.sub.max_update_rate_ms) {
if (!subWs.timeout) {
subWs.timeout = setTimeout(function () {
subWs.socket.send(JSON.stringify(subWs.builder), { mask : false });
delete subWs.builder;
delete subWs.timeout;
subWs.lastTime = tsBase;
}, subWs.sub.max_update_rate_ms - (tsBase - subWs.lastTime));
}
} else {
subWs.socket.send(JSON.stringify(subWs.builder), { mask : false });
delete subWs.builder;
delete subWs.timeout;
subWs.lastTime = tsBase;
};
});
};
});
if (!validPort(port))
return new Error('Port is not a valid value. Must be an integer greater than zero.');
// return immutable(this, { prototype : QueryAPI.prototype });
}
/**
* Function called when server has been started or stopped.
* @callback {QueryAPI~trackStatus}
* @param {Error=} Set if an error occurred when starting or stopping the server.
*/
util.inherits(QueryAPI, EventEmitter);
module.exports = QueryAPI;