From eee646752b4d2fe003eca80da601ebdf077cf7b6 Mon Sep 17 00:00:00 2001 From: Attila Orosz Date: Mon, 3 Apr 2023 20:52:10 +0200 Subject: [PATCH] feat: interceptor support --- README.md | 56 ++++- src/SocketControllers.ts | 48 +++- src/decorators/UseInterceptor.ts | 20 ++ src/index.ts | 2 + src/types/ActionMetadata.ts | 1 + src/types/InterceptorInterface.ts | 5 + src/types/SocketControllersOptions.ts | 4 +- ...rGetterParams.ts => SocketEventContext.ts} | 3 +- .../add-interceptor-to-action-metadata.ts | 21 ++ src/util/chain-execute.ts | 11 + test/functional/scoped-controllers.spec.ts | 11 +- test/functional/use-interceptor.spec.ts | 219 ++++++++++++++++++ 12 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 src/decorators/UseInterceptor.ts create mode 100644 src/types/InterceptorInterface.ts rename src/types/{ScopedContainerGetterParams.ts => SocketEventContext.ts} (80%) create mode 100644 src/util/add-interceptor-to-action-metadata.ts create mode 100644 src/util/chain-execute.ts create mode 100644 test/functional/use-interceptor.spec.ts diff --git a/README.md b/README.md index f1a8c312..0f8d9419 100644 --- a/README.md +++ b/README.md @@ -487,13 +487,13 @@ You can enable scoped controllers by providing a `scopedContainerGetter` functio You will get a new instance for each event in the controller. -The `scopedContainerGetter` function receives a parameter which contains the socket, socket.io instance, event type, event name, namespace parameters and the message arguments if they are applicable. +The `scopedContainerGetter` function receives the `SocketEventContext`. The `scopedContainerDisposer` function receives the container instance you created with `scopedContainerGetter` after the socket action is finished. Use this function to dispose the container if needed. ```typescript import 'reflect-metadata'; -import { SocketControllers, ScopedContainerGetterParams } from 'socket-controllers'; +import { SocketControllers, SocketEventContext } from 'socket-controllers'; import { Container, ContainerInstance, Token } from "typedi"; const myDiToken = new Token(); @@ -502,7 +502,7 @@ const myDiToken = new Token(); const server = new SocketControllers({ port: 3000, container: Container, - scopedContainerGetter: (args: ScopedContainerGetterParams) => { + scopedContainerGetter: (args: SocketEventContext) => { const container = Container.of(YOUR_REQUEST_CONTEXT); container.set(myDiToken, 'MY_VALUE'); return container; @@ -515,6 +515,56 @@ const server = new SocketControllers({ }); ``` +## Interceptors + +Interceptors allow you to wrap your event handlers in higher order functions. +With interceptors you can add logging or modify the incoming or outgoing data for event handlers. + +```typescript +import { + SocketController, + OnMessage, + EmitOnSuccess, + EmitOnFail, + SkipEmitOnEmptyResult, + UseInterceptor, + MessageBody +} from 'socket-controllers'; + +const interceptor: InterceptorInterface = { + use: (ctx: SocketEventContext, next: () => any) => { + ctx.messageArgs[0] = 'modified message from controller - ' + ctx.messageArgs[0]; + const resp = next(); + return 'modified response from controller - ' + resp; // modified response from controller - modified response from method - reponse + }, +}; + +@Service() +class Interceptor implements InterceptorInterface { + async use(ctx: SocketEventContext, next: () => any) { + ctx.messageArgs[0] = 'modified message from method - ' + ctx.messageArgs[0]; + const resp = await next(); + return 'modified response from method - ' + resp; // modified response from method - reponse + } +} + +@SocketController() +@UseInterceptor(interceptor) +export class MessageController { + @OnMessage('get') + @EmitOnSuccess('get_success') + @SkipEmitOnEmptyResult() + @UseInterceptor(Interceptor) + get(@MessageBody() message: string): Promise { + console.log(message); // modified message from controller - modified message from method - original message + return 'response'; + } +} +``` + +Interceptors are executed in order of definition, starting with the controller interceptors. + + ## Decorators Reference | Signature | Description | diff --git a/src/SocketControllers.ts b/src/SocketControllers.ts index 8f93d133..9c9c6897 100644 --- a/src/SocketControllers.ts +++ b/src/SocketControllers.ts @@ -18,8 +18,10 @@ import { TransformOptions } from './types/TransformOptions'; import { defaultTransformOptions } from './types/constants/defaultTransformOptions'; import { ActionTransformOptions } from './types/ActionTransformOptions'; import { instanceToPlain, plainToInstance } from 'class-transformer'; -import { ScopedContainerGetterParams } from './types/ScopedContainerGetterParams'; import { MiddlewareInterface } from './types/MiddlewareInterface'; +import { InterceptorInterface } from './types/InterceptorInterface'; +import { chainExecute } from './util/chain-execute'; +import { SocketEventContext } from './types/SocketEventContext'; export class SocketControllers { public container: { get(someClass: { new (...args: any[]): T } | Function): T }; @@ -201,18 +203,44 @@ export class SocketControllers { data?: any[], ack?: Function | null ) { - const parameters = this.resolveParameters(socket, controller.metadata, action.parameters || [], data, ack); + const eventContext = this.resolveEventContext( + socket, + action.type, + eventName, + data, + controller.metadata.namespace, + ack + ); let container = this.container; if (this.options.scopedContainerGetter) { - container = this.options.scopedContainerGetter( - this.collectScopedContainerParams(socket, action.type, eventName, data, controller.metadata.namespace) - ); + container = this.options.scopedContainerGetter(eventContext); } try { const controllerInstance: any = container.get(controller.target); - const actionResult = controllerInstance[action.methodName](...parameters); + + const actions = [ + ...(action.interceptors || []).map(interceptor => { + return ( + ((interceptor as any) instanceof Function + ? container.get(interceptor) + : interceptor) as InterceptorInterface + ).use.bind(interceptor); + }), + (context: SocketEventContext) => { + const parameters = this.resolveParameters( + socket, + controller.metadata, + action.parameters || [], + context.messageArgs, + ack + ); + return controllerInstance[action.methodName](...parameters); + }, + ]; + + const actionResult = chainExecute(eventContext, actions); const result = await Promise.resolve(actionResult); this.handleActionResult(socket, action, result, ResultType.EMIT_ON_SUCCESS); } catch (error: any) { @@ -347,13 +375,14 @@ export class SocketControllers { return value; } - private collectScopedContainerParams( + private resolveEventContext( socket: Socket, eventType: SocketEventType, eventName?: string, messageBody?: any[], - namespace?: string | RegExp - ): ScopedContainerGetterParams { + namespace?: string | RegExp, + ack?: Function | null + ): SocketEventContext { return { eventType, eventName, @@ -361,6 +390,7 @@ export class SocketControllers { socketIo: this.io, nspParams: this.extractNamespaceParameters(socket, namespace), messageArgs: messageBody, + ack, }; } diff --git a/src/decorators/UseInterceptor.ts b/src/decorators/UseInterceptor.ts new file mode 100644 index 00000000..2b3e3391 --- /dev/null +++ b/src/decorators/UseInterceptor.ts @@ -0,0 +1,20 @@ +import { addInterceptorToActionMetadata } from '../util/add-interceptor-to-action-metadata'; +import { getMetadata } from '../util/get-metadata'; +import { ControllerMetadata } from '../types/ControllerMetadata'; + +export function UseInterceptor(...interceptors: any[]): Function { + return function (object: Function | Object, methodName?: string) { + for (const interceptor of interceptors) { + if (object instanceof Function) { + // Class interceptor + const existingMetadata: ControllerMetadata = getMetadata(object); + for (const key of Object.keys(existingMetadata?.actions || {})) { + addInterceptorToActionMetadata(object, key, interceptor as Function); + } + } else { + // Method interceptor + addInterceptorToActionMetadata(object.constructor, methodName as string, interceptor as Function); + } + } + }; +} diff --git a/src/index.ts b/src/index.ts index 03498c53..c250c08d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,8 +18,10 @@ export * from './decorators/SocketRequest'; export * from './decorators/SocketRooms'; export * from './types/MiddlewareInterface'; +export * from './types/InterceptorInterface'; export * from './types/TransformOptions'; export * from './types/SocketControllersOptions'; export * from './types/enums/SocketEventType'; +export * from './types/SocketEventContext'; export * from './SocketControllers'; diff --git a/src/types/ActionMetadata.ts b/src/types/ActionMetadata.ts index ffc496d6..c0d36fe3 100644 --- a/src/types/ActionMetadata.ts +++ b/src/types/ActionMetadata.ts @@ -8,4 +8,5 @@ export interface ActionMetadata { options: any; parameters: ParameterMetadata[]; results: ResultMetadata[]; + interceptors: Function[]; } diff --git a/src/types/InterceptorInterface.ts b/src/types/InterceptorInterface.ts new file mode 100644 index 00000000..d5ca0257 --- /dev/null +++ b/src/types/InterceptorInterface.ts @@ -0,0 +1,5 @@ +import { SocketEventContext } from './SocketEventContext'; + +export interface InterceptorInterface { + use(context: SocketEventContext, next: () => any): any; +} diff --git a/src/types/SocketControllersOptions.ts b/src/types/SocketControllersOptions.ts index eb838ae4..0daaef76 100644 --- a/src/types/SocketControllersOptions.ts +++ b/src/types/SocketControllersOptions.ts @@ -1,11 +1,11 @@ import { Server } from 'socket.io'; import { TransformOptions } from './TransformOptions'; -import { ScopedContainerGetterParams } from './ScopedContainerGetterParams'; +import { SocketEventContext } from './SocketEventContext'; export interface SocketControllersOptions { container: { get(someClass: { new (...args: any[]): T } | Function): T }; - scopedContainerGetter?: (params: ScopedContainerGetterParams) => { + scopedContainerGetter?: (context: SocketEventContext) => { get(someClass: { new (...args: any[]): T } | Function): T; }; diff --git a/src/types/ScopedContainerGetterParams.ts b/src/types/SocketEventContext.ts similarity index 80% rename from src/types/ScopedContainerGetterParams.ts rename to src/types/SocketEventContext.ts index b00482e8..aeaf9edb 100644 --- a/src/types/ScopedContainerGetterParams.ts +++ b/src/types/SocketEventContext.ts @@ -1,11 +1,12 @@ import { SocketEventType } from './enums/SocketEventType'; import { Server, Socket } from 'socket.io'; -export interface ScopedContainerGetterParams { +export interface SocketEventContext { socketIo: Server; socket: Socket; eventType: SocketEventType; eventName?: string; messageArgs?: any[]; nspParams?: Record; + ack?: Function | null; } diff --git a/src/util/add-interceptor-to-action-metadata.ts b/src/util/add-interceptor-to-action-metadata.ts new file mode 100644 index 00000000..bb82ed8a --- /dev/null +++ b/src/util/add-interceptor-to-action-metadata.ts @@ -0,0 +1,21 @@ +import { SOCKET_CONTROLLER_META_KEY } from '../types/SocketControllerMetaKey'; +import { getMetadata } from './get-metadata'; +import { ControllerMetadata } from '../types/ControllerMetadata'; + +export const addInterceptorToActionMetadata = (target: Function, methodName: string, interceptor: Function) => { + const existingMetadata = getMetadata(target); + (Reflect as any).defineMetadata( + SOCKET_CONTROLLER_META_KEY, + { + ...existingMetadata, + actions: { + ...existingMetadata?.actions, + [methodName]: { + ...existingMetadata?.actions?.[methodName], + interceptors: [interceptor, ...(existingMetadata?.actions?.[methodName]?.interceptors || [])], + }, + }, + }, + target + ); +}; diff --git a/src/util/chain-execute.ts b/src/util/chain-execute.ts new file mode 100644 index 00000000..af711bcd --- /dev/null +++ b/src/util/chain-execute.ts @@ -0,0 +1,11 @@ +export function chainExecute(context: any, chain: Function[]) { + function next() { + const middleware: Function = chain.shift() as Function; + + if (middleware && typeof middleware === 'function') { + return middleware(context, next); + } + } + + return next(); +} diff --git a/test/functional/scoped-controllers.spec.ts b/test/functional/scoped-controllers.spec.ts index 9547511a..1649a24b 100644 --- a/test/functional/scoped-controllers.spec.ts +++ b/test/functional/scoped-controllers.spec.ts @@ -7,8 +7,7 @@ import { SocketController } from '../../src/decorators/SocketController'; import { OnConnect } from '../../src/decorators/OnConnect'; import { ConnectedSocket } from '../../src/decorators/ConnectedSocket'; import { waitForEvent } from '../utilities/waitForEvent'; -import { EmitOnSuccess, OnMessage } from '../../src'; -import { ScopedContainerGetterParams } from '../../src/types/ScopedContainerGetterParams'; +import { EmitOnSuccess, OnMessage, SocketEventContext } from '../../src'; describe('Scoped controllers', () => { const PORT = 8080; @@ -73,7 +72,7 @@ describe('Scoped controllers', () => { io: wsApp, container: Container, controllers: [TestController], - scopedContainerGetter: (args: ScopedContainerGetterParams) => { + scopedContainerGetter: (args: SocketEventContext) => { return Container.of(Math.random().toString()); }, }); @@ -112,7 +111,7 @@ describe('Scoped controllers', () => { io: wsApp, container: Container, controllers: [TestController], - scopedContainerGetter: (args: ScopedContainerGetterParams) => { + scopedContainerGetter: (args: SocketEventContext) => { return Container.of(Math.random().toString()); }, }); @@ -154,7 +153,7 @@ describe('Scoped controllers', () => { io: wsApp, container: Container, controllers: [TestController], - scopedContainerGetter: (args: ScopedContainerGetterParams) => { + scopedContainerGetter: (args: SocketEventContext) => { const container = Container.of(counter.toString()); container.set(token, counter); counter++; @@ -189,7 +188,7 @@ describe('Scoped controllers', () => { io: wsApp, container: Container, controllers: [TestController], - scopedContainerGetter: (args: ScopedContainerGetterParams) => { + scopedContainerGetter: (args: SocketEventContext) => { testResult.push(args); return Container.of(''); }, diff --git a/test/functional/use-interceptor.spec.ts b/test/functional/use-interceptor.spec.ts new file mode 100644 index 00000000..46babbf7 --- /dev/null +++ b/test/functional/use-interceptor.spec.ts @@ -0,0 +1,219 @@ +import { createServer, Server as HttpServer } from 'http'; +import { Server } from 'socket.io'; +import { io, Socket } from 'socket.io-client'; +import { SocketControllers } from '../../src/SocketControllers'; +import { Container, Service } from 'typedi'; +import { SocketController } from '../../src/decorators/SocketController'; +import { OnConnect } from '../../src/decorators/OnConnect'; +import { ConnectedSocket } from '../../src/decorators/ConnectedSocket'; +import { waitForEvent } from '../utilities/waitForEvent'; +import { EmitOnSuccess, MessageBody, OnMessage, SocketEventType } from '../../src'; +import { UseInterceptor } from '../../src/decorators/UseInterceptor'; +import { InterceptorInterface } from '../../src/types/InterceptorInterface'; +import { SocketEventContext } from '../../src/types/SocketEventContext'; + +describe('UseInterceptor', () => { + const PORT = 8080; + const PATH_FOR_CLIENT = `ws://localhost:${PORT}`; + + let httpServer: HttpServer; + let wsApp: Server; + let wsClient: Socket; + let testResult = []; + let socketControllers: SocketControllers; + + beforeEach(done => { + httpServer = createServer(); + wsApp = new Server(httpServer, { + cors: { + origin: '*', + }, + }); + httpServer.listen(PORT, () => { + done(); + }); + }); + + afterEach(() => { + testResult = []; + + Container.reset(); + wsClient.close(); + wsClient = null; + socketControllers = null; + return new Promise(resolve => { + if (wsApp) + return wsApp.close(() => { + resolve(null); + }); + resolve(null); + }); + }); + + it('interceptor should be executed in the correct order', async () => { + @Service() + class testInterceptor implements InterceptorInterface { + use(ctx: SocketEventContext, next: any) { + testResult.push('testInterceptor start'); + const resp = next(); + testResult.push('testInterceptor end'); + return resp; + } + } + + const plain: InterceptorInterface = { + use: (ctx: SocketEventContext, next: () => any) => { + testResult.push('plain start'); + const resp = next(); + testResult.push('plain end'); + return resp; + }, + }; + + @SocketController('/string') + @Service() + @UseInterceptor(plain) + class TestController { + @OnConnect() + connected(@ConnectedSocket() socket: Socket) { + socket.emit('connected'); + } + + @OnMessage('test') + @UseInterceptor(testInterceptor) + @EmitOnSuccess('finished') + test() { + testResult.push('action'); + return 'test'; + } + } + + socketControllers = new SocketControllers({ + io: wsApp, + container: Container, + controllers: [TestController], + }); + wsClient = io(PATH_FOR_CLIENT + '/string', { reconnection: false, timeout: 5000, forceNew: true }); + + await waitForEvent(wsClient, 'connected'); + wsClient.emit('test'); + const response = await waitForEvent(wsClient, 'finished'); + expect(testResult).toEqual([ + 'plain start', + 'plain end', + 'plain start', + 'testInterceptor start', + 'action', + 'testInterceptor end', + 'plain end', + ]); + expect(response).toEqual('test'); + }); + + it('interceptor should be able to skip further actions', async () => { + @Service() + class testInterceptor implements InterceptorInterface { + use(ctx: SocketEventContext, next: any) { + testResult.push('testInterceptor start'); + const response = next(); + testResult.push('testInterceptor end'); + return response; + } + } + + const plain: InterceptorInterface = { + use: (ctx: SocketEventContext, next: () => any) => { + testResult.push('plain start'); + + if (ctx.eventType === SocketEventType.CONNECT) { + next(); + } + + testResult.push('plain end'); + return 'plain response'; + }, + }; + + @SocketController('/string') + @Service() + @UseInterceptor(plain) + class TestController { + @OnConnect() + connected(@ConnectedSocket() socket: Socket) { + socket.emit('connected'); + } + + @OnMessage('test') + @UseInterceptor(testInterceptor) + @EmitOnSuccess('finished') + test() { + testResult.push('action'); + return 'test'; + } + } + + socketControllers = new SocketControllers({ + io: wsApp, + container: Container, + controllers: [TestController], + }); + wsClient = io(PATH_FOR_CLIENT + '/string', { reconnection: false, timeout: 5000, forceNew: true }); + + await waitForEvent(wsClient, 'connected'); + wsClient.emit('test'); + const response = await waitForEvent(wsClient, 'finished'); + expect(testResult).toEqual(['plain start', 'plain end', 'plain start', 'plain end']); + expect(response).toEqual('plain response'); + }); + + it('interceptor should be able to mutate the context', async () => { + @Service() + class testInterceptor implements InterceptorInterface { + use(ctx: SocketEventContext, next: any) { + testResult.push(ctx.messageArgs?.[0]); + ctx.messageArgs = ['testInterceptor']; + return next(); + } + } + + const plain: InterceptorInterface = { + use: (ctx: SocketEventContext, next: () => any) => { + testResult.push(ctx.messageArgs?.[0]); + ctx.messageArgs = ['plain']; + return next(); + }, + }; + + @SocketController('/string') + @Service() + @UseInterceptor(plain) + class TestController { + @OnConnect() + connected(@ConnectedSocket() socket: Socket, @MessageBody() message: any) { + testResult.push(message); + socket.emit('connected'); + } + + @OnMessage('test') + @UseInterceptor(testInterceptor) + @EmitOnSuccess('finished') + test(@MessageBody() message: any) { + testResult.push(message); + return 'test'; + } + } + + socketControllers = new SocketControllers({ + io: wsApp, + container: Container, + controllers: [TestController], + }); + wsClient = io(PATH_FOR_CLIENT + '/string', { reconnection: false, timeout: 5000, forceNew: true }); + + await waitForEvent(wsClient, 'connected'); + wsClient.emit('test', 'my body'); + const response = await waitForEvent(wsClient, 'finished'); + expect(testResult).toEqual([undefined, 'plain', 'my body', 'plain', 'testInterceptor']); + expect(response).toEqual('test'); + }); +});