@hosoft/restful-api-framework
Version:
Base framework of the headless cms HoServer provided by http://helloreact.cn
1,073 lines (924 loc) • 33.6 kB
JavaScript
/* eslint-disable no-eval */
/**
* HoServer API Server Ver 2.0
* Copyright http://hos.helloreact.cn
*
* create: 2018/11/15
**/
const _ = require('lodash')
const CommUtils = require('../utils/common')
const config = require('@hosoft/config')
const Constants = require('./constants/constants')
const Context = require('./context')
const DefaultApiHandler = require('./default-api')
const ErrorCodes = require('./constants/error-codes')
const express = require('express')
const fs = require('fs')
const GlobalModels = require('../models/index')
const nlp = require('compromise')
const path = require('path')
const PluginManager = require('./plugin-manager')
const RouteProxy = require('./router/route-proxy')
const router = require('express').Router()
const RouterHelper = require('./router/route-helper')
const shortid = require('shortid')
const SystemRoutes = require('../default-app/routes')
// prettier-ignore
const apiEditableFields = [
'category_name', 'disabled',
'input_example', 'output_example', 'description', 'mock_result'
]
const allActions = [...Constants.API_DEF_ROUTE_ACTIONS, 'batch_update', 'batch_delete']
const systemModels = ['Api', 'Model', 'Service', 'Plugin']
const unPublicModels = [...systemModels, 'ServerLog', 'ClientLog', 'SiteMaintain']
// container instance
let containerInstance = null
/**
* Container for all models, api routes、services, controllers
*/
class Container {
constructor() {
this.hasInit = false
this.models = GlobalModels
this.middlewares = { before: [], after: [] }
this.beans = []
this.services = {}
this.controllers = {}
this.modifiedApiList = []
this.apiRoutes = []
this.contextApiHooks = {}
this.siteManitainInfo = null
}
// get container instance
static getInstance() {
if (containerInstance == null) {
containerInstance = new Container()
}
return containerInstance
}
/**
* initialize all models, routers and components.
* @param app
* @param callback
*/
async initialize(app, callback) {
// load user modified apis
this.modifiedApiList = await this.getModel('Api').find({ sort: 'order', lean: true })
await this.loadMiddlewares(app)
await this.loadPlugins(app)
await this.loadServices()
await this.loadControllers()
this.executeHook('initialize', null, null)
if (app) {
await this.initRouter(app)
} else {
logger.warn('Container initialize, app undefined!')
}
this.hasInit = true
if (callback) setTimeout(() => callback(), 3000)
}
// all requests entrance
async initRouter(app) {
// static route
app.use('/public', express.static(path.join(global.APP_PATH, 'public')))
// all requests entrance
app.use(this.createContext)
// system default routes
SystemRoutes.init(this, this.routeCreator(''), app)
// register manual router if needed
for (const controllerName in this.controllers) {
const controller = this.controllers[controllerName]
if (!controller.instance) {
logger.error('initRouter, controller instance is null: ' + controllerName)
continue
}
if (controller.instance.initRoutes) {
const routeCreator = this.routeCreator(controllerName)
await controller.instance.initRoutes(this, routeCreator, app)
}
}
// createApiRoutes will create all express routes
app.use('/', await this.createApiRoutes())
// error handler
this.initErrRoutes(app)
}
routeCreator(controllerName, apiType) {
const setApiType = (options) => {
if (apiType) {
if (!options) {
options = {}
}
options.type = apiType
}
return options
}
return {
def: (modelPath, actions, options) => {
if (!actions) {
actions = Constants.API_DEF_ROUTE_ACTIONS
} else if (typeof actions === 'string') {
actions = [actions]
}
if (!(actions instanceof Array)) {
throw new Error('actions must be array')
}
if (_.intersection(allActions, actions).length < actions.length) {
throw new Error('actions must be one or more of list, detail, create, update, delete')
}
options = setApiType(options)
return this.createDefaultRouteProxy(controllerName, modelPath, actions, options)
},
// path: Api route,will auto add /api/v1 prefix
// disName: Api display name
// func: api execute function
// options: custom options
get: (path, disName, func, options) => {
options = setApiType(options)
return this.createRouteProxy(
controllerName,
Constants.API_HTTP_METHOD.GET,
path,
disName,
func,
options
)
},
post: (path, disName, func, options) => {
options = setApiType(options)
return this.createRouteProxy(
controllerName,
Constants.API_HTTP_METHOD.POST,
path,
disName,
func,
options
)
},
delete: (path, disName, func, options) => {
options = setApiType(options)
return this.createRouteProxy(
controllerName,
Constants.API_HTTP_METHOD.DELETE,
path,
disName,
func,
options
)
}
}
}
mergeApiFields(api) {
const modifiedApi = this.modifiedApiList.find((r) => r.method === api.method && r.path === api.path)
if (modifiedApi) {
api.id = modifiedApi.id
for (const field of apiEditableFields) {
if (modifiedApi[field] instanceof Array) {
if (modifiedApi[field].length > 0) {
_.merge(api[field], modifiedApi[field])
}
} else if (typeof modifiedApi[field] === 'object') {
_.merge(api[field], modifiedApi[field])
} else if (modifiedApi[field]) {
api[field] = modifiedApi[field]
}
}
// in_params, out_fields now allow to modify, only append
if (modifiedApi.modified) {
if (modifiedApi.in_params && modifiedApi.in_params.length > 0) {
for (const param of modifiedApi.in_params) {
if (api.in_params.find((p) => p.name === param.name)) {
continue
}
api.in_params.push(param)
}
}
if (modifiedApi.out_fields && modifiedApi.out_fields.length > 0) {
for (const field of modifiedApi.out_fields) {
if (api.out_fields.find((o) => o.name === field.name)) {
continue
}
api.out_fields.push(field)
}
}
}
}
}
createDefaultRouteProxy(controller, modelPath, actions, options) {
const defApis = DefaultApiHandler.createDefaultRoutes(modelPath, actions)
if (!defApis) {
throw new Error('createDefaultRouteProxy failed!')
}
const routeProxies = []
for (let i = 0; i < defApis.length; i++) {
const defApi = defApis[i]
const existRouteProxy = this.apiRoutes.find(
(r) => r.api.method === defApi.method && r.api.path === defApi.path
)
if (existRouteProxy) {
routeProxies.push(existRouteProxy)
// console.info('api already registered,skip:' + defApi.path)
continue
}
this.mergeApiFields(defApi)
let func = defApi.func
if (typeof func === 'string') {
func = eval(func)
}
const routeProxy = new RouteProxy(
this,
defApi,
defApi.method,
defApi.path,
defApi.dis_name,
defApi.func,
options
)
this.apiRoutes.push(routeProxy)
routeProxies.push(routeProxy)
}
if (routeProxies.length === 1) {
return routeProxies[0]
}
const result = {
beforeProcess: (func, ...args) => {
routeProxies.forEach((rp) => {
rp.beforeProcess(func, args)
})
return result
},
afterProcess(func, ...args) {
routeProxies.forEach((rp) => {
rp.afterProcess(func, args)
})
return result
},
beforeDbProcess(func, ...args) {
routeProxies.forEach((rp) => {
rp.beforeDbProcess(func, args)
})
return result
},
outFields(...args) {
routeProxies.forEach((rp) => {
rp.outFields(args[0], args[1])
})
return result
}
}
return result
}
createRouteProxy(controller, method, path, disName, func, options) {
method = method.toUpperCase()
path = path.toLowerCase()
if (!path.startsWith(Constants.API_PREFIX)) {
path = Constants.API_PREFIX + path
}
if (typeof disName === 'function') {
throw new Error('wrong parameter, please set display name')
}
const existRouteProxy = this.apiRoutes.find((r) => r.method === method && r.path === path)
if (existRouteProxy) {
console.error(`api route with path ${path} has already been registered`)
return existRouteProxy
}
let apiInfo = null
const modifiedApi = this.modifiedApiList.find((r) => r.method === method && r.path === path)
if (modifiedApi) {
apiInfo = {}
apiInfo.id = modifiedApi.id
for (const field of apiEditableFields) {
if (modifiedApi[field] instanceof Array) {
if (modifiedApi[field].length > 0) {
apiInfo[field] = modifiedApi[field]
}
} else if (modifiedApi[field]) {
apiInfo[field] = modifiedApi[field]
}
}
apiInfo.in_params = modifiedApi.in_params
apiInfo.out_fields = modifiedApi.out_fields
}
const routeProxy = new RouteProxy(this, apiInfo, method, path, disName, func, options)
this.apiRoutes.push(routeProxy)
return routeProxy
}
/**
* init creation of call context
* @param req
* @param res
* @param next
* @returns {Promise<void>}
*/
createContext(req, res, next) {
const container = Container.getInstance()
const httpContext = new Context(container, req, res)
httpContext.apiHooks = container.contextApiHooks
res._context = httpContext
next()
}
/**
* execution before call service interface
* @param context
*/
async beforeExecute(context) {
let continueExecute = true
for (let i = 0; i < this.middlewares.before.length; i++) {
const middleware = this.middlewares.before[i]
if (middleware.before) {
const executeResult = await middleware.before(context)
if (executeResult === Constants.HOOK_RESULT.STOP_OTHER_HOOK) {
break
} else if (executeResult === Constants.HOOK_RESULT.RETURN) {
continueExecute = false
break
}
}
}
return continueExecute
}
/**
* execution after call service interface
* @param context
*/
async afterExecute(context) {
let continueExecute = true
for (let i = 0; i < this.middlewares.after.length; i++) {
const middleware = this.middlewares.after[i]
if (middleware.after) {
const executeResult = await middleware.after(context)
if (executeResult === Constants.HOOK_RESULT.STOP_OTHER_HOOK) {
break
} else if (executeResult === Constants.HOOK_RESULT.RETURN) {
continueExecute = false
break
}
}
}
return continueExecute
}
/**
* set api hook function
*/
setHook(hookName, hookFunc, routePath = '', method = '', order = -1) {
method = method.toUpperCase()
if (method && !Constants.API_HTTP_METHOD[method]) {
throw new Error('invalid param: method')
}
if (!hookName) {
throw new Error('invalid param: hookName')
}
const hookKey = hookName + (method ? ':' + method : '')
if (!this.contextApiHooks[hookKey]) {
this.contextApiHooks[hookKey] = []
}
if (order > -1) {
this.contextApiHooks[hookKey].splice(order, 0, {
obj: null, // hookObj,
route: routePath,
func: hookFunc
})
} else {
this.contextApiHooks[hookKey].push({
obj: null, // hookObj,
route: routePath,
func: hookFunc
})
}
}
/**
* execute api hook function
* @param context
* @param hookName
*/
async executeHook(hookName, context, api, ...args) {
let routePath = ''
if (!api && context) {
api = _.get(context, ['apiRoute', 'api'])
}
if (api) {
routePath = api.path
} else if (context && context.req) {
routePath = context.req.route.path
}
const method = api ? api.method : ''
// const hookPath = routePath.substr(Constants.API_PREFIX.length)
const hookKey = hookName + `${method ? ':' + method : ''}`
const hooks = this.contextApiHooks[hookKey] || this.contextApiHooks[hookName]
if (hooks) {
for (let i = 0; i < hooks.length; i++) {
const hookItem = hooks[i]
if (
!routePath ||
!hookItem.route ||
(hookItem.route &&
((hookItem.route instanceof RegExp && hookItem.route.test(routePath)) ||
hookItem.route === routePath))
) {
const hookResult = await this.executeHookFunc(context, hookItem, ...args)
if (hookResult === Constants.HOOK_RESULT.STOP_OTHER_HOOK) {
break
}
if (hookResult === Constants.HOOK_RESULT.RETURN) {
return hookResult
}
}
}
}
return Constants.HOOK_RESULT.CONTINUE
}
async executeHookFunc(context, hook, ...args) {
if (typeof hook.func === 'function') {
return hook.func(context, ...args)
}
if (typeof hook.func === 'string') {
try {
const func = hook.func
if (func && hook.obj && typeof hook.obj[func] === 'function') {
if (args instanceof Array) {
return await hook.obj[hook.func](context, ...args)
} else {
return await hook.obj[hook.func](context, args)
}
} else {
// try eval
return eval(hook.func)
}
} catch (e) {
logger.error(e.stack)
return Promise.reject({
message: 'execute api hook function error: ' + hook.func,
code: ErrorCodes.API_ERR_EXECUTE
})
}
}
}
/**
* create routes to all apis, which were defined through manager web
* by system admin
*/
async createApiRoutes() {
let order = 1
for (let i = 0; i < this.apiRoutes.length; i++) {
const apiRoute = this.apiRoutes[i]
await apiRoute.fillCategoryName()
// check Api service & func exists
const api = apiRoute.api
if (!api.id) {
api.id = shortid.generate()
}
if (!api.name) {
api.name = this._guessApiName(api)
api.is_auto_name = true
}
api.order = order++
if (api.model && api.model !== 'Service') {
const model = this.getModel(api.model)
if (!model) {
logger.error('createApiRoutes, Api modelMeta not found!', apiRoute)
process.exit()
}
}
switch (api.method) {
case Constants.API_HTTP_METHOD.GET:
router.get(api.path, (req, res, next) => {
RouterHelper(this.executeApi(apiRoute, res._context), res, next)
})
break
case Constants.API_HTTP_METHOD.POST:
router.post(api.path, (req, res, next) => {
RouterHelper(this.executeApi(apiRoute, res._context), res, next)
})
break
case Constants.API_HTTP_METHOD.DELETE:
router.delete(api.path, (req, res, next) => {
RouterHelper(this.executeApi(apiRoute, res._context), res, next)
})
break
}
if (this.getUnPublicModelNames().includes(api.model)) {
api.private = true
api.permissions = ['manage:system', '_manage:Api']
} else {
logger.debug(`api route created: ${api.method.toUpperCase()} ${api.path}`)
}
}
logger.debug(`createApiRoutes, total ${this.apiRoutes.length} api route loaded.`)
return router
}
/**
* the 404 error handle should put to the end of the route tables
* @param app
*/
initErrRoutes(app) {
// catch 404 and forward to error handler
app.use(function (req, res, next) {
const err = new Error('Not Found')
err.status = 404
next(err)
})
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function (err, req, res, next) {
res.status(err.status || 500)
res.json({
status: err.status || 500,
message: err.message,
stack: err.stack
})
})
} else {
app.use(function (err, req, res, next) {
res.status(err.status || 500)
res.json({
status: err.status || 500,
message: err.message
})
})
}
}
/**
* execute api
* @param apiRoute
* @param context the http context
*/
async executeApi(apiRoute, context) {
if (context.result) {
logger.info('executeApi, route already handled by other functions, please check route order')
return null
}
context.apiRoute = apiRoute
const container = context.container
// before execute
try {
const continueExecute = await container.beforeExecute(context)
if (continueExecute !== true) {
logger.debug('executeApi, a middleware stopped api execution')
return null
}
} catch (err) {
logger.error(`beforeExecute error: ${typeof err === 'string' ? err : err.stack || err.message}`)
context.error = err
return null
}
try {
const hookResult = await container.executeHook('beforeProcess', context, context.apiRoute.api, context)
if (hookResult === Constants.HOOK_RESULT.RETURN) {
logger.debug('executeApi, api execution stopped by beforeProcess hook')
return null
}
} catch (err) {
logger.error(`beforeProcess executeHook error: ${typeof err === 'string' ? err : err.stack || err.message}`)
context.error = err
return null
}
// execute
try {
const apiFunc = apiRoute.func
const result = await apiFunc(context, apiRoute.api)
if (result && context.result === null) {
context.setResult(result)
}
} catch (err) {
logger.error(`executeApi error: ${typeof err === 'string' ? err : err.stack || err.message}`)
context.error = err
}
}
/**
* get un-public model's name
*/
getUnPublicModelNames() {
return unPublicModels
}
/**
* get api editable fields
*/
getApiEditableFields() {
return apiEditableFields
}
/**
* get api detail
* @param id
* @returns {Promise<void>}
*/
async getRouteById(id) {
const apiRoute = this.apiRoutes.find((r) => r.api.id === id)
if (apiRoute) {
await apiRoute.fillCategoryName()
}
return apiRoute ? apiRoute.api : null
}
/**
* get api by path
* @param path
* @param method
*/
async getRoute(path, method = 'GET') {
let fullPath = ''
if (path.indexOf(Constants.API_PREFIX) < 0) {
fullPath = Constants.API_PREFIX + path
}
const routes = this.apiRoutes.filter((r) => r.api.method === method)
const apiRoute = routes.find((r) => r.api.path === path || r.api.path === fullPath)
if (apiRoute) {
await apiRoute.fillCategoryName()
}
return apiRoute
}
/**
* Get API list (in memory)
*/
async getRouteList(publicOnly, keepSysModels) {
for (let i = 0; i < this.apiRoutes.length; i++) {
const api = this.apiRoutes[i]
await api.fillCategoryName()
}
let result = this.apiRoutes.map((r) => r.api)
if (!keepSysModels) {
result = result.filter((api) => {
const modelName = this.getModelName(api.model)
return !(api.model && unPublicModels.includes(modelName))
})
}
if (publicOnly) {
return result.filter((api) => api.private !== true)
}
return result
}
/**
* register middleware
*/
registerMiddleware(isBefore, isAfter, middlewareFile) {
if (!fs.existsSync(middlewareFile)) {
logger.error('middlewareFile not exist: ' + middlewareFile)
return
}
if (isBefore) {
this.middlewares.before.splice(this.middlewares.length - 1, 0, require(middlewareFile))
}
if (isAfter) {
this.middlewares.after.splice(this.middlewares.length - 2, 0, require(middlewareFile))
}
}
/**
* get all models
*/
getAllModels() {
return this.models
}
/**
* get model name
* @param modelName
*/
getModelName(modelName) {
if (!modelName) {
return ''
}
const pos = modelName.indexOf('.')
if (pos > -1) {
modelName = modelName.substr(0, pos)
}
return modelName
}
/**
* get modelMeta with schema by modelMeta class name
*/
getModel(modelName) {
if (!this.models) {
throw new Error('db models has not init')
}
modelName = this.getModelName(modelName)
if (!modelName) {
return null
}
return this.models[modelName]
}
/**
* get service list
*/
getAllServices() {
return this.services
}
/**
* get modelMeta with schema by modelMeta class name
*/
getServiceInst(serviceName) {
const service = this.getService(serviceName)
if (!service) {
return null
}
return service.instance
}
/**
* get service modelMeta
* @param serviceName
* @returns {String|WebAssembly.Instance|null}
*/
getService(serviceName) {
const service = this.services[serviceName]
if (!service) {
return null
}
return service
}
/**
* load route controllers
* @returns {Promise<void>}
*/
async loadControllers() {
this.controllers = {}
const controllers = await this._scanLocalControllers()
for (const controller of controllers) {
const key = controller.category_name + controller.name
if (!this.controllers[key]) {
controller.instance = require(controller.file)
this.controllers[key] = controller
}
}
logger.debug(`loadControllers, total: ${Object.keys(this.services).length} of ${controllers.length} loaded`)
}
/**
* load all services from database
* @returns {Promise<void>}
*/
async loadServices() {
if (this.services) {
delete this.services
}
this.services = {}
const services = await this._scanLocalServices()
for (const service of services) {
const serviceKey = service.category_name ? `${service.category_name}/${service.name}` : service.name
if (this.services[serviceKey]) {
throw new Error(`${serviceKey}Service has already exist, please modify service file name`)
}
// load service apis
service.instance = require(service.file)
this.services[serviceKey] = service
}
logger.debug(`loadServices, total: ${Object.keys(this.services).length} of ${services.length} loaded`)
}
// init plugins
async loadPlugins(app) {
await PluginManager.initPlugins(this, this.routeCreator('', 2 /* plugin */), app)
}
async loadMiddlewares(app) {
const enabledCache = config.get('server.enableCache')
const apiAuth = require('../middlewares/api-auth')
const docExampleGen = require('../middlewares/doc-example-gen')
const promMetrics = require('../middlewares/prom-metrics')
const requestParser = require('../middlewares/request-parser')
const statusCheck = require('../middlewares/status-check')
promMetrics.init(app)
// pay attention to the middlewares order
this.middlewares.before = [
promMetrics,
requestParser,
statusCheck,
apiAuth,
...this.middlewares.before,
docExampleGen
]
this.middlewares.after = [requestParser, docExampleGen, ...this.middlewares.after, promMetrics]
// api cache
if (enabledCache) {
const apiCache = require('../middlewares/api-cache')
this.middlewares.before.splice(this.middlewares.before.indexOf(docExampleGen), 0, apiCache)
this.middlewares.after.splice(this.middlewares.after.indexOf(docExampleGen), 0, apiCache)
}
}
/**
* set site maintain info //TODO: plugin
*/
async enableMaintainInfo(args) {
this.siteManitainInfo = args
}
/**
* load all services from database
* @returns {Promise<*>}
*/
async _scanLocalServices() {
const serviceFiles = []
const loopServiceDir = (dir, category) => {
const pa = fs.readdirSync(dir)
pa.forEach((ele, index) => {
const info = fs.statSync(dir + '/' + ele)
if (info.isDirectory()) {
loopServiceDir(dir + '/' + ele, category)
} else {
const fileName = ele.endsWith('.jsc') ? path.basename(ele, '.jsc') : path.basename(ele, '.js')
if (fileName.endsWith('Service')) {
serviceFiles.push({
name: fileName,
category_name: category,
file: dir + '/' + ele
})
}
}
})
}
// system default app services
loopServiceDir(path.join(__dirname, '..', 'default-app', 'services'), 'sys')
loopServiceDir(path.join(global.APP_PATH, 'services'), '')
return serviceFiles
}
/**
* loop controoler diorectory and load all controllers
* @returns {Promise<*>}
*/
async _scanLocalControllers() {
const controllerFiles = []
const loopControllerDir = (dir, category) => {
const pa = fs.readdirSync(dir)
pa.forEach((ele, index) => {
const info = fs.statSync(dir + '/' + ele)
if (info.isDirectory()) {
loopControllerDir(dir + '/' + ele, category ? `${category}/${ele}` : ele)
} else {
const fileName = ele.endsWith('.jsc') ? path.basename(ele, '.jsc') : path.basename(ele, '.js')
if (fileName.endsWith('Controller')) {
controllerFiles.push({
name: fileName.substr(0, fileName.lastIndexOf('Controller')),
category_name: category,
file: dir + '/' + ele
})
}
}
})
}
loopControllerDir(path.join(__dirname, '..', 'default-app', 'controllers'), 'default')
loopControllerDir(path.join(global.APP_PATH, 'controllers'), '')
return controllerFiles
}
_guessApiName(api) {
const path = api.path
const pos = path.indexOf(Constants.API_PREFIX)
if (pos < 0) return ''
let routePath = path.substr(pos + Constants.API_PREFIX.length + 1)
if (routePath.startsWith('/')) {
routePath = routePath.substr(1)
}
const parts = routePath.split('/')
if (!(parts.length > 0 && routePath[0])) {
return ''
}
// at most check two words
if (parts.length > 2) {
parts.splice(0, parts.length - 2)
}
let isVerb = false
let isUpdate = false
let firstWord
if (api.method === 'POST') {
const routeWords = []
parts.forEach((p, index) => {
if (!p.startsWith(':')) {
p.split('_').forEach((p1) => routeWords.push(p1))
} else if (index === parts.length - 1) {
isUpdate = true
}
})
_.reverse(routeWords)
const taggedWords = nlp(routeWords.join(' ')).json()
if (taggedWords.length > 0) {
const tagTerms = taggedWords[0].terms
tagTerms[tagTerms.length - 1].tags.forEach((t) => {
// put verb in front
if (t.startsWith('V')) {
isVerb = true
firstWord = tagTerms[tagTerms.length - 1].text
}
})
}
}
const getParts = (name, isLast) => {
let names = ''
name.split('_').forEach((n) => {
names += CommUtils.capitalizeFirstLetter(n)
// if (isLast) {
// names += CommUtils.capitalizeFirstLetter(n)
// } else {
// names += CommUtils.capitalizeFirstLetter(pluralize(n, 1))
// }
})
return names
}
let apiName = ''
let len = parts.length
if (isVerb) {
apiName = firstWord.toLowerCase()
len--
} else if (api.method === 'POST') {
apiName = isUpdate ? 'update' : 'set'
} else {
apiName = api.method.toLowerCase()
}
for (let i = 0; i < len; i++) {
if (!parts[i].startsWith(':')) {
apiName += CommUtils.capitalizeFirstLetter(getParts(parts[i], i === len - 1))
}
}
return apiName.replace(/[^a-zA-Z0-9_\\-]/g, '')
}
}
module.exports = Container