├── .gitignore ├── src ├── errors │ ├── index.ts │ ├── base-error.ts │ ├── not-found-error.ts │ ├── publish-error.ts │ ├── bad-request-error.ts │ ├── not-authorized-error.ts │ └── request-validation-error.ts ├── events │ ├── event.ts │ ├── user-created.ts │ ├── user-updated.ts │ ├── product-created.ts │ ├── product-updated.ts │ ├── broker.ts │ └── __tests__ │ │ └── broker.test.ts ├── middlewares │ ├── index.ts │ ├── require-auth.ts │ ├── require-admin.ts │ ├── validate-request.ts │ ├── current-user.ts │ ├── error-handler.ts │ └── tracing.ts ├── index.ts ├── db │ └── mongo-client.ts └── logging │ ├── __mocks__ │ └── tracer.ts │ └── tracer.ts ├── __mocks__ └── node-nats-streaming.ts ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bad-request-error'; 2 | export * from './request-validation-error'; 3 | export * from './not-authorized-error'; 4 | export * from './not-found-error'; 5 | -------------------------------------------------------------------------------- /src/events/event.ts: -------------------------------------------------------------------------------- 1 | export interface Event { 2 | metadata: { 3 | type: string; 4 | timestamp: string; 5 | id: string; 6 | }; 7 | context: any; 8 | data: any; 9 | } 10 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './current-user'; 2 | export * from './error-handler'; 3 | export * from './require-auth'; 4 | export * from './require-admin'; 5 | export * from './validate-request'; 6 | export * from './tracing'; 7 | -------------------------------------------------------------------------------- /src/errors/base-error.ts: -------------------------------------------------------------------------------- 1 | export interface ErrorDescription { 2 | message: string; 3 | field?: string; 4 | } 5 | 6 | export abstract class BaseError extends Error { 7 | abstract handleResponse(): { 8 | code: number; 9 | errors: ErrorDescription[]; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/events/user-created.ts: -------------------------------------------------------------------------------- 1 | import { Event } from './event'; 2 | 3 | export const userCreatedEventName = 'user-created'; 4 | export interface UserCreatedEvent extends Event { 5 | data: { 6 | id: string; 7 | email: string; 8 | role: 'user' | 'admin'; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/events/user-updated.ts: -------------------------------------------------------------------------------- 1 | import { Event } from './event'; 2 | 3 | export const userUpdatedEventName = 'user-updated'; 4 | export interface UserUpdatedEvent extends Event { 5 | data: { 6 | id: string; 7 | email: string; 8 | role: 'user' | 'admin'; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/errors/not-found-error.ts: -------------------------------------------------------------------------------- 1 | import { BaseError } from './base-error'; 2 | 3 | export class NotFoundError extends BaseError { 4 | code = 400; 5 | 6 | handleResponse() { 7 | return { 8 | code: this.code, 9 | errors: [{ message: this.message || 'Not Found' }] 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/errors/publish-error.ts: -------------------------------------------------------------------------------- 1 | import { BaseError } from './base-error'; 2 | 3 | export class PublishError extends BaseError { 4 | code = 500; 5 | 6 | handleResponse() { 7 | return { 8 | code: this.code, 9 | errors: [{ message: this.message || 'Error publishing event' }] 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/errors/bad-request-error.ts: -------------------------------------------------------------------------------- 1 | import { BaseError } from './base-error'; 2 | 3 | export class BadRequestError extends BaseError { 4 | code = 400; 5 | 6 | handleResponse() { 7 | return { 8 | code: this.code, 9 | errors: [ 10 | { 11 | message: this.message 12 | } 13 | ] 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/events/product-created.ts: -------------------------------------------------------------------------------- 1 | import { Event } from './event'; 2 | 3 | export const productCreatedEventName = 'product-created'; 4 | export interface ProductCreatedEvent extends Event { 5 | data: { 6 | id: string; 7 | title: string; 8 | price: number; 9 | images: { 10 | thumb: string; 11 | raw: string; 12 | }[]; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/events/product-updated.ts: -------------------------------------------------------------------------------- 1 | import { Event } from './event'; 2 | 3 | export const productUpdatedEventName = 'product-updated'; 4 | export interface ProductUpdatedEvent extends Event { 5 | data: { 6 | id: string; 7 | title: string; 8 | price: number; 9 | images: { 10 | thumb: string; 11 | raw: string; 12 | }[]; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './db/mongo-client'; 2 | export * from './errors'; 3 | export * from './logging/tracer'; 4 | export * from './middlewares'; 5 | export * from './events/broker'; 6 | export * from './events/product-created'; 7 | export * from './events/product-updated'; 8 | export * from './events/user-created'; 9 | export * from './events/user-updated'; 10 | -------------------------------------------------------------------------------- /src/middlewares/require-auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { NotAuthorizedError } from '../errors'; 3 | 4 | export const requireAuth = ( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction 8 | ) => { 9 | if (!req.currentUser) { 10 | throw new NotAuthorizedError(); 11 | } 12 | 13 | next(); 14 | }; 15 | -------------------------------------------------------------------------------- /src/db/mongo-client.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | class MongoClient { 4 | static connect(url: string) { 5 | return mongoose.connect(url, { 6 | useNewUrlParser: true, 7 | useUnifiedTopology: true 8 | }); 9 | } 10 | 11 | static close() { 12 | return mongoose.connection.close(); 13 | } 14 | } 15 | 16 | export { MongoClient }; 17 | -------------------------------------------------------------------------------- /src/errors/not-authorized-error.ts: -------------------------------------------------------------------------------- 1 | import { BaseError } from './base-error'; 2 | 3 | export class NotAuthorizedError extends BaseError { 4 | code = 401; 5 | 6 | constructor(public message: string = 'Not authorized') { 7 | super(message); 8 | } 9 | 10 | handleResponse() { 11 | return { 12 | code: this.code, 13 | errors: [{ message: this.message }] 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/middlewares/require-admin.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { NotAuthorizedError } from '../errors'; 3 | 4 | export const requireAdmin = async ( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction 8 | ) => { 9 | if (!req.currentUser) { 10 | throw new NotAuthorizedError(); 11 | } 12 | 13 | if (req.currentUser.role !== 'admin') { 14 | throw new NotAuthorizedError(); 15 | } 16 | 17 | next(); 18 | }; 19 | -------------------------------------------------------------------------------- /src/middlewares/validate-request.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { validationResult } from 'express-validator'; 3 | import { RequestValidationError } from '../errors'; 4 | 5 | export const validateRequest = ( 6 | req: Request, 7 | res: Response, 8 | next: NextFunction 9 | ) => { 10 | const errors = validationResult(req); 11 | 12 | if (!errors.isEmpty()) { 13 | throw new RequestValidationError(errors.array()); 14 | } 15 | 16 | next(); 17 | }; 18 | -------------------------------------------------------------------------------- /src/logging/__mocks__/tracer.ts: -------------------------------------------------------------------------------- 1 | import { Tracer as _Tracer } from 'opentracing'; 2 | 3 | class Tracer extends _Tracer { 4 | startSpan = jest.fn(); 5 | inject = jest.fn(); 6 | extract = jest.fn(); 7 | spanFromRequest = jest 8 | .fn() 9 | .mockReturnValue({ log: jest.fn(), finish: jest.fn() }); 10 | spanFromEvent = jest 11 | .fn() 12 | .mockReturnValue({ log: jest.fn(), finish: jest.fn() }); 13 | injectEvent = jest.fn().mockImplementation((span: any, context: any) => { 14 | context['context'] = { context: 1 }; 15 | }); 16 | } 17 | 18 | export const tracer = new Tracer(); 19 | -------------------------------------------------------------------------------- /src/errors/request-validation-error.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'express-validator'; 2 | import { BaseError } from './base-error'; 3 | 4 | export class RequestValidationError extends BaseError { 5 | code = 400; 6 | 7 | constructor(private errors: ValidationError[]) { 8 | super('Validation Error'); 9 | } 10 | 11 | formatErrors() { 12 | return this.errors.map(err => { 13 | return { 14 | field: err.param, 15 | message: err.msg 16 | }; 17 | }); 18 | } 19 | 20 | handleResponse() { 21 | return { 22 | code: this.code, 23 | errors: this.formatErrors() 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/middlewares/current-user.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | interface UserPayload { 5 | id: string; 6 | email: string; 7 | role: 'user' | 'admin'; 8 | } 9 | 10 | declare global { 11 | namespace Express { 12 | interface Request { 13 | currentUser?: UserPayload; 14 | } 15 | } 16 | } 17 | 18 | export const currentUser = (JWT_KEY: string) => async ( 19 | req: Request, 20 | res: Response, 21 | next: NextFunction 22 | ) => { 23 | if (!req.session) { 24 | return next(); 25 | } 26 | if (!req.session.jwt) { 27 | return next(); 28 | } 29 | 30 | try { 31 | const payload = jwt.verify(req.session.jwt, JWT_KEY) as UserPayload; 32 | req.currentUser = payload; 33 | } catch (err) {} 34 | 35 | next(); 36 | }; 37 | -------------------------------------------------------------------------------- /__mocks__/node-nats-streaming.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import nats, { Stan } from 'node-nats-streaming'; 3 | 4 | class Subscription extends EventEmitter { 5 | close = jest.fn(); 6 | unsubscribe = jest.fn(); 7 | closed = false; 8 | 9 | isClosed(): boolean { 10 | return this.closed; 11 | } 12 | } 13 | 14 | class StanMock extends EventEmitter implements Stan { 15 | subscriptionOptions = jest.fn().mockReturnValue({ 16 | setStartWithLastReceived: jest 17 | .fn() 18 | .mockReturnValue({ start: 'last_received ' }) 19 | }); 20 | close = jest.fn().mockReturnValue(() => new Subscription()); 21 | publish = jest.fn(); 22 | subscribe = jest.fn().mockReturnValue({ 23 | on: jest.fn() 24 | }); 25 | } 26 | 27 | export default { 28 | connect() { 29 | return new StanMock(); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/middlewares/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import mongoose from 'mongoose'; 3 | import { 4 | RequestValidationError, 5 | NotAuthorizedError, 6 | BadRequestError, 7 | NotFoundError 8 | } from '../errors'; 9 | 10 | export const errorHandler = ( 11 | err: Error, 12 | req: Request, 13 | res: Response, 14 | next: NextFunction 15 | ) => { 16 | if ( 17 | err instanceof NotFoundError || 18 | err instanceof BadRequestError || 19 | err instanceof RequestValidationError || 20 | err instanceof NotAuthorizedError 21 | ) { 22 | const { code, errors } = err.handleResponse(); 23 | return res.status(code).json(errors); 24 | } 25 | 26 | if (err instanceof mongoose.Error.ValidationError) { 27 | const errors = []; 28 | for (let key in err.errors) { 29 | errors.push({ 30 | field: key, 31 | message: err.errors[key].message 32 | }); 33 | } 34 | return res.status(400).json({ 35 | errors: errors 36 | }); 37 | } 38 | 39 | console.error('UnhandledError', err); 40 | 41 | res.status(500).json({ 42 | errors: [{ message: 'Server Error' }] 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/logging/tracer.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { initTracer, Tracer } from 'jaeger-client'; 3 | import { FORMAT_HTTP_HEADERS, Span, FORMAT_TEXT_MAP } from 'opentracing'; 4 | import { Event } from 'events/event'; 5 | 6 | declare module 'jaeger-client' { 7 | export interface Tracer extends JaegerTracer { 8 | spanFromRequest(name: string, req?: Request): Span; 9 | spanFromEvent(event: Event): Span; 10 | injectEvent(span: Span, carrier: any): void; 11 | } 12 | } 13 | 14 | const tracer = initTracer( 15 | { 16 | serviceName: process.env.POD_NAME || 'service', 17 | sampler: { 18 | type: 'const', 19 | param: 1 20 | } 21 | }, 22 | {} 23 | ) as Tracer; 24 | 25 | tracer.spanFromRequest = (name: string, req?: Request) => { 26 | const context = tracer.extract(FORMAT_HTTP_HEADERS, req) || undefined; 27 | return tracer.startSpan(name, { 28 | childOf: context 29 | }); 30 | }; 31 | 32 | tracer.spanFromEvent = (event: Event) => { 33 | const context = tracer.extract(FORMAT_TEXT_MAP, event.context) || undefined; 34 | return tracer.startSpan(event.metadata.type, { 35 | childOf: context 36 | }); 37 | }; 38 | 39 | tracer.injectEvent = (span: Span, carrier: any) => { 40 | tracer.inject(span, FORMAT_TEXT_MAP, carrier); 41 | }; 42 | 43 | export { tracer, Tracer }; 44 | -------------------------------------------------------------------------------- /src/middlewares/tracing.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { FORMAT_HTTP_HEADERS, Span } from 'opentracing'; 3 | import { tracer } from '../logging/tracer'; 4 | 5 | declare global { 6 | namespace Express { 7 | interface Request { 8 | tracerContext: Span; 9 | } 10 | } 11 | } 12 | 13 | export const tracing = () => { 14 | return (req: Request, res: Response, next: NextFunction) => { 15 | const { headers, path, url, method, body, query, params } = req; 16 | 17 | const context = tracer.extract(FORMAT_HTTP_HEADERS, headers); 18 | const span = tracer.startSpan(req.url, { childOf: context || undefined }); 19 | 20 | req.tracerContext = span; 21 | span.setTag('http.request.url', url); 22 | span.setTag('http.request.method', method); 23 | span.setTag('http.request.path', path); 24 | span 25 | .log({ body: filterField(body, 'password') }) 26 | .log({ query: filterField(query, 'password') }) 27 | .log({ params: filterField(params, 'password') }); 28 | 29 | tracer.inject(span, FORMAT_HTTP_HEADERS, headers); 30 | req.headers = headers; 31 | 32 | res.once('finish', () => { 33 | span.setTag('http.response.status_code', res.statusCode); 34 | span.setTag('http.response.status_message', res.statusMessage); 35 | span.finish(); 36 | }); 37 | 38 | next(); 39 | }; 40 | }; 41 | 42 | const filterField = (obj: T, name: Extract) => { 43 | const ret = {} as T; 44 | 45 | for (let key in obj) { 46 | //@ts-ignore 47 | ret[key] = key === name ? '***' : obj[key]; 48 | } 49 | 50 | return ret; 51 | }; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "build/src/index.js", 6 | "types": "build/src/index.d.ts", 7 | "scripts": { 8 | "clean": "del ./build/*", 9 | "build": "npm run clean && tsc", 10 | "test": "JWT_KEY=asdf jest --watchAll --no-cache" 11 | }, 12 | "jest": { 13 | "testEnvironment": "node", 14 | "preset": "ts-jest", 15 | "moduleDirectories": [ 16 | "node_modules", 17 | "src" 18 | ], 19 | "globals": { 20 | "ts-jest": { 21 | "isolatedModules": true 22 | } 23 | } 24 | }, 25 | "keywords": [], 26 | "author": "", 27 | "license": "ISC", 28 | "dependencies": { 29 | "@types/cookie-session": "^2.0.38", 30 | "@types/express": "^4.17.2", 31 | "@types/jaeger-client": "^3.15.3", 32 | "@types/jsonwebtoken": "^8.3.7", 33 | "@types/mongoose": "^5.7.3", 34 | "@types/uuid": "^7.0.0", 35 | "cookie-session": "^1.4.0", 36 | "express": "^4.17.1", 37 | "express-validator": "^6.4.0", 38 | "jaeger-client": "^3.17.2", 39 | "jsonwebtoken": "^8.5.1", 40 | "mongoose": "^5.9.2", 41 | "node-nats-streaming": "^0.2.6", 42 | "opentracing": "^0.14.4", 43 | "typescript": "^3.8.2", 44 | "uuid": "^3.4.0" 45 | }, 46 | "devDependencies": { 47 | "@types/jest": "^25.1.3", 48 | "@types/supertest": "^2.0.8", 49 | "del-cli": "^3.0.0", 50 | "express-async-errors": "^3.1.1", 51 | "jest": "^25.1.0", 52 | "jest-runner-tsc": "^1.6.0", 53 | "rimraf": "^3.0.2", 54 | "supertest": "^4.0.2", 55 | "ts-jest": "^25.2.1", 56 | "ts-node-dev": "^1.0.0-pre.44" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/events/broker.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import nats, { 3 | Stan, 4 | Message, 5 | SubscriptionOptions as _SubscriptionOptions 6 | } from 'node-nats-streaming'; 7 | import { EventEmitter } from 'events'; 8 | import { tracer, Tracer } from 'logging/tracer'; 9 | import { Event } from 'events/event'; 10 | 11 | interface SubscriptionOptions extends _SubscriptionOptions { 12 | groupName: string; 13 | } 14 | 15 | export class Broker { 16 | client?: Stan; 17 | 18 | constructor(process: EventEmitter, private tracer: Tracer) { 19 | process.on('SIGUSR2', this.closeClient); 20 | } 21 | 22 | setClient(client: Stan) { 23 | this.client = client; 24 | client.on('connection_lost', this.onClientClose); 25 | client.on('error', this.closeClient); 26 | } 27 | 28 | onClientClose = (err: Error) => { 29 | throw err; 30 | }; 31 | 32 | closeClient = () => { 33 | if (!this.client) { 34 | return; 35 | } 36 | 37 | this.client.close(); 38 | throw new Error('Broker closed'); 39 | }; 40 | 41 | private defaultOptions() { 42 | return this.client!.subscriptionOptions().setStartWithLastReceived() as SubscriptionOptions; 43 | } 44 | 45 | on(eventName: string, callback: (data: T, message: Message) => void): void; 46 | on( 47 | eventName: string, 48 | callback: (data: T, message: Message) => void, 49 | _options?: SubscriptionOptions 50 | ): void { 51 | if (!this.client) { 52 | throw new Error('Client not available'); 53 | } 54 | 55 | const options = _options || this.defaultOptions(); 56 | 57 | const subscription = options.groupName 58 | ? this.client.subscribe(eventName, options.groupName, options) 59 | : this.client.subscribe(eventName, options); 60 | 61 | subscription.on('message', (message: Message) => { 62 | const parsedData = this.parseMessage(message); 63 | const span = this.tracer.spanFromEvent(parsedData); 64 | span.log({ eventId: parsedData.metadata.id }); 65 | 66 | try { 67 | callback(parsedData, message); 68 | } catch (err) { 69 | span.log({ error: err }); 70 | } finally { 71 | span.finish(); 72 | } 73 | }); 74 | } 75 | 76 | private parseMessage(message: Message) { 77 | const data = message.getData(); 78 | return typeof data === 'string' 79 | ? JSON.parse(data) 80 | : JSON.parse(data.toString('utf8')); 81 | } 82 | 83 | publish(event: Event, contextSource?: Request): Promise { 84 | if (!this.client) { 85 | throw new Error('Client not available'); 86 | } 87 | 88 | const span = this.tracer.spanFromRequest( 89 | event.metadata.type, 90 | contextSource 91 | ); 92 | 93 | this.tracer.injectEvent(span, event); 94 | span.log({ event }); 95 | 96 | return new Promise((resolve, reject) => { 97 | this.client!.publish( 98 | event.metadata.type, 99 | JSON.stringify(event), 100 | (err, guid) => { 101 | if (err) { 102 | span.log({ error: err }); 103 | span.finish(); 104 | return reject(err); 105 | } 106 | 107 | span.log({ guid }); 108 | span.finish(); 109 | 110 | resolve(guid); 111 | } 112 | ); 113 | }); 114 | } 115 | } 116 | 117 | export const broker = new Broker(process, tracer); 118 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // "exclude": ["**/__mocks__/*", "**/__tests__/*", "./lib/**/*"], 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 7 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true /* Generates corresponding '.d.ts' file. */, 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./build" /* Redirect output structure to the directory. */, 17 | // "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | "baseUrl": "./src" /* Base directory to resolve non-absolute module names. */, 45 | // "paths": {} /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": ["./middlewares" ] /* List of folders to include type definitions from. */, 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | 64 | /* Advanced Options */ 65 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/events/__tests__/broker.test.ts: -------------------------------------------------------------------------------- 1 | import nats, { Message } from 'node-nats-streaming'; 2 | import { EventEmitter } from 'events'; 3 | import { Broker } from 'events/broker'; 4 | import { UserCreatedEvent } from 'events/user-created'; 5 | import { Event } from 'events/event'; 6 | import { tracer } from 'logging/tracer'; 7 | 8 | jest.mock('logging/tracer'); 9 | 10 | const createBroker = () => { 11 | const client = nats.connect('clsuterId', 'clientId', { url: 'url' }); 12 | const _process = new EventEmitter(); 13 | const broker = new Broker(_process, tracer); 14 | broker.setClient(client); 15 | return { 16 | tracer, 17 | client, 18 | _process, 19 | broker 20 | }; 21 | }; 22 | 23 | const createUserCreatedEvent = (): UserCreatedEvent => { 24 | return { 25 | data: { 26 | id: '1', 27 | role: 'user', 28 | email: 'asdf@asdf.com' 29 | }, 30 | metadata: { 31 | type: 'user-created', 32 | id: '1', 33 | timestamp: '1' 34 | }, 35 | context: {} 36 | }; 37 | }; 38 | 39 | beforeEach(() => { 40 | jest.clearAllMocks(); 41 | }); 42 | 43 | it('the client losing a connection throws an error', () => { 44 | const { broker, client } = createBroker(); 45 | 46 | expect(() => { 47 | client.emit('connection_lost'); 48 | }).toThrow(); 49 | }); 50 | 51 | it('when the process emits SIGUSR2 the client closes', () => { 52 | const { broker, client, _process } = createBroker(); 53 | 54 | expect(() => { 55 | _process.emit('SIGUSR2'); 56 | }).toThrow(); 57 | 58 | expect(client.close).toHaveBeenCalled(); 59 | }); 60 | 61 | describe('publishing', () => { 62 | it('can publish an event', () => { 63 | const { broker, client } = createBroker(); 64 | const event = createUserCreatedEvent(); 65 | 66 | broker.publish(event); 67 | 68 | expect(client.publish).toHaveBeenCalled(); 69 | 70 | // @ts-ignore 71 | const [subject, data] = client.publish.mock.calls[0]; 72 | expect(subject).toEqual('user-created'); 73 | expect(data).toEqual(JSON.stringify(event)); 74 | }); 75 | 76 | it('when publishing, it starts a span', () => { 77 | const { broker, client, tracer } = createBroker(); 78 | const event = createUserCreatedEvent(); 79 | 80 | broker.publish(event); 81 | 82 | expect(tracer.spanFromRequest).toHaveBeenCalledWith( 83 | 'user-created', 84 | undefined 85 | ); 86 | }); 87 | 88 | it('if given some context, it will start the span with context', () => { 89 | const { broker, client, tracer } = createBroker(); 90 | const event = createUserCreatedEvent(); 91 | 92 | // @ts-ignore 93 | broker.publish(event, {}); 94 | 95 | expect(tracer.spanFromRequest).toHaveBeenCalledWith('user-created', {}); 96 | }); 97 | 98 | it('injects context into the event', () => { 99 | const { broker, client, tracer } = createBroker(); 100 | const event = createUserCreatedEvent(); 101 | broker.publish(event); 102 | 103 | expect(event.context).toEqual({ context: 1 }); 104 | }); 105 | 106 | it('logs the entire event', () => { 107 | const { broker, client, tracer } = createBroker(); 108 | const event = createUserCreatedEvent(); 109 | broker.publish(event); 110 | 111 | //@ts-ignore 112 | const span = tracer.spanFromRequest(); 113 | 114 | expect(span.log).toHaveBeenCalledWith({ event }); 115 | }); 116 | 117 | it('resolves on successful publish', async () => { 118 | const { broker, client, tracer } = createBroker(); 119 | const event = createUserCreatedEvent(); 120 | const promise = broker.publish(event); 121 | 122 | //@ts-ignore 123 | client.publish.mock.calls[0][2](null, 'guid'); 124 | //@ts-ignore 125 | const span = tracer.spanFromRequest(); 126 | 127 | const guid = await promise; 128 | expect(guid).toEqual('guid'); 129 | expect(span.log).toHaveBeenCalledWith({ guid: 'guid' }); 130 | expect(span.finish).toHaveBeenCalled(); 131 | }); 132 | 133 | it('rejects on unsuccessful publish', async () => { 134 | const { broker, client, tracer } = createBroker(); 135 | const event = createUserCreatedEvent(); 136 | const promise = broker.publish(event); 137 | 138 | //@ts-ignore 139 | client.publish.mock.calls[0][2]('error message', null); 140 | //@ts-ignore 141 | const span = tracer.spanFromRequest(); 142 | 143 | let err; 144 | try { 145 | await promise; 146 | } catch (error) { 147 | err = error; 148 | } 149 | 150 | expect(err).toEqual('error message'); 151 | expect(span.log).not.toHaveBeenCalledWith({ guid: 'guid' }); 152 | expect(span.log).toHaveBeenCalledWith({ error: 'error message' }); 153 | expect(span.finish).toHaveBeenCalled(); 154 | }); 155 | }); 156 | 157 | describe('handling', () => { 158 | it('should be able to receive fully decoded messages', done => { 159 | const { broker, client, tracer } = createBroker(); 160 | const event = createUserCreatedEvent(); 161 | 162 | broker.on('user-created', (data: Event, message: Message) => { 163 | expect(data).toEqual(event); 164 | expect(message.getSubject()).toEqual('user-created'); 165 | done(); 166 | }); 167 | 168 | const message = { 169 | getSubject: jest.fn().mockReturnValue('user-created'), 170 | getData: jest.fn().mockReturnValue(JSON.stringify(event)) 171 | }; 172 | 173 | // @ts-ignore 174 | client.subscribe().on.mock.calls[0][1](message); 175 | }, 200); 176 | 177 | it('should enforce some types', done => { 178 | const { broker, client, tracer } = createBroker(); 179 | const _event = createUserCreatedEvent(); 180 | 181 | broker.on('user-created', (event: UserCreatedEvent, message: Message) => { 182 | expect(event).toEqual(_event); 183 | expect(message.getSubject()).toEqual('user-created'); 184 | 185 | // This is really a check to make sure TS picks up correct typings 186 | expect(event.data.email).toBeDefined(); 187 | expect(event.data.id).toBeDefined(); 188 | expect(event.data.role).toBeDefined(); 189 | 190 | done(); 191 | }); 192 | 193 | const message = { 194 | getSubject: jest.fn().mockReturnValue('user-created'), 195 | getData: jest.fn().mockReturnValue(JSON.stringify(_event)) 196 | }; 197 | 198 | // @ts-ignore 199 | client.subscribe().on.mock.calls[0][1](message); 200 | }, 200); 201 | 202 | it('creates and finishes a span', done => { 203 | const { broker, client, tracer } = createBroker(); 204 | const _event = createUserCreatedEvent(); 205 | 206 | broker.on('user-created', (event: UserCreatedEvent, message: Message) => { 207 | expect(event).toEqual(_event); 208 | expect(message.getSubject()).toEqual('user-created'); 209 | 210 | //@ts-ignore 211 | const span = tracer.spanFromEvent(); 212 | 213 | expect(span.log).toHaveBeenCalled(); 214 | 215 | setImmediate(() => { 216 | expect(span.finish).toHaveBeenCalled(); 217 | done(); 218 | }); 219 | }); 220 | 221 | const message = { 222 | getSubject: jest.fn().mockReturnValue('user-created'), 223 | getData: jest.fn().mockReturnValue(JSON.stringify(_event)) 224 | }; 225 | 226 | // @ts-ignore 227 | client.subscribe().on.mock.calls[0][1](message); 228 | }, 200); 229 | }); 230 | --------------------------------------------------------------------------------