From cb3b0545ecaa441d403bcb394470e54ff499aaba Mon Sep 17 00:00:00 2001 From: pmb Date: Mon, 18 Oct 2021 15:12:10 +0200 Subject: [PATCH] feat: allow user defined context data (#199) --- README.md | 53 ++++++++++++++++--- e2e/docker-compose.yml | 40 ++++++++++++++ e2e/specifications.e2e-spec.ts | 13 ++--- e2e/src/app.controller.ts | 16 +++++- e2e/src/app.module.ts | 9 ++-- e2e/src/my-custom-strategy.ts | 11 ++-- .../strategy/strategy.interface.ts | 4 +- src/unleash/unleash.context.spec.ts | 16 ++++++ src/unleash/unleash.context.ts | 14 ++++- src/unleash/unleash.service.spec.ts | 31 ++++++----- src/unleash/unleash.service.ts | 20 +++++-- 11 files changed, 181 insertions(+), 46 deletions(-) create mode 100644 e2e/docker-compose.yml diff --git a/README.md b/README.md index 1ecf7415..e8214cc7 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,17 @@ # Table of contents -- [Usage](#usage) +- [Setup](#setup) - [Synchronous configuration](#synchronous-configuration) - [Asynchronous configuration](#asynchronous-configuration) - - [Usage in controllers or providers](#usage-in-controllers-or-providers) +- [Usage in controllers or providers](#usage-in-controllers-or-providers) + - [Custom context](#custom-context) - [Configuration](#configuration) - [Default strategies](#default-strategies) - [Custom strategies](#custom-strategies) - [License](#license) -# Usage +# Setup ```sh $ npm install --save nestjs-unleash @@ -35,7 +36,7 @@ Import the module with `UnleashModule.forRoot(...)` or `UnleashModule.forRootAsy ## Synchronous configuration -Use `UnleashModule.forRoot()`. Available ptions are described in the [UnleashModuleOptions interface](#configuration). +Use `UnleashModule.forRoot()`. Available options are described in the [UnleashModuleOptions interface](#configuration). ```ts @Module({ @@ -52,7 +53,7 @@ export class MyModule {} ## Asynchronous configuration -If you want to use retrieve you [Unleash options](#configuration) dynamically, use `UnleashModule.forRootAsync()`. Use `useFactory` and `inject` to import your dependencies. Example using the `ConfigService`: +If you want to use your [Unleash options](#configuration) dynamically, use `UnleashModule.forRootAsync()`. Use `useFactory` and `inject` to import your dependencies. Example using the `ConfigService`: ```ts @Module({ @@ -72,7 +73,7 @@ If you want to use retrieve you [Unleash options](#configuration) dynamically, u export class MyModule {} ``` -## Usage in controllers or providers +# Usage in controllers or providers In your controller use the `UnleashService` or the `@IfEnabled(...)` route decorator: @@ -101,6 +102,46 @@ export class AppController { } ``` +## Custom context + +The `UnleashContext` grants access to request related information like user ID or IP address. + +In addition, the context can be dynamically enriched with further information and subsequently used in a separate strategy: + +```ts +export interface MyCustomData { + foo: string; + bar: number; +} + +@Injectable() +class SomeProvider { + constructor(private readonly unleash: UnleashService) {} + + someMethod() { + return this.unleash.isEnabled("someToggleName", undefined, { + foo: "bar", + bar: 123, + }) + ? "feature is active" + : "feature is not active"; + } +} + +// Custom strategy with custom data: +@Injectable() +export class MyCustomStrategy implements UnleashStrategy { + name = "MyCustomStrategy"; + + isEnabled( + _parameters: unknown, + context: UnleashContext + ): boolean { + return context.customData?.foo === "bar"; + } +} +``` + ## Configuration NestJS-Unleash can be configured with the following options: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 00000000..c918028a --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,40 @@ +# https://github.com/Unleash/unleash-docker/blob/master/docker-compose.yml +# admin / unleash4all +version: "3.4" +services: + web: + image: unleashorg/unleash-server + ports: + - "4242:4242" + environment: + DATABASE_URL: "postgres://postgres:unleash@db/postgres" + DATABASE_SSL: "false" + depends_on: + - db + command: npm run start + healthcheck: + test: ["CMD", "nc", "-z", "db", "5432"] + interval: 1s + timeout: 1m + retries: 5 + start_period: 15s + db: + expose: + - "5432" + image: postgres:10-alpine + environment: + POSTGRES_DB: "db" + POSTGRES_HOST_AUTH_METHOD: "trust" + healthcheck: + test: + [ + "CMD", + "pg_isready", + "--username=postgres", + "--host=127.0.0.1", + "--port=5432", + ] + interval: 2s + timeout: 1m + retries: 5 + start_period: 10s diff --git a/e2e/specifications.e2e-spec.ts b/e2e/specifications.e2e-spec.ts index 692b22ae..aad3c256 100644 --- a/e2e/specifications.e2e-spec.ts +++ b/e2e/specifications.e2e-spec.ts @@ -19,7 +19,6 @@ import { GradualRolloutRandomStrategy, GradualRolloutSessionIdStrategy, RemoteAddressStrategy, - UnleashContext, UnleashStrategiesService, UserWithIdStrategy, } from '../src' @@ -28,8 +27,11 @@ import { CUSTOM_STRATEGIES } from '../src/unleash-strategies/unleash-strategies. import { ToggleEntity } from '../src/unleash/entity/toggle.entity' import { MetricsService } from '../src/unleash/metrics.service' import { ToggleRepository } from '../src/unleash/repository/toggle-repository' +import { UnleashContext } from '../src/unleash/unleash.context' import { UnleashService } from '../src/unleash/unleash.service' +jest.mock('../src/unleash/unleash.context') + // 09-strategy-constraints.json is an enterprise feature. can't test. const testSuite = [s1, s2, s3, s4, s5, s6, s7, s10] @@ -49,14 +51,7 @@ describe('Specification test', () => { UnleashService, { provide: CUSTOM_STRATEGIES, useValue: [] }, { provide: MetricsService, useValue: { increase: jest.fn() } }, - { - provide: UnleashContext, - useValue: { - getRemoteAddress: jest.fn(), - getSessionId: jest.fn(), - getUserId: jest.fn(), - }, - }, + UnleashContext, ApplicationHostnameStrategy, DefaultStrategy, FlexibleRolloutStrategy, diff --git a/e2e/src/app.controller.ts b/e2e/src/app.controller.ts index 4801384f..8fce48dc 100644 --- a/e2e/src/app.controller.ts +++ b/e2e/src/app.controller.ts @@ -1,12 +1,16 @@ -import { Controller, Get, UseGuards } from '@nestjs/common' +import { Controller, Get, Param, UseGuards } from '@nestjs/common' import { IfEnabled } from '../../src/unleash' import { UnleashService } from '../../src/unleash/unleash.service' import { UserGuard } from './user.guard' +export interface MyCustomData { + foo: string +} + @Controller() @UseGuards(UserGuard) export class AppController { - constructor(private readonly unleash: UnleashService) {} + constructor(private readonly unleash: UnleashService) {} @Get('/') index(): string { @@ -20,4 +24,12 @@ export class AppController { getContent(): string { return 'my content' } + + @Get('/custom-context/:foo') + customContext(@Param('foo') foo: string): string { + // Provide "foo" as custom context data + return this.unleash.isEnabled('test', undefined, { foo }) + ? 'feature is active' + : 'feature is not active' + } } diff --git a/e2e/src/app.module.ts b/e2e/src/app.module.ts index 8dcb5534..40f3c977 100644 --- a/e2e/src/app.module.ts +++ b/e2e/src/app.module.ts @@ -19,15 +19,16 @@ import { UsersService } from './users.service' UnleashModule.forRootAsync({ useFactory: () => ({ // disableRegistration: true, - // url: 'http://127.0.0.1:3000/unleash', - url: 'https://unleash.herokuapp.com/api/client', + url: 'http://localhost:4242/api/client', appName: 'my-app-name', instanceId: 'my-unique-instance', //process.pid.toString(), refreshInterval: 20_000, - // metricsInterval: 3000, - // strategies: [MyCustomStrategy], + metricsInterval: 3000, + strategies: [MyCustomStrategy], http: { headers: { + Authorization: + '8b2d15c99270b809d47eef3bc7d8988059d7215adafa4c5175e2f4fe7b387f60', 'X-Foo': 'bar', }, }, diff --git a/e2e/src/my-custom-strategy.ts b/e2e/src/my-custom-strategy.ts index 9e28efea..578284ce 100644 --- a/e2e/src/my-custom-strategy.ts +++ b/e2e/src/my-custom-strategy.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common' -import { UnleashStrategy } from '../../src' +import { UnleashContext, UnleashStrategy } from '../../src' +import { MyCustomData } from './app.controller' @Injectable() export class MyCustomStrategy implements UnleashStrategy { name = 'MyCustomStrategy' - isEnabled(_parameters: unknown): boolean { - // eslint-disable-next-line no-magic-numbers - return Math.random() < 0.5 + isEnabled( + _parameters: unknown, + context: UnleashContext, + ): boolean { + return context.customData?.foo === 'bar' } } diff --git a/src/unleash-strategies/strategy/strategy.interface.ts b/src/unleash-strategies/strategy/strategy.interface.ts index 4fb13140..ec6d0d6c 100644 --- a/src/unleash-strategies/strategy/strategy.interface.ts +++ b/src/unleash-strategies/strategy/strategy.interface.ts @@ -1,6 +1,6 @@ import { UnleashContext } from '../../unleash' -export interface UnleashStrategy { +export interface UnleashStrategy { /** * Must match the name you used to create the strategy in your Unleash * server UI @@ -13,5 +13,5 @@ export interface UnleashStrategy { * @param parameters Custom paramemters as configured in Unleash server UI * @param context applicaton/request context, i.e. UserID */ - isEnabled(parameters: T, context: UnleashContext): boolean + isEnabled(parameters: T, context: UnleashContext): boolean } diff --git a/src/unleash/unleash.context.spec.ts b/src/unleash/unleash.context.spec.ts index 5e75f870..05c4fddc 100644 --- a/src/unleash/unleash.context.spec.ts +++ b/src/unleash/unleash.context.spec.ts @@ -8,6 +8,11 @@ function createRequest( return data } +interface MyCustomData { + foo: boolean + bar: string +} + describe('UnleashContext', () => { let context: UnleashContext let req: Request<{ @@ -50,4 +55,15 @@ describe('UnleashContext', () => { context.request = { hello: 'world' } expect(context.getRequest()).toStrictEqual({ hello: 'world' }) }) + + describe('Custom data', () => { + test('extend()', () => { + const context = new UnleashContext( + req, + {} as UnleashModuleOptions, + ) + const extendedContext = context.extend({ foo: true, bar: 'baz' }) + expect(extendedContext.customData).toEqual({ foo: true, bar: 'baz' }) + }) + }) }) diff --git a/src/unleash/unleash.context.ts b/src/unleash/unleash.context.ts index be6430f7..befef7a3 100644 --- a/src/unleash/unleash.context.ts +++ b/src/unleash/unleash.context.ts @@ -9,7 +9,9 @@ const defaultUserIdFactory = (request: Request<{ id: string }>) => { } @Injectable({ scope: Scope.REQUEST }) -export class UnleashContext { +export class UnleashContext { + #customData?: TCustomData + constructor( @Inject(REQUEST) private request: Request<{ id: string }>, @Inject(UNLEASH_MODULE_OPTIONS) @@ -35,4 +37,14 @@ export class UnleashContext { getRequest>(): T { return this.request as T } + + get customData(): TCustomData | undefined { + return this.#customData + } + + extend(customData: TCustomData | undefined): UnleashContext { + this.#customData = customData + + return this + } } diff --git a/src/unleash/unleash.service.spec.ts b/src/unleash/unleash.service.spec.ts index a6ec4ca7..f0dbbb30 100644 --- a/src/unleash/unleash.service.spec.ts +++ b/src/unleash/unleash.service.spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing' -import { UnleashStrategiesService, UnleashStrategy } from '..' +import { + UnleashStrategiesService, + UnleashStrategy, + UNLEASH_MODULE_OPTIONS, +} from '..' import { ToggleEntity } from './entity/toggle.entity' import { MetricsService } from './metrics.service' import { ToggleRepository } from './repository/toggle-repository' @@ -71,7 +75,8 @@ describe('UnleashService', () => { }, }, { provide: MetricsService, useValue: { increase: jest.fn() } }, - { provide: UnleashContext, useValue: {} }, + { provide: UNLEASH_MODULE_OPTIONS, useValue: {} }, + UnleashContext, ], }).compile() @@ -85,28 +90,28 @@ describe('UnleashService', () => { describe('_isEnabled()', () => { it('returns the default value if the feature is not found', () => { - expect(service._isEnabled('foo', true)).toBe(true) - expect(service._isEnabled('foo', false)).toBe(false) + expect(service.isEnabled('foo', true)).toBe(true) + expect(service.isEnabled('foo', false)).toBe(false) }) it('returns the default value if the feature exists, but is disabled', () => { toggles.create(createFeatureToggle({ name: 'foo', enabled: false })) - expect(service._isEnabled('foo', false)).toBe(false) - expect(service._isEnabled('foo', true)).toBe(true) + expect(service.isEnabled('foo', false)).toBe(false) + expect(service.isEnabled('foo', true)).toBe(true) }) describe('returns the enabled property if no strategy is found', () => { test('enabled: true', () => { toggles.create(createFeatureToggle({ name: 'foo', enabled: true })) - expect(service._isEnabled('foo')).toBe(true) + expect(service.isEnabled('foo')).toBe(true) }) test('enabled: false', () => { toggles.create(createFeatureToggle({ name: 'foo', enabled: false })) - expect(service._isEnabled('foo')).toBe(false) + expect(service.isEnabled('foo')).toBe(false) }) }) @@ -118,7 +123,7 @@ describe('UnleashService', () => { }), ) - expect(service._isEnabled('foo')).toBe(false) + expect(service.isEnabled('foo')).toBe(false) }) describe('strategy testing', () => { @@ -130,7 +135,7 @@ describe('UnleashService', () => { }), ) - expect(service._isEnabled('foo')).toBe(true) + expect(service.isEnabled('foo')).toBe(true) }) test('enabled: false', () => { @@ -141,7 +146,7 @@ describe('UnleashService', () => { }), ) - expect(service._isEnabled('foo')).toBe(false) + expect(service.isEnabled('foo')).toBe(false) }) it('interprets exceptios as `false`', () => { @@ -152,7 +157,7 @@ describe('UnleashService', () => { }), ) - expect(service._isEnabled('foo')).toBe(false) + expect(service.isEnabled('foo')).toBe(false) }) it('warns when a stale toggle is used', () => { @@ -164,7 +169,7 @@ describe('UnleashService', () => { }), ) - service._isEnabled('foo') + service.isEnabled('foo') expect(warnSpy).toHaveBeenCalledWith('Toggle is stale: foo') }) }) diff --git a/src/unleash/unleash.service.ts b/src/unleash/unleash.service.ts index 039db7b3..e3691e7e 100644 --- a/src/unleash/unleash.service.ts +++ b/src/unleash/unleash.service.ts @@ -5,20 +5,26 @@ import { ToggleRepository } from './repository/toggle-repository' import { UnleashContext } from './unleash.context' @Injectable({ scope: Scope.REQUEST }) -export class UnleashService { +export class UnleashService { protected readonly logger = new Logger(UnleashService.name) constructor( private readonly toggles: ToggleRepository, private readonly strategies: UnleashStrategiesService, private readonly metrics: MetricsService, - private readonly context: UnleashContext, + private readonly context: UnleashContext, ) {} // eslint-disable-next-line sonarjs/cognitive-complexity - _isEnabled(name: string, defaultValue = false): boolean { + #isEnabled( + name: string, + defaultValue = false, + customData?: TCustomData, + ): boolean { const toggle = this.toggles.find(name) + this.context.extend(customData) + if (!toggle) { this.logger.warn(`Toggle not found: ${name}`) return defaultValue @@ -57,8 +63,12 @@ export class UnleashService { }) } - isEnabled(name: string, defaultValue = false): boolean { - const isEnabled = this._isEnabled(name, defaultValue) + isEnabled( + name: string, + defaultValue = false, + customData?: TCustomData, + ): boolean { + const isEnabled = this.#isEnabled(name, defaultValue, customData) this.metrics.increase(name, isEnabled) return isEnabled }