Skip to content

Changes 06-07 – Feature Toggles using fflip #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: 06-web-security-and-rate-limiting
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
]
23 changes: 23 additions & 0 deletions service/server/middlewares/feature-toggles/setupFeatureToggles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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'

export const applyFeatureToggles = (server: Express) => {
fflip.config({ criteria, features })
const fflipExpressIntegration = new FFlipExpressIntegration(fflip, {
cookieName: 'fflip',
manualRoutePath: '/api/toggles/local/:name/:action'
})

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
}