From b4a333aaf985d1cba3067a02732de6c290249052 Mon Sep 17 00:00:00 2001 From: Jonas Verhoelen Date: Tue, 21 May 2019 10:45:15 +0200 Subject: [PATCH] setup fflip feature toggles and use one for CatEndpoints --- package-lock.json | 10 +++++ package.json | 2 + service/server/ExpressServer.ts | 6 +++ service/server/cats/CatEndpoints.ts | 7 ++- service/server/cats/CatEndpointsSpec.ts | 13 +++++- .../middlewares/feature-toggles/criteria.ts | 12 +++++ .../middlewares/feature-toggles/features.ts | 17 +++++++ .../feature-toggles/setupFeatureToggles.ts | 25 +++++++++++ .../server/types/fflip-express/express.d.ts | 14 ++++++ service/server/types/fflip-express/index.d.ts | 22 +++++++++ service/server/types/fflip/index.d.ts | 45 +++++++++++++++++++ 11 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 service/server/middlewares/feature-toggles/criteria.ts create mode 100644 service/server/middlewares/feature-toggles/features.ts create mode 100644 service/server/middlewares/feature-toggles/setupFeatureToggles.ts create mode 100644 service/server/types/fflip-express/express.d.ts create mode 100644 service/server/types/fflip-express/index.d.ts create mode 100644 service/server/types/fflip/index.d.ts diff --git a/package-lock.json b/package-lock.json index 4582423..11e8386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2332,6 +2332,16 @@ "resolved": "https://registry.npmjs.org/feature-policy/-/feature-policy-0.3.0.tgz", "integrity": "sha512-ZtijOTFN7TzCujt1fnNhfWPFPSHeZkesff9AXZj+UEjYBynWNUIYpC87Ve4wHzyexQsImicLu7WsC2LHq7/xrQ==" }, + "fflip": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fflip/-/fflip-4.0.0.tgz", + "integrity": "sha1-Q/bAoetkF+LXRdnlQrWL9t+W2Bw=" + }, + "fflip-express": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fflip-express/-/fflip-express-1.0.2.tgz", + "integrity": "sha1-bpv1xpzSmzIVT3NC+67HqgwWBhA=" + }, "figgy-pudding": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", diff --git a/package.json b/package.json index cbcf6fc..f879065 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "csurf": "^1.10.0", "express": "^4.16.2", "express-rate-limit": "^3.5.1", + "fflip": "^4.0.0", + "fflip-express": "^1.0.2", "fs-extra": "^7.0.1", "helmet": "^3.18.0", "hot-shots": "^4.7.0", diff --git a/service/server/ExpressServer.ts b/service/server/ExpressServer.ts index 853a818..ed18cc4 100644 --- a/service/server/ExpressServer.ts +++ b/service/server/ExpressServer.ts @@ -17,6 +17,7 @@ import { RequestServices } from './types/CustomRequest' import { addServicesToRequest } from './middlewares/ServiceDependenciesMiddleware' import { Environment } from './Environment' import { FrontendContext } from '../shared/FrontendContext' +import { applyFeatureToggles } from './middlewares/feature-toggles/setupFeatureToggles' /** * Abstraction around the raw Express.js server and Nodes' HTTP server. @@ -34,6 +35,7 @@ export class ExpressServer { const server = express() this.setupStandardMiddlewares(server) this.setupSecurityMiddlewares(server) + this.setupFeatureToggles(server) this.applyWebpackDevMiddleware(server) this.setupTelemetry(server) this.setupServiceDependencies(server) @@ -82,6 +84,10 @@ export class ExpressServer { server.use('/api/', new RateLimit(baseRateLimitingOptions)) } + private setupFeatureToggles(server: Express) { + applyFeatureToggles(server) + } + private configureEjsTemplates(server: Express) { server.set('views', [ 'resources/views' ]) server.set('view engine', 'ejs') diff --git a/service/server/cats/CatEndpoints.ts b/service/server/cats/CatEndpoints.ts index 906a546..87b197e 100644 --- a/service/server/cats/CatEndpoints.ts +++ b/service/server/cats/CatEndpoints.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from 'express' import * as HttpStatus from 'http-status-codes' +import { FeatureToggles } from '../middlewares/feature-toggles/features' export class CatEndpoints { public getCatDetails = async (req: Request, res: Response, next: NextFunction) => { @@ -29,7 +30,11 @@ export class CatEndpoints { public getCatsStatistics = async (req: Request, res: Response, next: NextFunction) => { try { - res.json(req.services.catService.getCatsStatistics()) + if (req.fflip.has(FeatureToggles.WITH_CAT_STATISTICS)) { + res.json(req.services.catService.getCatsStatistics()) + } else { + res.sendStatus(HttpStatus.NOT_FOUND) + } } catch (err) { next(err) } diff --git a/service/server/cats/CatEndpointsSpec.ts b/service/server/cats/CatEndpointsSpec.ts index 2e12866..f9c0c9a 100644 --- a/service/server/cats/CatEndpointsSpec.ts +++ b/service/server/cats/CatEndpointsSpec.ts @@ -20,7 +20,10 @@ describe('CatEndpoints', () => { } sampleRequest = { services: { catService }, - params: { catId: 1 } + params: { catId: 1 }, + fflip: { + has: sandbox.stub().returns(true) + } } }) @@ -81,6 +84,14 @@ describe('CatEndpoints', () => { .expectJson({ amount: 30, averageAge: 50 }) }) + it('should send status 404 if the feature toggle is deactivated', () => { + sampleRequest.fflip.has.returns(false) + + return ExpressMocks.create(sampleRequest) + .test(endpoints.getCatsStatistics) + .expectSendStatus(HttpStatus.NOT_FOUND) + }) + it('should handle thrown errors by passing them to NextFunction', () => { const thrownError = new Error('Some problem with accessing the data') catService.getCatsStatistics.throws(thrownError) diff --git a/service/server/middlewares/feature-toggles/criteria.ts b/service/server/middlewares/feature-toggles/criteria.ts new file mode 100644 index 0000000..9ea0707 --- /dev/null +++ b/service/server/middlewares/feature-toggles/criteria.ts @@ -0,0 +1,12 @@ +import * as fflip from 'fflip' + +export const criteria: fflip.Criteria[] = [ + { + id: 'isPaidUser', + check: (user: any, needsToBePaid: boolean) => user && user.isPaid === needsToBePaid + }, + { + id: 'shareOfUsers', + check: (user: any, share: number) => user && user.id % 100 < share * 100 + } +] diff --git a/service/server/middlewares/feature-toggles/features.ts b/service/server/middlewares/feature-toggles/features.ts new file mode 100644 index 0000000..6865140 --- /dev/null +++ b/service/server/middlewares/feature-toggles/features.ts @@ -0,0 +1,17 @@ +import * as fflip from 'fflip' + +export const FeatureToggles: { [ key: string ]: string } = { + CLOSED_BETA: 'CLOSED_BETA', + WITH_CAT_STATISTICS: 'WITH_CAT_STATISTICS' +} + +export const features: fflip.Feature[] = [ + { + id: FeatureToggles.CLOSED_BETA, + criteria: { isPaidUser: true, shareOfUsers: 0.5 } + }, + { + id: FeatureToggles.WITH_CAT_STATISTICS, + enabled: true + } +] diff --git a/service/server/middlewares/feature-toggles/setupFeatureToggles.ts b/service/server/middlewares/feature-toggles/setupFeatureToggles.ts new file mode 100644 index 0000000..08a4e41 --- /dev/null +++ b/service/server/middlewares/feature-toggles/setupFeatureToggles.ts @@ -0,0 +1,25 @@ +import { Express, NextFunction, Response, Request } from 'express' +import * as fflip from 'fflip' +import * as FFlipExpressIntegration from 'fflip-express' +import { criteria } from './criteria' +import { features } from './features' + +const createFFlipExpressIntegration = () => new FFlipExpressIntegration(fflip, { + cookieName: 'fflip', + manualRoutePath: '/api/toggles/local/:name/:action' +}) + +export const applyFeatureToggles = (server: Express) => { + fflip.config({ criteria, features }) + const fflipExpressIntegration = createFFlipExpressIntegration() + + server.use(fflipExpressIntegration.middleware) + server.use((req: Request, _: Response, next: NextFunction) => { + try { + req.fflip.setForUser(req.user) + } catch (err) { + console.error('Error while binding feature toggles to req.user') + } + next() + }) +} diff --git a/service/server/types/fflip-express/express.d.ts b/service/server/types/fflip-express/express.d.ts new file mode 100644 index 0000000..b5cc56b --- /dev/null +++ b/service/server/types/fflip-express/express.d.ts @@ -0,0 +1,14 @@ +// tslint:disable no-implicit-dependencies + +import 'express-serve-static-core' + +declare module 'express-serve-static-core' { + interface Request { + fflip: { + features: { [s: string]: boolean } + setForUser(user: any): void + has(featureName: string): boolean + } + user?: any + } +} diff --git a/service/server/types/fflip-express/index.d.ts b/service/server/types/fflip-express/index.d.ts new file mode 100644 index 0000000..21af62e --- /dev/null +++ b/service/server/types/fflip-express/index.d.ts @@ -0,0 +1,22 @@ +/* tslint:disable no-namespace */ + +declare module 'fflip-express' { + import * as FFlip from 'fflip' + import { CookieOptions, Handler } from 'express' + + class FFlipExpressIntegration { + public middleware: Handler + public manualRoute: Handler + constructor(fflip: FFlip, options: FFlipExpressIntegration.Options) + } + + namespace FFlipExpressIntegration { + export interface Options { + cookieName?: string + cookieOptions?: CookieOptions + manualRoutePath?: string + } + } + + export = FFlipExpressIntegration +} diff --git a/service/server/types/fflip/index.d.ts b/service/server/types/fflip/index.d.ts new file mode 100644 index 0000000..bf8ac01 --- /dev/null +++ b/service/server/types/fflip/index.d.ts @@ -0,0 +1,45 @@ +/* tslint:disable no-namespace */ +declare module 'fflip' { + + class FFlip { } + + namespace FFlip { + type GetFeaturesSync = () => Feature[] + type GetFeaturesAsync = (callback: (features: Feature[]) => void) => void + type CriteriaConfig = StringMap | StringMap[] + + export interface Config { + criteria: Criteria[] + features: Feature[] | GetFeaturesSync | GetFeaturesAsync + reload?: number + } + + export interface Criteria { + id: string + check(user: any, config: any): boolean + } + + export interface Feature { + id: string + criteria?: CriteriaConfig + enabled?: boolean + [s: string]: any + } + + export interface Features { + [featureName: string]: boolean + } + + export interface StringMap { + $veto?: boolean + [s: string]: any + } + + export function config(config: Config): void + export function isFeatureEnabledForUser(featureName: string, user: any): boolean + export function getFeaturesForUser(user: any): Features + export function reload(): void + } + + export = FFlip +}