take-five
Version:
Very minimal JSON-REST server
302 lines (250 loc) • 8.05 kB
JavaScript
const http = require('http')
const https = require('https')
const querystring = require('querystring')
const Buffer = require('buffer').Buffer
const stringify = require('fast-safe-stringify')
const wayfarer = require('wayfarer')
const concat = require('concat-stream')
const methods = ['get', 'put', 'post', 'delete', 'patch']
const dataMethods = ['put', 'post', 'patch']
const MAX_POST = 512 * 1024
const ORIGIN = '*'
const CREDENTIALS = true
const ALLOWED_TYPES = ['application/json']
const HEADERS = ['Content-Type', 'Accept', 'X-Requested-With']
class TakeFive {
constructor (opts) {
this._allowMethods = ['options'].concat(methods)
this._allowHeaders = HEADERS.slice(0)
this._allowContentTypes = ALLOWED_TYPES.slice(0)
this._parsers = {
'application/json': JSON.parse
}
opts = opts || {}
this.maxPost = opts.maxPost || MAX_POST
this.allowedContentTypes = opts.allowContentTypes
this.allowOrigin = opts.allowOrigin || ORIGIN
this.allowCredentials = CREDENTIALS
if (typeof opts.allowCredentials === 'boolean') {
this.allowCredentials = opts.allowCredentials
}
if (opts.allowHeaders) {
this.allowHeaders = opts.allowHeaders
}
if (opts.allowMethods) {
this.allowMethods = opts.allowMethods
}
this._httpLib = http
this._httpOpts = opts.http || {}
this._ctx = {}
if (this._httpOpts.key && this._httpOpts.cert) {
this._httpLib = https
}
this.routers = new Map()
const args = []
if (Object.keys(this._httpOpts).length > 0) {
args.push(this._httpOpts)
}
args.push(this._onRequest.bind(this))
this.server = this._httpLib.createServer.apply(this._httpLib, args)
this.methods = methods.concat(opts.methods || [])
this._addRouters()
}
set allowContentTypes (types) {
if (!Array.isArray(types)) {
types = [types]
}
this._allowContentTypes = this._allowContentTypes.concat(types)
}
get allowContentTypes () {
return this._allowContentTypes
}
addParser (type, func) {
if (typeof type === 'string' && typeof func === 'function') {
this._parsers[type] = func
}
}
set allowHeaders (headers) {
headers = Array.isArray(headers) ? headers : [headers]
this._allowHeaders = this._allowHeaders.concat(headers)
}
get allowHeaders () {
return this._allowHeaders
}
set allowMethods (methods) {
methods = Array.isArray(methods) ? methods : [methods]
this._allowMethods = this._allowMethods.concat(methods)
}
get allowMethods () {
return this._allowMethods
}
set ctx (ctx) {
const ctxType = Object.prototype.toString.call(ctx)
if (ctxType !== '[object Object]') {
throw new Error(`ctx must be an object, was ${ctxType}`)
}
this._ctx = Object.assign({}, ctx)
}
get ctx () {
return this._ctx
}
parseBody (data, type) {
const parser = this._parsers[type]
if (typeof parser === 'function') {
return parser(data)
}
return data
}
makeCtx (res) {
function send (code, content) {
if (typeof content === 'undefined') {
content = code
code = 200
}
if (typeof content !== 'string') {
content = stringify(content)
}
res.statusCode = code
res.setHeader('content-type', 'application/json')
res.end(content, 'utf8')
}
function err (code, content) {
if (typeof content === 'undefined') {
if (parseInt(code, 10)) {
content = http.STATUS_CODES[code]
} else {
content = code
code = 500
}
}
res.statusCode = code
res.statusMessage = content
res.setHeader('content-type', 'application/json')
res.end(stringify({message: content}))
}
return Object.assign({}, this.ctx, {send, err})
}
_handleError (err, req, res, ctx) {
if (typeof this.handleError === 'function') {
this.handleError(err, req, res, ctx)
}
if (!res.finished) {
ctx.err('Internal server error')
}
}
cors (res) {
res.setHeader('Access-Control-Allow-Origin', this.allowOrigin)
res.setHeader('Access-Control-Allow-Headers', this.allowHeaders.join(','))
res.setHeader('Access-Control-Allow-Credentials', this.allowCredentials)
res.setHeader('Access-Control-Allow-Methods', this.allowMethods.join(',').toUpperCase())
}
listen (port, func) {
this.server.listen(port, func)
}
close () {
this.server.close()
}
_verifyBody (req, res, ctx) {
return new Promise((resolve) => {
const type = req.headers['content-type']
const size = req.headers['content-length']
const _ctxMax = parseInt(ctx.maxPost, 10)
const maxPost = Number.isNaN(_ctxMax) ? this.maxPost : _ctxMax
let allowContentTypes = this.allowContentTypes.slice(0)
if (ctx.allowContentTypes) {
allowContentTypes = allowContentTypes.concat(ctx.allowContentTypes)
}
if (size > maxPost) {
return ctx.err(413, `Payload size exceeds maximum size for requests`)
}
if (!allowContentTypes.includes(type)) {
return ctx.err(415, `Expected data to be of ${allowContentTypes.join(', ')} not ${type}`)
} else {
const parser = concat((data) => {
try {
ctx.body = this.parseBody(data.toString('utf8'), type)
} catch (err) {
return ctx.err(400, `Payload is not valid ${type}`)
}
resolve()
})
req.pipe(parser)
const body = []
req.on('data', (chunk) => {
body.push(chunk.toString('utf8'))
if (chunk.length > this.maxPost || Buffer.byteLength(body.join(''), 'utf8') > this.maxPost) {
req.pause()
return ctx.err(413, 'Payload size exceeds maximum body length')
}
})
}
})
}
_onRequest (req, res) {
this.cors(res)
if (req.method === 'OPTIONS') {
res.statusCode = 204
return res.end()
}
const ctx = this.makeCtx(res)
try {
const method = req.method.toLowerCase()
const url = req.url.split('?')[0]
const router = this.routers.get(method)
router(url, req, res, ctx)
} catch (err) {
if (res.finished) {
throw err
}
return ctx.err(404, 'Not found')
}
}
_addRouters () {
this.methods.forEach((method) => {
Object.defineProperty(this, method, {value: generateRouter(method)})
})
function generateRouter (method) {
return function (matcher, handler, ctxOpts) {
let router = this.routers.get(method)
if (!router) {
router = wayfarer('/_')
this.routers.set(method, router)
}
const handlers = Array.isArray(handler) ? handler : [handler]
if (handlers.some((f) => typeof f !== 'function')) {
throw new Error('handlers must be functions')
}
router.on(matcher, (params, req, res, ctx) => {
const routeHandlers = handlers.slice(0)
const conlen = parseInt(req.headers['content-length'], 10) || 0
if (conlen !== 0 && dataMethods.includes(req.method.toLowerCase())) {
if (ctxOpts) ctx = Object.assign({}, ctx, ctxOpts)
routeHandlers.unshift(this._verifyBody.bind(this))
}
ctx.query = querystring.parse(req.url.split('?')[1])
ctx.params = params
this._resolveHandlers(req, res, ctx, routeHandlers)
})
}
}
}
_resolveHandlers (req, res, ctx, handlers) {
const iterate = (handler) => {
const p = handler(req, res, ctx)
if (p && typeof p.then === 'function') {
p.then(() => {
if (!res.finished && handlers.length > 0) {
const next = handlers.shift()
iterate(next)
}
})
.catch((err) => {
this._handleError(err, req, res, ctx)
})
}
}
const next = handlers.shift()
iterate(next)
}
}
module.exports = TakeFive