mali
Version:
Minimalistic gRPC microservice framework
523 lines (443 loc) • 16.9 kB
JavaScript
const util = require('util')
const assert = require('assert')
const Emitter = require('events')
const compose = require('@malijs/compose')
const grpc = require('@grpc/grpc-js')
const protoLoader = require('@grpc/proto-loader')
const _ = require('./lo')
const Context = require('./context')
const { exec } = require('./run')
const mu = require('./utils')
const Request = require('./request')
const Response = require('./response')
const REMOVE_PROPS = [
'grpc',
'servers',
'load',
'proto',
'data'
]
const EE_PROPS = Object.getOwnPropertyNames(new Emitter())
/**
* Represents a gRPC service
* @extends Emitter
*
* @example <caption>Create service dynamically</caption>
* const PROTO_PATH = path.resolve(__dirname, './protos/helloworld.proto')
* const app = new Mali(PROTO_PATH, 'Greeter')
* @example <caption>Create service from static definition</caption>
* const services = require('./static/helloworld_grpc_pb')
* const app = new Mali(services, 'GreeterService')
*/
class Mali extends Emitter {
/**
* Create a gRPC service
* @class
* @param {String|Object} path - Optional path to the protocol buffer definition file
* - Object specifying <code>root</code> directory and <code>file</code> to load
* - Loaded grpc object
* - The static service proto object itself
* @param {Object} name - Optional name of the service or an array of names. Otherwise all services are used.
* In case of proto path the name of the service as defined in the proto definition.
* In case of proto object the name of the constructor.
* @param {Object} options - Options to be passed to <code>grpc.load</code>
*/
constructor (path, name, options) {
super()
this.grpc = grpc
this.servers = []
this.ports = []
this.data = {}
// app options / settings
this.context = new Context()
this.env = process.env.NODE_ENV || 'development'
if (path) {
this.addService(path, name, options)
}
}
/**
* Add the service and initialize the app with the proto.
* Basically this can be used if you don't have the data at app construction time for some reason.
* This is different than `grpc.Server.addService()`.
* @param {String|Object} path - Path to the protocol buffer definition file
* - Object specifying <code>root</code> directory and <code>file</code> to load
* - Loaded grpc object
* - The static service proto object itself
* @param {Object} name - Optional name of the service or an array of names. Otherwise all services are used.
* In case of proto path the name of the service as defined in the proto definition.
* In case of proto object the name of the constructor.
* @param {Object} options - Options to be passed to <code>grpc.load</code>
*/
addService (path, name, options) {
const load = typeof path === 'string' || (_.isObject(path) && path.root && path.file)
let proto = path
if (load) {
let protoFilePath = path
const loadOptions = Object.assign({}, options)
if (typeof path === 'object' && path.root && path.file) {
protoFilePath = path.file
if (!loadOptions.includeDirs) {
// Support either multiple or single paths.
loadOptions.includeDirs = Array.isArray(path.root) ? path.root : [path.root]
}
}
const pd = protoLoader.loadSync(protoFilePath, loadOptions)
proto = grpc.loadPackageDefinition(pd)
}
const data = mu.getServiceDefinitions(proto)
if (!name) {
name = Object.keys(data)
} else if (typeof name === 'string') {
name = [name]
}
for (const k in data) {
const v = data[k]
if (name.indexOf(k) >= 0 || name.indexOf(v.shortServiceName) >= 0) {
v.middleware = []
v.handlers = {}
for (const method in v.methods) {
v.handlers[method] = null
}
this.data[k] = v
}
}
if (!this.name) {
if (Array.isArray(name)) {
this.name = name[0]
}
}
}
/**
* Define middleware and handlers.
* @param {String|Object} service Service name
* @param {String|Function} name RPC name
* @param {Function|Array} fns - Middleware and/or handler
*
* @example <caption>Define handler for RPC function 'getUser' in first service we find that has that call name.</caption>
* app.use('getUser', getUser)
*
* @example <caption>Define handler with middleware for RPC function 'getUser' in first service we find that has that call name.</caption>
* app.use('getUser', mw1, mw2, getUser)
*
* @example <caption>Define handler with middleware for RPC function 'getUser' in service 'MyService'. We pick first service that matches the name.</caption>
* app.use('MyService', 'getUser', mw1, mw2, getUser)
*
* @example <caption>Define handler with middleware for rpc function 'getUser' in service 'MyService' with full package name.</caption>
* app.use('myorg.myapi.v1.MyService', 'getUser', mw1, mw2, getUser)
*
* @example <caption>Using destructuring define handlers for rpc functions 'getUser' and 'deleteUser'. Here we would match the first service that has a `getUser` RPC method.</caption>
* app.use({ getUser, deleteUser })
*
* @example <caption>Apply middleware to all handlers for a given service. We match first service that has the given name.</caption>
* app.use('MyService', mw1)
*
* @example <caption>Apply middleware to all handlers for a given service using full namespaced package name.</caption>
* app.use('myorg.myapi.v1.MyService', mw1)
*
* @example <caption>Using destructuring define handlers for RPC functions 'getUser' and 'deleteUser'. We match first service that has the given name.</caption>
* // deleteUser has middleware mw1 and mw2
* app.use({ MyService: { getUser, deleteUser: [mw1, mw2, deleteUser] } })
*
* @example <caption>Using destructuring define handlers for RPC functions 'getUser' and 'deleteUser'.</caption>
* // deleteUser has middleware mw1 and mw2
* app.use({ 'myorg.myapi.v1.MyService': { getUser, deleteUser: [mw1, mw2, deleteUser] } })
*
* @example <caption>Multiple services using object notation.</caption>
* app.use(mw1) // global for all services
* app.use('MyService', mw2) // applies to first matched service named 'MyService'
* app.use({
* 'myorg.myapi.v1.MyService': { // matches MyService
* sayGoodbye: handler1, // has mw1, mw2
* sayHello: [ mw3, handler2 ] // has mw1, mw2, mw3
* },
* 'myorg.myapi.v1.MyOtherService': {
* saySomething: handler3 // only has mw1
* }
* })
*/
use (service, name, ...fns) {
if (typeof service === 'function') {
const isFunction = typeof name === 'function'
for (const serviceName in this.data) {
const _service = this.data[serviceName]
if (isFunction) {
_service.middleware = _service.middleware.concat(service, name, fns)
} else {
_service.middleware = _service.middleware.concat(service, fns)
}
}
} else if (typeof service === 'object') {
// we have object notation
const testKey = Object.keys(service)[0]
if (typeof service[testKey] === 'function' || Array.isArray(service[testKey])) {
// first property of object is a function or array
// that means we have service-level middleware of RPC handlers
for (const key in service) {
// lets try to match the key to any service name first
const val = service[key]
const serviceName = this._getMatchingServiceName(key)
if (serviceName) {
// we have a matching service
// lets add service-level middleware to that service
this.data[serviceName].middleware.push(val)
} else {
// we need to find the matching function to set it as handler
const { serviceName, methodName } = this._getMatchingCall(key)
if (serviceName && methodName) {
if (typeof val === 'function') {
this.use(serviceName, methodName, val)
} else {
this.use(serviceName, methodName, ...val)
}
} else {
throw new TypeError(`Unknown method: ${key}`)
}
}
}
} else if (typeof service[testKey] === 'object') {
for (const serviceName in service) {
for (const middlewareName in service[serviceName]) {
const middleware = service[serviceName][middlewareName]
if (typeof middleware === 'function') {
this.use(serviceName, middlewareName, middleware)
} else if (Array.isArray(middleware)) {
this.use(serviceName, middlewareName, ...middleware)
} else {
throw new TypeError(`Handler for ${middlewareName} is not a function or array`)
}
}
}
} else {
throw new TypeError(`Invalid type for handler for ${testKey}`)
}
} else {
if (typeof name !== 'string') {
// name is a function pre-pand it to fns
fns.unshift(name)
// service param can either be a service name or a function name
// first lets try to match a service
const serviceName = this._getMatchingServiceName(service)
if (serviceName) {
// we have a matching service
// lets add service-level middleware to that service
const sd = this.data[serviceName]
sd.middleware = sd.middleware.concat(fns)
return
} else {
// service param is a function name
// lets try to find the matching call and service
const { serviceName, methodName } = this._getMatchingCall(service)
if (!serviceName || !methodName) {
throw new Error(`Unknown identifier: ${service}`)
}
this.use(serviceName, methodName, ...fns)
return
}
}
// we have a string service, and string name
const serviceName = this._getMatchingServiceName(service)
if (!serviceName) {
throw new Error(`Unknown service ${service}`)
}
const sd = this.data[serviceName]
let methodName
for (const _methodName in sd.methods) {
if (this._getMatchingHandlerName(sd.methods[_methodName], _methodName, name)) {
methodName = _methodName
break
}
}
if (!methodName) {
throw new Error(`Unknown method ${name} for service ${serviceName}`)
}
if (sd.handlers[methodName]) {
throw new Error(`Handler for ${name} already defined for service ${serviceName}`)
}
sd.handlers[methodName] = sd.middleware.concat(fns)
}
}
callback (descriptor, mw) {
const handler = compose(mw)
if (!this.listeners('error').length) this.on('error', this.onerror)
return (call, callback) => {
const context = this._createContext(call, descriptor)
return exec(context, handler, callback)
}
}
/**
* Default error handler.
*
* @param {Error} err
*/
onerror (err, ctx) {
assert(err instanceof Error, `non-error thrown: ${err}`)
if (this.silent) return
const msg = err.stack || err.toString()
console.error()
console.error(msg.replace(/^/gm, ' '))
console.error()
}
/**
* Start the service. All middleware and handlers have to be set up prior to calling <code>start</code>.
* Throws in case we fail to bind to the given port.
* @param {String} port - The hostport for the service. Default: <code>127.0.0.1:0</code>
* @param {Object} creds - Credentials options. Default: <code>grpc.ServerCredentials.createInsecure()</code>
* @param {Object} options - The start options to be passed to `grpc.Server` constructor.
* @return {Promise<Object>} server - The <code>grpc.Server</code> instance
* @example
* app.start('localhost:50051')
* @example <caption>Start same app on multiple ports</caption>
* app.start('127.0.0.1:50050')
* app.start('127.0.0.1:50051')
*/
async start (port, creds, options) {
if (_.isObject(port)) {
if (_.isObject(creds)) {
options = creds
}
creds = port
port = null
}
if (!port || typeof port !== 'string' || (typeof port === 'string' && port.length === 0)) {
port = '127.0.0.1:0'
}
if (!creds || !_.isObject(creds)) {
creds = this.grpc.ServerCredentials.createInsecure()
}
const server = new this.grpc.Server(options)
server.tryShutdownAsync = util.promisify(server.tryShutdown)
const bindAsync = util.promisify(server.bindAsync).bind(server)
for (const sn in this.data) {
const sd = this.data[sn]
const handlerValues = Object.values(sd.handlers).filter(Boolean)
const hasHandlers = handlerValues && handlerValues.length
if (sd.handlers && hasHandlers) {
const composed = {}
for (const k in sd.handlers) {
const v = sd.handlers[k]
if (!v) { continue }
const md = sd.methods[k]
const shortComposedKey = md.originalName || _.camelCase(md.name)
composed[shortComposedKey] = this.callback(sd.methods[k], v)
}
server.addService(sd.service, composed)
}
}
const bound = await bindAsync(port, creds)
if (!bound) {
throw new Error(`Failed to bind to port: ${port}`)
}
this.ports.push(bound)
server.start()
this.servers.push({
server,
port
})
return server
}
/**
* Close the service(s).
* @example
* app.close()
*/
async close () {
await Promise.all(this.servers.map(({ server }) => server.tryShutdownAsync()))
}
/**
* Return JSON representation.
* We only bother showing settings.
*
* @return {Object}
* @api public
*/
toJSON () {
const own = Object.getOwnPropertyNames(this)
const props = _.pull(own, ...REMOVE_PROPS, ...EE_PROPS)
return _.pick(this, props)
}
/**
* Inspect implementation.
* @return {Object}
*/
[util.inspect.custom] (depth, options) {
return this.toJSON()
}
/**
* @member {String} name The service name.
* If multiple services are initialized, this will be equal to the first service loaded.
* @memberof Mali#
* @example
* console.log(app.name) // 'Greeter'
*/
/**
* @member {String} env The environment. Taken from <code>process.end.NODE_ENV</code>. Default: <code>development</code>
* @memberof Mali#
* @example
* console.log(app.env) // 'development'
*/
/**
* @member {Array} ports The ports of the started service(s)
* @memberof Mali#
* @example
* console.log(app.ports) // [ 52239 ]
*/
/**
* @member {Boolean} silent Whether to supress logging errors in <code>onerror</code>. Default: <code>false</code>, that is errors will be logged to `stderr`.
* @memberof Mali#
*/
/*!
* Internal create context
*/
_createContext (call, descriptor) {
const type = mu.getCallTypeFromCall(call) || mu.getCallTypeFromDescriptor(descriptor)
const { name, fullName, service } = descriptor
const pkgName = descriptor.package
const context = new Context()
Object.assign(context, this.context)
context.request = new Request(call, type)
context.response = new Response(call, type)
Object.assign(context, {
name,
fullName,
service,
app: this,
package: pkgName,
locals: {} // set fresh locals
})
return context
}
/*!
* gets matching service name
*/
_getMatchingServiceName (key) {
if (this.data[key]) {
return key
}
for (const serviceName in this.data) {
if (serviceName.endsWith('.' + key)) {
return serviceName
}
}
return null
}
_getMatchingCall (key) {
for (const _serviceName in this.data) {
const service = this.data[_serviceName]
for (const _methodName in service.methods) {
const method = service.methods[_methodName]
if (this._getMatchingHandlerName(method, _methodName, key)) {
return { methodName: key, serviceName: _serviceName }
}
}
}
return { serviceName: null, methodName: null }
}
_getMatchingHandlerName (handler, name, value) {
return name === value ||
name.endsWith('/' + value) ||
(handler?.originalName === value) ||
(handler?.name === value) ||
(handler && _.camelCase(handler.name) === _.camelCase(value))
}
}
module.exports = Mali