Skip to content

Commit

Permalink
lib: implement API versioning
Browse files Browse the repository at this point in the history
* Allow to specify API version during handshake.
* Make createAppsIndex handle multiple API versions.
* Add API version parameter to Application.
* Add tests for interface versioning.

Fixes: #31
PR-URL: #231
Reviewed-By: Dmytro Nechai <nechaido@gmail.com>
Reviewed-By: Alexey Orlenko <eaglexrlnk@gmail.com>
Reviewed-By: Mykola Bilochub <nbelochub@gmail.com>
  • Loading branch information
lundibundi authored and belochub committed Jan 22, 2018
1 parent 2be4d1e commit 7f6e51f
Show file tree
Hide file tree
Showing 15 changed files with 431 additions and 54 deletions.
55 changes: 49 additions & 6 deletions lib/applications.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
'use strict';

const semver = require('semver');

const common = require('./common');
const errors = require('./errors');

// Generic application class. You are free to substitute it with whatever suits
// your needs.
// name - application name
// api - application API
// name - application name that may contain version after '@'
// (e.g. app@1.0.0). Version in name is preferred over
// 'version' parameter
// api - object that contains interfaces as its fields each of which
// contains functions by method names. Each method has the following
// signature (connection, <0 or more method arguments>, callback)
// version - application version of 'name' application
//
class Application {
constructor(name, api) {
this.name = name;
constructor(name, api, version) {
[this.name, this.version] = common.rsplit(name, '@');
if (!this.version) this.version = version;
if (this.version && !semver.valid(this.version)) {
throw new TypeError('Invalid semver version');
}
this.api = api;
}

Expand Down Expand Up @@ -54,10 +66,41 @@ class Application {
// applications - array of JSTP applications
//
const createAppsIndex = (applications) => {
const index = {};
const latestApps = new Map();
const index = new Map();

applications.forEach((application) => {
index[application.name] = application;
let appVersions = index.get(application.name);
if (!appVersions) {
appVersions = new Map();
index.set(application.name, appVersions);
}
if (!application.version) {
// no version means latest version
if (appVersions.has('latest')) {
throw new Error(
`Multiple entries of '${application.name} without version`
);
} else {
appVersions.set('latest', application);
}
} else {
appVersions.set(application.version, application);

// save latest version to fill missing latest versions later
const latestApp = latestApps.get(application.name);
if (!latestApp || semver.gt(application.version, latestApp.version)) {
latestApps.set(application.name, application);
}
}
});

// set latest versions of apps without explicit latest version
latestApps.forEach((application, appName) => {
const appVersions = index.get(appName);
if (!appVersions.has('latest')) {
appVersions.set('latest', application);
}
});

return index;
Expand Down
4 changes: 2 additions & 2 deletions lib/cli/command-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ module.exports = class CommandProcessor extends EventEmitter {
callback();
}

connect(protocol, host, port, appName, interfaces, callback) {
connect(protocol, host, port, app, interfaces, callback) {
let transport;
let args;

Expand All @@ -139,7 +139,7 @@ module.exports = class CommandProcessor extends EventEmitter {
return;
}
this.cli.connectInitiated = true;
transport.connectAndInspect(appName, null, interfaces, ...args,
transport.connectAndInspect(app, null, interfaces, ...args,
(err, connection, api) => {
if (err) {
this.cli.connectInitiated = false;
Expand Down
6 changes: 3 additions & 3 deletions lib/cli/line-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ module.exports = class LineProcessor {
return;
}
}
const appName = args[1];
if (appName === undefined) {
const app = args[1];
if (app === undefined) {
callback(reportMissingArgument('Application name'));
return;
}
const interfaces = args[2] ? utils.split(args[2], ' ') : [];
this.commandProcessor.connect(
scheme, host, port, appName, interfaces,
scheme, host, port, app, interfaces,
(err) => {
if (err) {
callback(err);
Expand Down
9 changes: 9 additions & 0 deletions lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,20 @@ const extractCallback = (args) => {
//
const doNothing = () => {};

// Splits string by the last occurrence of separator
//
const rsplit = (string, separator) => {
const lastIndex = string.lastIndexOf(separator);
if (lastIndex < 0) return [string];
return [string.slice(0, lastIndex), string.slice(lastIndex + 1)];
};

module.exports = {
forwardEvent,
forwardMultipleEvents,
safeRequire,
mixin,
extractCallback,
doNothing,
rsplit,
};
69 changes: 63 additions & 6 deletions lib/connection.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { EventEmitter } = require('events');
const semver = require('semver');
const timers = require('timers');

const common = require('./common');
Expand Down Expand Up @@ -117,15 +118,46 @@ class Connection extends EventEmitter {
}

// Send a handshake message over the connection
// appName - name of an application to connect to
// app - string or object, application to connect to as 'name' or
// 'name@version' or { name, version }, where version
// must be a valid semver range
// login - user name (optional)
// password - user password (optional)
// callback - callback function to invoke after the handshake is completed
//
handshake(appName, login, password, callback) {
const message = login && password ?
this._createMessage('handshake', appName, 'login', [login, password]) :
this._createMessage('handshake', appName);
handshake(app, login, password, callback) {
let name, version;
if (typeof app === 'string') {
[name, version] = common.rsplit(app, '@');
} else {
name = app.name;
version = app.version;
}

if (version && !semver.validRange(version)) {
const error = new Error('Invalid semver version range');
if (callback) {
callback(error);
} else {
this.emit('error', error);
}
return;
}

let message;
if (version) {
message = login && password ?
this._createMessageWithArray(
'handshake', [name, version], 'login', [login, password]
) :
this._createMessageWithArray(
'handshake', [name, version]
);
} else {
message = login && password ?
this._createMessage('handshake', name, 'login', [login, password]) :
this._createMessage('handshake', name);
}

const messageId = message.handshake[0];
this._callbacks[messageId] = (error, sessionId) => {
Expand Down Expand Up @@ -216,6 +248,28 @@ class Connection extends EventEmitter {
}
}

// Create a JSTP message
// kind - message kind
// target - array of arguments for kind key, usually a name
// of an interface or an application (optional)
// with api version
// verb - action specific for different message kinds
// args - action arguments
//
_createMessageWithArray(kind, target, verb, args) {
const message = {
[kind]: [this._nextMessageId, ...target],
};

if (verb) {
message[verb] = args;
}

this._nextMessageId += this._messageIdDelta;

return message;
}

// Create a JSTP message
// kind - message kind
// target - name of an interface or an application (optional)
Expand Down Expand Up @@ -394,7 +448,10 @@ class Connection extends EventEmitter {
}

const applicationName = message.handshake[1];
const application = this.server.applications[applicationName];
const applicationVersion = message.handshake[2];
const application = this.server._getApplication(
applicationName, applicationVersion
);

if (!application) {
this._handshakeError(errors.ERR_APP_NOT_FOUND);
Expand Down
8 changes: 4 additions & 4 deletions lib/net.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ module.exports = {
Server,
createServer: (options, listener) =>
createServer(options, listener),
connect: (appName, client, ...options) =>
connect(appName, client, ...options),
connectAndInspect: (appName, client, interfaces, ...options) =>
connectAndInspect(appName, client, interfaces, ...options),
connect: (app, client, ...options) =>
connect(app, client, ...options),
connectAndInspect: (app, client, interfaces, ...options) =>
connectAndInspect(app, client, interfaces, ...options),
};
34 changes: 34 additions & 0 deletions lib/server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const semver = require('semver');

const apps = require('./applications');
const Connection = require('./connection');
const SimpleAuthPolicy = require('./simple-auth-policy');
Expand All @@ -24,6 +26,16 @@ const initServer = function(
applications = apps.createAppsIndex(applications);
}

// versions cached for efficient search when provided version is a range
this._cachedVersions = new Map();
applications.forEach((appVersions, appName) => {
const versions = Array.from(appVersions)
.filter(version => version[0] !== 'latest')
.map(version => [new semver.SemVer(version[0]), version[1]])
.sort((a, b) => semver.rcompare(a[0], b[0]));
this._cachedVersions.set(appName, versions);
});

if (typeof(authPolicy) === 'number') {
heartbeatInterval = authPolicy;
authPolicy = null;
Expand Down Expand Up @@ -84,6 +96,28 @@ class Server {
});
}

_getApplication(name, version) {
const appVersions = this.applications.get(name);
if (!appVersions) return null;
if (!version) return appVersions.get('latest');
// when version is not a range simply return matched
if (semver.valid(version)) return appVersions.get(version);

// search matching version, first matched will be the latest
try {
const range = new semver.Range(version);
const versions = this._cachedVersions.get(name);
for (let i = 0; i < versions.length; i++) {
// version === [versionCode, app]
const version = versions[i];
if (range.test(version[0])) return version[1];
}
} catch (error) {
// ignored
}
return null;
}

// Client connection event handler.
// connection - JSTP connection instance
//
Expand Down
10 changes: 6 additions & 4 deletions lib/simple-connect-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ module.exports = class SimpleConnectPolicy {
// Should send handshake message with appropriate credentials
// You can get client object provided upon connection creation
// with connection.client.
// app - string or object, application to connect to as 'name' or
// 'name@version' or { name, version }, where version
// must be a valid semver range
// connection - JSTP connection
// callback - callback function that has signature
// (error, connection)
//
connect(appName, connection, callback) {
connect(app, connection, callback) {
connection.handshake(
appName, this.login, this.password,
app, this.login, this.password,
(error) => {
callback(error, connection);
}
);
});
}
};
8 changes: 4 additions & 4 deletions lib/tls.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ module.exports = {
Server,
createServer: (options, listener) =>
createServer(options, listener),
connect: (appName, client, ...options) =>
connect(appName, client, ...options),
connectAndInspect: (appName, client, interfaces, ...options) =>
connectAndInspect(appName, client, interfaces, ...options),
connect: (app, client, ...options) =>
connect(app, client, ...options),
connectAndInspect: (app, client, interfaces, ...options) =>
connectAndInspect(app, client, interfaces, ...options),
};
12 changes: 7 additions & 5 deletions lib/transport-common.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const common = require('./common');
// transportClass - class that will be instantiated with rawConnection
//
// returns function with arguments
// appName - remote application name to connect to
// app - string or object, application to connect to as 'name' or
// 'name@version' or { name, version }, where version
// must be a valid semver range
// client - optional client object with following properties:
// * application - object, optional (see jstp.Application)
// * connectPolicy - function or
Expand All @@ -26,7 +28,7 @@ const common = require('./common');
//
const newConnectFn = (
connFactory, transportClass
) => (appName, client, ...options) => {
) => (app, client, ...options) => {
const callback = common.extractCallback(options);
connFactory(...options, (error, rawConnection) => {
if (error) {
Expand All @@ -51,7 +53,7 @@ const newConnectFn = (
client.connectPolicy.connect.bind(client.connectPolicy);
}
const connection = new Connection(transport, null, client);
client.connectPolicy(appName, connection, (error, connection) => {
client.connectPolicy(app, connection, (error, connection) => {
if (error) {
callback(error, connection);
return;
Expand All @@ -70,9 +72,9 @@ const newConnectFn = (
//
const newConnectAndInspectFn = (connFactory, transportClass) => {
const connect = newConnectFn(connFactory, transportClass);
return (appName, client, interfaces, ...options) => {
return (app, client, interfaces, ...options) => {
const callback = common.extractCallback(options);
connect(appName, client, ...options, (error, connection) => {
connect(app, client, ...options, (error, connection) => {
if (error) {
callback(error);
return;
Expand Down
8 changes: 4 additions & 4 deletions lib/ws-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ const connectAndInspect =

module.exports = {
Transport,
connect: (appName, client, url, callback) =>
connect(appName, client, url, callback),
connectAndInspect: (appName, client, interfaces, url, callback) =>
connectAndInspect(appName, client, interfaces, url, callback),
connect: (app, client, url, callback) =>
connect(app, client, url, callback),
connectAndInspect: (app, client, interfaces, url, callback) =>
connectAndInspect(app, client, interfaces, url, callback),
};
Loading

0 comments on commit 7f6e51f

Please sign in to comment.