UNPKG

@lightbend/akkaserverless-javascript-sdk

Version:
475 lines (441 loc) 14.9 kB
/* * Copyright 2021 Lightbend Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const AnySupport = require('./protobuf-any'); const EffectSerializer = require('./effect-serializer'); const { ContextFailure } = require('./context-failure'); const { Metadata } = require('./metadata'); const { Reply } = require('./reply'); /** * Creates the base for context objects. * @private */ class CommandHelper { constructor( entityId, service, streamId, call, handlerFactory, allComponents, debug, ) { this.entityId = entityId; this.service = service; this.streamId = streamId; this.call = call; this.effectSerializer = new EffectSerializer(allComponents); this.debug = debug; this.handlerFactory = handlerFactory; } /** * Handle a command. * * @param command The command to handle. * @private */ async handleCommand(command) { try { const reply = await this.handleCommandLogic(command); this.call.write(reply); } catch (err) { if (err.failure && err.failure.commandId === command.id) { this.call.write(err); this.call.end(); } else { console.error(err); throw err; } } } async handleCommandLogic(command) { let metadata = new Metadata([]); if (command.metadata && command.metadata.entries) { metadata = new Metadata(command.metadata.entries); } const ctx = this.createContext(command.id, metadata); const errorReply = (msg, status) => { return { failure: { commandId: command.id, description: msg, grpcStatusCode: status, }, }; }; if (!this.service.methods.hasOwnProperty(command.name)) { ctx.commandDebug("Command '%s' unknown", command.name); return errorReply( 'Unknown command named ' + command.name, 12 /* unimplemented */, ); } else { try { const grpcMethod = this.service.methods[command.name]; // todo maybe reconcile whether the command URL of the Any type matches the gRPC response type let commandBuffer = command.payload.value; if (typeof commandBuffer === 'undefined') { commandBuffer = new Buffer(0); } const deserCommand = grpcMethod.resolvedRequestType.decode(commandBuffer); const handler = this.handlerFactory(command.name, grpcMethod); if (handler !== null) { ctx.streamed = command.streamed; const reply = await this.invokeHandlerLogic( () => handler(deserCommand, ctx), ctx, grpcMethod, 'Command', ); if (reply && reply.reply) { return reply; } else { return { reply: reply }; } } else { const msg = "No handler registered for command '" + command.name + "'"; ctx.commandDebug(msg); return errorReply(msg, 12 /* unimplemented */); } } catch (err) { const error = "Error handling command '" + command.name + "'"; ctx.commandDebug(error); console.error(err); throw errorReply(error + ': ' + err, 2 /* unknown */); } } } async invoke(handler, ctx) { ctx.reply = {}; let userReply = null; try { userReply = await Promise.resolve(handler()); } catch (err) { if (ctx.error === null) { // If the error field isn't null, then that means we were explicitly told // to fail, so we can ignore this thrown error and fail gracefully with a // failure message. Otherwise, we rethrow, and handle by closing the connection // higher up. throw err; } } finally { ctx.active = false; } return userReply; } errorReply(msg, status, ctx, desc) { ctx.commandDebug("%s failed with message '%s'", desc, msg); const failure = { commandId: ctx.commandId, description: msg, }; if (status !== undefined) { failure.grpcStatusCode = status; } return { reply: { commandId: ctx.commandId, clientAction: { failure: failure, }, }, }; } async invokeHandlerLogic(handler, ctx, grpcMethod, desc) { const userReply = await this.invoke(handler, ctx); if (ctx.error !== null) { return this.errorReply( ctx.error.message, ctx.error.grpcStatus, ctx, desc, ); } else if (userReply instanceof Reply) { if (userReply.failure) { // handle failure with a separate write to make sure we don't write back events etc return this.errorReply( userReply.failure.description, userReply.failure.status, ctx, desc, ); } else { // effects need to go first to end up in reply // note that we amend the ctx.reply to get events etc passed along from the entities ctx.reply.commandId = ctx.commandId; if (userReply.effects) { ctx.reply.sideEffects = userReply.effects.map((effect) => this.effectSerializer.serializeSideEffect( effect.method, effect.message, effect.synchronous, effect.metadata, ), ); } if (userReply.message) { ctx.reply.clientAction = { reply: { payload: AnySupport.serialize( grpcMethod.resolvedResponseType.create(userReply.message), false, false, ), metadata: userReply.metadata || null, }, }; ctx.commandDebug( '%s reply with type [%s] with %d side effects.', desc, ctx.reply.clientAction.reply.payload.type_url, ctx.effects.length, ); } else if (userReply.forward) { ctx.reply.clientAction = { forward: this.effectSerializer.serializeEffect( userReply.forward.method, userReply.forward.message, userReply.forward.metadata, ), }; ctx.commandDebug( '%s forward to %s with %d side effects.', desc, userReply.forward.method, ctx.effects.length, ); } else { // empty reply // FIXME should this be Protobuf Empty rather than no reply at all? ctx.commandDebug( '%s no reply with %d side effects.', desc, ctx.effects.length, ); ctx.reply.clientAction = { reply: { payload: AnySupport.serialize( grpcMethod.resolvedResponseType.create({}), false, false, ), metadata: userReply.metadata || null, }, }; } return ctx.reply; } } else { ctx.reply.commandId = ctx.commandId; ctx.reply.sideEffects = ctx.effects; if (ctx.forward !== null) { ctx.reply.clientAction = { forward: ctx.forward, }; ctx.commandDebug( '%s forward to %s.%s with %d side effects.', desc, ctx.forward.serviceName, ctx.forward.commandName, ctx.effects.length, ); } else if (userReply !== undefined) { ctx.reply.clientAction = { reply: { payload: AnySupport.serialize( grpcMethod.resolvedResponseType.create(userReply), false, false, ), metadata: ctx.replyMetadata.entries.length ? { entries: ctx.replyMetadata.entries } : null, }, }; ctx.commandDebug( '%s reply with type [%s] with %d side effects.', desc, ctx.reply.clientAction.reply.payload.type_url, ctx.effects.length, ); } else { ctx.commandDebug( '%s no reply with %d side effects.', desc, ctx.effects.length, ); } return ctx.reply; } } commandDebug(msg, ...args) { this.debug( '%s [%s] (%s) - ' + msg, ...[this.streamId, this.entityId].concat(args), ); } // This creates the context. Note that the context has two levels, first is the internal implementation context, this // has everything the ReplicatedEntity and EventSourcedEntity support needs to do its stuff, it's where effects and // metadata are recorded, etc. The second is the user facing context, which is a property on the internal context // called "context". createContext(commandId, metadata) { const accessor = {}; accessor.commandDebug = (msg, ...args) => { this.commandDebug(msg, ...[commandId].concat(args)); }; accessor.commandId = commandId; accessor.effects = []; accessor.active = true; accessor.ensureActive = () => { if (!accessor.active) { throw new Error('Command context no longer active!'); } }; accessor.error = null; accessor.forward = null; accessor.replyMetadata = new Metadata([]); /** * Context for an entity. * * @interface module:akkaserverless.EntityContext * @property {string} entityId The id of the entity that the command is for. * @property {Long} commandId The id of the command. * @property {module:akkaserverless.Metadata} replyMetadata The metadata to send with a reply. */ /** * Effect context. * * @interface module:akkaserverless.EffectContext * @property {module:akkaserverless.Metadata} metadata The metadata associated with the command. */ /** * Context for a command. * * @interface module:akkaserverless.CommandContext * @extends module:akkaserverless.EffectContext */ accessor.context = { /** * @name module:akkaserverless.EntityContext#entityId * @type {string} */ entityId: this.entityId, /** * @name module:akkaserverless.EntityContext#commandId * @type {Long} */ commandId: commandId, /** * @name module:akkaserverless.EffectContext#metadata * @type {module:akkaserverless.Metadata} */ metadata: metadata, /** * @name module:akkaserverless.EntityContext#replyMetadata * @type {module:akkaserverless.Metadata} */ replyMetadata: accessor.replyMetadata, /** * DEPRECATED. Emit an effect after processing this command. * * @function module:akkaserverless.EffectContext#effect * @param {any} method The entity service method to invoke. * @param {object} message The message to send to that service. * @param {boolean} [synchronous] Whether the effect should be execute synchronously or not. * @param {module:akkaserverless.Metadata} [metadata] Metadata to send with the effect. * @param {boolean} [internalCall] For internal calls to this deprecated function. */ effect: ( method, message, synchronous = false, metadata, internalCall, ) => { accessor.ensureActive(); if (!internalCall) console.warn( "WARNING: Command context 'effect' is deprecated. Please use 'Reply.addEffect' instead.", ); accessor.effects.push( this.effectSerializer.serializeSideEffect( method, message, synchronous, metadata, ), ); }, // FIXME: remove for version 0.8 (https://github.com/lightbend/akkaserverless-framework/issues/410) /** * DEPRECATED. Forward this command to another service component call. * * @deprecated Since version 0.7. Will be deleted in version 0.8. Use 'forward' instead. * * @function module:akkaserverless.CommandContext#thenForward * @param {any} method The service component method to invoke. * @param {object} message The message to send to that service component. * @param {module:akkaserverless.Metadata} metadata Metadata to send with the forward. */ thenForward: (method, message, metadata) => { accessor.context.forward(method, message, metadata); }, /** * DEPRECATED. Forward this command to another service component call, use 'ReplyFactory.forward' instead. * * @function module:akkaserverless.CommandContext#forward * @param {any} method The service component method to invoke. * @param {object} message The message to send to that service component. * @param {module:akkaserverless.Metadata} [metadata] Metadata to send with the forward. * @param {boolean} [internalCall] For internal calls to this deprecated function. */ forward: (method, message, metadata, internalCall) => { accessor.ensureActive(); if (!internalCall) console.warn( "WARNING: Command context 'forward' is deprecated. Please use 'ReplyFactory.forward' instead.", ); accessor.forward = this.effectSerializer.serializeEffect( method, message, metadata, ); }, /** * Fail handling this command. * * An alternative to using this is to return a failed Reply created with 'ReplyFactory.failed'. * * @function module:akkaserverless.EffectContext#fail * @param {string} msg The failure message. * @param {number} [grpcStatus] The grpcStatus. * @throws An error that captures the failure message. Note that even if you catch the error thrown by this * method, the command will still be failed with the given message. */ fail: (msg, grpcStatus) => { accessor.ensureActive(); // We set it here to ensure that even if the user catches the error, for // whatever reason, we will still fail as instructed. accessor.error = new ContextFailure(msg, grpcStatus); // Then we throw, to end processing of the command. throw accessor.error; }, }; return accessor; } } module.exports = CommandHelper;