UNPKG

signavio-api

Version:

Unofficial node signavio workflow api wrapper

697 lines (574 loc) 20.5 kB
'use strict'; const _ = require('./lodashes'); const Base = require('./base/.'); const EventEmitter = require('events').EventEmitter; const Joi = require('joi'); const request = require('request'); const querystring = require('querystring'); const util = require('util'); const inspect = util.inspect; const debug = require('debug')('signavio-api:generator'); const pluralMap = { 'processes': 'process', 'activities': 'activity', 'versions': 'version', 'fields': 'field', 'transitions': 'transition', 'tasks': 'task', 'parameters': 'parameter', 'bindings': 'binding', 'mappings': 'mapping', 'casecolumns': 'caseColumn', 'variables': 'variable', 'accounts': 'account', 'services': 'service', 'options': 'option', 'actioninstances': 'actionInstance', 'files': 'file', 'registrations': 'registration', 'users': 'user', 'documents': 'document', 'mails': 'mail', 'workflows': 'workflow', 'cases': 'case', 'events': 'event', 'instances': 'instance', 'licenses': 'license' }; const operationMap = { User: { 'POST /registrations/{code}/activate': { name: 'activateRegistration' } }, Task: { 'POST /{organizationKey}/tasks/{taskId}/complete': { name: 'completeTask' }, 'POST /{organizationKey}/tasks/{taskId}/reopen': { name: 'reopenTask' } }, Case: { 'POST /{organizationKey}/cases/{caseId}/cancel': { name: 'cancel' }, 'POST /{organizationKey}/cases/{caseId}/close': { name: 'close' } } }; const schemaModels = {}; const ioptionsSchema = Joi.object().keys({ authorization: Joi.string().optional().description('Authorization token, will be set as Authorization http header'), credentials: Joi.object().optional().keys({ username: Joi.string().description('Username'), password: Joi.string().description('Password') }).description('Default credentials'), basePath: Joi.string().optional().description('Effektif-api base url, defaults to api endpoint documentation basePath'), onUnauthorized: Joi.func().optional().description('Excecuted when an unauthorized call was made or authorization token is missing').notes(['Receives operation arguments and callback']), users: Joi.object().optional().keys({ login: Joi.func().required().description('Login function') }).unknown(true).description('Users instance'), log: Joi.func().optional().description('Logging function, defaults to console.log'), baseRequest: Joi.func().optional().description('Default request') }); module.exports = function(apiName, apiDef, options) { let template = apiDef; if (typeof apiDef === 'string') { template = require(apiDef); } const api = generate(apiName, template, options || {}); api.version = template.apiVersion; api.basePath = template.basePath; return api; }; function generate(apiName, apiDef, options) { const internals = {}; apiDef.name = apiName; // Pre-load models preLoadModels(apiName, apiDef); internals.Interface = function(ioptions) { const Debug = require('debug'); if (this.constructor !== internals.Interface) { throw new Error(`${apiName} must be instantiated using new`); } this.name = apiName.toLowerCase(); Joi.attempt(ioptions, ioptionsSchema); this.options = ioptions || {}; this.defaults = {}; if (this.options.authorization) { this.defaults.authorization = this.options.authorization; } this.options.basePath = (ioptions && ioptions.basePath) || apiDef.basePath; this.baseRequest = this.options.baseRequest || request; this._debug = Debug('signavio-api:' + this.name); this._debugError = Debug('signavio-api:' + this.name + ':error'); const logFunction = (ioptions && ioptions.log) || options.log; // && typeof ioptions.log === 'function' && ioptions.log; if (logFunction) { this._debug.log = logFunction; this._debugError.log = logFunction; } }; internals.Interface.ctorSchema = ioptionsSchema; if (Base[apiName]) { util.inherits(internals.Interface, Base[apiName]); } else { util.inherits(internals.Interface, EventEmitter); } internals.Interface.schemas = {}; internals.Interface.apiName = apiName; internals.Interface.apiDoc = apiDef; // Placeholder for getting user interface. Set by index.js internals.Interface.prototype.getUserInstance = function() { if (this.options.users) return this.options.users; throw new Error('User interface is not loaded'); }; internals.Interface.prototype._onUnauthorized = function(requestArguments, callback) { const self = this; if (self.options.onUnauthorized) { self.log('info', 'using custom onUnauthorized'); return self.options.onUnauthorized.call(self, requestArguments, (err, token) => { if (err) return callback(err); if (typeof token !== 'string') return callback(new Error('Missing authorization token or not a string')); requestArguments.Authorization = token; self.log('info', `custom onUnauthorized responded with ${token}`); return callback(null, true); }); } if (!self.options.credentials) { return callback(new Error('Missing credentials')); } self.log('info', 'onUnauthorized'); const users = self._users || self.getUserInstance(self.options); if (!self._users) self._users = users; function loginCallback(err, body, resp) { if (err) return callback(err, false, resp); if (resp.statusCode === 200) { if (!body || !body.token) return callback(new Error('No authorization token'), false, resp); self.log('info', 'received new authorization token'); // Save new token to instance self.defaults.authorization = body.token; self.options.authorization = body.token; self.emit('authorized', { authorization: body.token, username: self.options.credentials.username }); // Set authorization token on request arguments requestArguments.Authorization = body.token; } else { self.log('warning', `login returned ${resp.statusCode}`); return callback(new Error(`Login failed with ${resp.statusCode}`), false, resp); } return callback(err, true, resp); } return users.login(self.options.credentials.username, self.options.credentials.password, loginCallback); }; internals.Interface.prototype._applyDefaults = function(args, schema) { const self = this; const schemaDescr = schema.describe(); const argsKeys = Object.keys(args); const lastArg = _.last(argsKeys); if (typeof args[lastArg] === 'function') { args.callback = args[lastArg]; delete args[lastArg]; } const schemaKeys = Object.keys(schemaDescr.children); schemaKeys.forEach((c) => { const childMeta = schemaDescr.children[c].meta[0]; if (childMeta.header && self.defaults) { args[childMeta.name] = self.defaults[c] || self.defaults[c.toLowerCase()]; } }); return args; }; internals.Interface.prototype.log = function(level, message) { if (level === 'error') { return this._debugError(message); } return this._debug(message); }; internals.Interface.prototype._getRequestOptions = function(operation, args) { const self = this; const descr = operation.schemas.input.describe(); const qs = {}; const headers = {}; let apiPath = operation.path; let qsDefined = false; let body; Object.keys(descr.children).forEach((p) => { const parm = descr.children[p]; const argValue = args[p]; const parmMeta = parm.meta[0]; if (parmMeta.header) { headers[parmMeta.name] = argValue; } else if (parmMeta.path) { apiPath = apiPath.replace('{' + p + '}', argValue); } else if (parmMeta.query) { if (typeof argValue !== 'undefined' & argValue !== null) { qsDefined = true; qs[p] = argValue.toString(); } } else if (parmMeta.body) { body = argValue; } }); const reqOptions = { method: operation.method, uri: self.options.basePath + apiPath + (qsDefined ? '?' + querystring.stringify(qs) : ''), headers: headers, json: body }; if (/GET/i.test(operation.method)) { const outputSchema = operation.schemas.output; reqOptions.json = (outputSchema && outputSchema.isJoi); } return reqOptions; }; internals.Interface.prototype.proxy = function(reqOptions, callback) { const self = this; const roptions = Object.assign({}, reqOptions); delete roptions.authorization; if (!roptions.uri && reqOptions.path) { roptions.uri = self.options.basePath + reqOptions.path; delete roptions.path; } if (this.defaults.authorization) { _.set(roptions, 'headers.authorization', this.defaults.authorization); } const reqSignature = `${roptions.method || 'GET'} ${roptions.uri}`; self._debug('making call', reqSignature); function innerCallback(err, resp, body) { if (!err) { self._debug('call', `${roptions.method || 'GET'} ${roptions.uri} responded with ${resp.statusCode}`); } // Check if request is unauthorized if (resp && resp.statusCode === 401) { self._debug('warning', reqSignature, 'call was unauthorized'); if (reqOptions.authorization === true) { // Make login request return self._onUnauthorized(roptions, (authErr, token, authResp) => { if (authErr) return callback(authErr, token, authResp); // Make authorized request _.set(roptions, 'headers.authorization', roptions.Authorization); self._debug('reissuing', reqSignature, 'with', inspect(roptions)); return self.baseRequest(roptions, (err2, resp2, body2) => { return callback(err2, body2, resp2); }); }); } } return callback.call(self, err, body, resp); } return self.baseRequest(roptions, innerCallback); }; apiDef.apis.forEach((api) => { api.operations.forEach((op) => { const opName = getOperationName(apiName, api.path, op.method); const opArgs = getOperationArguments(apiName, api.path, op); let schema; let requiresAuthorization = false; if (opArgs) { schema = mapArguments(opArgs, apiDef); const signatureDescr = schema.describe(); requiresAuthorization = !!signatureDescr.meta[0].requiresAuthorization; debug(api.path, op.method, opName, 'requires authorization:', requiresAuthorization); } const output = mapOutput(op.type, apiDef); internals.Interface.schemas[opName] = { input: schema, output: output }; internals.Interface.models = schemaModels; function makeRequest(reqArgs, callback) { const self = this; const reqOptions = self._getRequestOptions(operation, reqArgs); self.log('info', `#${opName} making call ${op.method} ${reqOptions.uri}`); function innerCallback(err, resp, body) { return requestCallback.call(self, err, resp, body, callback); } return self.baseRequest(reqOptions, innerCallback); } function operation() { const self = this; const callback = arguments.length > 0 ? arguments[arguments.length - 1] : null; if (typeof callback !== 'function') { throw new Error(opName + ' requires callback'); } // Validate operation arguments schema let args = Object.assign({}, arguments); args = self._applyDefaults(args, schema); const validationResult = schema.validate(args); const validationError = validationResult.error; const operationArgs = validationResult.value; self.log('info', `Calling #${opName} with ${inspect(operationArgs)}`); if (validationError) { // Check if authorization token is missing if (/authorization.*required/i.test(validationError.message)) { // Make login request return self._onUnauthorized(operationArgs, (authErr) => { if (authErr) return callback(authErr); // Perform request return makeRequest.call(self, operationArgs, callback); }); } // Leave if other validation error self.log('error', `#${opName} validation failed with ${validationError} ${inspect(validationError, false, 3)}`); return callback(validationError); } // Perform request function innerCallback(err, body, resp) { // Check if request is unauthorized if (resp && resp.statusCode === 401) { self.log('warning', `#${opName} call was unauthorized`); if (requiresAuthorization) { // Make login request return self._onUnauthorized(operationArgs, (authErr, token, authResp) => { if (authErr) return callback(authErr, token, authResp); // Make authorized request self.log('info', `Reissuing #${opName} with ${inspect(operationArgs)}`); return makeRequest.call(self, operationArgs, callback); }); } } return callback(err, body, resp); } return makeRequest.call(self, operationArgs, innerCallback); } operation.schemas = internals.Interface.schemas[opName]; operation.path = api.path; operation.method = op.method; operation.requiresAuthorization = requiresAuthorization; internals.Interface.prototype[opName] = operation; }); }); return internals.Interface; } function mapMethod(method) { switch (method) { case 'PUT': return 'update'; case 'POST': return 'create'; default: return method.toLowerCase(); } } function splitApiPath(apiPath) { const cleanPath = apiPath.replace(/^\//, ''); const parts = cleanPath.split('/'); const def = []; const pathRegex = /\{\w+\}/; parts.forEach((part, i) => { if (!pathRegex.test(part)) { if (i < parts.length - 1 && pathRegex.test(parts[i + 1])) { def.push(pluralMap[part] || part); } else { def.push(part); } } }); return def; } function getOperationName(interfaceName, apiPath, method) { const map = operationMap[interfaceName] || {}; const opId = util.format(method, apiPath); let opName = mapMethod(method); if (map[opId] && map[opId].name) { const newName = map[opId].name; debug(apiPath, method, opName, 'is renamed to', newName); return newName; } const parts = splitApiPath(apiPath); parts.forEach((part) => { opName += part.replace(/^\w/, (match) => { return match.toUpperCase(); }); }); return opName; } function getOperationArguments(interfaceName, apiPath, op) { const parameters = op.parameters; if (!parameters) return parameters; // Normalize parameters.forEach((parm) => { parm.type = parm.dataType; }); return parameters; } function mapArrayType(arrayProp, apiDef, parentTypeStack) { let model = Joi.array(); const refType = _.get(arrayProp, 'items.$ref'); if (!refType) { debug('warning', `${apiDef.name} ${arrayProp} is missing items`); return model; } else if (parentTypeStack[refType]) { return model.meta({ originalType: refType }).tags('model'); } const itemsType = mapToJoiType(refType, apiDef, null, parentTypeStack).meta({ originalType: refType }); model = model.items(itemsType); return model; } function mapToJoiType(type, apiDef, originalProp, parentTypeStack) { if (!type) return null; if (schemaModels[type]) { return schemaModels[type]; } let model = apiDef.models[type]; if (!model) { switch (type) { case 'string': case 'object-id': model = Joi.string().allow(''); break; case 'LocalDateTime': model = Joi.date(); break; case 'integer': case 'Long': model = Joi.number().integer(); break; case 'array': model = mapArrayType(originalProp, apiDef, parentTypeStack); break; default: if (typeof Joi[type] !== 'function') { debug('warning', `${apiDef.name}: type "${type}" not found among models`); model = Joi.any().meta({ originalType: type }); break; } model = Joi[type](); break; } return model; } // Add to parent stack to avoid circular references parentTypeStack[type] = true; let schema = Joi.object(); if (model.properties) { const keys = {}; Object.keys(model.properties).forEach((propName) => { const prop = model.properties[propName]; parentTypeStack[prop.type] = false; if (prop.type === type) { keys[propName] = Joi.any(); } else { keys[propName] = mapToJoiType(prop.type, apiDef, prop, parentTypeStack); } }); schema = schema.keys(keys).tags('model'); } schemaModels[type] = schema.unknown(); return schema; } function mapArgumentType(param, argIndex, apiDef) { let argSchema; const argMeta = { name: param.name, argument: true }; const parentTypeStack = {}; argMeta[param.paramType] = true; switch (param.paramType) { case 'header': argSchema = mapToJoiType(param.type, apiDef, param, parentTypeStack); argMeta.argument = false; argMeta.requiresAuthorization = (param.name.toUpperCase() === 'AUTHORIZATION'); break; case 'path': case 'query': argSchema = mapToJoiType(param.type, apiDef, param, parentTypeStack).allow(null); param.required = false; break; case 'body': if (!param.name) param.name = 'body'; argSchema = mapToJoiType(param.type, apiDef, param, parentTypeStack); argMeta.name = param.name; break; default: break; } if (!argSchema) { return null; } if (param.required) { argSchema = argSchema.required(); } argMeta.originalType = param.type; argSchema = argSchema.meta(argMeta); return argSchema; } function mapArguments(parameters, apiDef) { const keys = {}; const schemaMeta = {}; let schema = Joi.object(); let argumentIndex = 0; parameters.forEach((param) => { const argSchema = mapArgumentType(param, argumentIndex, apiDef); if (argSchema) { const argMeta = argSchema.describe().meta[0]; if (argMeta.argument) { schema = schema.rename(argumentIndex.toString(), argMeta.name); argumentIndex++; } keys[argMeta.name] = argSchema; if (argMeta.requiresAuthorization) { schemaMeta.requiresAuthorization = true; } } }); keys.callback = Joi.func().required().meta({ argument: true }).description('function(err, body, resp)'); const mSchema = Joi.compile(schema.keys(keys)).meta(schemaMeta); return mSchema; } function mapOutput(outputType, apiDef) { const outputSchema = mapToJoiType(outputType, apiDef); if (!outputSchema) { return outputSchema; } const mSchema = Joi.compile(outputSchema).label(outputType).meta({ 'originalType': outputType }); return mSchema; } function preLoadModels(apiName, apiDef) { Object.keys(apiDef.models).forEach((modelKey) => { const model = apiDef.models[modelKey]; if (!schemaModels[model.id]) { mapToJoiType(model.id, apiDef, model, {}); } }); } function requestCallback(err, resp, body, callback) { const self = this; if (err) return callback(err, body, resp); let result = body; let parseErr; if (typeof body === 'string') { if (/json/i.test(resp.headers['content-type'])) { try { result = JSON.parse(body); } catch (e) { parseErr = e; } if (parseErr) { self.log('error', `Failed to parse ${resp.headers['content-type']} json body >${body}<: ${parseErr}`); } } } const reqPath = resp.request.path; self.log('info', `${resp.request.method} ${reqPath} responded with ${resp.statusCode} ${resp.headers['content-type']}`); if (resp.statusCode >= 400) { if (result && result.message) err = new Error(result.message); else err = new Error(`Call to signavio-api failed with ${resp.statusCode}${_.isEmpty(body) ? '' : ': ' + body}`); } return callback(err || parseErr, result, resp); }