Skip to content
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

feat: offline-mode #136

Merged
merged 3 commits into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
149 changes: 104 additions & 45 deletions sdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { RequestInit } from "node-fetch";
import { RequestInit } from 'node-fetch';
import { getEnvironmentFeatureStates, getIdentityFeatureStates } from '../flagsmith-engine';
import { EnvironmentModel } from '../flagsmith-engine/environments/models';
import { buildEnvironmentModel } from '../flagsmith-engine/environments/util';
import { IdentityModel } from '../flagsmith-engine/identities/models';
import { TraitModel } from '../flagsmith-engine/identities/traits/models';

import { AnalyticsProcessor } from './analytics';
import { BaseOfflineHandler } from './offline_handlers';
import { FlagsmithAPIError, FlagsmithClientError } from './errors';

import { DefaultFlag, Flags } from './models';
Expand All @@ -14,7 +15,7 @@ import { generateIdentitiesData, retryFetch } from './utils';
import { SegmentModel } from '../flagsmith-engine/segments/models';
import { getIdentitySegments } from '../flagsmith-engine/segments/evaluators';
import { FlagsmithCache, FlagsmithConfig } from './types';
import pino, { Logger } from "pino";
import pino, { Logger } from 'pino';

export { AnalyticsProcessor } from './analytics';
export { FlagsmithAPIError, FlagsmithClientError } from './errors';
Expand All @@ -26,10 +27,9 @@ export { FlagsmithCache, FlagsmithConfig } from './types';
const DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/';
const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10;


export class Flagsmith {
environmentKey?: string;
apiUrl: string = DEFAULT_API_URL;
environmentKey?: string = undefined;
apiUrl?: string = undefined;
customHeaders?: { [key: string]: any };
agent: RequestInit['agent'];
requestTimeoutMs?: number;
Expand All @@ -40,12 +40,14 @@ export class Flagsmith {
defaultFlagHandler?: (featureName: string) => DefaultFlag;


environmentFlagsUrl: string;
identitiesUrl: string;
environmentUrl: string;
environmentFlagsUrl?: string;
identitiesUrl?: string;
environmentUrl?: string;

environmentDataPollingManager?: EnvironmentDataPollingManager;
environment!: EnvironmentModel;
offlineMode: boolean = false;
offlineHandler?: BaseOfflineHandler = undefined;

private cache?: FlagsmithCache;
private onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void;
Expand All @@ -65,6 +67,7 @@ export class Flagsmith {
* const featureEnabledForIdentity = identityFlags.isFeatureEnabled("foo")
*
* @param {string} data.environmentKey: The environment key obtained from Flagsmith interface
* Required unless offlineMode is True.
@param {string} data.apiUrl: Override the URL of the Flagsmith API to communicate with
@param data.customHeaders: Additional headers to add to requests made to the
Flagsmith API
Expand All @@ -78,31 +81,53 @@ export class Flagsmith {
@param {boolean} data.enableAnalytics: if enabled, sends additional requests to the Flagsmith
API to power flag analytics charts
@param data.defaultFlagHandler: callable which will be used in the case where
flags cannot be retrieved from the API or a non existent feature is
flags cannot be retrieved from the API or a non-existent feature is
requested
@param data.logger: an instance of the pino Logger class to use for logging
*/
constructor(data: FlagsmithConfig) {
@param {boolean} data.offlineMode: sets the client into offline mode. Relies on offlineHandler for
evaluating flags.
@param {BaseOfflineHandler} data.offlineHandler: provide a handler for offline logic. Used to get environment
document from another source when in offlineMode. Works in place of
defaultFlagHandler if offlineMode is not set and using remote evaluation.
*/
constructor(data: FlagsmithConfig = {}) {
// if (!data.offlineMode && !data.environmentKey) {
// throw new Error('ValueError: environmentKey is required.');
// }

this.agent = data.agent;
this.environmentKey = data.environmentKey;
this.apiUrl = data.apiUrl || this.apiUrl;
this.customHeaders = data.customHeaders;
this.requestTimeoutMs = 1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS);
this.requestTimeoutMs =
1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS);
this.enableLocalEvaluation = data.enableLocalEvaluation;
this.environmentRefreshIntervalSeconds =
data.environmentRefreshIntervalSeconds || this.environmentRefreshIntervalSeconds;
this.retries = data.retries;
this.enableAnalytics = data.enableAnalytics || false;
this.defaultFlagHandler = data.defaultFlagHandler;

this.environmentFlagsUrl = `${this.apiUrl}flags/`;
this.identitiesUrl = `${this.apiUrl}identities/`;
this.environmentUrl = `${this.apiUrl}environment-document/`;
this.onEnvironmentChange = data.onEnvironmentChange;
this.logger = data.logger || pino();
this.offlineMode = data.offlineMode || false;
this.offlineHandler = data.offlineHandler;

// argument validation
if (this.offlineMode && !this.offlineHandler) {
throw new Error('ValueError: offlineHandler must be provided to use offline mode.');
} else if (this.defaultFlagHandler && this.offlineHandler) {
throw new Error('ValueError: Cannot use both defaultFlagHandler and offlineHandler.');
}

if (this.offlineHandler) {
this.environment = this.offlineHandler.getEnvironment();
}

if (!!data.cache) {
const missingMethods: string[] = ['has', 'get', 'set'].filter(method => data.cache && !data.cache[method]);
const missingMethods: string[] = ['has', 'get', 'set'].filter(
method => data.cache && !data.cache[method]
);

if (missingMethods.length > 0) {
throw new Error(
Expand All @@ -114,44 +139,56 @@ export class Flagsmith {
this.cache = data.cache;
}

if (this.enableLocalEvaluation) {
if (!this.environmentKey.startsWith('ser.')) {
console.error(
'In order to use local evaluation, please generate a server key in the environment settings page.'
if (!this.offlineMode) {
if (!this.environmentKey) {
throw new Error('ValueError: environmentKey is required.');
}

const apiUrl = data.apiUrl || DEFAULT_API_URL;
this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
this.environmentFlagsUrl = `${this.apiUrl}flags/`;
this.identitiesUrl = `${this.apiUrl}identities/`;
this.environmentUrl = `${this.apiUrl}environment-document/`;

if (this.enableLocalEvaluation) {
if (!this.environmentKey.startsWith('ser.')) {
console.error(
'In order to use local evaluation, please generate a server key in the environment settings page.'
);
}
this.environmentDataPollingManager = new EnvironmentDataPollingManager(
this,
this.environmentRefreshIntervalSeconds
);
this.environmentDataPollingManager.start();
this.updateEnvironment();
}
this.environmentDataPollingManager = new EnvironmentDataPollingManager(
this,
this.environmentRefreshIntervalSeconds
);
this.environmentDataPollingManager.start();
this.updateEnvironment();
}

this.analyticsProcessor = data.enableAnalytics
? new AnalyticsProcessor({
environmentKey: this.environmentKey,
baseApiUrl: this.apiUrl,
requestTimeoutMs: this.requestTimeoutMs,
logger: this.logger
})
: undefined;
this.analyticsProcessor = data.enableAnalytics
? new AnalyticsProcessor({
environmentKey: this.environmentKey,
baseApiUrl: this.apiUrl,
requestTimeoutMs: this.requestTimeoutMs,
logger: this.logger
})
: undefined;
}
}
/**
* Get all the default for flags for the current environment.
*
* @returns Flags object holding all the flags for the current environment.
*/
async getEnvironmentFlags(): Promise<Flags> {
const cachedItem = !!this.cache && await this.cache.get(`flags`);
const cachedItem = !!this.cache && (await this.cache.get(`flags`));
if (!!cachedItem) {
return cachedItem;
}
if (this.enableLocalEvaluation) {
if (this.enableLocalEvaluation && !this.offlineMode) {
return new Promise((resolve, reject) =>
this.environmentPromise!.then(() => {
resolve(this.getEnvironmentFlagsFromDocument());
}).catch((e) => reject(e))
}).catch(e => reject(e))
);
}
if (this.environment) {
Expand All @@ -160,6 +197,7 @@ export class Flagsmith {

return this.getEnvironmentFlagsFromApi();
}

/**
* Get all the flags for the current environment for a given identity. Will also
upsert all traits to the Flagsmith API for future evaluations. Providing a
Expand All @@ -173,10 +211,10 @@ export class Flagsmith {
*/
async getIdentityFlags(identifier: string, traits?: { [key: string]: any }): Promise<Flags> {
if (!identifier) {
throw new Error("`identifier` argument is missing or invalid.")
throw new Error('`identifier` argument is missing or invalid.');
}

const cachedItem = !!this.cache && await this.cache.get(`flags-${identifier}`);
const cachedItem = !!this.cache && (await this.cache.get(`flags-${identifier}`));
if (!!cachedItem) {
return cachedItem;
}
Expand All @@ -188,6 +226,10 @@ export class Flagsmith {
}).catch(e => reject(e))
);
}
if (this.offlineMode) {
return this.getIdentityFlagsFromDocument(identifier, traits || {});
}

return this.getIdentityFlagsFromApi(identifier, traits);
}

Expand All @@ -207,7 +249,7 @@ export class Flagsmith {
traits?: { [key: string]: any }
): Promise<SegmentModel[]> {
if (!identifier) {
throw new Error("`identifier` argument is missing or invalid.")
throw new Error('`identifier` argument is missing or invalid.');
}

traits = traits || {};
Expand All @@ -224,7 +266,7 @@ export class Flagsmith {

const segments = getIdentitySegments(this.environment, identityModel);
return resolve(segments);
}).catch((e) => reject(e));
}).catch(e => reject(e));
});
}
console.error('This function is only permitted with local evaluation.');
Expand Down Expand Up @@ -286,7 +328,7 @@ export class Flagsmith {
headers: headers
},
this.retries,
this.requestTimeoutMs || undefined,
this.requestTimeoutMs || undefined
);

if (data.status !== 200) {
Expand All @@ -304,6 +346,9 @@ export class Flagsmith {
private environmentPromise: Promise<any> | undefined;

private async getEnvironmentFromApi() {
if (!this.environmentUrl) {
throw new Error('`apiUrl` argument is missing or invalid.');
}
const environment_data = await this.getJSONResponse(this.environmentUrl, 'GET');
return buildEnvironmentModel(environment_data);
}
Expand All @@ -321,7 +366,10 @@ export class Flagsmith {
return flags;
}

private async getIdentityFlagsFromDocument(identifier: string, traits: { [key: string]: any }): Promise<Flags> {
private async getIdentityFlagsFromDocument(
identifier: string,
traits: { [key: string]: any }
): Promise<Flags> {
const identityModel = this.buildIdentityModel(
identifier,
Object.keys(traits).map(key => ({
Expand All @@ -348,6 +396,9 @@ export class Flagsmith {
}

private async getEnvironmentFlagsFromApi() {
if (!this.environmentFlagsUrl) {
throw new Error('`apiUrl` argument is missing or invalid.');
}
try {
const apiFlags = await this.getJSONResponse(this.environmentFlagsUrl, 'GET');
const flags = Flags.fromAPIFlags({
Expand All @@ -361,6 +412,9 @@ export class Flagsmith {
}
return flags;
} catch (e) {
if (this.offlineHandler) {
return this.getEnvironmentFlagsFromDocument();
}
if (this.defaultFlagHandler) {
return new Flags({
flags: {},
Expand All @@ -373,6 +427,9 @@ export class Flagsmith {
}

private async getIdentityFlagsFromApi(identifier: string, traits: { [key: string]: any }) {
if (!this.identitiesUrl) {
throw new Error('`apiUrl` argument is missing or invalid.');
}
try {
const data = generateIdentitiesData(identifier, traits);
const jsonResponse = await this.getJSONResponse(this.identitiesUrl, 'POST', data);
Expand All @@ -387,6 +444,9 @@ export class Flagsmith {
}
return flags;
} catch (e) {
if (this.offlineHandler) {
return this.getIdentityFlagsFromDocument(identifier, traits);
}
if (this.defaultFlagHandler) {
return new Flags({
flags: {},
Expand All @@ -405,4 +465,3 @@ export class Flagsmith {
}

export default Flagsmith;

22 changes: 22 additions & 0 deletions sdk/offline_handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as fs from 'fs';
import { buildEnvironmentModel } from '../flagsmith-engine/environments/util';
import { EnvironmentModel } from '../flagsmith-engine/environments/models';

export class BaseOfflineHandler {
getEnvironment() : EnvironmentModel {
throw new Error('Not implemented');
}
}

export class LocalFileHandler extends BaseOfflineHandler {
environment: EnvironmentModel;
constructor(environment_document_path: string) {
super();
const environment_document = fs.readFileSync(environment_document_path, 'utf8');
this.environment = buildEnvironmentModel(JSON.parse(environment_document));
}

getEnvironment(): EnvironmentModel {
return this.environment;
}
}
5 changes: 4 additions & 1 deletion sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DefaultFlag, Flags } from "./models";
import { EnvironmentModel } from "../flagsmith-engine";
import { RequestInit } from "node-fetch";
import { Logger } from "pino";
import { BaseOfflineHandler } from "./offline_handlers";

export interface FlagsmithCache {
get(key: string): Promise<Flags|undefined> | undefined;
Expand All @@ -11,7 +12,7 @@ export interface FlagsmithCache {
}

export interface FlagsmithConfig {
environmentKey: string;
environmentKey?: string;
apiUrl?: string;
agent?:RequestInit['agent'];
customHeaders?: { [key: string]: any };
Expand All @@ -24,4 +25,6 @@ export interface FlagsmithConfig {
cache?: FlagsmithCache,
onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void,
logger?: Logger
offlineMode?: boolean;
offlineHandler?: BaseOfflineHandler;
}
Loading