Skip to content

Commit

Permalink
setup fflip feature toggles and use one for CatEndpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
Jonas Verhoelen committed May 21, 2019
1 parent 17686c4 commit b4a333a
Show file tree
Hide file tree
Showing 11 changed files with 171 additions and 2 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions service/server/ExpressServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
7 changes: 6 additions & 1 deletion service/server/cats/CatEndpoints.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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)
}
Expand Down
13 changes: 12 additions & 1 deletion service/server/cats/CatEndpointsSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ describe('CatEndpoints', () => {
}
sampleRequest = {
services: { catService },
params: { catId: 1 }
params: { catId: 1 },
fflip: {
has: sandbox.stub().returns(true)
}
}
})

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions service/server/middlewares/feature-toggles/criteria.ts
Original file line number Diff line number Diff line change
@@ -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
}
]
17 changes: 17 additions & 0 deletions service/server/middlewares/feature-toggles/features.ts
Original file line number Diff line number Diff line change
@@ -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
}
]
25 changes: 25 additions & 0 deletions service/server/middlewares/feature-toggles/setupFeatureToggles.ts
Original file line number Diff line number Diff line change
@@ -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()
})
}
14 changes: 14 additions & 0 deletions service/server/types/fflip-express/express.d.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
22 changes: 22 additions & 0 deletions service/server/types/fflip-express/index.d.ts
Original file line number Diff line number Diff line change
@@ -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
}
45 changes: 45 additions & 0 deletions service/server/types/fflip/index.d.ts
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit b4a333a

Please sign in to comment.