@perigress/perigress
Version:
Contract testing + Data Generation
523 lines (487 loc) • 18.3 kB
JavaScript
const ks = require('kitchen-sync');
const access = require('object-accessor');
const arrays = require('async-arrays');
const jsonToJSONSchema = require('to-json-schema');
const joiToJSONSchema = require('joi-to-json')
const jsonSchemaFaker = require('json-schema-faker');
const { makeGenerator } = require('./random');
const fs = require('fs');
const path = require('path');
const { WKR, classifyRegex, generateData } = require('well-known-regex');
const sql = require('json-schema2sql');
const sequelize = require('json-schema2sequelize');
const template = require('es6-template-strings');
const defaults = {
error : ()=>{
},
results: ()=>{
}
}
const returnError = (res, error, errorConfig, config)=>{
let response;
try{
response = JSON.parse(JSON.stringify(errorConfig.structure));
}catch(ex){
response = {
structure: {
status: 'error',
error: {}
}
};
}
access.set(response, errorConfig.code, error.code);
access.set(response, errorConfig.message, error.message);
res.send(JSON.stringify(response, null, ' '));
};
const returnContent = (res, result, errorConfig, config)=>{
try{
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(result, null, ' '));
}catch(ex){}
};
const capitalize = (s)=>{
return s.split(' ').map((word)=>{
return word[0].toUpperCase()+word.substring(1);
}).join('');
};
const getInstance = (ob, key, cb)=>{
if(ob.deleted.indexOf(key) !== -1) return cb(null, null);
if(ob.instances[key]){
cb(null, ob.instances[key]);
}else{
ob.generate(key, (err, instance)=>{
cb(err, instance);
});
}
};
const DummyEndpoint = function(options, api){
this.returnError = returnError;
this.returnContent = returnContent;
this.makeGenerator = makeGenerator;
this.save = save;
this.nextId = ()=>{
return nextId(this);
}
this.getInstance = (key, cb)=>{
return getInstance(this, key, cb);
};
this.monitor = ()=>{};
this.options = options || {};
this.api = api;
this.instances = {};
this.deleted = [];
this.endpointOptions = {};
this.cleanupOptions(this.endpointOptions);
if(this.options.spec && !this.options.name){
this.options.name = this.options.spec.split('.').shift();
}
function SplitCamelCaseWithAbbreviations(s){
return s.split(/([A-Z][a-z]+)/).filter(function(e){return e});
}
let conf = this.config();
if(!this.options.expandable){
if(!conf.expandable){
// use the default
let identifier= this.options.identifier || 'id';
let keyPartJoiner = conf.foreignKeyJoin || ((...parts)=>{
return parts.map((part, index)=>{
if(index === 0) return part;
return capitalize(part);
}).join('');
});
let fk = this.options.foreignKey || conf.foreignKey || ((id, getTables)=>{
let parts = id.split(/([A-Z][a-z]+)/).filter((e) => e).map((e) => e.toLowerCase());
let suffix = parts.pop();
if(suffix === identifier){
let remainder = keyPartJoiner.apply(keyPartJoiner, parts);
let tables = getTables(remainder);
return parts;
}
return false;
});
let e = this;
this.options.expandable = function(type, fieldName, fieldValue){
// returns falsy *OR* {type, value}
try{
let endpointNames = e.api.endpoints.map((endpoint)=> endpoint.options.name);
let expanded = fk(fieldName, (name)=>{
return endpointNames.filter((eName)=>{
return eName.indexOf(name) !== -1;
});
});
return expanded || false;
}catch(ex){
console.log(ex)
}
/*const sentinel = keyPartJoiner('a', identifier).substring(1);
const index = fieldName.lastIndexOf(sentinel);
if(index === -1) return false;
if(
// did we find it at the end of the string?
index + identifier.length === fieldName.length &&
// is the id an integer?
fieldValue === '::'?true:Number.isInteger(fieldValue)
){
const linkField = fieldName.substring(0, fieldName.length - sentinel.length);
return {
type: linkField,
suffix: fieldName.substring(fieldName.length - identifier.length)
};
}
return false;*/
};
}
if(conf.expandable && typeof conf.expandable === 'function'){
// use the default
this.options.expandable = conf.expandable;
}
}
}
DummyEndpoint.prototype.cleanupOptions = function(options){
if(!options.method){
options.method = 'ALL';
}
}
DummyEndpoint.prototype.log = function(message, level){
if(this.api && (this.api.options.verbose || this.api.options.debug)){
this.api.log(message, level);
}
}
DummyEndpoint.prototype.makeDataFileWrapper = function(opts, statements){
let options = opts || {format:'sql'};
let result = null;
let config = this.config();
// TODO: switch to a plugin loader pattern
let exportNames = opts.export || [ this.options.name.substring(0,1).toUpperCase()+
this.options.name.substring(1) ];
switch((options.format||'').toLowerCase()){
case 'sequelize':
let include = `const { Sequelize, DataTypes, Model } = require('@sequelize/core');`;
include += `\nconst sequelize = require('${options.sequelizePath}');\n`
let exportText = `module.exports = ###;`
result = include+statements.join("\n")+'';
if(!options.seperate){
result = result+"\n"+exportText.replace('###', `{${exportNames.join(', ')}}`)
}
break;
case 'sql':
result = statements.join(";\n")+';';
break;
default: throw new Error('Unknown Type: '+options.format);
}
return result;
}
DummyEndpoint.prototype.toDataDefinition = function(opts, names){
let options = opts || {format:'sql'};
let tableDefinitions = [];
let config = this.config();
// TODO: switch to a plugin loader pattern
let statements = null;
let isSerial = config.primaryKey?
[
'integer',
'number'
].indexOf(this.schema.properties[config.primaryKey].type) !== -1:
false;
switch((options.format||'').toLowerCase()){
case 'sequelize':
let capName = this.options.name.substring(0,1).toUpperCase()+
this.options.name.substring(1);
if(names) names.push(capName);
statements = sequelize.toSequelize(this.options.name, this.schema, {
primaryKey: config.primaryKey,
foreignKey: options.isForeignKey,
serial: isSerial
});
if(options.seperate){
statements = statements.map(
(s)=>include+"\n"+s+"\n"+exportText.replace('###', capName)
)
}
tableDefinitions = tableDefinitions.concat(statements);
break;
case 'sql':
statements = sql.toSQL(this.options.name, this.schema, {
primaryKey: config.primaryKey,
serial: isSerial,
foreignKey: options.isForeignKey
});
tableDefinitions = tableDefinitions.concat(statements);
break;
default: throw new Error('Unknown Type: \''+options.format+'\'');
}
return tableDefinitions;
}
DummyEndpoint.prototype.makeMigrationFileWrapper = function(opts, statements){
let options = opts || {format:'sql'};
let result = null;
let config = this.config();
// TODO: switch to a plugin loader pattern
let exportNames = opts.export || [ this.options.name.substring(0,1).toUpperCase()+
this.options.name.substring(1) ];
switch((options.format||'').toLowerCase()){
case 'sequelize':
break;
case 'sql':
break;
default: throw new Error('Unknown Type: '+options.format);
}
return result;
}
DummyEndpoint.prototype.toDataMigration = function(schema, opts, names){
let options = opts || {format:'sql'};
let tableDefinitions = [];
let config = this.config();
// TODO: switch to a plugin loader pattern
let statements = null;
switch((options.format||'').toLowerCase()){
case 'sequelize':
break;
case 'sql':
break;
default: throw new Error('Unknown Type: \''+options.format+'\'');
}
return tableDefinitions;
}
DummyEndpoint.prototype.cleanedSchema = function(s){
let schema = s || {};
if(schema && schema.type === 'object' && schema['$_root']){ //this is a joi def
schema = joiToJSONSchema(schema);
}
let copy = JSON.parse(JSON.stringify(schema));
(Object.keys(copy.properties || {})).forEach((key)=>{
if(copy.properties[key] && copy.properties[key].pattern){
copy.properties[key].pattern = copy.properties[key].pattern.replace(/\?<[A-Za-z][A-Za-z0-9]*>/g, '')
}
if(!copy.properties[key]){
process.exit();
}
//TODO: object, array support
});
return copy;
}
DummyEndpoint.prototype.formatItems = function(opts, items){
let options = opts || {format:'sql'};
let result = null;
let config = this.config();
// TODO: switch to a plugin loader pattern
let exportNames = opts.export || [ this.options.name.substring(0,1).toUpperCase()+
this.options.name.substring(1) ];
switch((options.format||'').toLowerCase()){
case 'sequelize':
result = sequelize.toSequelizeInsert(this.options.name, items, {});
break;
case 'sql':
result = sql.toSQLInsert(this.options.name, items, {});
break;
default: throw new Error('Unknown Type: '+options.format);
}
return result;
}
DummyEndpoint.prototype.generate = function(id, o, c){
let cb = typeof o === 'function'?o:(typeof c === 'function'?c:()=>{});
let options = typeof o === 'object'?o:{};
let gen = makeGenerator(id+'');
jsonSchemaFaker.option('random', () => gen.randomInt(0, 1000)/1000);
// JSF's underlying randexp barfs on named capture groups, which we care about
let cleaned = this.cleanedSchema(this.schema);
let config = this.config();
//TODO: make default come from datasource
let primaryKey = config.primaryKey || 'id';
jsonSchemaFaker.resolve(cleaned, [], process.cwd()).then((value)=>{
if(this.schema.properties[primaryKey] && value[primaryKey]){
switch(this.schema.properties[primaryKey].type){
case 'integer':
value[primaryKey] = parseInt(id);
break
case 'string':
value[primaryKey] = id;
break;
default : throw new Error(
'Cannot create a primary key with type:'+
this.schema.properties[primaryKey].type
)
}
}
let generated;
try{
generated = generateData(this.schema, {
locale: 'en_us',
seed: id
});
}catch(ex){
console.log(ex);
}
Object.keys(generated).forEach((key)=>{
value[key] = generated[key];
});
cb(null, value);
}).catch((ex)=>{
console.log(ex);
});
}
const nextId = (instance)=>{
let id = 1;
while(instance.instances[id]) id++;
return id;
};
const getEndpoint = (ob, type)=>{
let res = ob.api.endpoints.find((item)=>{
return item.options.name === type;
});
return res;
};
const save = ({ob, identifier, type, item}, cb)=>{
let instance = getEndpoint(ob, type);
if(instance.monitor) instance.monitor({ob, identifier, type, item});
if(!instance) return setTimeout(
()=> cb(new Error('No registered type:'+type))
);
if(!item[identifier]){
item[identifier] = nextId(instance);
}
instance.instances[item[identifier]] = item;
setTimeout(()=>{
cb(null, item);
});
};
DummyEndpoint.prototype.attach = function(expressInstance){
try{
if(!this.api.outputFormat) throw new Error('no output format');
if(!expressInstance) throw new Error('no express instance');
this.api.outputFormat.attach(expressInstance, this);
}catch(ex){
}
}
DummyEndpoint.prototype.config = function(){
return this.api.config(this.options.path);
}
DummyEndpoint.prototype.errorSpec = function(){
return this.api.errorSpec(this.options.path);
}
DummyEndpoint.prototype.resultSpec = function(){
return this.api.resultSpec(this.options.path);
}
DummyEndpoint.prototype.loadSchema = function(filePath, extension, callback){
let fixedPath = filePath[0] === '/'?filePath:path.join(process.cwd(), filePath);
switch(extension){
case 'spec.js':
try{
schema = require(fixedPath);
schema = joiToJSONSchema(schema);
setTimeout(()=>{
callback(null, schema);
});
}catch(ex){
callback(ex);
}
break;
case 'spec.json':
fs.readFile(fixedPath, (err, body)=>{
try{
schema = JSON.parse(body);
schema = jsonToJSONSchema(schema);
callback(null, schema);
}catch(ex){
callback(ex);
}
});
break;
case 'spec.schema.json':
fs.readFile(fixedPath, (err, body)=>{
try{
schema = JSON.parse(body);
schema = jsonToJSONSchema(schema);
callback(null, schema);
}catch(ex){
callback(ex);
}
});
break;
default : throw new Error('Unrecognized extension: '+extension);
}
}
DummyEndpoint.prototype.load = function(dir, name, extension, cb){
const callback = ks(cb);
const filePath = path.join(dir, `${name}.${extension}`);
const requestPath = filePath.replace('.spec.', '.request.');
const optionsPath = path.join(dir, `${name}.options.json`);
fs.readdir(dir, (err, list)=>{
let exampleFiles = list.filter(listname =>{
return (listname.indexOf(`.${name}.example.json`) !== -1) ||
(listname.indexOf(`.${name}.input.json`) !== -1);
});
let matched = null;
if(exampleFiles.length){
let examples = exampleFiles.filter(listname => listname.indexOf(`.${name}.example.json`) !== -1);
let inputs = exampleFiles.filter(listname => listname.indexOf(`.${name}.input.json`) !== -1);
matched = examples.map((i)=>{ return {
output: i,
input: (
inputs.filter(
item => item.indexOf(`${i.split('.').shift()}.${name}.input.json`) === 0
)[0]
)
}});
//now load
matched = matched.map((item)=>{
let res = {};
if(item.input) res.input = require(
dir[0] === '/'?
path.join(dir, item.input):
path.join(process.cwd(), dir, item.input)
);
if(item.output) res.output = require(
dir[0] === '/'?
path.join(dir, item.output):
path.join(process.cwd(), dir, item.output)
);
return res;
});
}
this.loadSchema(filePath, extension, (err, schema)=>{
if(err) return callback(err);
this.schema = schema;
this.originalSchema = JSON.parse(JSON.stringify(schema));
let config = this.config();
let primaryKey = config.primaryKey || 'id';
if(matched){
matched.forEach((item)=>{
if(item.output && item.output[primaryKey]){
this.instances[item.output[primaryKey]] = item.output;
}
});
//TODO: handle inputs
}
if(config && config.auditColumns && config.auditColumns['$_root']){
config.auditColumns = joiToJSONSchema(config.auditColumns);
}
if(config.auditColumns && config.auditColumns.properties){
Object.keys(config.auditColumns.properties).forEach((key)=>{
this.schema.properties[key] = config.auditColumns.properties[key];
});
}
if(config.auditColumns && config.auditColumns.required){
config.auditColumns.required.forEach((key)=>{
this.schema.required.push(key);
});
}
this.loadSchema(requestPath, extension, (err, requestSchema)=>{
if(!err){
this.requestSchema = requestSchema;
}
fs.readFile(optionsPath, (err, body)=>{
if(err) return callback(err);
try{
this.endpointOptions = JSON.parse(body);
this.cleanupOptions(this.endpointOptions);
}catch(ex){
callback(ex);
}
});
});
});
});
return callback.return;
}
module.exports = DummyEndpoint;