From c06e6dc5e982f1e72f9d3dda75b00e61cb965d49 Mon Sep 17 00:00:00 2001 From: Jonas Verhoelen Date: Wed, 17 Apr 2019 12:01:03 +0200 Subject: [PATCH 1/4] 03 docs --- docs/03-server-application-design.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/03-server-application-design.md diff --git a/docs/03-server-application-design.md b/docs/03-server-application-design.md new file mode 100644 index 0000000..8a50093 --- /dev/null +++ b/docs/03-server-application-design.md @@ -0,0 +1,3 @@ +## 03 – Cut and design of the Backend + +This branch introduces a proposal how to cut features in the backend: where to put data access, business logics and request handling. From f4046ae34ca88807c8b0313a0323edc81c591b6a Mon Sep 17 00:00:00 2001 From: Jonas Verhoelen Date: Wed, 17 Apr 2019 20:25:55 +0200 Subject: [PATCH 2/4] add statsd datadog middleware. move files around for structuring described in article --- package-lock.json | 18 +++++ package.json | 8 +- service/{ => server}/Application.ts | 0 service/{ => server}/ExpressServer.ts | 15 +++- service/{ => server}/cats/CatEndpoints.ts | 0 service/{ => server}/index.ts | 0 .../middlewares/DatadogStatsdMiddleware.ts | 36 +++++++++ .../middlewares}/NoCacheMiddleware.ts | 0 .../server/types/connect-datadog/index.d.ts | 4 + service/server/types/hot-shots/index.d.ts | 80 +++++++++++++++++++ 10 files changed, 155 insertions(+), 6 deletions(-) rename service/{ => server}/Application.ts (100%) rename service/{ => server}/ExpressServer.ts (73%) rename service/{ => server}/cats/CatEndpoints.ts (100%) rename service/{ => server}/index.ts (100%) create mode 100644 service/server/middlewares/DatadogStatsdMiddleware.ts rename service/{ => server/middlewares}/NoCacheMiddleware.ts (100%) create mode 100644 service/server/types/connect-datadog/index.d.ts create mode 100644 service/server/types/hot-shots/index.d.ts diff --git a/package-lock.json b/package-lock.json index f85cd83..bd5b29c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -167,6 +167,14 @@ } } }, + "connect-datadog": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/connect-datadog/-/connect-datadog-0.0.6.tgz", + "integrity": "sha1-b4jICCo0Fq8kfVmpvLKkYMcy9rk=", + "requires": { + "node-dogstatsd": "0.0.6" + } + }, "content-disposition": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", @@ -301,6 +309,11 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, + "hot-shots": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-4.8.0.tgz", + "integrity": "sha1-BSvkhDDvx9EXunzE1B8YM7o4x58=" + }, "http-errors": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", @@ -407,6 +420,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" }, + "node-dogstatsd": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/node-dogstatsd/-/node-dogstatsd-0.0.6.tgz", + "integrity": "sha1-1pfk0ZA6f/DBZHnNXRzeBDc34Ec=" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", diff --git a/package.json b/package.json index 255f38f..bd47d25 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "node-express-typescript-recipes", "version": "0.0.1", "description": "A cookbook full of recipes for developing web apps with Node.js and Express.js in TypeScript", - "main": "service/index.ts", + "main": "service/server/index.ts", "engines": { "node": ">=8.15.0", "npm": ">=6.4.1" }, "scripts": { - "start": "ts-node service/index.ts", + "start": "ts-node service/server/index.ts", "prettier:check": "prettier --check service/**/*.{ts,tsx,js,jsx}", "prettier:write": "prettier --write service/**/*.{ts,tsx,js,jsx}" }, @@ -16,7 +16,9 @@ "express": "^4.16.2", "compression": "1.7.2", "cookie-parser": "1.4.3", - "http-status-codes": "1.3.0" + "http-status-codes": "1.3.0", + "connect-datadog": "^0.0.6", + "hot-shots": "^4.7.0" }, "devDependencies": { "@types/express": "4.11.1", diff --git a/service/Application.ts b/service/server/Application.ts similarity index 100% rename from service/Application.ts rename to service/server/Application.ts diff --git a/service/ExpressServer.ts b/service/server/ExpressServer.ts similarity index 73% rename from service/ExpressServer.ts rename to service/server/ExpressServer.ts index ecde37a..295ead4 100644 --- a/service/ExpressServer.ts +++ b/service/server/ExpressServer.ts @@ -5,7 +5,8 @@ import * as compress from 'compression' import * as bodyParser from 'body-parser' import * as cookieParser from 'cookie-parser' -import { noCache } from './NoCacheMiddleware' +import { noCache } from './middlewares/NoCacheMiddleware' +import DatadogStatsdMiddleware from './middlewares/DatadogStatsdMiddleware' import { CatEndpoints } from './cats/CatEndpoints' /** @@ -17,12 +18,12 @@ export class ExpressServer { private server?: Express private httpServer?: Server - constructor(private catEndpoints: CatEndpoints) { - } + constructor(private catEndpoints: CatEndpoints) {} public async setup(port: number) { const server = express() this.setupStandardMiddlewares(server) + this.setupTelemetry(server) this.configureApiEndpoints(server) this.httpServer = this.listen(server, port) @@ -44,6 +45,14 @@ export class ExpressServer { server.use(compress()) } + private setupTelemetry(server: Express) { + DatadogStatsdMiddleware.applyTo(server, { + targetHost: 'https://datadog.mycompany.com', + enableTelemetry: false, + tags: ['team:cats', 'product:cats-provider'] + }) + } + private configureApiEndpoints(server: Express) { server.get('/api/cat/:catId', noCache, this.catEndpoints.getCatDetails) } diff --git a/service/cats/CatEndpoints.ts b/service/server/cats/CatEndpoints.ts similarity index 100% rename from service/cats/CatEndpoints.ts rename to service/server/cats/CatEndpoints.ts diff --git a/service/index.ts b/service/server/index.ts similarity index 100% rename from service/index.ts rename to service/server/index.ts diff --git a/service/server/middlewares/DatadogStatsdMiddleware.ts b/service/server/middlewares/DatadogStatsdMiddleware.ts new file mode 100644 index 0000000..b744082 --- /dev/null +++ b/service/server/middlewares/DatadogStatsdMiddleware.ts @@ -0,0 +1,36 @@ +import { Express } from 'express' +import * as StatsD from 'hot-shots' +import * as connectDatadog from 'connect-datadog' + +export interface DatadogStatsdConfig { + targetHost: string + enableTelemetry: boolean + tags: string[] +} + +export default class DatadogStatsdMiddleware { + public static applyTo(server: Express, config: DatadogStatsdConfig) { + const statsdClient = DatadogStatsdMiddleware.createStatsdClient({ + host: config.targetHost, + mock: !config.enableTelemetry + }) + + const datadogStatsdMiddleware = connectDatadog({ + dogstatsd: statsdClient, + tags: config.tags, + path: false, + method: true, + response_code: false + }) + + server.use(datadogStatsdMiddleware) + } + + private static createStatsdClient(options?: StatsD.ClientOptions) { + const statsdClient = new StatsD(options) + statsdClient.socket.on('error', (err: any) => { + console.error('Error sending datadog stats', err) + }) + return statsdClient + } +} diff --git a/service/NoCacheMiddleware.ts b/service/server/middlewares/NoCacheMiddleware.ts similarity index 100% rename from service/NoCacheMiddleware.ts rename to service/server/middlewares/NoCacheMiddleware.ts diff --git a/service/server/types/connect-datadog/index.d.ts b/service/server/types/connect-datadog/index.d.ts new file mode 100644 index 0000000..584be66 --- /dev/null +++ b/service/server/types/connect-datadog/index.d.ts @@ -0,0 +1,4 @@ +declare module 'connect-datadog' { + const x: any + export = x +} diff --git a/service/server/types/hot-shots/index.d.ts b/service/server/types/hot-shots/index.d.ts new file mode 100644 index 0000000..fec4080 --- /dev/null +++ b/service/server/types/hot-shots/index.d.ts @@ -0,0 +1,80 @@ +declare module 'hot-shots' { + namespace StatsD { + export interface ClientOptions { + host?: string + port?: number + prefix?: string + suffix?: string + globalize?: boolean + cacheDns?: boolean + mock?: boolean + globalTags?: string[] + maxBufferSize?: number + bufferFlushInterval?: number + telegraf?: boolean + sampleRate?: number + errorHandler?: (err: Error) => void + } + + export interface CheckOptions { + date_happened?: Date + hostname?: string + message?: string + } + + export interface DatadogChecks { + OK: 0 + WARNING: 1 + CRITICAL: 2 + UNKNOWN: 3 + } + + type unionFromInterfaceValues4< + T, + K1 extends keyof T, + K2 extends keyof T, + K3 extends keyof T, + K4 extends keyof T + > = T[K1] | T[K2] | T[K3] | T[K4] + + export type DatadogChecksValues = unionFromInterfaceValues4 + + export interface EventOptions { + aggregation_key?: string + alert_type?: 'info' | 'warning' | 'success' | 'error' + date_happened?: Date + hostname?: string + priority?: 'low' | 'normal' + source_type_name?: string + } + + export type StatsCb = (error: Error | undefined, bytes: any) => void + export type StatsCall = (stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsCb) => void + } + + // tslint:disable-next-line:no-shadowed-variable + class StatsD { + public CHECKS: StatsD.DatadogChecks + public socket: NodeJS.Socket + + constructor(options?: StatsD.ClientOptions) + public increment(stat: string): void + public increment(stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsD.StatsCb): void + + public decrement(stat: string): void + public decrement(stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsD.StatsCb): void + + public timing(stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsD.StatsCb): void + public histogram(stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsD.StatsCb): void + public gauge(stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsD.StatsCb): void + public set(stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsD.StatsCb): void + public unique(stat: string | string[], value: number, sampleRate?: number, tags?: string[], callback?: StatsD.StatsCb): void + + public close(callback: () => void): void + + public event(title: string, text?: string, options?: StatsD.EventOptions, tags?: string[], callback?: StatsD.StatsCb): void + public check(name: string, status: StatsD.DatadogChecksValues, options?: StatsD.CheckOptions, tags?: string[], callback?: StatsD.StatsCb): void + } + + export = StatsD +} From 61f125512449a5bdc20f3e2d8618848b7996c9ee Mon Sep 17 00:00:00 2001 From: Jonas Verhoelen Date: Wed, 17 Apr 2019 22:17:24 +0200 Subject: [PATCH 3/4] implement services on request and wire together the cat API features --- service/server/Application.ts | 7 ++- service/server/ExpressServer.ts | 12 ++++- service/server/cats/Cat.d.ts | 14 +++++ service/server/cats/CatEndpoints.ts | 22 ++++++-- service/server/cats/CatRepository.ts | 54 +++++++++++++++++++ service/server/cats/CatService.ts | 25 +++++++++ .../ServiceDependenciesMiddleware.ts | 7 +++ service/server/types/CustomRequest.d.ts | 15 ++++++ 8 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 service/server/cats/Cat.d.ts create mode 100644 service/server/cats/CatRepository.ts create mode 100644 service/server/cats/CatService.ts create mode 100644 service/server/middlewares/ServiceDependenciesMiddleware.ts create mode 100644 service/server/types/CustomRequest.d.ts diff --git a/service/server/Application.ts b/service/server/Application.ts index 2d615fe..63dfc87 100644 --- a/service/server/Application.ts +++ b/service/server/Application.ts @@ -1,5 +1,7 @@ import { ExpressServer } from './ExpressServer' import { CatEndpoints } from './cats/CatEndpoints' +import { CatService } from './cats/CatService' +import { CatRepository } from './cats/CatRepository' /** * Wrapper around the Node process, ExpressServer abstraction and complex dependencies such as services that ExpressServer needs. @@ -7,7 +9,10 @@ import { CatEndpoints } from './cats/CatEndpoints' */ export class Application { public static async createApplication() { - const expressServer = new ExpressServer(new CatEndpoints()) + const catService = new CatService(new CatRepository()) + const requestServices = { catService } + const expressServer = new ExpressServer(new CatEndpoints(), requestServices) + await expressServer.setup(8000) Application.handleExit(expressServer) diff --git a/service/server/ExpressServer.ts b/service/server/ExpressServer.ts index 295ead4..275ab02 100644 --- a/service/server/ExpressServer.ts +++ b/service/server/ExpressServer.ts @@ -8,6 +8,8 @@ import * as cookieParser from 'cookie-parser' import { noCache } from './middlewares/NoCacheMiddleware' import DatadogStatsdMiddleware from './middlewares/DatadogStatsdMiddleware' import { CatEndpoints } from './cats/CatEndpoints' +import { RequestServices } from './types/CustomRequest' +import { addServicesToRequest } from './middlewares/ServiceDependenciesMiddleware' /** * Abstraction around the raw Express.js server and Nodes' HTTP server. @@ -18,12 +20,13 @@ export class ExpressServer { private server?: Express private httpServer?: Server - constructor(private catEndpoints: CatEndpoints) {} + constructor(private catEndpoints: CatEndpoints, private requestServices: RequestServices) {} public async setup(port: number) { const server = express() this.setupStandardMiddlewares(server) this.setupTelemetry(server) + this.setupServiceDependencies(server) this.configureApiEndpoints(server) this.httpServer = this.listen(server, port) @@ -53,7 +56,14 @@ export class ExpressServer { }) } + private setupServiceDependencies(server: Express) { + const servicesMiddleware = addServicesToRequest(this.requestServices) + server.use(servicesMiddleware) + } + private configureApiEndpoints(server: Express) { + server.get('/api/cat', noCache, this.catEndpoints.getAllCats) + server.get('/api/statistics/cat', noCache, this.catEndpoints.getCatStatistics) server.get('/api/cat/:catId', noCache, this.catEndpoints.getCatDetails) } } diff --git a/service/server/cats/Cat.d.ts b/service/server/cats/Cat.d.ts new file mode 100644 index 0000000..f3c6912 --- /dev/null +++ b/service/server/cats/Cat.d.ts @@ -0,0 +1,14 @@ +export type CatGender = 'female' | 'male' | 'diverse' + +export interface Cat { + id: number + name: string + breed: string + gender: CatGender + age: number +} + +export interface CatsStatistics { + amount: number + averageAge: number +} \ No newline at end of file diff --git a/service/server/cats/CatEndpoints.ts b/service/server/cats/CatEndpoints.ts index a5b9f5a..f49e0bd 100644 --- a/service/server/cats/CatEndpoints.ts +++ b/service/server/cats/CatEndpoints.ts @@ -4,11 +4,11 @@ import * as HttpStatus from 'http-status-codes' export class CatEndpoints { public getCatDetails = async (req: Request, res: Response, next: NextFunction) => { try { - // usually we will contact some service here and do some logic const catId = req.params.catId + const cat = req.services.catService.getCat(catId) - if (catId >= 90) { - res.json({ catId, name: 'Some lovely Kitty' }) + if (cat) { + res.json(cat) } else { res.sendStatus(HttpStatus.NOT_FOUND) } @@ -18,4 +18,20 @@ export class CatEndpoints { next(err) } } + + public getAllCats = async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(req.services.catService.getAllCats()) + } catch (err) { + next(err) + } + } + + public getCatStatistics = async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(req.services.catService.getCatsStatistics()) + } catch (err) { + next(err) + } + } } diff --git a/service/server/cats/CatRepository.ts b/service/server/cats/CatRepository.ts new file mode 100644 index 0000000..39f3998 --- /dev/null +++ b/service/server/cats/CatRepository.ts @@ -0,0 +1,54 @@ +import { Cat } from './Cat' + +export class CatRepository { + public getById(id: number): Cat | undefined { + return catsById[id] + } + + public getAll(): Cat[] { + return cats + } +} + +const cats: Cat[] = [ + { + id: 1, + name: 'Tony Iommi', + breed: 'British Shorthair', + gender: 'male', + age: 71 + }, + { + id: 2, + name: 'Ozzy Osbourne', + breed: 'British Semi-longhair', + gender: 'male', + age: 70 + }, + { + id: 3, + name: 'Geezer Butler', + breed: 'British Longhair', + gender: 'male', + age: 69 + }, + { + id: 4, + name: 'Bill Ward', + breed: 'Burmilla', + gender: 'male', + age: 70 + }, + { + id: 5, + name: 'Sharon Osbourne', + breed: 'Bambino', + gender: 'female', + age: 66 + } +] +type CatsById = { [id: number]: Cat } +const catsById: CatsById = cats.reduce((catzById: CatsById, currentCat) => { + catzById[currentCat.id] = currentCat + return catzById +}, {}) diff --git a/service/server/cats/CatService.ts b/service/server/cats/CatService.ts new file mode 100644 index 0000000..701bc2d --- /dev/null +++ b/service/server/cats/CatService.ts @@ -0,0 +1,25 @@ +import { CatRepository } from './CatRepository' +import { CatsStatistics, Cat } from './Cat' + +export class CatService { + constructor(private catRepository: CatRepository) { + } + + public getCat(id: number): Cat | undefined { + return this.catRepository.getById(id) + } + + public getAllCats(): Cat[] { + return this.catRepository.getAll() + } + + public getCatsStatistics(): CatsStatistics { + const allCats = this.catRepository.getAll() + const catsAgeSum = allCats.map(cat => cat.age).reduce((sum: number, nextAge: number) => sum + nextAge, 0) + + return { + amount: allCats.length, + averageAge: catsAgeSum / allCats.length + } + } +} \ No newline at end of file diff --git a/service/server/middlewares/ServiceDependenciesMiddleware.ts b/service/server/middlewares/ServiceDependenciesMiddleware.ts new file mode 100644 index 0000000..97250f8 --- /dev/null +++ b/service/server/middlewares/ServiceDependenciesMiddleware.ts @@ -0,0 +1,7 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express' +import { RequestServices } from '../types/CustomRequest' + +export const addServicesToRequest = (services: RequestServices): RequestHandler => (req: Request, res: Response, next: NextFunction) => { + (req as any).services = services + next() +} diff --git a/service/server/types/CustomRequest.d.ts b/service/server/types/CustomRequest.d.ts new file mode 100644 index 0000000..c80daa3 --- /dev/null +++ b/service/server/types/CustomRequest.d.ts @@ -0,0 +1,15 @@ +/* tslint:disable no-namespace */ +import 'express' +import { CatService } from '../cats/CatService' + +export interface RequestServices { + catService: CatService +} + +declare global { + namespace Express { + interface Request { + services: RequestServices + } + } +} From f84643f6eb635c424f271311d7aac4f70d1c136a Mon Sep 17 00:00:00 2001 From: Jonas Verhoelen Date: Wed, 1 May 2019 23:03:55 +0200 Subject: [PATCH 4/4] extend docs for 03-... --- docs/03-server-application-design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/03-server-application-design.md b/docs/03-server-application-design.md index 8a50093..088fc7b 100644 --- a/docs/03-server-application-design.md +++ b/docs/03-server-application-design.md @@ -1,3 +1,9 @@ ## 03 – Cut and design of the Backend This branch introduces a proposal how to cut features in the backend: where to put data access, business logics and request handling. + +Like always, `npm start` the server and try out the new enpoints: + +1. `http://localhost:8000/api/cat` – Get all available cats +2. `http://localhost:8000/api/cat/1` – Now there are some more cats and details available. Try Cat ID 1 to 5. +3. `http://localhost:800/api/statistics/cat` – It responds statistics about all cats