Commit c1fcc17c authored by Dan Jones's avatar Dan Jones
Browse files

Merge branch '1-import-prototype-adapter-code-from-example-web-client' into 'dev'

Resolve "Import prototype adapter code from example-web-client"

Closes #1

See merge request !1
parents a8eef21a eb7cb068
# Decode and encode are provided as stubs which are intended to be overridden
# These can be used to translate the message or to invoke other functions
# to take action based on the type and content of messages
Feature: Encode stubs passthru message unchanged
The protocol encode method works as expected
Scenario: Encode passes the message through unaltered
Given a valid message
When the protocol.encode method is called
Then the message is returned unaltered
\ No newline at end of file
Feature: Can the protocol determine message type
The protocol getType method works as expected
Scenario: A valid message is successfully typed
Given a valid message
When protocol getType is called
Then getType returns message.payload.message_type if present
Scenario: An invalid message returns type:null
Given an invalid message
When protocol getType is called
Then getType returns null if message.payload.message_type is not present
Feature: Can the protocol validate messages?
The adapter validate method works as expected
Scenario: A valid message is successfully validated against the protocol schema
Given a valid message
When the protocol.validate method is called
Then the message is validated successfully
Scenario: An invalid message fails to validate against the protocol schema
Given an invalid message
When the protocol.validate method is called
Then the message fails to validate
\ No newline at end of file
# If the mock schema fixture fails to validate
# it can cause invalid messages to show as valid
Feature: Is the mock schema valid?
The mock schema must validate in order for the adapter test to work
Scenario: The schema matches the OpenAPI specification
Given the test schema
When it is validated
Then it matches the OpenAPI specification
\ No newline at end of file
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^~/(.*)$': '<rootDir>/$1',
},
moduleFileExtensions: ['js', 'json'],
transform: {
'^.+\\.js$': 'babel-jest',
},
collectCoverage: true,
collectCoverageFrom: [
],
testEnvironment: 'jsdom',
};
{
"name": "@noc-comms-backbone/backbone-adapter-javascript",
"version": "1.0.0",
"private": true,
"contributors": [
{
"name": "James Kirk",
"email": "james.kirk@noc.ac.uk"
},
{
"name": "Dan Jones",
"email": "dan.jones@noc.ac.uk"
},
{
"name": "Trishna Saeharaseelan",
"email": "trishna.saeharaseelan@noc.ac.uk"
}
],
"license": "MIT",
"scripts": {
"lint:js": "eslint --ext \".js\" --ignore-path .gitignore .",
"lint:prettier": "prettier --check .",
"lint": "yarn lint:js && yarn lint:prettier",
"lintfix": "prettier --write --list-different . && yarn lint:js --fix",
"prepare": "husky install",
"test": "yarn build && yarn cucumber",
"cucumber": "cucumber-js",
"build": "cross-env NODE_ENV=production rollup -c"
},
"lint-staged": {
"*.{js,vue}": "eslint --cache",
"*.**": "prettier --check --ignore-unknown"
},
"dependencies": {
"faye-websocket": "^0.11.4",
"swagger-model-validator": "^3.0.21"
},
"devDependencies": {
"@babel/core": "^7.0.0-0",
"@babel/eslint-parser": "^7.0.0",
"@commitlint/cli": "^15.0.0",
"@commitlint/config-conventional": "^15.0.0",
"@cucumber/cucumber": "^8.10.0",
"@rollup/plugin-babel": "^6.0.3",
"axios": "^1.2.3",
"axios-mock-adapter": "^1.21.2",
"babel-jest": "^27.4.4",
"cross-env": "^7.0.3",
"eslint": "^8.4.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.2.1",
"husky": "^7.0.4",
"jest": "^27.4.4",
"lint-staged": "^12.1.2",
"openapi-schema-validator": "^12.1.0",
"prettier": "^2.5.1",
"rollup": "^3.9.1"
}
}
exports.default = [
{
input: 'src/adapter/index.js',
output: [
{
file: 'dist/adapter.esm.js',
format: 'es',
},
{
file: 'dist/adapter.js',
format: 'cjs',
},
],
},
{
input: 'src/protocol/index.js',
output: [
{
file: 'dist/protocol.esm.js',
format: 'es',
},
{
file: 'dist/protocol.js',
format: 'cjs',
},
],
},
];
import axios from 'axios';
/**
* Handle authentication and send/receive with the backbone
*/
export class Adapter {
constructor(protocol, config) {
this.protocol = protocol;
this.config = config;
this.axios = axios;
}
/**
* Test parsing the message based on the provided protocol schema
*
* The message must be successfully json decoded into an object
* prior to validation
*
* At present this returns the validation result which is an
* object containing a boolean valid field as well as details
* of any errors.
* @param {object} message
* @returns {object}
*/
validate(message) {
return this.protocol.validate(message);
}
/**
* Ensure the token in this.credentials is still valid
* @returns {boolean}
*/
tokenValid() {
let valid = false;
if (this.credentials) {
const now = new Date().toISOString();
valid = this.credentials.expiry > now;
}
return valid;
}
/**
* Return an http headers object containing a bearer token authorisation header
* @returns {object}
*/
getAuthorizationHeader() {
if (!this.tokenValid())
return this.auth().then((response) => {
return {
Authorization: `Bearer ${response.data.token}`,
};
});
else {
return Promise.resolve({
Authorization: `Bearer ${this.credentials.token}`,
});
}
}
/**
* Do a client credentials grant and store in this.credentials
* @returns {object}
*/
auth() {
let adapterConfig = this.config;
return this.axios
.get(`${adapterConfig.api}/token`, {
params: {
client_id: adapterConfig.client_id,
secret: adapterConfig.secret,
},
})
.then((response) => {
this.credentials = response.data;
return response;
})
.catch((error) => {
return Promise.reject(error);
});
}
/**
* Call the GET /receive endpoint and process the messages with decode
*
* Returns the response
* @returns {object}
*/
poll() {
let adapterConfig = this.config;
return this.getAuthorizationHeader()
.then((headers) => {
return this.axios.get(`${adapterConfig.api}/receive`, {
headers,
});
})
.then((response) => {
response.data.forEach((message) => {
const parsed = JSON.parse(message.message);
const validation = this.validate(parsed);
if (validation.valid) {
const type = this.protocol.getType(parsed);
this.protocol.decode(type, parsed);
}
});
return response;
})
.catch((error) => {
return Promise.reject(error);
});
}
/**
* Publish a message to the backbone with the specified topic
*
* Messages should be passed through encode before sending
* @param {string} topic
* @param {string} body
* @returns
*/
publish(topic, body) {
let adapterConfig = this.config;
return this.getAuthorizationHeader()
.then((headers) => {
return this.axios.post(
`${adapterConfig.api}/send`,
{
topic,
body,
},
{
headers,
}
);
})
.then((response) => {
return response;
})
.catch((error) => {
return Promise.reject(error);
});
}
/**
* Broadcast the message on the backbone
*
* Broadcast messages bypass the normal publisher queues
* this means they can be used to deliver messages more
* quickly in an emergency scenario.
*
* Messages should be passed through encode before sending
* @param {*} body
* @returns
*/
broadcast(body) {
let adapterConfig = this.config;
return this.getAuthorizationHeader()
.then((headers) => {
return this.axios.post(
`${adapterConfig.api}/notify`,
{
body,
},
{
headers,
}
);
})
.then((response) => {
return response;
})
.catch((error) => {
return Promise.reject(error);
});
}
}
import { GenericProtocol } from '~/modules/comms-adapter/protocol';
/**
*
*/
export class ExampleClientProtocol extends GenericProtocol {
constructor(schema, services) {
super(schema, services);
// bootstrap any injected services
}
/**
* Process a message received from the backbone
*
* This method is overridden to take appropriate
* action for each message type.
*
* Further methods can then be defined to handle
* each message type.
*
* You can use this to take action directly or
* transform that data to invoke an existing process.
* @param {string} type
* @param {object} message
* @returns
*/
decode(type, message) {
switch (type) {
case 'VehicleStatus':
this.decodeVehicleStatus(message);
break;
case 'VehicleMission':
this.decodeVehicleMission(message);
break;
}
return message;
}
/**
* Transform local data into a message conforming to the protocol schema
*
* When a client event occurs the local data is passed through
* encode to handle the translation from the native client format
* into a message conforming to the protocol schema.
* @param {string} type
* @param {object} message
* @returns
*/
encode(type, message) {
return message;
}
/**
* Act upon a received VehicleStatus message
* @param {object} message
* @returns {object}
*/
decodeVehicleStatus(message) {
const battery = message.battery_percentage;
message.battery_rag_status =
battery > 20 ? 'green' : battery > 10 ? 'amber' : 'red';
this.services.bridge.$emit('soar.vehicle.status', message);
return message;
}
/**
* Act upon a received VehicleMission message
* @param {object} message
* @returns {object}
*/
decodeVehicleMission(message) {
this.services.bridge.$emit('soar.vehicle.mission', message);
return message;
}
/**
* Transform local data into a VehicleStatus message
* @param {object} message
* @returns {object}
*/
encodeVehicleStatus(message) {
return message;
}
}
import Validator from 'swagger-model-validator';
/**
* GenericProtocol defines a simple passthru handler for messages
* This can be extended to handle different schemas
*
* The assumption is that all messages conform to a single
* wrapper schema definition. Different payloads are handled
* by a oneOf definitions within the schema determined by a
* field value.
*
* This class can be extended and overridden to handle
* different schemas and to implement the client specific
* logic related to be executed when invoked for a given
* message type.
*
* By default encode and decode are just passthru stubs
*
* decode is invoked when receiving a message from the backbone
* encode is invoked before delivering a message to the backbone
*
* The intention is that these allow you to transform the message
* and call invoke internal functions and services as required
*/
export class GenericProtocol {
constructor(schema, services) {
this.schema = schema;
this.validator = new Validator(schema);
this.services = services;
}
/**
* Validate that a message meets the reqiured schema
* @param {object} message
* @returns {object}
*/
validate(message) {
return this.validator.validate(
message,
this.schema.components.schemas.Message,
this.schema.components.schemas,
false,
true
);
}
/**
* Identify the payload type from the message content
* @param {object} message
* @returns {string}
*/
getType(message) {
try {
return message.payload.message_type;
} catch(error) {
return null;
}
}
/**
* Invoked on receiving a message from the backbone
*
* Whilst type isn't used in the generic stub it will
* be needed by sub-classes overriding the decode method
* @param {string} type
* @param {object} message
* @returns {*}
*/
decode(type, message) {
return message;
}
/**
* Optionally invoked before delivering a message to the backbone
*
* Whilst type isn't used in the generic stub it will
* be needed by sub-classes overriding the encode method
* @param {string} type
* @param {*} message
* @returns {object}
*/
encode(type, message) {
return message;
}
}
const assert = require('assert');
const { When, Then } = require('@cucumber/cucumber');
const { fixtures } = require('../../fixtures/server');
When('the auth method is called', async function() {
await this.adapter.auth();
});
Then('the adapter credentials are populated', function() {
assert.equal(this.adapter.credentials.token, fixtures.get('response-valid-token').token);
});
Then('the adapter auth fails', function() {
this.adapter.auth()
.catch((error) => {
assert.equal(error.response.status, 403);
});
});
\ No newline at end of file
const assert = require('assert');
const { Before } = require('@cucumber/cucumber');
const axios = require("axios");
const MockAdapter = require("axios-mock-adapter");
// This sets the mock adapter on the default instance
const mockAxios = new MockAdapter(axios);
const { fixtures } = require('../../fixtures/server');
const mockValidConfig = fixtures.get('config-valid');
const mockInvalidConfig = fixtures.get('config-invalid');
const mockSchema = require('../../mock/swagger.json');
const { GenericProtocol } = require('../../../dist/protocol');
/**
* Use assert.CallTracker to track internal method calls
* Instead of adding trackers to each method, create a
* single tracked stub function and then use the parameters
* to record what is being tracked.
*/
const tracker = new assert.CallTracker();
const trackedFunction = function(method, params) {
// do nothing;
};
const recorder = tracker.calls(trackedFunction);
class TrackedGenericProtocol extends GenericProtocol {
constructor(schema, services) {
super(schema, services);
this.recorder = services.recorder;
this.tracker = services.tracker;
}
encode(type, message) {
this.recorder('encode', {type, message});
return super.encode(type, message)
}
decode(type, message) {
this.recorder('decode', {type, message});
return super.decode(type, message)
}
validate(message) {
this.recorder('validate', {message});
return super.validate(message);
}
getTrackedCalls(method) {
let calls = this.tracker.getCalls(this.recorder);
let methodCalls = calls.filter(call => call.arguments[0] === method);
return methodCalls;
}
resetTracker() {
this.tracker.reset();
}
}
Before(function() {
this.schema = mockSchema;
this.tracker = tracker;
this.recorder = recorder;
let services = {
recorder,
tracker
};
this.protocol = new TrackedGenericProtocol(this.schema, services);
this.protocol.resetTracker();
this.mockAxios = mockAxios;
this.mockAxios.reset();
this.mockAxios.onGet(
`${mockValidConfig.api}/token`,
{ params: { client_id: mockValidConfig.client_id, secret: mockValidConfig.secret } }
).reply(200, fixtures.get('response-valid-token'));
this.mockAxios.onGet(
`${mockInvalidConfig.api}/token`,
{ params: { client_id: mockInvalidConfig.client_id, secret: mockInvalidConfig.secret } }
).reply(403, fixtures.get('response-denied-token'));
});
\ No newline at end of file
const { When } = require('@cucumber/cucumber');
const { fixtures } = require('../../fixtures/server');
const mockValidConfig = fixtures.get('config-valid');
When('a mock notify API response is configured to return success', function() {
const response = {};
this.mockAxios.onPost(
`${mockValidConfig.api}/notify`,
).reply(200, response);
});
When('a mock notify API response is configured to return an error', function() {
this.mockAxios.onPost(
`${mockValidConfig.api}/notify`,
).reply(403, { message: 'Token expired' })
});
When('the broadcast method is called', function() {
const message = fixtures.get('message-vehicle-status');
this.message = message;
const body = JSON.stringify(message);
this.call = this.adapter.broadcast(body);
});
\ No newline at end of file
const assert = require('assert');
const { Given, When, Then } = require('@cucumber/cucumber');
const { fixtures } = require('../../fixtures/server');
const mockValidConfig = fixtures.get('config-valid');
const mockInvalidConfig = fixtures.get('config-invalid');
const mockSchema = require('../../mock/swagger.json');
const { Adapter } = require('../../../dist/adapter');
Given('valid config', function() {
this.config = mockValidConfig
});
Given('invalid config', function() {
this.schema = mockSchema;
this.config = mockInvalidConfig;
});
When('the adapter instance is created', function() {
let mockAdapter = new Adapter(this.protocol, this.config);
this.adapter = mockAdapter;
});
Then('a successful response is returned with status {int}', function(expectedStatus) {
this.call
.then(response => {
assert.equal(response.status, expectedStatus);
});
});
Then('an error response is returned with status {int}', function(expectedStatus) {
this.call
.catch((error) => {
assert.equal(error.response.status, expectedStatus);
});
});
\ No newline at end of file
const assert = require('assert');
const { When, Then } = require('@cucumber/cucumber');
When('the getAuthorizationHeader method is called', function() {
this.call = this.adapter.getAuthorizationHeader()
});
Then('a headers object is returned containing a bearer token authorization header', function() {
this.call.then(headers => {
assert.ok('Authorization' in headers);
const authHeaderWords = headers.Authorization.split(" ");
assert.ok(authHeaderWords[0] === 'Bearer');
assert.ok(authHeaderWords[1] === this.adapter.credentials.token);
});
});
\ No newline at end of file
const assert = require('assert');
const { When, Then } = require('@cucumber/cucumber');
const { fixtures } = require('../../fixtures/server');
const mockValidConfig = fixtures.get('config-valid');
const xMessageResponse = function(xMessages) {
const message = fixtures.get('message-vehicle-status');
let response = [];
for (let i=0; i<xMessages; i++) {
response.push({
topic: "broadcast",
message: JSON.stringify(message)
});
}
return response;
};
When('a mock receive API response is configured to return {int} messages', function(xMessages) {
const response = xMessageResponse(xMessages);
this.mockAxios.onGet(
`${mockValidConfig.api}/receive`,
).reply(200, response);
});
When('a mock receive API response is configured to return an error', function() {
this.mockAxios.onGet(
`${mockValidConfig.api}/receive`,
).reply(403, { message: 'Token expired' })
});
When('the poll method is called', function() {
this.call = this.adapter.poll();
});
Then('a successful response is returned with {int} messages', function(xMessages) {
this.call
.then(response => {
assert.equal(response.data.length, xMessages);
});
});
Then('the protocol {string} method is called {int} times', function(method, xInvokes) {
const decodes = this.protocol.getTrackedCalls(method);
assert.equal(decodes.length, xInvokes);
});
const assert = require('assert');
const { When, Then } = require('@cucumber/cucumber');
const { fixtures } = require('../../fixtures/server');
const mockValidConfig = fixtures.get('config-valid');
When('a mock send API response is configured to return success', function() {
const response = {};
this.mockAxios.onPost(
`${mockValidConfig.api}/send`,
).reply(200, response);
});
When('a mock send API response is configured to return an error', function() {
this.mockAxios.onPost(
`${mockValidConfig.api}/send`,
).reply(403, { message: 'Token expired' })
});
When('the publish method is called', function() {
const message = fixtures.get('message-vehicle-status');
this.message = message;
const topic = message.metadata.destination;
const body = JSON.stringify(message);
this.call = this.adapter.publish(topic, body);
});
\ No newline at end of file
const assert = require('assert');
const { When, Then } = require('@cucumber/cucumber');
When('the token expiry is in the future', function() {
const expiry = new Date();
expiry.setHours(expiry.getHours()+1);
this.adapter.credentials.expiry = expiry.toISOString();
});
When('the token expiry is in the past', function() {
const expiry = new Date();
expiry.setHours(expiry.getHours()-1);
this.adapter.credentials.expiry = expiry.toISOString();
});
// Boolean parameters are not supported
// and returns "true" would be misleading
Then('tokenValid returns true', function() {
const isValid = this.adapter.tokenValid();
assert.ok(isValid);
});
Then('tokenValid returns false', function() {
const isValid = this.adapter.tokenValid();
assert.ok(!isValid);
});
\ No newline at end of file
const assert = require('assert');
const { Given, When, Then } = require('@cucumber/cucumber');
const { fixtures } = require('../../fixtures/server');
Given('a valid message', function() {
this.message = fixtures.get('message-vehicle-status');
});
Given('an invalid message', function() {
this.message = fixtures.get('message-vehicle-status-invalid');
});
When('the validate method is called', function() {
this.validation = this.adapter.validate(this.message);
});
\ No newline at end of file
const assert = require('assert');
const { Then } = require('@cucumber/cucumber');
Then('the message is returned unaltered', function() {
assert.equal(this.message, this.response);
});
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment