From 9bab2c53d6f3ed668559b678861cfec74de66cad Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Mon, 15 Sep 2025 18:02:47 +0200 Subject: [PATCH 1/3] feat: add feature flag service for angular Signed-off-by: Lukas Reining --- .../src/lib/feature-flag.service.ts | 174 ++++++++++++++++++ .../angular-sdk/src/lib/internal/is-equal.ts | 38 ++++ .../projects/angular-sdk/src/public-api.ts | 1 + .../angular-sdk/src/test/test.utils.ts | 3 + 4 files changed, 216 insertions(+) create mode 100644 packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts create mode 100644 packages/angular/projects/angular-sdk/src/lib/internal/is-equal.ts diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts new file mode 100644 index 000000000..ba8abe7f0 --- /dev/null +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts @@ -0,0 +1,174 @@ +import { Injectable, Signal } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + Client, + EvaluationDetails, + type FlagEvaluationOptions, + FlagValue, + type JsonValue, + OpenFeature, + ProviderEvents, + ProviderStatus, +} from '@openfeature/web-sdk'; +import { isEqual } from './internal/is-equal'; +import { toSignal } from '@angular/core/rxjs-interop'; + +export type AngularFlagEvaluationOptions = { + /** + * Update the component if the provider emits a ConfigurationChanged event. + * Set to false to prevent updating the value when flag value changes + * are received by the associated provider. + * Defaults to true. + */ + updateOnConfigurationChanged?: boolean; + /** + * Emit a new value when the OpenFeature context changes. + * Set to false to prevent updating the value when attributes which + * may be factors in flag evaluation change. + * Defaults to true. + */ + updateOnContextChanged?: boolean; +} & FlagEvaluationOptions; + +@Injectable({ + providedIn: 'root', +}) +export class FeatureFlagService { + constructor() {} + + public getBooleanDetails( + flagKey: string, + defaultValue: boolean, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Observable> { + return this.getFlagDetails(flagKey, defaultValue, (client) => client.getBooleanDetails, domain, options); + } + + public getBooleanDetailsSignal( + flagKey: string, + defaultValue: boolean, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Signal> { + return toSignal(this.getBooleanDetails(flagKey, defaultValue, domain, options)); + } + + public getStringDetails( + flagKey: string, + defaultValue: string, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Observable> { + return this.getFlagDetails(flagKey, defaultValue, (client) => client.getStringDetails, domain, options); + } + + public getStringDetailsSignal( + flagKey: string, + defaultValue: string, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Signal> { + return toSignal(this.getStringDetails(flagKey, defaultValue, domain, options)); + } + + public getNumberDetails( + flagKey: string, + defaultValue: number, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Observable> { + return this.getFlagDetails(flagKey, defaultValue, (client) => client.getNumberDetails, domain, options); + } + + public getNumberDetailsSignal( + flagKey: string, + defaultValue: number, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Signal> { + return toSignal(this.getNumberDetails(flagKey, defaultValue, domain, options)); + } + + public getObjectDetails( + flagKey: string, + defaultValue: T, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Observable> { + return this.getFlagDetails(flagKey, defaultValue, (client) => client.getObjectDetails, domain, options); + } + + public getObjectDetailsSignal( + flagKey: string, + defaultValue: T, + domain?: string, + options?: AngularFlagEvaluationOptions, + ): Signal> { + return toSignal(this.getObjectDetails(flagKey, defaultValue, domain, options)); + } + + private shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean { + // if flagsChange is missing entirely, we don't know what to re-render + return !flagsChanged || flagsChanged.includes(flagKey); + } + + private getFlagDetails( + flagKey: string, + defaultValue: T, + resolver: ( + client: Client, + ) => (flagKey: string, defaultValue: T, options?: FlagEvaluationOptions) => EvaluationDetails, + domain: string | undefined, + options?: AngularFlagEvaluationOptions, + ): Observable> { + const client = domain ? OpenFeature.getClient(domain) : OpenFeature.getClient(); + + return new Observable>((subscriber) => { + let currentResult: EvaluationDetails | undefined = undefined; + + const updateValue = () => { + const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options); + if (!isEqual(updatedEvaluationDetails, currentResult)) { + currentResult = updatedEvaluationDetails; + subscriber.next(currentResult); + } + }; + + // Initial evaluation + updateValue(); + + const controller = new AbortController(); + if (client.providerStatus === ProviderStatus.NOT_READY) { + // update when the provider is ready + client.addHandler(ProviderEvents.Ready, updateValue, { signal: controller.signal }); + } + + if (options?.updateOnContextChanged ?? true) { + // update when the context changes + client.addHandler(ProviderEvents.ContextChanged, updateValue, { signal: controller.signal }); + } + + if (options?.updateOnConfigurationChanged ?? true) { + client.addHandler( + ProviderEvents.ConfigurationChanged, + (eventDetails) => { + /** + * Avoid re-rendering if the value hasn't changed. We could expose a means + * to define a custom comparison function if users require a more + * sophisticated comparison in the future. + */ + if (this.shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) { + // update when the provider configuration changes + updateValue(); + } + }, + { signal: controller.signal }, + ); + } + return () => { + controller.abort(); + }; + }); + } +} diff --git a/packages/angular/projects/angular-sdk/src/lib/internal/is-equal.ts b/packages/angular/projects/angular-sdk/src/lib/internal/is-equal.ts new file mode 100644 index 000000000..25016851d --- /dev/null +++ b/packages/angular/projects/angular-sdk/src/lib/internal/is-equal.ts @@ -0,0 +1,38 @@ +import { type FlagValue } from '@openfeature/web-sdk'; + +/** + * Deeply compare two values to determine if they are equal. + * Supports primitives and serializable objects. + * @param {FlagValue} value First value to compare + * @param {FlagValue} other Second value to compare + * @returns {boolean} True if the values are equal + */ +export function isEqual(value: FlagValue, other: FlagValue): boolean { + if (value === other) { + return true; + } + + if (typeof value !== typeof other) { + return false; + } + + if (typeof value === 'object' && value !== null && other !== null) { + const valueKeys = Object.keys(value); + const otherKeys = Object.keys(other); + + if (valueKeys.length !== otherKeys.length) { + return false; + } + + for (const key of valueKeys) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!isEqual((value as any)[key], (other as any)[key])) { + return false; + } + } + + return true; + } + + return false; +} diff --git a/packages/angular/projects/angular-sdk/src/public-api.ts b/packages/angular/projects/angular-sdk/src/public-api.ts index 9fe557b78..841706fbb 100644 --- a/packages/angular/projects/angular-sdk/src/public-api.ts +++ b/packages/angular/projects/angular-sdk/src/public-api.ts @@ -3,6 +3,7 @@ */ export * from './lib/feature-flag.directive'; +export * from './lib/feature-flag.service'; export * from './lib/open-feature.module'; // re-export the web-sdk so consumers can access that API from the angular-sdk diff --git a/packages/angular/projects/angular-sdk/src/test/test.utils.ts b/packages/angular/projects/angular-sdk/src/test/test.utils.ts index 8af481648..ebb19ee36 100644 --- a/packages/angular/projects/angular-sdk/src/test/test.utils.ts +++ b/packages/angular/projects/angular-sdk/src/test/test.utils.ts @@ -6,6 +6,9 @@ export class TestingProvider extends InMemoryProvider { private delay: number, ) { super(flagConfiguration); + if (!delay) { + Object.assign(this, { initialize: async () => {} }); + } } // artificially delay our init (delaying PROVIDER_READY event) From 1fe2926a1d3b500c62c0a3a97672edc8b66d97b0 Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Wed, 24 Sep 2025 16:09:57 +0200 Subject: [PATCH 2/3] feat: add tests and docs Signed-off-by: Lukas Reining --- .../angular/projects/angular-sdk/README.md | 99 ++++- .../src/lib/feature-flag.service.spec.ts | 372 ++++++++++++++++++ .../src/lib/feature-flag.service.ts | 174 ++++++-- .../src/lib/open-feature.module.ts | 2 +- .../angular-sdk/src/test/test.utils.ts | 3 +- 5 files changed, 600 insertions(+), 50 deletions(-) create mode 100644 packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts diff --git a/packages/angular/projects/angular-sdk/README.md b/packages/angular/projects/angular-sdk/README.md index 7978f9654..21b9b378e 100644 --- a/packages/angular/projects/angular-sdk/README.md +++ b/packages/angular/projects/angular-sdk/README.md @@ -144,6 +144,13 @@ No `else`, `initializing`, or `reconciling` templates are required in this case. #### How to use +The library provides two main ways to work with feature flags: + +1. **Structural Directives** - For template-based conditional rendering +2. **FeatureFlagService** - For programmatic access with Observables + +##### Structural Directives + The library provides four primary directives for feature flags, `booleanFeatureFlag`, `numberFeatureFlag`, `stringFeatureFlag` and `objectFeatureFlag`. @@ -167,7 +174,7 @@ The template referenced in `initializing` and `reconciling` will be rendered if corresponding states. This parameter is _optional_, if omitted, the `then` and `else` templates will be rendered according to the flag value. -##### Boolean Feature Flag +###### Boolean Feature Flag ```html
``` -##### Number Feature Flag +###### Number Feature Flag ```html
``` -##### String Feature Flag +###### String Feature Flag ```html
``` -##### Object Feature Flag +###### Object Feature Flag ```html
``` -##### Opting-out of automatic re-rendering +###### Opting-out of automatic re-rendering By default, the directive re-renders when the flag value changes or the context changes. @@ -251,7 +258,7 @@ In cases, this is not desired, re-rendering can be disabled for both events:
``` -##### Consuming the evaluation details +###### Consuming the evaluation details The `evaluation details` can be used when rendering the templates. The directives [`$implicit`](https://angular.dev/guide/directives/structural-directives#structural-directive-shorthand) @@ -282,6 +289,86 @@ This can be used to just render the flag value or details without conditional re
``` +##### FeatureFlagService + +The `FeatureFlagService` provides programmatic access to feature flags through reactive patterns. All methods return +Observables that automatically emit new values when flag configurations or evaluation context changes. + +###### Using with Observables + +```typescript +import { Component, inject } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { FeatureFlagService } from '@openfeature/angular-sdk'; + +@Component({ + selector: 'my-component', + standalone: true, + imports: [AsyncPipe], + template: ` +
+ Feature is enabled! Reason: {{ (isFeatureEnabled$ | async)?.reason }} +
+
Theme: {{ (currentTheme$ | async)?.value }}
+
Max items: {{ (maxItems$ | async)?.value }}
+ ` +}) +export class MyComponent { + private flagService = inject(FeatureFlagService); + + // Boolean flag + isFeatureEnabled$ = this.flagService.getBooleanDetails('my-feature', false); + + // String flag + currentTheme$ = this.flagService.getStringDetails('theme', 'light'); + + // Number flag + maxItems$ = this.flagService.getNumberDetails('max-items', 10); + + // Object flag with type safety + config$ = this.flagService.getObjectDetails<{ timeout: number }>('api-config', { timeout: 5000 }); +} +``` + +###### Using with Angular Signals + +You can convert any Observable from the service to an Angular Signal using `toSignal()`: + +```typescript +import { Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FeatureFlagService } from '@openfeature/angular-sdk'; + +@Component({ + selector: 'my-component', + standalone: true, + template: ` +
+ Feature is enabled! Reason: {{ isFeatureEnabled()?.reason }} +
+
Theme: {{ currentTheme()?.value }}
+ ` +}) +export class MyComponent { + private flagService = inject(FeatureFlagService); + + // Convert Observables to Signals + isFeatureEnabled = toSignal(this.flagService.getBooleanDetails('my-feature', false)); + currentTheme = toSignal(this.flagService.getStringDetails('theme', 'light')); +} +``` + +###### Service Options + +The service methods accept the [same options as the directives](#opting-out-of-automatic-re-rendering): + +```typescript +const flag$ = this.flagService.getBooleanDetails('my-flag', false, 'my-domain', { + updateOnConfigurationChanged: false, // default: true + updateOnContextChanged: false, // default: true +}); +``` + ##### Setting evaluation context To set the initial evaluation context, you can add the `context` parameter to the `OpenFeatureModule` configuration. diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts new file mode 100644 index 000000000..cda74625b --- /dev/null +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts @@ -0,0 +1,372 @@ +import { TestBed } from '@angular/core/testing'; +import { Component, inject } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { firstValueFrom, map } from 'rxjs'; +import { JsonValue, OpenFeature, ResolutionDetails } from '@openfeature/web-sdk'; +import { FeatureFlagService } from './feature-flag.service'; +import { AsyncPipe } from '@angular/common'; +import { TestingProvider } from '../test/test.utils'; +import { OpenFeatureModule } from './open-feature.module'; +import { toSignal } from '@angular/core/rxjs-interop'; + +const FLAG_KEY = 'thumbs'; + +@Component({ + selector: 'test', + template: ` +
{{ (thumbs$ | async)?.value ? '👍' : '👎' }}
+
reason: {{ (thumbs$ | async)?.reason }}
+ `, + standalone: true, + imports: [AsyncPipe], +}) +class TestComponent { + private flagService = inject(FeatureFlagService); + thumbs$ = this.flagService.getBooleanDetails(FLAG_KEY, false); +} + +@Component({ + selector: 'test-signal', + template: ` +
{{ thumbs().value ? '👍' : '👎' }}
+
reason: {{ thumbs().reason }}
+ `, + standalone: true, +}) +class TestComponentWithSignal { + private flagService = inject(FeatureFlagService); + thumbs = toSignal(this.flagService.getBooleanDetails(FLAG_KEY, false)); +} + +@Component({ + selector: 'config-change-disabled', + template: ` +
{{ (thumbs$ | async)?.value ? '👍' : '👎' }}
+
reason: {{ (thumbs$ | async)?.reason }}
+ `, + standalone: true, + imports: [AsyncPipe], +}) +class ConfigChangeDisabledComponent { + private flagService = inject(FeatureFlagService); + thumbs$ = this.flagService.getBooleanDetails(FLAG_KEY, false, undefined, { updateOnConfigurationChanged: false }); +} + +@Component({ + selector: 'context-change-disabled', + template: ` +
{{ (thumbs$ | async)?.value ? '👍' : '👎' }}
+
reason: {{ (thumbs$ | async)?.reason }}
+ `, + standalone: true, + imports: [AsyncPipe], +}) +class ContextChangeDisabledComponent { + private flagService = inject(FeatureFlagService); + thumbs$ = this.flagService.getBooleanDetails(FLAG_KEY, false, undefined, { updateOnContextChanged: false }); +} + +describe('FeatureFlagService', () => { + let service: FeatureFlagService; + let currentProvider: TestingProvider; + let currentTestComponentFixture: ComponentFixture; + let currentTestComponentWithSignalFixture: ComponentFixture; + let currentConfigChangeDisabledComponentFixture: ComponentFixture; + let currentContextChangeDisabledComponentFixture: ComponentFixture; + + async function createTestingModule(config?: { + flagConfiguration?: ConstructorParameters[0]; + providerInitDelay?: number; + }) { + const defaultFlagConfig = { + [FLAG_KEY]: { + variants: { default: true }, + defaultVariant: 'default', + disabled: false, + }, + }; + currentProvider = new TestingProvider( + config?.flagConfiguration ?? defaultFlagConfig, + config?.providerInitDelay ?? 0, + ); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [OpenFeatureModule.forRoot({ provider: currentProvider })], + providers: [FeatureFlagService], + }); + + currentTestComponentFixture?.destroy(); + currentTestComponentFixture = TestBed.createComponent(TestComponent); + currentTestComponentFixture.detectChanges(); + + currentTestComponentWithSignalFixture?.destroy(); + currentTestComponentWithSignalFixture = TestBed.createComponent(TestComponentWithSignal); + currentTestComponentWithSignalFixture.detectChanges(); + + currentContextChangeDisabledComponentFixture?.destroy(); + currentContextChangeDisabledComponentFixture = TestBed.createComponent(ContextChangeDisabledComponent); + currentContextChangeDisabledComponentFixture.detectChanges(); + + currentConfigChangeDisabledComponentFixture?.destroy(); + currentConfigChangeDisabledComponentFixture = TestBed.createComponent(ConfigChangeDisabledComponent); + currentConfigChangeDisabledComponentFixture.detectChanges(); + } + + afterEach(async () => { + await OpenFeature.close(); + await OpenFeature.setContext({}); + currentTestComponentFixture?.destroy(); + currentContextChangeDisabledComponentFixture?.destroy(); + currentConfigChangeDisabledComponentFixture?.destroy(); + }); + + it('should show value based on feature flag', async () => { + await createTestingModule(); + service = TestBed.inject(FeatureFlagService); + + currentTestComponentFixture.detectChanges(); + const observableValue = currentTestComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(observableValue?.textContent).toBe('👍'); + }); + + it('should render updated value after delay', async () => { + const delay = 50; + await createTestingModule({ providerInitDelay: delay }); + + let valueElement = currentTestComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + + await new Promise((resolve) => setTimeout(resolve, delay * 2)); + currentTestComponentFixture.detectChanges(); + + valueElement = currentTestComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👍'); + }); + + it('should render updated value after delay with signal', async () => { + const delay = 50; + await createTestingModule({ providerInitDelay: delay }); + + let valueElement = currentTestComponentWithSignalFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + + await new Promise((resolve) => setTimeout(resolve, delay * 2)); + currentTestComponentWithSignalFixture.detectChanges(); + + valueElement = currentTestComponentWithSignalFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👍'); + }); + + describe('context changes', () => { + it('should update when context changes', async () => { + await createTestingModule({ + flagConfiguration: { + [FLAG_KEY]: { + variants: { default: false, premium: true }, + defaultVariant: 'default', + disabled: false, + contextEvaluator: (context) => (context['userType'] === 'premium' ? 'premium' : 'default'), + }, + }, + }); + + // Initially should show default + let valueElement = currentTestComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + + // Change context + await OpenFeature.setContext({ userType: 'premium' }); + currentTestComponentFixture.detectChanges(); + + // Should now show premium value + valueElement = currentTestComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👍'); + }); + + it('should not update when context changes and updateOnContextChanged is false', async () => { + await createTestingModule({ + flagConfiguration: { + [FLAG_KEY]: { + variants: { default: false, premium: true }, + defaultVariant: 'default', + disabled: false, + contextEvaluator: (context) => (context['userType'] === 'premium' ? 'premium' : 'default'), + }, + }, + }); + + // Initially should show default + let valueElement = + currentContextChangeDisabledComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + + // Change context + await OpenFeature.setContext({ userType: 'premium' }); + currentContextChangeDisabledComponentFixture.detectChanges(); + + // Should not show premium value + valueElement = currentContextChangeDisabledComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + }); + }); + + describe('config changes', () => { + it('should update when flag config changes', async () => { + await createTestingModule({ + flagConfiguration: { + [FLAG_KEY]: { + variants: { default: false, premium: true }, + defaultVariant: 'default', + disabled: false, + }, + }, + }); + + // Initially should show default + let valueElement = currentTestComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + + // Change flag config + await currentProvider.putConfiguration({ + [FLAG_KEY]: { + variants: { default: false, premium: true }, + defaultVariant: 'premium', + disabled: false, + }, + }); + currentTestComponentFixture.detectChanges(); + + // Should now show premium value + valueElement = currentTestComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👍'); + }); + + it('should not update when flag config changes and updateOnConfigurationChanged is false', async () => { + await createTestingModule({ + flagConfiguration: { + [FLAG_KEY]: { + variants: { default: false, premium: true }, + defaultVariant: 'default', + disabled: false, + }, + }, + }); + + // Initially should show default + let valueElement = + currentConfigChangeDisabledComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + + // Change flag config + await currentProvider.putConfiguration({ + [FLAG_KEY]: { + variants: { default: false, premium: true }, + defaultVariant: 'premium', + disabled: false, + }, + }); + currentTestComponentFixture.detectChanges(); + + // Should not show premium value + valueElement = currentConfigChangeDisabledComponentFixture.nativeElement.querySelector('[data-testid="value"]'); + expect(valueElement?.textContent).toBe('👎'); + }); + }); + + describe('different flag types', () => { + beforeEach(async () => { + await createTestingModule({ + flagConfiguration: { + booleanFlag: { + variants: { default: true }, + defaultVariant: 'default', + disabled: false, + }, + stringFlag: { + variants: { default: 'test-value' }, + defaultVariant: 'default', + disabled: false, + }, + numberFlag: { + variants: { default: 42 }, + defaultVariant: 'default', + disabled: false, + }, + objectFlag: { + variants: { default: { theme: 'dark', count: 5 } }, + defaultVariant: 'default', + disabled: false, + }, + }, + }); + service = TestBed.inject(FeatureFlagService); + }); + + it('should handle boolean flags', async () => { + const value = await firstValueFrom(service.getBooleanDetails('booleanFlag', false).pipe(map((d) => d.value))); + expect(value).toBe(true); + }); + + it('should handle string flags', async () => { + const value = await firstValueFrom(service.getStringDetails('stringFlag', 'default').pipe(map((d) => d.value))); + expect(value).toBe('test-value'); + }); + + it('should handle number flags', async () => { + const value = await firstValueFrom(service.getNumberDetails('numberFlag', 0).pipe(map((d) => d.value))); + expect(value).toBe(42); + }); + + it('should handle object flags', async () => { + const value = await firstValueFrom(service.getObjectDetails('objectFlag', {}).pipe(map((d) => d.value))); + expect(value).toEqual({ theme: 'dark', count: 5 }); + }); + }); + + describe('error handling', () => { + it('should handle provider errors gracefully', async () => { + class ErrorProvider extends TestingProvider { + override async initialize(): Promise { + throw new Error('Provider initialization failed'); + } + + override resolveBooleanEvaluation(): ResolutionDetails { + throw new Error('Evaluation failed'); + } + + override resolveStringEvaluation(): ResolutionDetails { + throw new Error('Evaluation failed'); + } + + override resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Evaluation failed'); + } + + override resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Evaluation failed'); + } + } + + const errorProvider = new ErrorProvider( + { + [FLAG_KEY]: { + variants: { default: true }, + defaultVariant: 'default', + disabled: false, + }, + }, + 0, + ); + await OpenFeature.close(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [OpenFeatureModule.forRoot({ provider: errorProvider })], + providers: [FeatureFlagService], + }); + service = TestBed.inject(FeatureFlagService); + + const value = await firstValueFrom(service.getBooleanDetails(FLAG_KEY, false).pipe(map((d) => d.value))); + expect(value).toBe(false); + }); + }); +}); diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts index ba8abe7f0..c1a0d7115 100644 --- a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Signal } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { Client, @@ -11,7 +11,6 @@ import { ProviderStatus, } from '@openfeature/web-sdk'; import { isEqual } from './internal/is-equal'; -import { toSignal } from '@angular/core/rxjs-interop'; export type AngularFlagEvaluationOptions = { /** @@ -30,12 +29,64 @@ export type AngularFlagEvaluationOptions = { updateOnContextChanged?: boolean; } & FlagEvaluationOptions; +/** + * Angular service for evaluating feature flags using OpenFeature. + * + * This service provides reactive methods to evaluate feature flags that automatically + * update when flag values or evaluation context changes. All methods return Observables + * that emit new values when the underlying flag configuration changes. + * + * @example + * ```typescript + * @Component({ + * standalone: true, + * }) + * export class MyComponent { + * private flagService = inject(FeatureFlagService); + * + * // Boolean flag evaluation + * isEnabled$ = this.flagService.getBooleanDetails('my-flag', false); + * + * // Using with signals + * isEnabledSignal = toSignal(this.isEnabled$); + * } + * ``` + */ @Injectable({ providedIn: 'root', }) export class FeatureFlagService { constructor() {} + /** + * Evaluates a boolean feature flag and returns an Observable of evaluation details. + * + * The returned Observable will emit new values when: + * - The provider becomes ready (if it wasn't already) + * - The flag configuration changes (if updateOnConfigurationChanged is true) + * - The evaluation context changes (if updateOnContextChanged is true) + * + * @param flagKey - The key of the feature flag to evaluate + * @param defaultValue - The default value to return if the flag cannot be evaluated + * @param domain - Optional domain for the OpenFeature client. If not provided, uses the global client + * @param options - Optional evaluation options including update behavior configuration + * @returns Observable that emits EvaluationDetails containing the boolean flag value and metadata + * + * @example + * ```typescript + * // Basic usage + * const isFeatureEnabled$ = flagService.getBooleanDetails('feature-toggle', false); + * + * // With domain + * const isDomainFeatureEnabled$ = flagService.getBooleanDetails('feature-toggle', false, 'my-domain'); + * + * // With options to disable automatic updates + * const isStaticFeatureEnabled$ = flagService.getBooleanDetails('feature-toggle', false, undefined, { + * updateOnConfigurationChanged: false, + * updateOnContextChanged: false + * }); + * ``` + */ public getBooleanDetails( flagKey: string, defaultValue: boolean, @@ -45,15 +96,29 @@ export class FeatureFlagService { return this.getFlagDetails(flagKey, defaultValue, (client) => client.getBooleanDetails, domain, options); } - public getBooleanDetailsSignal( - flagKey: string, - defaultValue: boolean, - domain?: string, - options?: AngularFlagEvaluationOptions, - ): Signal> { - return toSignal(this.getBooleanDetails(flagKey, defaultValue, domain, options)); - } - + /** + * Evaluates a string feature flag and returns an Observable of evaluation details. + * + * The returned Observable will emit new values when: + * - The provider becomes ready (if it wasn't already) + * - The flag configuration changes (if updateOnConfigurationChanged is true) + * - The evaluation context changes (if updateOnContextChanged is true) + * + * @param flagKey - The key of the feature flag to evaluate + * @param defaultValue - The default value to return if the flag cannot be evaluated + * @param domain - Optional domain for the OpenFeature client. If not provided, uses the global client + * @param options - Optional evaluation options including update behavior configuration + * @returns Observable that emits EvaluationDetails containing the string flag value and metadata + * + * @example + * ```typescript + * // Theme selection + * const theme$ = flagService.getStringDetails('theme', 'light'); + * + * // API endpoint selection with domain + * const apiEndpoint$ = flagService.getStringDetails('api-endpoint', 'https://api.example.com', 'config-domain'); + * ``` + */ public getStringDetails( flagKey: string, defaultValue: string, @@ -63,15 +128,26 @@ export class FeatureFlagService { return this.getFlagDetails(flagKey, defaultValue, (client) => client.getStringDetails, domain, options); } - public getStringDetailsSignal( - flagKey: string, - defaultValue: string, - domain?: string, - options?: AngularFlagEvaluationOptions, - ): Signal> { - return toSignal(this.getStringDetails(flagKey, defaultValue, domain, options)); - } - + /** + * Evaluates a number feature flag and returns an Observable of evaluation details. + * + * The returned Observable will emit new values when: + * - The provider becomes ready (if it wasn't already) + * - The flag configuration changes (if updateOnConfigurationChanged is true) + * - The evaluation context changes (if updateOnContextChanged is true) + * + * @param flagKey - The key of the feature flag to evaluate + * @param defaultValue - The default value to return if the flag cannot be evaluated + * @param domain - Optional domain for the OpenFeature client. If not provided, uses the global client + * @param options - Optional evaluation options including update behavior configuration + * @returns Observable that emits EvaluationDetails containing the number flag value and metadata + * + * @example + * ```typescript + * // Timeout configuration + * const timeout$ = flagService.getNumberDetails('request-timeout', 5000); + * ``` + */ public getNumberDetails( flagKey: string, defaultValue: number, @@ -81,15 +157,39 @@ export class FeatureFlagService { return this.getFlagDetails(flagKey, defaultValue, (client) => client.getNumberDetails, domain, options); } - public getNumberDetailsSignal( - flagKey: string, - defaultValue: number, - domain?: string, - options?: AngularFlagEvaluationOptions, - ): Signal> { - return toSignal(this.getNumberDetails(flagKey, defaultValue, domain, options)); - } - + /** + * Evaluates an object feature flag and returns an Observable of evaluation details. + * + * The returned Observable will emit new values when: + * - The provider becomes ready (if it wasn't already) + * - The flag configuration changes (if updateOnConfigurationChanged is true) + * - The evaluation context changes (if updateOnContextChanged is true) + * + * @template T - The type of the JSON object, must extend JsonValue + * @param flagKey - The key of the feature flag to evaluate + * @param defaultValue - The default value to return if the flag cannot be evaluated + * @param domain - Optional domain for the OpenFeature client. If not provided, uses the global client + * @param options - Optional evaluation options including update behavior configuration + * @returns Observable that emits EvaluationDetails containing the object flag value and metadata + * + * @example + * ```typescript + * interface FeatureConfig { + * maxRetries: number; + * retryDelay: number; + * enableLogging: boolean; + * } + * + * // Configuration object + * const defaultConfig: FeatureConfig = { + * maxRetries: 3, + * retryDelay: 1000, + * enableLogging: false + * }; + * + * const config$ = flagService.getObjectDetails('api-config', defaultConfig); + * ``` + */ public getObjectDetails( flagKey: string, defaultValue: T, @@ -99,15 +199,6 @@ export class FeatureFlagService { return this.getFlagDetails(flagKey, defaultValue, (client) => client.getObjectDetails, domain, options); } - public getObjectDetailsSignal( - flagKey: string, - defaultValue: T, - domain?: string, - options?: AngularFlagEvaluationOptions, - ): Signal> { - return toSignal(this.getObjectDetails(flagKey, defaultValue, domain, options)); - } - private shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean { // if flagsChange is missing entirely, we don't know what to re-render return !flagsChanged || flagsChanged.includes(flagKey); @@ -135,9 +226,6 @@ export class FeatureFlagService { } }; - // Initial evaluation - updateValue(); - const controller = new AbortController(); if (client.providerStatus === ProviderStatus.NOT_READY) { // update when the provider is ready @@ -145,7 +233,6 @@ export class FeatureFlagService { } if (options?.updateOnContextChanged ?? true) { - // update when the context changes client.addHandler(ProviderEvents.ContextChanged, updateValue, { signal: controller.signal }); } @@ -166,6 +253,9 @@ export class FeatureFlagService { { signal: controller.signal }, ); } + + // Initial evaluation + updateValue(); return () => { controller.abort(); }; diff --git a/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts b/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts index e667b6e8b..a5e845b14 100644 --- a/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts +++ b/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts @@ -5,7 +5,7 @@ import { EvaluationContext, OpenFeature, Provider } from '@openfeature/web-sdk'; export type EvaluationContextFactory = () => EvaluationContext; export interface OpenFeatureConfig { - provider: Provider; + provider?: Provider; domainBoundProviders?: Record; context?: EvaluationContext | EvaluationContextFactory; } diff --git a/packages/angular/projects/angular-sdk/src/test/test.utils.ts b/packages/angular/projects/angular-sdk/src/test/test.utils.ts index ebb19ee36..f31dc4dd7 100644 --- a/packages/angular/projects/angular-sdk/src/test/test.utils.ts +++ b/packages/angular/projects/angular-sdk/src/test/test.utils.ts @@ -7,13 +7,14 @@ export class TestingProvider extends InMemoryProvider { ) { super(flagConfiguration); if (!delay) { - Object.assign(this, { initialize: async () => {} }); + Object.assign(this, { initialize: undefined }); } } // artificially delay our init (delaying PROVIDER_READY event) async initialize(): Promise { await new Promise((resolve) => setTimeout(resolve, this.delay)); + console.log('TestingProvider initialized'); } // artificially delay context changes From 55fc386dcf2c2334d598b587c1b38df10c093391 Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Thu, 25 Sep 2025 15:23:21 +0200 Subject: [PATCH 3/3] feat: add docs Signed-off-by: Lukas Reining --- .../src/lib/feature-flag.service.spec.ts | 12 +++++------ .../src/lib/feature-flag.service.ts | 2 +- .../src/lib/open-feature.module.ts | 20 ++++++++++++++++++- .../angular-sdk/src/test/test.utils.ts | 2 +- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts index cda74625b..139fe27cb 100644 --- a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts @@ -14,8 +14,8 @@ const FLAG_KEY = 'thumbs'; @Component({ selector: 'test', template: ` -
{{ (thumbs$ | async)?.value ? '👍' : '👎' }}
-
reason: {{ (thumbs$ | async)?.reason }}
+
{{ (thumbs$ | async).value ? '👍' : '👎' }}
+
reason: {{ (thumbs$ | async).reason }}
`, standalone: true, imports: [AsyncPipe], @@ -41,8 +41,8 @@ class TestComponentWithSignal { @Component({ selector: 'config-change-disabled', template: ` -
{{ (thumbs$ | async)?.value ? '👍' : '👎' }}
-
reason: {{ (thumbs$ | async)?.reason }}
+
{{ (thumbs$ | async).value ? '👍' : '👎' }}
+
reason: {{ (thumbs$ | async).reason }}
`, standalone: true, imports: [AsyncPipe], @@ -55,8 +55,8 @@ class ConfigChangeDisabledComponent { @Component({ selector: 'context-change-disabled', template: ` -
{{ (thumbs$ | async)?.value ? '👍' : '👎' }}
-
reason: {{ (thumbs$ | async)?.reason }}
+
{{ (thumbs$ | async).value ? '👍' : '👎' }}
+
reason: {{ (thumbs$ | async).reason }}
`, standalone: true, imports: [AsyncPipe], diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts index c1a0d7115..4e5e68ded 100644 --- a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts @@ -200,7 +200,7 @@ export class FeatureFlagService { } private shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean { - // if flagsChange is missing entirely, we don't know what to re-render + // if flagsChanged is missing entirely, we don't know what to re-render return !flagsChanged || flagsChanged.includes(flagKey); } diff --git a/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts b/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts index a5e845b14..692190cd1 100644 --- a/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts +++ b/packages/angular/projects/angular-sdk/src/lib/open-feature.module.ts @@ -5,8 +5,26 @@ import { EvaluationContext, OpenFeature, Provider } from '@openfeature/web-sdk'; export type EvaluationContextFactory = () => EvaluationContext; export interface OpenFeatureConfig { + /** + * The default provider to be used by OpenFeature. + * If not provided, the provider can be set later using {@link OpenFeature.setProvider} + * or {@link OpenFeature.setProviderAndWait}. + */ provider?: Provider; + /** + * A map of domain-bound providers to be registered with OpenFeature. + * The key is the domain name, and the value is the provider instance. + * Providers can also be registered later using {@link OpenFeature.setProvider} + * or {@link OpenFeature.setProviderAndWait}. + */ domainBoundProviders?: Record; + + /** + * An optional evaluation context or a factory function that returns an {@link EvaluationContext}. + * This context will be used as the context for all providers registered by the module. + * If a factory function is provided, it will be invoked to obtain the context. + * This allows for dynamic context generation at runtime. + */ context?: EvaluationContext | EvaluationContextFactory; } @@ -24,7 +42,7 @@ export class OpenFeatureModule { if (config.domainBoundProviders) { Object.entries(config.domainBoundProviders).map(([domain, provider]) => - OpenFeature.setProvider(domain, provider), + OpenFeature.setProvider(domain, provider, context), ); } diff --git a/packages/angular/projects/angular-sdk/src/test/test.utils.ts b/packages/angular/projects/angular-sdk/src/test/test.utils.ts index f31dc4dd7..f1a43598d 100644 --- a/packages/angular/projects/angular-sdk/src/test/test.utils.ts +++ b/packages/angular/projects/angular-sdk/src/test/test.utils.ts @@ -7,6 +7,7 @@ export class TestingProvider extends InMemoryProvider { ) { super(flagConfiguration); if (!delay) { + // If no delay, we remove the optional initialize method to avoid the provider not being ready immediately Object.assign(this, { initialize: undefined }); } } @@ -14,7 +15,6 @@ export class TestingProvider extends InMemoryProvider { // artificially delay our init (delaying PROVIDER_READY event) async initialize(): Promise { await new Promise((resolve) => setTimeout(resolve, this.delay)); - console.log('TestingProvider initialized'); } // artificially delay context changes