Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib: implement API versioning #231

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to swap parameters api and version but it's up to you.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though I agree that it will look better first of all it will not be compatible and second in case version is specified in the name you will have to pass null to the version parameter, because api will come after it.

[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) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also rename this argument in lib/cli/command-processor.js.

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