UNPKG

reach-deconstruct-api

Version:

A desconstructed, extendable API framework, requiring the minimum of work to get things done

358 lines (306 loc) 15.5 kB
const H = require ( 'highland' ); const R = require ( 'ramda' ); const express = require ( 'express' ), app = express (); const bodyParser = require ( 'body-parser' ); const fs = require ( 'fs' ), dirStream = H.wrapCallback ( R.bind ( fs.readdir, fs ) ), lstatStream = H.wrapCallback ( R.bind ( fs.lstat, fs ) ); const path = require ( 'path' ); const crypto = require ( 'crypto' ); const jwt = require ( 'jsonwebtoken' ); const uuid = require ( 'uuid' ); const unless = require('express-unless'); const log = require ( './lib/log.js' ); const rDir = { path: null }; const utils = { sha256: string => { return crypto.createHash('sha256').update(string).digest('hex'); }, md5: string => { return crypto.createHash('md5').update(string).digest('hex'); }, log: R.compose ( console.log, R.partialRight ( JSON.stringify, [ null, 4 ] ) ), streamRoute: H.wrapCallback ( ( routeName, utils, req, res, callback ) => { if ( R.type ( res ) === 'Function' && typeof callback === 'undefined' ) { return res ( { code: 500, message: `res is undefined in streamRoute call on route ${routeName}` } ); } return require ( `${rDir.path}/${routeName}` )( R.assocPath ( [ 'callback' ], ( res, error, result ) => { if ( error === undefined && result === undefined ) { return callback; } return callback ( error, result ); }, utils ), req, res ); } ), streamPrivateRoute: ( issuer, routeName, utils, req, res ) => { const timestamp = new Date ().valueOf ().toString (); return H.wrapCallback ( utils.auth.getIssuerSecret )( issuer ) .errors ( ( error, push ) => { return push ( null, null ); } ) .flatMap ( secret => utils.streamRoute ( routeName, utils, { ...req, issuer, headers: secret ? { ...req.headers, Authorization: undefined, authorization: [ 'Sig', utils.sha256 ( [ secret, timestamp ].join ( '' ) ), timestamp ].join ( ' ' ) } : req.headers }, res ) ); }, treatResourceAsPrivate: req => H.wrapCallback ( utils.auth.verifyKey )( { options: { private: true, issuer: 'root' }, authParms: utils.auth.getKey ( req ) } ), jsonHeader: req => ( { ...req.headers, 'Content-Type': 'application/json' } ), bearerAuthHeader: ( req, key ) => ( { ...req.headers, authorization: [ 'Bearer', key ].join ( ' ' ), Authorization: undefined } ), sigAuthHeader: ( req, sig, timestamp ) => ( { ...req.headers, authorization: [ 'Sig', sig, timestamp ].join ( ' ' ), Authorization: undefined } ), auth: { options: { algorithm: 'HS256' }, generateAuthCode: digits => { return R.reduce ( ( authCode, i ) => { return authCode + ( Math.floor ( Math.random () * 10 ) ).toString (); }, '', R.range ( 0, digits ) ); }, generateSecret: uuid.v4, generateKeyWithSecret: ( { secret, expiresInDays = 1, audience = 'users', payload, issuer }, callback ) => { return H.wrapCallback ( R.bind ( jwt.sign, jwt ) )( payload, secret, { ...utils.auth.options, expiresIn: expiresInDays * 24 * 60 * 60, issuer, audience } ) .toCallback ( callback ); }, generateKey: ( { expiresInDays = 1, audience = 'users', payload, issuer }, callback ) => { return H.wrapCallback ( utils.auth.getIssuerSecret )( issuer ) .flatMap ( secret => H.wrapCallback ( utils.auth.generateKeyWithSecret )( { secret, expiresInDays, audience, payload, issuer } ) ) .toCallback ( callback ); }, getKey: ( { headers: { Authorization, authorization }, bypassAuth } ) => { const authHeader = Authorization || authorization; if ( ! authHeader && ! bypassAuth ) { return null; } if ( authHeader ) { const [ authType, key, timestamp ] = authHeader.split ( ' ' ); return { authType, key, timestamp, bypassAuth }; } return { bypassAuth }; }, verifyKey: ( { options, authParms }, callback ) => { if ( ! authParms ) { if ( process.env.BYPASS_AUTH === "true" ) { return callback ( null, { bypassed: true } ); } return callback ( { code: 401, message: 'Authentication failed' } ); } if ( authParms.authType === 'Bypass' && authParms.key === 'this' && authParms.timestamp === 'shit' ) { return callback ( null, { bypassed: true } ); } return H.wrapCallback ( utils.auth.getIssuerSecret )( options.issuer ) .flatMap ( H.wrapCallback ( ( secret, callback ) => { if ( ! authParms || ! authParms.key || ! authParms.authType ) { return callback ( { code: 401, message: `No authParms sent` } ); } if ( authParms.authType === 'Sig' ) { if ( ! authParms.timestamp ) { return callback ( { code: 401, message: `No timestamp` } ); } if ( authParms.timestamp < ( new Date ().valueOf () - 60000 ) ) { return callback ( { code: 401, message: `Timestamp has expired` } ); } if ( utils.sha256 ( [ secret, authParms.timestamp.toString () ].join ( '' ) ) !== authParms.key ) { return callback ( { code: 401, message: `Signature verification failed` } ); } return callback ( null, { name: options.issuer } ); } if ( options.private ) { return callback ( { code: 401, message: `Only auth type Sig supported on this endpoint` } ); } if ( authParms.authType !== 'Bearer' ) { return callback ( { code: 401, message: `Auth type ${authParms.authType} not supported` } ); } return jwt.verify ( authParms.key, secret, { ...utils.auth.options, ...R.pick ( [ 'issuer', 'audience', 'ignoreExpiration' ], options ) }, ( error, payload ) => { if ( error ) { return callback ( { code: 401, message: error.message || 'Not authorized' } ); } return callback ( null, payload ); } ); } ) ) .toCallback ( callback ); } }, error: ( res, error ) => { const localCallback = error => { return res.status ( error.code && parseInt ( error.code, 10 ) >= 200 ? parseInt ( error.code, 10 ) : 500 ).json ( error ); }; if ( error === undefined ) { return localCallback; } return localCallback ( error ); }, callback: ( res, error, result ) => { const localCallback = ( error, result ) => { if ( error ) { return utils.error ( res, error ); } return res.json ( result ); }; if ( res === undefined ) { throw new Error ( 'utils.callback needs the response object as its first argument' ); } if ( error === undefined && result === undefined ) { return localCallback; } return localCallback ( error, result ); } }; app .use ( bodyParser.json ( { limit: '50mb' } ) ) .use(bodyParser.urlencoded({ extended: false })); app.use ( ( req, res, next ) => { res.header ( 'Access-Control-Allow-Origin', '*' ); res.header ( 'Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE, OPTIONS, HEAD' ); res.header ( 'Access-Control-Allow-Headers', 'Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Content-MD5' ); next (); } ); module.exports = { setSecretRetriever: secretRetriever => utils.auth.getIssuerSecret = secretRetriever, addUtil: ( name, util ) => { utils[name] = util ( utils ); }, loadRoutes: ( options, callback ) => { const getRoutes = ( dir ) => dirStream ( dir ) .flatMap ( R.map ( content => lstatStream ( `${dir}/${content}`) .flatMap ( stat => { if( stat.isFile () ) { return H ( [ content ] ) } else { return getRoutes ( `${dir}/${content}` ) .map ( R.flatten ) .map ( R.map ( route => `${content}/${route}` ) ) } } ) ) ) .parallel ( 100 ) .collect ( ) const routeDir = (typeof options === 'object' ? options.routeDir : options) || './routes'; const pathParameterFlag = typeof options === 'object' && options.pathParameterFlag ? options.pathParameterFlag : ':'; rDir.path = path.resolve ( routeDir ); return getRoutes ( routeDir ) .map ( R.flatten ) .map ( R.sort ( ( a, b ) => { const getFirstDynamicComponent = route => { return R.reduce ( ( memo, component ) => { if ( memo.found ) { return memo; } if ( component.match ( pathParameterFlag ) ) { return { index: memo.index, found: true } } return { index: memo.index + 1, found: memo.found }; }, { index: 0, found: false }, R.tail ( R.last ( route.split ( '/' ) ).split ( '~' ) ) ); }; return getFirstDynamicComponent ( b ).index - getFirstDynamicComponent ( a ).index; } ) ) .sequence () .filter ( route => R.reduce ( ( accept, re ) => { return accept && R.last ( route.split ( '/' ) ).match ( re ); }, true, [ /^~/, /\.js$/ ] ) ) .collect () .map ( routes => R.reduce ( ( reduced, route ) => { const method = R.last ( route.split ( '~' ) ).replace ( '.js', '' ); const corrRoute = postfix => R.compose ( R.join ( '~' ), R.flip ( R.concat )( [ postfix ] ), R.init, R.split ( '~' ) ); const corrOptionsRoute = corrRoute ( 'options.js' ); const corrPreFlightRoute = corrRoute ( 'preflight' ); const preFlightRoute = R.find ( rRoute => rRoute.indexOf ( corrPreFlightRoute ( route ) ) === 0, reduced ); if ( R.contains ( corrOptionsRoute ( route ), routes ) ) { return R.concat ( reduced, [ route ] ); } if ( preFlightRoute ) { return R.concat ( R.reject ( route => route === preFlightRoute, reduced ), [ route, `${preFlightRoute}-${method}` ] ); } return R.concat ( reduced, [ route, `${corrPreFlightRoute ( route )}-${method}` ] ); }, [], routes ) ) .sequence () .doto ( routeFileName => { const routeComponents = routeFileName.replace ( /\.js$|\//g, '' ).split ( '~' ) .map( component => component.replace(pathParameterFlag, ':') ); const pathSpec = R.init ( routeComponents ).join ( '/' ); const method = R.last ( routeComponents ); ( ( pathSpec, routeFileName, method ) => { if ( method.match ( 'preflight' ) ) { const methods = R.concat ( R.tail ( method.split ( '-' ) ), method.match ( 'get' ) ? [ 'head' ] : [] ); const methodHeader = R.map ( method => method.toUpperCase (), methods ).join ( ', ' ); console.log ( `registering ${pathSpec} OPTIONS (preflight ${methodHeader})` ); return app.options ( pathSpec, ( req, res ) => { log ( 1, `OPTIONS ${pathSpec}` ); return res.set ( 'Access-Control-Allow-Methods', methodHeader ).send ( '' ); } ); } const routeFn = require ( `${routeDir}/${routeFileName}` ) if ( typeof routeFn !== "function" ) { console.log ( `ERROR: ${routeDir}/${routeFileName}: No module export.`) return; } const routeHandler = routeFn ( utils ); if ( method.toLowerCase () === 'get' ) { console.log ( `registering ${pathSpec} HEAD` ); app.head ( pathSpec, ( req, res ) => { log ( 1, `HEAD ${pathSpec}` ); return utils.streamRoute ( routeFileName, utils, req, res ) .toCallback ( ( error, response ) => { if ( error ) { return utils.error ( res, error ); } return res.set ( 'X-Content-MD5', utils.md5 ( JSON.stringify ( response ) ) ).send ( '' ); } ); } ); console.log ( `registering ${pathSpec} GET` ); return app.get ( pathSpec, ( req, res ) => { log ( 1, `GET ${pathSpec}` ); return utils.streamRoute ( routeFileName, utils, req, res ) .toCallback ( utils.callback ( res ) ); } ); } console.log ( `registering ${pathSpec} ${method.toUpperCase ()}` ); return app[method] ( pathSpec, ( req, res ) => { log ( 1, `${method.toUpperCase()} ${pathSpec}` ); return routeHandler ( req, res ); } ); } )( pathSpec, routeFileName, method ); } ) .collect () .toCallback ( callback ); }, start: port => { app.get ( '/healthcheck', ( req, res ) => res.json ( 200 ) ); return app.listen ( port ); }, getApp: () => { return app; } }; if ( !module.parent ) { return H.wrapCallback ( module.exports.loadRoutes )( path.resolve ( './testRoutes' ) ) .errors ( error => { console.error ( error ); } ) .each ( () => { console.log ( 'WORKER: started' ); module.exports.start ( process.env.PORT || 8080 ); } ); }