UNPKG

pop-api

Version:
2 lines (1 loc) 14.8 kB
"use strict";Object.defineProperty(exports,"__esModule",{value:!0});function _interopDefault(e){return e&&"object"==typeof e&&"default"in e?e.default:e}var debug=_interopDefault(require("debug")),express=_interopDefault(require("express")),cluster=require("cluster"),cluster__default=_interopDefault(cluster),path=require("path"),Command=_interopDefault(require("commander")),mongoose=_interopDefault(require("mongoose")),fs=require("fs"),url=require("url"),del=_interopDefault(require("del")),mkdirp=_interopDefault(require("mkdirp")),child_process=require("child_process"),http=require("http"),http__default=_interopDefault(http),os=require("os"),expressWinston=require("@chrisalderson/express-winston"),winston=require("winston"),bodyParser=_interopDefault(require("body-parser")),compress=_interopDefault(require("compression")),helmet=_interopDefault(require("helmet")),responseTime=_interopDefault(require("response-time")),pMap=_interopDefault(require("p-map"));class Cli{constructor(e,{argv:t,name:r,version:s}){const{name:o}=this.constructor;if(e.debug(`Registering ${o} middleware with options: %o`,{argv:t,name:r,version:s}),!r||!s)throw new TypeError("'name' and 'version' are required options for the Cli middleware!");this.program=Command,this.name=r,this.version=s,this.initOptions(),this.program.on("--help",this.printHelp.bind(this)),t&&this.run(e,t)}initOptions(){return this.program.version(`${this.name} v${this.version}`).option("-m, --mode <type>","Run the API in a particular mode.",/^(pretty|quiet|ugly)$/i)}getHelp(){return[""," Examples:","",` $ ${this.name} -m <pretty|quiet|ugly>`,` $ ${this.name} --mode <pretty|quiet|ugly>`]}printHelp(){console.info(`${this.getHelp().join("\n")}\n`)}mode(e){const t="test"===process.env.NODE_ENV;switch(e){case"quiet":return{pretty:!1,quiet:!0};case"ugly":return{pretty:!1,quiet:t};case"pretty":default:return{pretty:!t,quiet:t}}}run(e,t){if(t&&this.program.parse(t),!this.program.mode)return console.error("\n error: no valid command given, please check below:"),this.program.help();e.loggerArgs=this.mode(this.program.mode)}}async function createTemp(e){return fs.existsSync(e)&&await del([`${e}/**`]).then(([e])=>e),new Promise(t=>(mkdirp.sync(e),t(e)))}function executeCommand(e,t){return new Promise((r,s)=>{const o=child_process.spawn(e,t);o.stdout.on("data",e=>r(e.toString())),o.on("error",s),o.on("close",t=>{if(0===t)return r();const o=new Error(`${e} exited with code: ${t}`);return s(o)})})}var utils=Object.freeze({createTemp:createTemp,executeCommand:executeCommand});class Database{constructor(e,{database:t,hosts:r=["localhost"],dbPort:s=27017,username:o,password:n}){const{name:i}=this.constructor;if(e.debug(`Registering ${i} middleware with options: %o`,{database:t,hosts:r,dbPort:s,username:o,password:n}),!t)throw new TypeError("'database' is a required option for the Database middleware!");process.env.NODE_ENV=process.env.NODE_ENV||"development";const{MONGO_PORT_27017_TCP_ADDR:a,MONGO_PORT_27017_TCP_PORT:p,NODE_ENV:l}=process.env;this.database=`${t}-${l}`,this.hosts=a?[a]:r,this.dbPort=Number(p)||s,this.username=o||"",this.password=n||"",e.database=this}connect(){const e=new url.URL(`mongodb://${this.username}:${this.password}@${this.hosts.join(",")}:${this.dbPort}/${this.database}`);return mongoose.connect(e.href)}disconnect(){return mongoose.disconnect()}exportFile(e,t){return executeCommand("mongoexport",["-d",this.database,"-c",`${e}s`,"-o",t])}importFile(e,t){const r=path.isAbsolute(t)?path.join(...[__dirname,"..","..",t]):t;if(!fs.existsSync(r)){const e=new Error(`no such file found for '${r}'`);return Promise.reject(e)}return executeCommand("mongoimport",["-d",this.database,"-c",`${e}s`,"--file",t,"--upsert"])}}class HttpServer{constructor(e,{app:t,serverPort:r=process.env.PORT||5e3,workers:s=2}){const{name:o}=this.constructor;if(e.debug(`Registering ${o} middleware with options: %o`,{serverPort:r,workers:s}),!t)throw new TypeError("'app' is a required option for the HttpServer middleware!");this.server="function"==typeof t?http__default.createServer(t):t,this.serverPort=r,this.workers=s,this.setupApi(t),e.server=this}forkWorkers(){for(let e=0;e<Math.min(os.cpus().length,this.workers);e++)cluster__default.fork()}workersOnExit(){cluster__default.on("exit",({process:e})=>{const t=`Worker '${e.pid}' died, spinning up another!`;logger.error(t),cluster__default.fork()})}setupApi(e){(cluster__default.isWorker||0===this.workers)&&(this.server=e.listen(this.serverPort)),(cluster__default.isMaster||0===this.workers)&&(this.forkWorkers(),this.workersOnExit(),logger.info(`API started on port: ${this.serverPort}`))}closeApi(e,t=(()=>{})){this.server.close(()=>{e.disconnect().then(()=>{logger.info("Closed out remaining connections."),t()})})}}function padStart(e,t){let r=e>>0,s=String(t||" ");return this.length>r?String(this):((r-=this.length)>s.length&&(s+=s.repeat(r/s.length)),s.slice(0,r)+String(this))}class Logger{constructor(e,{name:t,logDir:r,pretty:s,quiet:o}){const{name:n}=this.constructor;if(e.debug(`Registering ${n} middleware with options: %o`,{name:t,logDir:r,pretty:s,quiet:o}),!t||!r)throw new TypeError("'name' and 'logDir' are required options for the Logger middleware!");this.levels={error:0,warn:1,info:2,debug:3},this.name=t,this.logDir=r,String.prototype.padStart=String.prototype.padStart||padStart,global.logger=this.getLogger("logger",s,o),"test"!==process.env.NODE_ENV&&(e.httpLogger=this.getLogger("http",s,o))}getLevelColor(e="info"){const t={error:"",warn:"",info:"",debug:""};return t[e]}prettyPrintConsole(e){const{level:t,message:r,timestamp:s}=e,o=this.getLevelColor(t);return e.splat=[s,t.toUpperCase().padStart(5),this.name.padStart(2),process.pid,r],e.message=`[%s] ${o}%s: %s/%d: %s`,e}_getMessage(e){return e.message}consoleFormatter(){return winston.format.combine(winston.format.timestamp(),winston.format.printf(this.prettyPrintConsole.bind(this)),winston.format.splat(),winston.format.printf(this._getMessage))}fileFormatter(){return winston.format.combine(winston.format.timestamp(),winston.format.printf(e=>(Object.assign(e,{name:this.name,pid:process.pid}),e)),winston.format.json())}getConsoleTransport(e){const t=e?this.consoleFormatter():winston.format.simple();return new winston.transports.Console({name:this.name,format:t})}getFileTransport(e){return Logger.fileTransport||(Logger.fileTransport=new winston.transports.File({level:"warn",filename:path.join(...[this.logDir,`${e}.log`]),format:this.fileFormatter(),maxsize:5242880,handleExceptions:!0})),Logger.fileTransport}createLoggerInstance(e,t){const r=`${this.name}-${e}`;return winston.loggers.add(r,{levels:this.levels,level:"debug",exitOnError:!1,transports:[this.getConsoleTransport(t),this.getFileTransport(r)]})}getHttpLoggerMessage(e,t){return`HTTP ${e.method} ${e.url} ${t.statusCode} ${t.responseTime}ms`}createHttpLogger(e){const t=this.createLoggerInstance("http",e),r={winstonInstance:t,meta:!0,msg:this.getHttpLoggerMessage,statusLevels:!0};if("development"===process.env.NODE_ENV){const{Console:e}=winston.transports;t.add(new e({name:this.name,format:winston.format.json({space:2})})),r.requestWhitelist=[].concat(expressWinston.requestWhitelist,"body"),r.responseWhitelist=[].concat(expressWinston.responseWhitelist,"body")}return expressWinston.logger(r)}createLogger(e,t){const r=this.createLoggerInstance("app",e);return t&&Object.keys(this.levels).map(e=>{r[e]=(()=>{})}),r}getLogger(e,t,r){if(!e)return;const s=e.toUpperCase();switch(s){case"HTTP":return this.createHttpLogger(t);case"LOGGER":return this.createLogger(t,r);default:return}}}const statusCodes=Object.keys(http.STATUS_CODES).reduce((e,t)=>{const r=parseInt(t,10);return e[http.STATUS_CODES[r].replace(/'/g,"").replace(/\s+/g,"_").toUpperCase()]=r,e},{});class ApiError extends Error{constructor({message:e,status:t=statusCodes.INTERNAL_SERVER_ERROR,isPublic:r=!1}){super(e),this.name=this.constructor.name,this.message=e,this.status=t,this.isPublic=r,this.isOperational=!0,Error.captureStackTrace(this,ApiError)}}class Routes{constructor(e,{app:t,controllers:r}){const{name:s}=this.constructor;if(e.debug(`Registering ${s} middleware with options: %o`,{controllers:r}),!t)throw new TypeError("'app' is a required option for the Routes middleware!");this.setupRoutes(t,e,r)}registerControllers(e,t,r){r.forEach(r=>{const{Controller:s,args:o}=r,n=new s(o);t.debug(`Registering ${s.name} route controller`),n.registerRoutes(e,t)})}convertErrors(e,t,r,s){if(!(e instanceof ApiError)){return s(new ApiError({message:e.message}))}return s(e)}setNotFoundHandler(e,t,r){const s=new ApiError({message:"Api not found",status:statusCodes.NOT_FOUND});return r(s)}setErrorHandler(e,t,r,s){const{status:o}=e,n={message:e.isPublic?e.message:`${o} ${http.STATUS_CODES[o]}`};return"development"===process.env.NODE_ENV&&(n.stack=e.stack),r.setHeader("Content-Type","application/json"),r.status(o),r.send(n)}removeServerHeader(e,t,r){return t.removeHeader("Server"),r()}preRoutes(e){e.use(bodyParser.urlencoded({extended:!0})),e.use(bodyParser.json()),e.use(compress({threshold:1400,level:4,memLevel:3})),e.use(responseTime()),e.use(helmet()),e.use(helmet.contentSecurityPolicy({directives:{defaultSrc:["'none'"]}})),e.use(this.removeServerHeader)}postRoutes(e){e.use(this.convertErrors),e.use(this.setNotFoundHandler),e.use(this.setErrorHandler)}setupRoutes(e,t,r){this.preRoutes(e),t&&t.httpLogger&&e.use(t.httpLogger),r&&this.registerControllers(e,t,r),this.postRoutes(e)}}var name="pop-api",objectWithoutProperties=function(e,t){var r={};for(var s in e)t.indexOf(s)>=0||Object.prototype.hasOwnProperty.call(e,s)&&(r[s]=e[s]);return r};const defaultLogDir=path.join(...[__dirname,"..","tmp"]);class PopApi{static async init(e,t=[Cli,Logger,Database,Routes,HttpServer]){let{app:r=PopApi.app,controllers:s,name:o,version:n,logDir:i=defaultLogDir,hosts:a,dbPort:p,username:l,password:u,serverPort:c,workers:h}=e,d=objectWithoutProperties(e,["app","controllers","name","version","logDir","hosts","dbPort","username","password","serverPort","workers"]);return PopApi.app=r,cluster.isMaster&&await createTemp(i),t.map(e=>{PopApi.use(e,Object.assign({app:r,controllers:s,name:o,version:n,logDir:i,database:o,hosts:a,dbPort:p,username:l,password:u,serverPort:c,workers:h,argv:process.argv},PopApi.loggerArgs,d))}),await PopApi.database.connect(),PopApi}static use(e,...t){if(PopApi._installedPlugins.has(e))return this;const r="function"==typeof e?new e(this,...t):null;return r&&PopApi._installedPlugins.set(e,r),this}}PopApi.app=express(),PopApi.debug=debug(name),PopApi._installedPlugins=new Map;class IController{registerRoutes(e,t){throw new Error("Using default method: 'registerRoutes'")}}class IContentController extends IController{getContents(e,t,r){throw new Error("Using default method: 'getContents'")}sortContent(e,t){throw new Error("Using default method: 'sortContent'")}getPage(e,t,r){throw new Error("Using default method: 'getPage'")}getContent(e,t,r){throw new Error("Using default method: 'getContent'")}createContent(e,t,r){throw new Error("Using default method: 'createContent'")}updateContent(e,t,r){throw new Error("Using default method: 'updateContent'")}deleteContent(e,t,r){throw new Error("Using default method: 'deleteContent'")}getRandomContent(e,t,r){throw new Error("Using default method: 'getRandomContent'")}}class BaseContentController extends IContentController{constructor({basePath:e,service:t}){super(),this.basePath=e,this.service=t}registerRoutes(e,t){const r=this.basePath;e.get(`/${r}s`,this.getContents.bind(this)),e.get(`/${r}s/:page`,this.getPage.bind(this)),e.get(`/${r}/:id`,this.getContent.bind(this)),e.post(`/${r}s`,this.createContent.bind(this)),e.put(`/${r}/:id`,this.updateContent.bind(this)),e.get(`/random/${r}`,this.getRandomContent.bind(this)),"function"==typeof e.delete?e.delete(`/${r}/:id`,this.deleteContent.bind(this)):e.del(`/${r}/:id`,this.deleteContent.bind(this))}checkEmptyContent(e,t){return e.setHeader("Content-Type","application/json"),t&&0!==t.length?(e.status(200),e.send(t)):(e.status(204),e.send())}getContents(e,t,r){return this.service.getContents(`/${this.basePath}`).then(e=>this.checkEmptyContent(t,e)).catch(e=>r(e))}sortContent(e,t){return{[e]:t}}getPage(e,t,r){const{page:s}=e.params,{sort:o,order:n}=e.query,i=parseInt(n,10)?parseInt(n,10):-1,a="string"==typeof o?this.sortContent(o,i):null;return this.service.getPage(a,Number(s)).then(e=>this.checkEmptyContent(t,e)).catch(e=>r(e))}getContent(e,t,r){return this.service.getContent(e.params.id).then(e=>this.checkEmptyContent(t,e)).catch(e=>r(e))}createContent(e,t,r){return t.setHeader("Content-Type","application/json"),this.service.createContent(e.body).then(e=>t.send(e)).catch(e=>r(e))}updateContent(e,t,r){return t.setHeader("Content-Type","application/json"),this.service.updateContent(e.params.id,e.body).then(e=>t.send(e)).catch(e=>r(e))}deleteContent(e,t,r){return t.setHeader("Content-Type","application/json"),this.service.deleteContent(e.params.id).then(e=>t.send(e)).catch(e=>r(e))}getRandomContent(e,t,r){return this.service.getRandomContent().then(e=>this.checkEmptyContent(t,e)).catch(e=>r(e))}}class ContentService{constructor({Model:e,projection:t,query:r={},pageSize:s=25}){this.Model=e,this.pageSize=s,this.projection=t,this.query=r}getContents(e=""){return this.Model.count(this.query).then(t=>{const r=Math.ceil(t/this.pageSize),s=[];for(let t=1;t<r+1;t++)s.push(`${e}/${t}`);return s})}getPage(e,t=1,r=Object.assign({},this.query)){const s=isNaN(t)?0:Number(t)-1,o=s*this.pageSize;let n=[{$match:r},{$project:this.projection}];return e&&(n=[...n,{$sort:e}]),"string"==typeof t&&"all"===t.toLowerCase()?this.Model.aggregate(n):(n=[...n,{$skip:o},{$limit:this.pageSize}],this.Model.aggregate(n))}getContent(e,t){return this.Model.findOne({_id:e},t)}createContent(e){return new this.Model(e).save()}createMany(e){return pMap(e,async e=>{return await this.Model.findOne({_id:e.slug})?this.updateContent(e.slug,e):this.createContent(e)},{concurrency:1})}updateContent(e,t){return this.Model.findOneAndUpdate({_id:e},new this.Model(t),{upsert:!0,new:!0})}updateMany(e){return this.createMany(e)}deleteContent(e){return this.Model.findOneAndRemove({_id:e})}deleteMany(e){return pMap(e,e=>this.deleteContent(e._id))}getRandomContent(){return this.Model.aggregate([{$match:this.query},{$project:this.projection},{$sample:{size:1}},{$limit:1}]).then(([e])=>e)}}exports.PopApi=PopApi,exports.utils=utils,exports.BaseContentController=BaseContentController,exports.ContentService=ContentService,exports.IContentController=IContentController,exports.IController=IController,exports.ApiError=ApiError,exports.statusCodes=statusCodes,exports.Cli=Cli,exports.Database=Database,exports.HttpServer=HttpServer,exports.Logger=Logger,exports.Routes=Routes;