Skip to content

Commit

Permalink
Issue-30: added json-schema implementation to validate response confi…
Browse files Browse the repository at this point in the history
…gs (#45)

* Issue-30: added json-schema implementation to validate response configs

* Issue-30: improve logging and coverage

* Dependency updates
- Bumped supertest to version 6
- Bumped yargs to version 16
- Upgraded patch/minor versions

* Issue-30: increased project version for next release
  • Loading branch information
dtobe committed Nov 6, 2020
1 parent 0aa1573 commit dc870cd
Show file tree
Hide file tree
Showing 6 changed files with 589 additions and 252 deletions.
60 changes: 60 additions & 0 deletions lib/configValidator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
var Ajv = require('ajv');

const SUPPORTED_METHODS = ['get', 'post', 'put', 'delete', 'patch'];

const responseSchema = {
'$id': 'Response.json',
'resposeDef': {
'type': 'object',
'properties': {
'status': {
'type': 'integer'
},
'headers': {
'type': 'object',
'propertyNames': {
'pattern': '^[\\w-]+$'
},
'patternProperties': {
'[\\w-]+': { 'type': 'string' }
}
},
'data': {
'type': 'object'
}
},
'additionalProperties': false
}
};

const schema = {
'$id': 'ResponseDefs.json',
'oneOf': [
{
'$ref': 'Response.json#/resposeDef'
},
{
'type': 'array',
'items': {
'$ref': 'Response.json#/resposeDef'
}
}
]
};

module.exports = (responseConfig, resourcePath, method) => {
if (!SUPPORTED_METHODS.includes(method)) {
throw new Error(`Method ${method} is not supported.`);
}
var ajv = new Ajv({ schemas: [schema, responseSchema] });
var validate = ajv.getSchema('ResponseDefs.json');
const valid = validate(responseConfig);
if (!valid) {
// usually only the first error is interesting, further ones are just the result of parser panicking and
// failing the oneOf check
const prop = validate.errors[0].propertyName;
const path = validate.errors[0].dataPath;
const msg = validate.errors[0].message;
throw new Error(`Validation error: ${prop ? `property [${prop}] at path ` : ''}'${path}' ${msg}.`);
}
};
42 changes: 20 additions & 22 deletions mock-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ const express = require('express');
const _ = require('lodash');
const enableDestroy = require('server-destroy');
const unconfiguredRoutesHandler = require('./lib/errorHandler');
const validateResponses = require('./lib/configValidator');
const ResponseConfig = require('./lib/response');
const { log, accessLog } = require('./lib/logging');

const SUPPORTED_METHODS = ['get', 'post', 'put', 'delete'];

class AietesServer {
constructor(responsesConfig, port) {
this.stats = {};
Expand Down Expand Up @@ -81,24 +80,22 @@ class AietesServer {
}

let numCalls = 0;
if (matchingPathStats) {
const statList = _.flatMap(matchingPathStats, (statBlock) => {
return _.filter(statBlock, (stats, method) => {
if (Array.isArray(methodMatcher)) {
return methodMatcher.map(value => value.toLowerCase()).includes(method);
} else {
return method === methodMatcher.toLowerCase();
}
})
.map((stats) => {
return stats.numCalls;
});
});
const statList = _.flatMap(matchingPathStats, (statBlock) => {
return _.filter(statBlock, (stats, method) => {
if (Array.isArray(methodMatcher)) {
return methodMatcher.map(value => value.toLowerCase()).includes(method);
} else {
return method === methodMatcher.toLowerCase();
}
})
.map((stats) => {
return stats.numCalls;
});
});

numCalls = _.reduce(statList, function(sum, n) {
return sum + n;
}, 0);
}
numCalls = _.reduce(statList, function(sum, n) {
return sum + n;
}, 0);

return numCalls;
}
Expand Down Expand Up @@ -135,10 +132,11 @@ const createResponses = (responsesConfig) => {
return _.flatMap(responsesConfig, (responsesByMethod, path) => {
return _.map(responsesByMethod, (responses, method) => {
const methodForExpress = method.toLowerCase();
if (SUPPORTED_METHODS.includes(methodForExpress)) {
try {
validateResponses(responses, path, methodForExpress);
return new ResponseConfig(path, methodForExpress, responses);
} else {
log.warn(`Method ${method} is not supported. Path '${path}'-${method} will be skipped.`);
} catch (error) {
log.warn(`${error.message} ${methodForExpress}::${path} skipped`);
}
});
}).filter(response => {
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "aietes-js",
"version": "0.6.0",
"version": "0.7.0",
"description": "JSON mock server for NodeJs applications",
"main": "mock-server.js",
"scripts": {
Expand All @@ -21,12 +21,13 @@
],
"license": "MIT",
"dependencies": {
"ajv": "^6.12.4",
"express": "^4.17.1",
"fs": "^0.0.1-security",
"lodash": "^4.17.14",
"morgan": "^1.9.1",
"server-destroy": "^1.0.1",
"yargs": "^13.2.4"
"yargs": "^16.1.0"
},
"devDependencies": {
"coveralls": "^3.0.5",
Expand All @@ -39,6 +40,6 @@
"get-port": "^5.0.0",
"jest": "^24.8.0",
"pre-commit": "^1.2.2",
"supertest": "^4.0.2"
"supertest": "^6.0.0"
}
}
82 changes: 82 additions & 0 deletions test/configValidation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const validateResponseConfig = require('../lib/configValidator');

describe('response config validation', () => {
describe('validate HTTP methods', () => {
const validResponseObject = {
status: 201,
headers: { 'some-header': 'foo' },
data: { field1: 1, field2: 'value', field3: false }
};

it.each(['get', 'post', 'put', 'delete'])('valid method [%s]', (supportedMethod) => {
expect(() => validateResponseConfig(validResponseObject, '/foo', supportedMethod)).not.toThrow();
});

it.each(['connect', 'foo'])('invalid method [%s] results in error thrown', (supportedMethod) => {
expect(() => validateResponseConfig(validResponseObject, '/foo', supportedMethod)).toThrow(Error);
});
});

describe('validate response config', () => {
it('successfully validates empty response config', () => {
expect(() => validateResponseConfig({}, '/foo', 'get')).not.toThrow();
});

it('successfully validates response config with only status', () => {
const validResponseObject = {
status: 201
};
expect(() => validateResponseConfig(validResponseObject, '/foo', 'get')).not.toThrow();
});

it('successfully validates response config with only headers', () => {
const validResponseObject = {
headers: { 'some-header': 'foo' }
};
expect(() => validateResponseConfig(validResponseObject, '/foo', 'get')).not.toThrow();
});

it('successfully validates response config with only response body', () => {
const validResponseObject = {
data: { field1: 1, field2: 'value', field3: false }
};
expect(() => validateResponseConfig(validResponseObject, '/foo', 'get')).not.toThrow();
});

it('successfully validates complex response body config', () => {
const validResponseObject = {
status: 201,
headers: { 'some-header': 'foo', 'some-other-header': 'bar' },
data: { field1: 1, field2: 'value', field3: false }
};
expect(() => validateResponseConfig(validResponseObject, '/foo', 'get')).not.toThrow();
});

it('throws error for invalid status config', () => {
const validResponseObject = {
status: '400?',
data: { '3field1': 1, field2: 'value', field3: false }
};
expect(() => validateResponseConfig(validResponseObject, '/foo', 'get'))
.toThrow('Validation error: \'.status\' should be integer');
});

it('throws error for invalid headers config', () => {
const validResponseObject = {
status: 201,
headers: { 'some-header/3': 1, 'some-other-header': 'bar' },
data: { '3field1': 1, field2: 'value', field3: false }
};
expect(() => validateResponseConfig(validResponseObject, '/foo', 'get'))
.toThrow('Validation error: property [some-header/3] at path \'.headers\' should match pattern "^[\\w-]+$"');
});

it('throws error for invalid response body config', () => {
const validResponseObject = {
data: false
};
expect(() => validateResponseConfig(validResponseObject, '/foo', 'get'))
.toThrow('Validation error: \'.data\' should be object');
});
});
});
49 changes: 46 additions & 3 deletions test/mock-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ describe('AietesServer IT', () => {
expect(res.body.error.message).toMatch('Route /unconfiguredroute and method GET are not configured.');
});

it('mock responds with 404 for unsuppored operation (e.g. PATCH)', async() => {
const res = await request(mockServer.server).patch('/endpoint1');
it('mock responds with 404 for unsuppored operation (e.g. TRACE)', async() => {
const res = await request(mockServer.server).trace('/endpoint1');
expect(res.status).toBe(404);
expect(res.body.error.message).toMatch('Route /endpoint1 and method PATCH are not configured.');
expect(res.body.error.message).toMatch('Route /endpoint1 and method TRACE are not configured.');
});

it('should ignore capitalization of method keys', async() => {
Expand Down Expand Up @@ -207,4 +207,47 @@ describe('AietesServer IT', () => {

expect(res.status).toBe(404);
});

describe('Configuration errors', () => {
// TBD would be nice to test but terminates jest
// it('Aietes server exits cleanly if an error is thrown at startup (port not given)', () => {
// const mockServer = new AietesServer(
// {
// '/new-endpoint': {
// get: {
// status: 200
// }
// }
// },
// undefined
// );
// mockServer.start();
// expect(mockServer.server).toBeFalsy();
// });

it('Badly configured endpoints are skipped and server startup continues', async() => {
const mockServer = new AietesServer(
{
'/faulty-endpoint': {
get: {
status: '200'
}
},
'/endpoint2': {
get: {}
}
},
await getPort()
);
mockServer.start();

expect(mockServer.server).toBeTruthy();
let res = await request(mockServer.server).get('/faulty-endpoint');
expect(res.status).toBe(404);
res = await request(mockServer.server).get('/endpoint2');
expect(res.status).toBe(200);

mockServer.stop();
});
});
});
Loading

0 comments on commit dc870cd

Please sign in to comment.