UNPKG

apinode

Version:

An API server that can greatly reduce the work needed to implment API services. It can also cooperate with other API node to make it a mesh of services.

824 lines (710 loc) 20.8 kB
/*! * apinode * authors: Ben Lue * Copyright(c) 2015~2017 Gocharm Inc. */ /** * The main flow: * handleRequest -> validateToken -> singleRequest -> doResponse -> answerReq * + reqCanUseApp -> invokeService */ var admin = require('../base/adminOffice.js'), apiQueue = require('../base/apiQueue.js'), async = require('async'), crypto = require('crypto'), dd = require('./pp/DeviceDetector.js'), acUtil = require('../util/acUtil.js'), errUtil = require('../util/errUtil.js'), logUtil = require('../util/logUtil.js'), reqUtil = require('../util/reqUtil.js'), fs = require('fs'), operators = require('../base/operators.js'), path = require('path'), Promise = require('bluebird'), soar = require('sql-soar'), tokenUtil = require('../util/tokenUtil.js'), url = require('url'); var TOKEN_LENGTH = 48, GUEST_USER_ID = 20; var _tsWindow = 60 * 24 * 60 * 60 * 1000; // service point injection var reqCanUseApp; exports.init = function(options) { options.appPath = options.appPath || path.join(__dirname, '../app/'); soar.config( options.db || options ); operators.config(options); logUtil.setLogDir( options.fileRoot ); // inject plugin functions reqCanUseApp = canUseApp; }; /** * the main entrance */ exports.intake = function(req, res, next) { getAppInfo(req) .then(function(caData) { handleRequest(caData, req, res, next); }) .catch(function(err) { answerReq( null, res, next, err ); }); }; /** * When a request has already been trusted, invoke this function to run an endpoint directly. * Return a promise which should contain the result object. */ exports.serviceWithTrust = function(rt, caData) { // setup the runtime environment rt.forward = internalRequest; rt.returnError = returnErrorMessage; return singleRequest(rt, caData); }; /** * This is the main request handling funciton. * * @param {*} caData * @param {*} req * @param {*} res * @param {*} next */ function handleRequest(caData, req, res, next) { try { var appCode = caData.caCode, rt = buildRT(appCode, req); rt.app = caData; //console.log(JSON.stringify(rt.ep, '', 4)); if (rt.ep.app === 'api') { // 'api' is a special app. it will take a different route. if (rt.ep.rs === 'batch') batchRequest(rt, caData, res, next); else if (rt.ep.rs === 'list') { operators.getAPIList(function(result) { answerReq( rt, res, next, result ); }); } else if (rt.ep.rs === 'queue' && rt.ep.op === 'add') { var inData = rt.inData, task = { rt: rt, ep: inData.ep, param: inData.param, listener: inData.listener }; apiQueue.push( task ); answerReq(rt, res, next, {code: 0, message: 'Ok'}); } else operators.getAPIDoc( rt.ep, function(result) { answerReq( rt, res, next, result ); }); } else { validateToken(caData, rt) .then(function(uPro) { rt.uPro = uPro; return singleRequest(rt, caData); }) .then(function(result) { doResponse( rt, res, next, result ); }) .catch(function(err) { //console.log('error is\n%s', JSON.stringify(err, null, 4)); answerReq( rt, res, next, err ); }); } } catch (err) { if (err.stack) { console.log( err.stack ); err = errUtil.err( errUtil.INTERNAL_ERR ); } answerReq( rt, res, next, err ); } }; /** * Handle a single request. */ function singleRequest(rt, caData) { return reqCanUseApp(caData, rt.ep) /* .then(function(isOk) { if (isOk) return reqCanUseDS(caData, rt.ep); throw errUtil.err( errUtil.NO_PERMISSION ); }) */ .then(function(isOk) { if (isOk) return invokeService(rt); throw errUtil.err( errUtil.NO_PERMISSION ); }); }; /** * We'll allow apps to leverage functions of other apps, and this is how it can be done. */ function internalRequest(rt, endpoint, inData, cb) { if (arguments.length === 3) { cb = inData; inData = {}; } if (endpoint.indexOf('@') === -1) endpoint = '/@' + endpoint; else endpoint = '/' + endpoint; var ep = reqUtil.parseEndpoint( rt.app.caCode, endpoint ), caData = rt.app; ep.caID = caData.CApp_id; //console.log('roleID: ' + rt.uPro.roleID); //console.log('endpoint spec: ' + endpoint); //console.log("forward to endpoint:\n%s", JSON.stringify(ep, null, 4)); var nrt = { req: rt.req, remoteAddr: rt.remoteAddr, app: caData, ep: ep, uPro: rt.uPro, inData: inData || {}, forward: internalRequest, returnError: returnErrorMessage }; reqCanUseApp(caData, ep) /* .then(function(isOk) { if (isOk) return reqCanUseDS(caData, ep); throw errUtil.err( errUtil.NO_PERMISSION ); }) */ .then(function(isOk) { if (isOk) { if (ep.app === 'admin' && ep.rs === 'token' && ep.op === 'request') { // a special case of token request? nrt.req.headers['x-deva-appkey'] = rt.app.appKey; nrt.req.headers['x-deva-appsecret'] = rt.app.appSecret; } return nrt; } throw errUtil.err( errUtil.NO_PERMISSION ); }). then(function(nrt) { return checkPermission(nrt); }) .then(function(opData) { return route(nrt, opData); }) .then(function(result) { if (result.code) // in this case, the execution is not successful. Treat the result as errors. cb( result ); else cb( null, result ); }) .catch(function(err) { if (err.stack) console.log( err.stack ); //console.log( JSON.stringify(err, null, 4) ); if (err.code) cb(err); else cb( null, err ); }); } /** * This function can be used to read the error message of endpoint based on the given locale. * This will be deprecated in favor of answerError(). */ function returnErrorMessage(errCode, cb) { var ep = this.ep, locale = this.req ? (this.req.headers['x-deva-locale'] || 'en') : 'en'; operators.getErrorMessage( ep, function(err, errObj) { var message; if (errObj) { //var msgObj = errObj[String.valueOf(errCode)]; var msgObj = errObj[errCode + '']; if (msgObj) message = msgObj[locale] || msgObj['en']; } cb( {code: errCode, message: message || ''} ); }); } function answerError(rt, errCode, cb) { var ep = rt.ep, locale = rt.req ? (rt.req.headers['x-deva-locale'] || 'en') : 'en'; operators.getErrorMessage( ep, function(err, errObj) { var message; if (errObj) { //var msgObj = errObj[String.valueOf(errCode)]; var msgObj = errObj[errCode + '']; if (msgObj) message = msgObj[locale] || msgObj['en']; } cb( {code: errCode, message: message || ''} ); }); } function answerOk(result, cb) { if (arguments.length == 1) { cb = result; result = null; } if (result) cb({code: 0, message: 'Ok', value: result}); else cb({code: 0, message: 'Ok'}); } function getAppInfo(req) { var appCode = req.headers.host.split('.')[0]; if (!isNaN(appCode)) appCode = req.headers['x-deva-appcode']; return new Promise(function(resolve, reject) { if (!appCode) return reject( errUtil.err(errUtil.NO_APP_REQ) ); admin.getCA(appCode, function(err, caData) { if (err) return reject(err); //console.log('app data is\n%s', JSON.stringify(caData, null, 4)); resolve( caData ); }); }); }; function batchRequest(rt, caData, res, next) { validateToken(caData, rt) .then(function(uPro) { rt.uPro = uPro; var inData = rt.inData, op = rt.ep.op, appCode = caData.caCode; if (inData._req) { if (op === 'serial') { //console.log('Serial uPro:\n%s', JSON.stringify(rt.uPro, null, 4)); var resultList = []; async.eachSeries( inData._req, function(req, cb) { // some op may add something to inData, so we have to clone inData var eachInData = cloneObj( req.in ), eachUPro = cloneObj( uPro ); var nrt = {req: rt.req, inData: eachInData, ep: reqUtil.parseEndpoint(appCode, req.ep), uPro: eachUPro, remoteAddr: rt.remoteAddr, forward: internalRequest, returnError: returnErrorMessage, app: caData}; singleRequest(nrt, caData).then(function(result) { resultList.push( result ); cb(); }).catch(cb); }, function(err) { var result; if (err) { resultList.push( err ); result = {code: errUtil.BATCH_SERIAL_FAIL, message: 'Serial batch failed.', value: resultList}; } else result = {code: 0, message: 'Ok', value: resultList }; answerReq( null, res, next, result ); }); } else if (op === 'parallel') { var rtnList = new Array( inData._req.length ), //rtList = new Array( inData._req.length ), errFlag = false; // if errors occurred, Promise cannot return every result of each request. So use 'async' instead async.forEachOf(inData._req, function(req, idx, cb) { // some op may add something to inData, so we have to clone inData var eachInData = cloneObj( req.in ), eachUPro = cloneObj( uPro ); var nrt = {req: rt.req, inData: eachInData, ep: reqUtil.parseEndpoint(appCode, req.ep), uPro: eachUPro, forward: internalRequest, remoteAddr: rt.remoteAddr, returnError: returnErrorMessage, app: caData}; //rtList[idx] = nrt; singleRequest(nrt, caData) .then(function(result) { rtnList[idx] = result; }) .catch(function(err) { errFlag = true; rtnList[idx] = err; }) .finally(function() { //logBatch( rtList, rtnList ); cb(); }); }, function(err) { if (errFlag) { var rtnObj = errUtil.err( errUtil.SOME_BATCH_ERR ); rtnObj.value = rtnList; answerReq( rt, res, next, rtnObj ); } else answerReq( rt, res, next, {code: 0, message: 'Ok', value: rtnList} ); }); } else answerReq( rt, res, next, errUtil.err( errUtil.WRONG_BATCH_OP ) ); } else answerReq( rt, res, next, errUtil.err(errUtil.WRONG_PARAM) ); }) .catch(function(err) { answerReq( rt, res, next, err ); }); }; function cloneObj(obj) { var nobj = {}; for (var k in obj) nobj[k] = obj[k]; return nobj; } /** * Check if the calling app can use the functional module. */ function canUseApp(caData, ep) { return acUtil.canUseApp(caData, ep); }; /** * parse the client request and return a "request-terms" object with the following properties: * .appCode: app-code of the request * .ep: the parsed endpoint into components * .inData: input data of the request * .req: the original client request * .remoteAddr: where is the request from (IP address or device ID of mobile devices) */ function buildRT(appCode, req) { var uri = req.url, psURI = url.parse(uri, true), rt = {req: req, forward: internalRequest, returnError: returnErrorMessage}; rt.ep = reqUtil.parseEndpoint(appCode, psURI.pathname); //console.log('endpoint is\n%s', JSON.stringify(rt.ep, null, 4)); if (req.method === 'GET') rt.inData = psURI.query; else if (req.method === 'POST') { var contentType = req.headers['content-type'], inData; // checking || psURI.query.state is to get around service relay from some 3rd party API services such as 永豐金 if (contentType === 'application/json' || psURI.query.state) { if (psURI.query) { inData = {}; for (var k in psURI.query) inData[k] = psURI.query[k]; for (var k in req.body) inData[k] = req.body[k]; } else inData = req.body; rt.inData = inData; } else if (contentType.substring(0, 19) === 'multipart/form-data') { inData = req.body; inData.files = req.files; rt.inData = inData; } else throw errUtil.err(errUtil.WRONG_REQ_CTYPE); } // one more thing: where is the request from rt.remoteAddr = req.headers['x-deva-clientkey']; if (!rt.remoteAddr) { rt.remoteAddr = req.connection.remoteAddress; var idx = rt.remoteAddr.lastIndexOf(':'); if (idx > 0) rt.remoteAddr = rt.remoteAddr.substring(idx+1); } return rt; }; /** * validate access tokens. The token could be in user scope or application scope. * If the token is valid, uPro will be embedded with userID and grpID in it. */ function validateToken(caData, rt) { return new Promise(function(resolve, reject) { var ep = rt.ep; if (ep.rs === 'token' && ep.app === 'admin') // automatic pass of @admin/token/... resolve( {userID: GUEST_USER_ID} ); else { var inData = rt.inData; if (inData.state) { // "state" is used as the anti-csrf token var stateStr = inData.state, idx = stateStr.indexOf('http', 32); // 32 is the default appKey length if (stateStr.substring(0, 2) === '__' && idx > 0) { // when talking to FB, we'll embed the appKey and login done page in the 'state' variable var appKey = stateStr.substring(2, idx); if (appKey === caData.appKey) { inData.nextPage = stateStr.substring(idx); return resolve( {userID: GUEST_USER_ID} ); } } } // when dealing with FB login, we have to accept token as part of the URL var token = rt.req.headers['x-deva-token'] || rt.inData._tkn; if (token) { if (token.length < TOKEN_LENGTH) return reject( errUtil.err(errUtil.INVALID_TOKEN) ); tokenUtil.validateToken(caData, token, function(err, uPro) { if (err) return reject( err ); uPro.token = token; resolve( uPro ); }); } else { var appKey = rt.req.headers['x-deva-appkey'], appSecret = rt.req.headers['x-deva-appsecret']; //console.log('input appKey: %s, appSecret: %s', appKey, appSecret); //console.log('app appKey: %s, appSecret: %s', caData.appKey, caData.appSecret); if (caData.appKey === appKey) { var uPro = {userID: GUEST_USER_ID}; resolve( uPro ); } /* if (caData.appKey === appKey && (!appSecret || caData.appSecret === appSecret)) { var uPro = {userID: GUEST_USER_ID}; resolve( uPro ); } */ else reject( errUtil.err(errUtil.NO_TOKEN) ); } } }); }; function invokeService(rt) { var uPro = rt.uPro; return new Promise(function(resolve, reject) { //console.log('invoke service, uPro is\n%s', JSON.stringify(uPro, null, 4)); admin.updateUPro(uPro, function(err) { if (err) return reject(errUtil.err( errUtil.DB_ERR )); //console.log('user profile is:\n%s', JSON.stringify(uPro, null, 4)); checkPermission(rt) .then(function(opData) { return route(rt, opData); }) .then(function(result) { //return cb(null, result); resolve( result ); }) .catch(function(err) { if (err.stack) { console.log( err.stack ); err = errUtil.err( errUtil.INTERNAL_ERR ); } reject(err); }); }); }); }; function doResponse(rt, res, next, result) { if (result.send) { res.writeHead(200, {'Content-Type': result.send.mimeType}); //res.setHeader('content-type', result.send.mimeType); if (result.send.file) fs.createReadStream(result.send.file).pipe(res); else // respond with a stream result.send.stream.pipe( res ); next(); } else answerReq( rt, res, next, result ); }; function checkPermission(rt) { return Promise.resolve(true); /* return new Promise(function(resolve, reject) { var caID = rt.ep.caID, uPro = rt.uPro; //console.log('check permission: caID is ' + caID); //console.log('uPro is\n%s', JSON.stringify(uPro, null, 4)); async.waterfall([ function getRS(cb) { admin.getRS(caID, rt.ep.rs, function(err, rsData) { if (err) return cb( errUtil.err(errUtil.DB_ERROR) ); if (rsData) { if (rsData.isPublic || !uPro.isGuest) cb(null, rsData); else cb( errUtil.err(errUtil.NO_PERMISSION) ); } else cb( errUtil.err(errUtil.INVALID_RS) ); }); }, function getOP(rsData, cb) { admin.getOP(rsData.Resource_id, rt.ep.op, function(err, opData) { if (err) return cb( errUtil.err(errUtil.DB_ERROR) ); if (opData) cb(null, opData); else cb( errUtil.err(errUtil.INVALID_OP) ); }); }, function getOpAcc(opData, cb) { //console.log('get op access...roleID: ' + uPro.roleID); admin.getOpAcc(opData.RsOp_id, uPro.roleID, function(err, opAcc) { if (err) return cb( errUtil.err(errUtil.DB_ERROR) ); if (opAcc && opAcc.ok) { cb(null, opData); } else { cb( errUtil.err(errUtil.NO_PERMISSION) ); } }); } ], function(err, opData) { if (err) reject(err); else resolve(opData); }); }); */ }; function route(rt, opData) { return new Promise(function(resolve, reject) { var op = operators.getOperator(rt.ep); if (op) { async.waterfall([ function checkParam(cb) { if (op.checkArguments) { op.checkArguments(rt, function(err, isOk) { if (err || !isOk) cb( err ? err : errUtil.err(errUtil.WRONG_PARAM) ); else cb(); }); } else cb(); }, function checkPermit(cb) { if (op.checkPermission) { op.checkPermission(rt, function(err, isOk) { if (err || !isOk) cb( err ? err : errUtil.err(errUtil.NO_PERMISSION) ); else cb(); }); } else cb(); }, function run(cb) { op.forward = internalRequest; op.answerError = answerError; op.answerOk = answerOk; try { op.run(rt, (result) => { cb(null, result); }); } catch (err) { //console.log('operator error'); if (err.stack) console.log( err.stack ); cb( errUtil.err(errUtil.INTERNAL_ERR) ); } } ], function(err, result) { if (err) reject(err); else resolve( result ); }); } else reject( errUtil.err(errUtil.UNDEFINED_OP) ); }); }; /** * An helper function to answer requests */ function answerReq(rt, res, next, result) { //res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); //res.setHeader('Access-Control-Allow-Headers', 'origin, X-Requested-With, content-type, accept'); res.setHeader('Access-Control-Allow-Headers', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST'); // the codes below are mostly useful for url redirect var moveOn = true; if (result._httpCode) { if (result._httpHeaders) res.writeHead( result._httpCode, result._httpHeaders ); else res.writeHead( result._httpCode ); if (!result._data) { moveOn = false; res.end(); } } if (moveOn) { var postFix = rt ? rt.ep.postFix : undefined; if (postFix === 'txt') { res.setHeader('content-type', 'text/plain'); res.end(result.value); } else { res.setHeader('content-type', 'application/json'); res.end( JSON.stringify(result) ); } // do loggin if (rt) { // we don't log /api/batch... var ep = rt.ep; if (ep.app != 'api') logging(rt, result); } } next(); }; /** * Log each request */ function logging(rt, result) { var uPro = rt.uPro, ep = rt.ep, endpoint = ep.app + '/' + ep.rs + "/" + ep.op; if (ep.id) endpoint += "/" + ep.id; var userAgent = rt.req ? (rt.req.headers['user-agent'] || '') : '', clientType = userAgent.length > 16 ? "mobile" : dd.detect(userAgent).category, logData = { caID: rt.app.CApp_id, dsID: rt.ep.ds.dsID, ep : endpoint, app: ep.app, rs: ep.rs, op: ep.op, id: ep.id, c_ip: rt.remoteAddr, c_type: clientType, c_agent: userAgent, time: new Date() }; if (uPro) { logData.userID = uPro.userID; logData.isGuest = uPro.isGuest; logData.token = uPro.token; } else { logData.userID = GUEST_USER_ID; logData.isGuest = true; logData.token = ''; } //console.log('user agent: ' + userAgent); //console.log('client type: ' + JSON.stringify(logData.c_type, null, 4)); logData.code = result ? result.code : errUtil.INVALID_EP; logUtil.log( logData ); //console.log('[%d/%d] %s %d %s', logData.userID, logData.caID, logData.ep, logData.code, logData.time); }; /* function logBatch(rtList, resultList) { for (var i = 0, len = rtList.length; i < len; i++) logging(rtList[i], resultList[i]); }; */