egg-raml-validate
Version:
Validate parameters via raml for egg.
404 lines (328 loc) • 9.37 kB
JavaScript
'use strict'
const vm = require('vm')
const debug = require('debug')
const raml = require('raml-1-parser')
const { isArray } = Array
const isEmpty = require('lodash.isempty')
const set = require('lodash.set')
const get = require('lodash.get')
const pathMatching = require('egg-path-matching')
function _debugVerbose (object) {
debug('egg-raml-validate:verbose')(JSON.stringify(object, '', 2))
}
function _debugMeta (object) {
debug('egg-raml-validate:meta')(JSON.stringify(object, '', 2))
}
function sanitizeRules (rules) {
for (const [ name, { type } ] of Object.entries(rules)) {
const _type = type.replace(/^_*/, '')
set(rules, [ name, 'type' ], _type)
}
return rules
}
/**
* Covert bracket path to express-style path
*
* /user/{id}/{age} => /user/:id/:age
*
*/
function bracketPathToExpressPath (path) {
return path.replace(/\{/g, ':').replace(/\}/g, '')
}
function isNumberable (string) {
const numberRe = /^[+-]?\d*\.?\d+(?:[Ee][+-]?\d+)?$/
return (numberRe.test(string))
}
function isBooleanable (string) {
return string === 'false' || string === 'true'
}
function toNumber (string) {
return Number(string)
}
function toBoolean (string) {
if (string === 'true') return true
if (string === 'false') return false
}
/**
* Lowercase keys of object
*
* Convert
*
* {
* Authorization: {},
* Accept: {}
* }
*
* to
*
* {
* authorization: {},
* accept: {}
* }
*
*/
function lowerCaseObjectKey (object) {
const _object = {}
for (const [ key, value ] of Object.entries(object)) {
const _key = key.toLowerCase()
_object[_key] = value
}
return _object
}
/**
* Convert values of object to a primative value
*
* Convert
*
* {
* id: '1234',
* admin: 'true',
* password: 'password'
* }
*
* to
*
* {
* id: 1234,
* admin: true,
* password: 'password'
* }
*
*/
function primeObjectValue (object, rule) {
const _object = {}
for (const [ key, value ] of Object.entries(object)) {
let _value
const type = get(rule, [ key, 'type' ])
if (typeof value === 'string') {
if ((isNumberable(value) && type === 'integer') ||
(isNumberable(value) && type === 'number')) {
_value = toNumber(value)
} else if (isBooleanable(value) && type === 'boolean') {
_value = toBoolean(value)
} else {
_value = value
}
} else {
_value = value
}
// Undefined / Null is ignored due to security reason.
// Symbol is not necessary.
_object[key] = _value
}
return _object
}
/**
* Add more metadata for e throwed by egg-validate
*
* assign meta to every element in e.errors
*
*/
function enrichValidateError (e, meta) {
// ensure e is throw by egg-validate
if (e.code === 'invalid_param') {
for (const error of e.errors) {
Object.assign(error, meta)
}
}
}
function matchedPath (_path, paths) {
for (const path of paths) {
const match = pathMatching({ match: path })
if (match({ path: _path })) {
return path
}
}
}
function getFixedFacets (typeDeclaration) {
const fixedFacets = typeDeclaration.fixedFacets()
if (fixedFacets) {
return fixedFacets.toJSON()
} else {
return {}
}
}
function genRule (typeDeclaration) {
const t = typeDeclaration
const type = t.type()[0]
const required = t.required()
const requiredOptions = { type, required }
const optionalOptions = getFixedFacets(t)
const rule = Object.assign(requiredOptions, optionalOptions)
return rule
}
function typeDeclarationsToRules (typeDeclarations) {
const rules = {}
for (const typeDeclaration of typeDeclarations) {
const name = typeDeclaration.name()
const rule = genRule(typeDeclaration)
set(rules, name, rule)
}
return rules
}
function getAllUriParameters (res) {
const rules = {}
const resourcesChain = []
addParent(res)
for (const resource of resourcesChain) {
for (const uriParameter of resource.allUriParameters()) {
const name = uriParameter.name()
const rule = genRule(uriParameter)
set(rules, name, rule)
}
}
return rules
function addParent (res) {
resourcesChain.splice(0, 0, res)
const parentRes = res.parentResource()
if (parentRes) {
addParent(parentRes)
}
}
}
function bodyTypeDeclarationsToRules (bodyTypeDeclarations) {
if (isEmpty(bodyTypeDeclarations)) return {}
for (const bodyTypeDeclaration of bodyTypeDeclarations) {
const bodyType = bodyTypeDeclaration.name()
const [ type ] = bodyTypeDeclaration.type()
if (bodyType !== 'application/json') return {}
if (type === 'object') {
const rules = {}
bodyTypeDeclaration.properties().forEach(function (prop) {
const name = prop.name()
const rule = genRule(prop)
set(rules, name, rule)
})
return rules
} else {
return {}
}
}
}
/**
* Generate meta info from parsed RAML
*/
function genMeta (api) {
const meta = {
paths: [],
verbose: {}
}
processResource(api, meta)
// reverse sort pathsList for right order of paths
meta.paths = meta.paths.sort().reverse()
return meta
function processResource (resource, meta) {
for (const childResource of resource.resources()) {
const path = bracketPathToExpressPath(childResource.completeRelativeUri())
meta.paths.push(path)
let uriParameters = getAllUriParameters(childResource)
uriParameters = sanitizeRules(uriParameters)
// methods
for (const method of childResource.methods()) {
const methodName = method.method()
// uriParameters
set(meta, ['verbose', path, methodName, 'rules', 'uriParameters'], uriParameters)
// headers
let headersRules = typeDeclarationsToRules(method.headers())
headersRules = sanitizeRules(headersRules)
headersRules = lowerCaseObjectKey(headersRules) // egg use lowercased headers
set(meta, ['verbose', path, methodName, 'rules', 'headers'], headersRules)
// queryParameters
let queryParametersRules = typeDeclarationsToRules(method.queryParameters())
queryParametersRules = sanitizeRules(queryParametersRules)
set(meta, ['verbose', path, methodName, 'rules', 'queryParameters'], queryParametersRules)
// body
let bodyRules = bodyTypeDeclarationsToRules(method.body())
bodyRules = sanitizeRules(bodyRules)
set(meta, ['verbose', path, methodName, 'rules', 'body'], bodyRules)
// middlewares & controller
set(meta, ['verbose', path, methodName, 'middlewares'], [])
set(meta, ['verbose', path, methodName, 'controller'], undefined)
for (const annotation of method.annotations()) {
const name = annotation.name()
const value = annotation.structuredValue().toJSON()
if (name === 'middlewares') {
let middlewares = []
if (isArray(value)) {
middlewares = value
}
set(meta, ['verbose', path, methodName, 'middlewares'], middlewares)
}
if (name === 'controller') {
const controller = value
set(meta, ['verbose', path, methodName, 'controller'], controller)
}
}
}
processResource(childResource, meta)
}
}
}
/**
* bind paths to controllers
*/
function routes (app, meta, ramlValidate) {
for (const [ path, pathObject ] of Object.entries(meta.verbose)) {
for (const [ method, { middlewares, controller } ] of Object.entries(pathObject)) {
const _middlewares = middlewares.map(mw => {
const sandbox = { app }
vm.runInNewContext(`mw = ${mw}`, sandbox)
return sandbox.mw
})
if (controller) {
app[method](path, ..._middlewares, ramlValidate, controller)
}
}
}
}
module.exports = (options, app) => {
const { ramlFile } = options
const api = raml.loadApiSync(ramlFile).expand()
const meta = genMeta(api)
_debugMeta(meta)
app.beforeStart(() => {
routes(app, meta, ramlValidate)
})
async function ramlValidate (ctx, next) {
const _path = ctx.request.path.toLowerCase()
const _method = ctx.request.method.toLowerCase()
const path = matchedPath(_path, meta.paths)
const method = _method
const rules = get(meta, [ 'verbose', path, method, 'rules' ])
if (!rules) {
await next()
return
}
const dataMap = {
headers: ctx.header,
uriParameters: ctx.params,
queryParameters: ctx.query,
body: ctx.request.body
}
_debugVerbose(dataMap)
_debugVerbose(rules)
for (let [location, rule] of Object.entries(rules)) {
if (!isEmpty(rule)) {
let data = dataMap[location]
switch (location) {
case 'uriParameters':
Object.assign(ctx.params, primeObjectValue(ctx.params, rule))
break
case 'queryParameters':
Object.assign(ctx.query, primeObjectValue(ctx.query, rule))
break
}
try {
ctx.validate(rule, data)
} catch (e) {
enrichValidateError(e, { in: location })
throw e
}
}
}
await next()
}
return async function (ctx, next) {
await next()
}
}