UNPKG

pop-api

Version:
2 lines (1 loc) 14 kB
import debug from"debug";import express from"express";import cluster,{isMaster}from"cluster";import{isAbsolute,join}from"path";import Command from"commander";import mongoose from"mongoose";import{existsSync}from"fs";import{URL}from"url";import del from"del";import mkdirp from"mkdirp";import{spawn}from"child_process";import http,{STATUS_CODES}from"http";import{cpus}from"os";import{logger as logger$1,requestWhitelist,responseWhitelist}from"@chrisalderson/express-winston";import{format,loggers,transports}from"winston";import bodyParser from"body-parser";import compress from"compression";import helmet from"helmet";import responseTime from"response-time";import pMap from"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 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=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:c}=process.env;this.database=`${t}-${c}`,this.hosts=a?[a]:r,this.dbPort=Number(p)||s,this.username=o||"",this.password=n||"",e.database=this}connect(){const e=new 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=isAbsolute(t)?join(...[__dirname,"..","..",t]):t;if(!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.createServer(t):t,this.serverPort=r,this.workers=s,this.setupApi(t),e.server=this}forkWorkers(){for(let e=0;e<Math.min(cpus().length,this.workers);e++)cluster.fork()}workersOnExit(){cluster.on("exit",({process:e})=>{const t=`Worker '${e.pid}' died, spinning up another!`;logger.error(t),cluster.fork()})}setupApi(e){(cluster.isWorker||0===this.workers)&&(this.server=e.listen(this.serverPort)),(cluster.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 format.combine(format.timestamp(),format.printf(this.prettyPrintConsole.bind(this)),format.splat(),format.printf(this._getMessage))}fileFormatter(){return format.combine(format.timestamp(),format.printf(e=>(Object.assign(e,{name:this.name,pid:process.pid}),e)),format.json())}getConsoleTransport(e){const t=e?this.consoleFormatter():format.simple();return new transports.Console({name:this.name,format:t})}getFileTransport(e){return Logger.fileTransport||(Logger.fileTransport=new transports.File({level:"warn",filename:join(...[this.logDir,`${e}.log`]),format:this.fileFormatter(),maxsize:5242880,handleExceptions:!0})),Logger.fileTransport}createLoggerInstance(e,t){const r=`${this.name}-${e}`;return 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}=transports;t.add(new e({name:this.name,format:format.json({space:2})})),r.requestWhitelist=[].concat(requestWhitelist,"body"),r.responseWhitelist=[].concat(responseWhitelist,"body")}return logger$1(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(STATUS_CODES).reduce((e,t)=>{const r=parseInt(t,10);return e[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} ${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=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:c,password:h,serverPort:l,workers:d}=e,m=objectWithoutProperties(e,["app","controllers","name","version","logDir","hosts","dbPort","username","password","serverPort","workers"]);return PopApi.app=r,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:c,password:h,serverPort:l,workers:d,argv:process.argv},PopApi.loggerArgs,m))}),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)}}export{PopApi,utils,BaseContentController,ContentService,IContentController,IController,ApiError,statusCodes,Cli,Database,HttpServer,Logger,Routes};