Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ViewContainerRef,
inject,
} from '@angular/core';
import { setFrameworkMetadata } from '@openfeature/core';
import {
BooleanFlagKey,
Client,
Comment thread
jonathannorris marked this conversation as resolved.
Expand Down Expand Up @@ -132,7 +133,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
if (this._client) {
this.disposeClient(this._client);
}
this._client = OpenFeature.getClient(this._featureFlagDomain);
this._client = setFrameworkMetadata(OpenFeature.getClient(this._featureFlagDomain), 'angular');

const baseHandler = () => {
const result = this.getFlagDetails(this._featureFlagKey, this._featureFlagDefault);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AsyncPipe } from '@angular/common';
import { TestingProvider } from '../test/test.utils';
import { OpenFeatureModule } from './open-feature.module';
import { toSignal } from '@angular/core/rxjs-interop';
import { vi } from 'vitest';

const FLAG_KEY = 'thumbs';

Expand Down Expand Up @@ -114,6 +115,7 @@ describe('FeatureFlagService', () => {

afterEach(async () => {
await OpenFeature.close();
OpenFeature.clearHooks();
await OpenFeature.setContext({});
currentTestComponentFixture?.destroy();
currentContextChangeDisabledComponentFixture?.destroy();
Expand All @@ -129,6 +131,26 @@ describe('FeatureFlagService', () => {
expect(observableValue?.textContent).toBe('👍');
});

it('should surface angular metadata in hook contexts', async () => {
const hook = { before: vi.fn() };

OpenFeature.addHooks(hook);
await createTestingModule();
service = TestBed.inject(FeatureFlagService);

await firstValueFrom(service.getBooleanDetails(FLAG_KEY, false));

expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
sdk: 'web',
framework: 'angular',
}),
}),
undefined,
);
});

it('should render updated value after delay', async () => {
const delay = 50;
await createTestingModule({ providerInitDelay: delay });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setFrameworkMetadata } from '@openfeature/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
Comment thread
jonathannorris marked this conversation as resolved.
Outdated
import {
Expand Down Expand Up @@ -60,6 +61,8 @@ export type AngularFlagEvaluationOptions = {
providedIn: 'root',
})
export class FeatureFlagService {
private _clients: Map<string | undefined, Client> = new Map();
Comment thread
jonathannorris marked this conversation as resolved.
Outdated

constructor() {}

/**
Expand Down Expand Up @@ -217,7 +220,7 @@ export class FeatureFlagService {
domain: string | undefined,
options?: AngularFlagEvaluationOptions,
): Observable<EvaluationDetails<T>> {
const client = domain ? OpenFeature.getClient(domain) : OpenFeature.getClient();
const client = this.getClient(domain);

return new Observable<EvaluationDetails<T>>((subscriber) => {
let currentResult: EvaluationDetails<T> | undefined = undefined;
Expand Down Expand Up @@ -265,4 +268,15 @@ export class FeatureFlagService {
};
});
}

private getClient(domain?: string): Client {
const cachedClient = this._clients.get(domain);
if (cachedClient) {
return cachedClient;
}

const client = setFrameworkMetadata(domain ? OpenFeature.getClient(domain) : OpenFeature.getClient(), 'angular');
this._clients.set(domain, client);
return client;
}
Comment thread
jonathannorris marked this conversation as resolved.
Outdated
}
5 changes: 3 additions & 2 deletions packages/nest/src/open-feature.module.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setFrameworkMetadata } from '@openfeature/core';
import type {
DynamicModule,
FactoryProvider as NestFactoryProvider,
Comment thread
jonathannorris marked this conversation as resolved.
Outdated
Expand Down Expand Up @@ -45,7 +46,7 @@ export class OpenFeatureModule {
const clientValueProviders: NestFactoryProvider<Client>[] = [
{
provide: getOpenFeatureClientToken(),
useFactory: () => OpenFeature.getClient(),
useFactory: () => setFrameworkMetadata(OpenFeature.getClient(), 'nest'),
},
];

Expand All @@ -58,7 +59,7 @@ export class OpenFeatureModule {
OpenFeature.setProvider(domain, provider);
clientValueProviders.push({
provide: getOpenFeatureClientToken(domain),
useFactory: () => OpenFeature.getClient(domain),
useFactory: () => setFrameworkMetadata(OpenFeature.getClient(domain), 'nest'),
});
});
}
Expand Down
3 changes: 2 additions & 1 deletion packages/nest/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setFrameworkMetadata } from '@openfeature/core';
import type { Client, EvaluationContext } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';
Comment thread
jonathannorris marked this conversation as resolved.
Outdated

Expand All @@ -8,5 +9,5 @@ import { OpenFeature } from '@openfeature/server-sdk';
* @returns {Client} The OpenFeature client.
*/
export function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
return setFrameworkMetadata(domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context), 'nest');
}
41 changes: 40 additions & 1 deletion packages/nest/test/open-feature.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing';
import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src';
import type { Client } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';
import { getOpenFeatureDefaultTestModule } from './fixtures';
import { defaultProvider, getOpenFeatureDefaultTestModule } from './fixtures';

describe('OpenFeatureModule', () => {
let moduleRef: TestingModule;
Expand Down Expand Up @@ -49,6 +49,45 @@ describe('OpenFeatureModule', () => {
expect(client).toBeDefined();
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped');
});

it('should expose nest framework metadata on injected clients', () => {
const defaultClient = moduleRef.get<Client>(getOpenFeatureClientToken());
const scopedClient = moduleRef.get<Client>(getOpenFeatureClientToken('domainScopedClient'));

expect(defaultClient.metadata).toMatchObject({
sdk: 'server',
framework: 'nest',
});
expect(scopedClient.metadata).toMatchObject({
sdk: 'server',
framework: 'nest',
});
});

it('should surface nest metadata in hook contexts', async () => {
const hook = { before: jest.fn() };
const hookModuleRef = await Test.createTestingModule({
imports: [OpenFeatureModule.forRoot({ defaultProvider, hooks: [hook] })],
}).compile();

try {
const client = hookModuleRef.get<Client>(getOpenFeatureClientToken());
await client.getBooleanValue('testBooleanFlag', false);

expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
sdk: 'server',
framework: 'nest',
}),
}),
undefined,
);
} finally {
await hookModuleRef.close();
OpenFeature.clearHooks();
}
});
});

describe('handlers', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/react/src/provider/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setFrameworkMetadata } from '@openfeature/core';
import type { Client } from '@openfeature/web-sdk';
import { OpenFeature } from '@openfeature/web-sdk';
Comment thread
jonathannorris marked this conversation as resolved.
Outdated
import * as React from 'react';
Expand Down Expand Up @@ -32,7 +33,10 @@ type ProviderProps = {
* @returns {OpenFeatureProvider} context provider
*/
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) {
const stableClient = React.useMemo(() => client || OpenFeature.getClient(domain), [client, domain]);
const stableClient = React.useMemo(
() => setFrameworkMetadata(client || OpenFeature.getClient(domain), 'react'),
[client, domain],
);

return <Context.Provider value={{ client: stableClient, options }}>{children}</Context.Provider>;
}
49 changes: 48 additions & 1 deletion packages/react/test/provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,14 @@ describe('OpenFeatureProvider', () => {

beforeEach(async () => {
await OpenFeature.clearContexts();
OpenFeature.clearHooks();
});

describe('useOpenFeatureClient', () => {
const DOMAIN = 'useOpenFeatureClient';

describe('client specified', () => {
it('should return client from provider', () => {
it('should return client from provider with react metadata', () => {
const client = OpenFeature.getClient(DOMAIN);

const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
Expand All @@ -69,6 +70,11 @@ describe('OpenFeatureProvider', () => {
const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });

expect(result.current).toEqual(client);
expect(result.current.metadata).toMatchObject({
domain: DOMAIN,
sdk: 'web',
framework: 'react',
});
});
});

Expand All @@ -81,6 +87,8 @@ describe('OpenFeatureProvider', () => {
const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });

expect(result.current.metadata.domain).toEqual(DOMAIN);
expect(result.current.metadata.sdk).toEqual('web');
expect(result.current.metadata.framework).toEqual('react');
});

it('should return a stable client across renders', () => {
Expand All @@ -96,6 +104,45 @@ describe('OpenFeatureProvider', () => {

expect(firstClient).toBe(secondClient);
});

it('should surface react metadata in hook contexts', async () => {
const hook = { before: jest.fn() };

OpenFeature.setProvider(
DOMAIN,
new InMemoryProvider({
greeting: {
disabled: false,
variants: { default: 'hello' },
defaultVariant: 'default',
},
}),
);

const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
<OpenFeatureProvider domain={DOMAIN}>{children}</OpenFeatureProvider>
);

const { result } = renderHook(
() =>
useStringFlagValue('greeting', 'fallback', {
hooks: [hook],
}),
{ wrapper },
);

await waitFor(() => expect(result.current).toEqual('hello'));
expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
domain: DOMAIN,
sdk: 'web',
framework: 'react',
}),
}),
undefined,
);
});
});
});

Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageContext, ManageLogger } from '@openfeature/core';
import type { EvaluationLifeCycle, Eventing, ManageContext, ManageLogger, MetadataClient } from '@openfeature/core';
import type { Features } from '../evaluation';
import type { ProviderStatus } from '../provider';
import type { ProviderEvents } from '../events';
Expand All @@ -11,8 +11,8 @@ export interface Client
ManageContext<Client>,
ManageLogger<Client>,
Tracking,
Eventing<ProviderEvents> {
readonly metadata: ClientMetadata;
Eventing<ProviderEvents>,
MetadataClient {
/**
* Returns the status of the associated provider.
*/
Expand Down
17 changes: 17 additions & 0 deletions packages/server/src/client/internal/open-feature-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
ClientFramework,
ClientMetadata,
EvaluationContext,
EvaluationDetails,
Expand Down Expand Up @@ -39,6 +40,7 @@ type OpenFeatureClientOptions = {
name?: string;
domain?: string;
version?: string;
framework?: ClientFramework;
};

/**
Expand All @@ -50,6 +52,7 @@ export class OpenFeatureClient implements Client {
private _context: EvaluationContext;
private _hooks: Hook[] = [];
private _clientLogger?: Logger;
private _framework?: ClientFramework;

constructor(
// we always want the client to use the current provider,
Expand All @@ -65,6 +68,7 @@ export class OpenFeatureClient implements Client {
context: EvaluationContext = {},
) {
this._context = context;
this._framework = options.framework;
}

get metadata(): ClientMetadata {
Expand All @@ -73,10 +77,23 @@ export class OpenFeatureClient implements Client {
name: this.options.domain ?? this.options.name,
domain: this.options.domain ?? this.options.name,
version: this.options.version,
sdk: 'server',
framework: this._framework,
providerMetadata: this.providerAccessor().metadata,
Comment thread
jonathannorris marked this conversation as resolved.
};
}

/**
* Sets framework metadata for SDK-owned clients used by framework wrappers.
* @param {ClientFramework} framework framework metadata to expose
* @returns {this} the updated client
* @internal
*/
setFrameworkMetadata(framework: ClientFramework): this {
this._framework = framework;
return this;
}

get providerStatus(): ProviderStatus {
return this.providerStatusAccessor();
}
Expand Down
4 changes: 4 additions & 0 deletions packages/server/test/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ describe('OpenFeatureClient', () => {
it('should have metadata accessor with domain', () => {
expect(client.metadata.domain).toEqual(domain);
});

it('should expose the server sdk family in metadata', () => {
expect(client.metadata.sdk).toEqual('server');
});
});

describe('Requirement 1.3.1, 1.3.2.1', () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import type { ProviderMetadata } from '../provider/provider';

export type ClientSdk = 'web' | 'server';
export type ClientFramework = 'react' | 'angular' | 'nest';

Comment thread
jonathannorris marked this conversation as resolved.
export interface ClientMetadata {
/**
* @deprecated alias of "domain", use domain instead
*/
readonly name?: string;
readonly domain?: string;
readonly version?: string;
readonly sdk?: ClientSdk;
readonly framework?: ClientFramework;
readonly providerMetadata: ProviderMetadata;
}

export interface MetadataClient {
readonly metadata: ClientMetadata;
}

/**
* @internal
*/
export interface FrameworkMetadataClient extends MetadataClient {
setFrameworkMetadata(framework: ClientFramework): this;
}
Loading
Loading