Skip to content

Commit

Permalink
feat: add support for domains (#805)
Browse files Browse the repository at this point in the history
## This PR

- adds domain as a concept to the server and web SDK
- adds a deprecation warning anywhere client name was exposed to users.
- fixes an issue in the web SDK where context set on a domain before a
provider is registered was not used.

## Addresses

fixes #820
4aa9657

### Notes

This change is based on [this
spec](open-feature/spec#229) change. I tried to
make it a non-breaking change but I may have missed an untested
condition. Please carefully review to make sure I didn't miss anything.

### Follow-up Tasks

- Update the doc readme parser to support "domain".
- Update the NestJS and React SDKS. We should consider making those a
breaking change since they're sub 1.0.

---------

Signed-off-by: Michael Beemer <[email protected]>
Signed-off-by: Todd Baert <[email protected]>
Co-authored-by: Todd Baert <[email protected]>
  • Loading branch information
beeme1mr and toddbaert authored Feb 20, 2024
1 parent e2f24fc commit 98ba00a
Show file tree
Hide file tree
Showing 19 changed files with 564 additions and 460 deletions.
47 changes: 25 additions & 22 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,16 @@ See [here](https://open-feature.github.io/js-sdk/modules/_openfeature_web_sdk.ht

## 🌟 Features

| Status | Features | Description |
| ------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
|| [Targeting](#targeting-and-context) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|| [Logging](#logging) | Integrate with popular logging packages. |
|| [Named clients](#named-clients) | Utilize multiple providers in a single application. |
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
| Status | Features | Description |
| ------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
|| [Targeting](#targeting-and-context) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|| [Logging](#logging) | Integrate with popular logging packages. |
|| [Domains](#domains) | Logically bind clients with providers. |
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |

<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>

Expand Down Expand Up @@ -129,7 +129,7 @@ OpenFeature.setProvider(new MyProvider());
Once the provider has been registered, the status can be tracked using [events](#eventing).

In some situations, it may be beneficial to register multiple providers in the same application.
This is possible using [named clients](#named-clients), which is covered in more detail below.
This is possible using [domains](#domains), which is covered in more detail below.

### Flag evaluation flow

Expand Down Expand Up @@ -205,26 +205,29 @@ const client = OpenFeature.getClient();
client.setLogger(logger);
```

### Named clients
### Domains

Clients can be given a name.
A name is a logical identifier that can be used to associate clients with a particular provider.
If a name has no associated provider, the global provider is used.
Clients can be assigned to a domain.
A domain is a logical identifier which can be used to associate clients with a particular provider.
If a domain has no associated provider, the default provider is used.

```ts
import { OpenFeature } from "@openfeature/web-sdk";
import { OpenFeature, InMemoryProvider } from "@openfeature/web-sdk";

// Registering the default provider
OpenFeature.setProvider(NewLocalProvider());
// Registering a named provider
OpenFeature.setProvider("clientForCache", new NewCachedProvider());
OpenFeature.setProvider(InMemoryProvider(myFlags));
// Registering a provider to a domain
OpenFeature.setProvider("my-domain", new InMemoryProvider(someOtherFlags));

// A Client backed by default provider
// A Client bound to the default provider
const clientWithDefault = OpenFeature.getClient();
// A Client backed by NewCachedProvider
const clientForCache = OpenFeature.getClient("clientForCache");
// A Client bound to the InMemoryProvider provider
const domainScopedClient = OpenFeature.getClient("my-domain");
```

Domains can be defined on a provider during registration.
For more details, please refer to the [providers](#providers) section.

### Eventing

Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions.
Expand Down
16 changes: 13 additions & 3 deletions packages/client/src/client/open-feature-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import { Provider } from '../provider';
import { Client } from './client';

type OpenFeatureClientOptions = {
/**
* @deprecated Use `domain` instead.
*/
name?: string;
domain?: string;
version?: string;
};

Expand All @@ -44,7 +48,9 @@ export class OpenFeatureClient implements Client {

get metadata(): ClientMetadata {
return {
name: this.options.name,
// Use domain if name is not provided
name: this.options.domain ?? this.options.name,
domain: this.options.domain ?? this.options.name,
version: this.options.version,
providerMetadata: this.providerAccessor().metadata,
};
Expand All @@ -61,7 +67,11 @@ export class OpenFeatureClient implements Client {
if (shouldRunNow) {
// run immediately, we're in the matching state
try {
handler({ clientName: this.metadata.name, providerName: this._provider.metadata.name });
handler({
clientName: this.metadata.name,
domain: this.metadata.domain,
providerName: this._provider.metadata.name,
});
} catch (err) {
this._logger?.error('Error running event handler:', err);
}
Expand Down Expand Up @@ -179,7 +189,7 @@ export class OpenFeatureClient implements Client {
const allHooksReversed = [...allHooks].reverse();

const context = {
...OpenFeature.getContext(this?.options?.name),
...OpenFeature.getContext(this?.options?.domain),
};

// this reference cannot change during the course of evaluation
Expand Down
119 changes: 59 additions & 60 deletions packages/client/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,17 @@ const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api');
type OpenFeatureGlobal = {
[GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI;
};
type NameProviderRecord = {
name?: string;
type DomainRecord = {
domain?: string;
provider: Provider;
}
};

const _globalThis = globalThis as OpenFeatureGlobal;

export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> implements ManageContext<Promise<void>> {
protected _events: GenericEventEmitter<ProviderEvents> = new OpenFeatureEventEmitter();
protected _defaultProvider: Provider = NOOP_PROVIDER;
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
protected _namedProviderContext: Map<string, EvaluationContext> = new Map();

private constructor() {
super('client');
Expand All @@ -52,56 +51,56 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme

/**
* Sets the evaluation context globally.
* This will be used by all providers that have not been overridden with a named client.
* This will be used by all providers that have not bound to a domain.
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext({ region: "us" });
*/
async setContext(context: EvaluationContext): Promise<void>;
/**
* Sets the evaluation context for a specific provider.
* This will only affect providers with a matching client name.
* @param {string} clientName The name to identify the client
* This will only affect providers bound to a domain.
* @param {string} domain An identifier which logically binds clients with providers
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext("test", { scope: "provider" });
* OpenFeature.setProvider(new MyProvider()) // Uses the default context
* OpenFeature.setProvider("test", new MyProvider()) // Uses context: { scope: "provider" }
*/
async setContext(clientName: string, context: EvaluationContext): Promise<void>;
async setContext<T extends EvaluationContext>(nameOrContext: T | string, contextOrUndefined?: T): Promise<void> {
const clientName = stringOrUndefined(nameOrContext);
const context = objectOrUndefined<T>(nameOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};
async setContext(domain: string, context: EvaluationContext): Promise<void>;
async setContext<T extends EvaluationContext>(domainOrContext: T | string, contextOrUndefined?: T): Promise<void> {
const domain = stringOrUndefined(domainOrContext);
const context = objectOrUndefined<T>(domainOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};

if (clientName) {
const provider = this._clientProviders.get(clientName);
if (domain) {
const provider = this._domainScopedProviders.get(domain);
if (provider) {
const oldContext = this.getContext(clientName);
this._namedProviderContext.set(clientName, context);
await this.runProviderContextChangeHandler(clientName, provider, oldContext, context);
const oldContext = this.getContext(domain);
this._domainScopedContext.set(domain, context);
await this.runProviderContextChangeHandler(domain, provider, oldContext, context);
} else {
this._namedProviderContext.set(clientName, context);
this._domainScopedContext.set(domain, context);
}
} else {
const oldContext = this._context;
this._context = context;

// collect all providers that are using the default context (not mapped to a name)
const defaultContextNameProviders: NameProviderRecord[] = Array.from(this._clientProviders.entries())
.filter(([name]) => !this._namedProviderContext.has(name))
.reduce<NameProviderRecord[]>((acc, [name, provider]) => {
acc.push({ name, provider });
// collect all providers that are using the default context (not bound to a domain)
const unboundProviders: DomainRecord[] = Array.from(this._domainScopedProviders.entries())
.filter(([domain]) => !this._domainScopedContext.has(domain))
.reduce<DomainRecord[]>((acc, [domain, provider]) => {
acc.push({ domain, provider });
return acc;
}, []);

const allProviders: NameProviderRecord[] = [
// add in the default (no name)
{ name: undefined, provider: this._defaultProvider },
...defaultContextNameProviders,
const allProviders: DomainRecord[] = [
// add in the default (no domain)
{ domain: undefined, provider: this._defaultProvider },
...unboundProviders,
];
await Promise.all(
allProviders.map((tuple) =>
this.runProviderContextChangeHandler(tuple.name, tuple.provider, oldContext, context),
this.runProviderContextChangeHandler(tuple.domain, tuple.provider, oldContext, context),
),
);
}
Expand All @@ -115,18 +114,18 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
/**
* Access the evaluation context for a specific named client.
* The global evaluation context is returned if a matching named client is not found.
* @param {string} clientName The name to identify the client
* @param {string} domain An identifier which logically binds clients with providers
* @returns {EvaluationContext} Evaluation context
*/
getContext(clientName?: string): EvaluationContext;
getContext(nameOrUndefined?: string): EvaluationContext {
const clientName = stringOrUndefined(nameOrUndefined);
if (clientName) {
const context = this._namedProviderContext.get(clientName);
getContext(domain?: string): EvaluationContext;
getContext(domainOrUndefined?: string): EvaluationContext {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
const context = this._domainScopedContext.get(domain);
if (context) {
return context;
} else {
this._logger.debug(`Unable to find context for '${clientName}'.`);
this._logger.debug(`Unable to find context for '${domain}'.`);
}
}
return this._context;
Expand All @@ -138,20 +137,20 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
clearContext(): Promise<void>;
/**
* Removes the evaluation context for a specific named client.
* @param {string} clientName The name to identify the client
* @param {string} domain An identifier which logically binds clients with providers
*/
clearContext(clientName: string): Promise<void>;
async clearContext(nameOrUndefined?: string): Promise<void> {
const clientName = stringOrUndefined(nameOrUndefined);
if (clientName) {
const provider = this._clientProviders.get(clientName);
clearContext(domain: string): Promise<void>;
async clearContext(domainOrUndefined?: string): Promise<void> {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
const provider = this._domainScopedProviders.get(domain);
if (provider) {
const oldContext = this.getContext(clientName);
this._namedProviderContext.delete(clientName);
const oldContext = this.getContext(domain);
this._domainScopedContext.delete(domain);
const newContext = this.getContext();
await this.runProviderContextChangeHandler(clientName, provider, oldContext, newContext);
await this.runProviderContextChangeHandler(domain, provider, oldContext, newContext);
} else {
this._namedProviderContext.delete(clientName);
this._domainScopedContext.delete(domain);
}
} else {
return this.setContext({});
Expand All @@ -160,15 +159,15 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme

/**
* Resets the global evaluation context and removes the evaluation context for
* all named clients.
* all domains.
*/
async clearContexts(): Promise<void> {
// Default context must be cleared first to avoid calling the onContextChange
// handler multiple times for named clients.
// handler multiple times for clients bound to a domain.
await this.clearContext();

// Use allSettled so a promise rejection doesn't affect others
await Promise.allSettled(Array.from(this._clientProviders.keys()).map((name) => this.clearContext(name)));
await Promise.allSettled(Array.from(this._domainScopedProviders.keys()).map((domain) => this.clearContext(domain)));
}

/**
Expand All @@ -178,18 +177,18 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
*
* If there is already a provider bound to this name via {@link this.setProvider setProvider}, this provider will be used.
* Otherwise, the default provider is used until a provider is assigned to that name.
* @param {string} name The name of the client
* @param {string} domain An identifier which logically binds clients with providers
* @param {string} version The version of the client (only used for metadata)
* @returns {Client} OpenFeature Client
*/
getClient(name?: string, version?: string): Client {
getClient(domain?: string, version?: string): Client {
return new OpenFeatureClient(
// functions are passed here to make sure that these values are always up to date,
// and so we don't have to make these public properties on the API class.
() => this.getProviderForClient(name),
() => this.buildAndCacheEventEmitterForClient(name),
() => this.getProviderForClient(domain),
() => this.buildAndCacheEventEmitterForClient(domain),
() => this._logger,
{ name, version },
{ domain, version },
);
}

Expand All @@ -199,11 +198,11 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
*/
async clearProviders(): Promise<void> {
await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
this._namedProviderContext.clear();
this._domainScopedContext.clear();
}

private async runProviderContextChangeHandler(
clientName: string | undefined,
domain: string | undefined,
provider: Provider,
oldContext: EvaluationContext,
newContext: EvaluationContext,
Expand All @@ -213,19 +212,19 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
await provider.onContextChange?.(oldContext, newContext);

// only run the event handlers if the onContextChange method succeeded
this.getAssociatedEventEmitters(clientName).forEach((emitter) => {
emitter?.emit(ProviderEvents.ContextChanged, { clientName, providerName });
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
});
this._events?.emit(ProviderEvents.ContextChanged, { clientName, providerName });
this._events?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
} catch (err) {
// run error handlers instead
const error = err as Error | undefined;
const message = `Error running ${provider?.metadata?.name}'s context change handler: ${error?.message}`;
this._logger?.error(`${message}`, err);
this.getAssociatedEventEmitters(clientName).forEach((emitter) => {
emitter?.emit(ProviderEvents.Error, { clientName, providerName, message });
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
});
this._events?.emit(ProviderEvents.Error, { clientName, providerName, message });
this._events?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
}
}
}
Expand Down
Loading

0 comments on commit 98ba00a

Please sign in to comment.