UNPKG

mali

Version:

Minimalistic gRPC microservice framework

523 lines (443 loc) 16.9 kB
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