Skip to content

Commit

Permalink
Allow flag overriding with preparsed query string
Browse files Browse the repository at this point in the history
  • Loading branch information
adams85 committed Nov 6, 2024
1 parent 4725afc commit eafc018
Show file tree
Hide file tree
Showing 2 changed files with 146 additions and 50 deletions.
56 changes: 41 additions & 15 deletions src/FlagOverrides.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ describe("Flag Overrides", () => {
it("Query string override should work - changes not watched", async () => {
const TestComponent = () => {
const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
return (< div>Feature flag value: {featureFlag}</div>);
return (<div>Feature flag value: {featureFlag}</div>);
};

const queryStringProvider: IQueryStringProvider & { currentValue?: string } = {
const queryStringProvider = {
currentValue: "?cc-stringDefaultCat=OVERRIDE_CAT&stringDefaultCat=NON_OVERRIDE_CAT"
};
} satisfies IQueryStringProvider;

const options: IReactAutoPollOptions = {
flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, false, void 0, queryStringProvider)
Expand All @@ -39,12 +39,12 @@ describe("Flag Overrides", () => {
it("Query string override should work - changes watched", async () => {
const TestComponent = () => {
const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
return (< div>Feature flag value: {featureFlag}</div>);
return (<div>Feature flag value: {featureFlag}</div>);
};

const queryStringProvider: IQueryStringProvider & { currentValue?: string } = {
const queryStringProvider = {
currentValue: "?cc-stringDefaultCat=OVERRIDE_CAT"
};
} satisfies IQueryStringProvider;

const options: IReactAutoPollOptions = {
flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, true, void 0, queryStringProvider)
Expand All @@ -62,15 +62,41 @@ describe("Flag Overrides", () => {
await screen.findByText("Feature flag value: CHANGED_OVERRIDE_CAT", void 0, { timeout: 2000 });
});

it("Query string override should work -q parsed query string", async () => {
const TestComponent = () => {
const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
return (<div>Feature flag value: {featureFlag}</div>);
};

const queryStringProvider = {
currentValue: { "cc-stringDefaultCat": "OVERRIDE_CAT" as string | ReadonlyArray<string> }
} satisfies IQueryStringProvider;

const options: IReactAutoPollOptions = {
flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, true, void 0, queryStringProvider)
};

const ui = <ConfigCatProvider sdkKey={sdkKey} options={options}><TestComponent /></ConfigCatProvider>;

await render(ui);
await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 });

cleanup();
queryStringProvider.currentValue = { "cc-stringDefaultCat": ["OVERRIDE_CAT", "CHANGED_OVERRIDE_CAT"] };

await render(ui);
await screen.findByText("Feature flag value: CHANGED_OVERRIDE_CAT", void 0, { timeout: 2000 });
});

it("Query string override should work - respects custom parameter name prefix", async () => {
const TestComponent = () => {
const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
return (< div>Feature flag value: {featureFlag}</div>);
return (<div>Feature flag value: {featureFlag}</div>);
};

const queryStringProvider: IQueryStringProvider & { currentValue?: string } = {
const queryStringProvider = {
currentValue: "?stringDefaultCat=OVERRIDE_CAT&cc-stringDefaultCat=NON_OVERRIDE_CAT"
};
} satisfies IQueryStringProvider;

const options: IReactAutoPollOptions = {
flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, "", queryStringProvider)
Expand All @@ -86,12 +112,12 @@ describe("Flag Overrides", () => {
const TestComponent = () => {
const { value: boolFeatureFlag } = useFeatureFlag("boolDefaultFalse", false);
const { value: stringFeatureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
return (< div>Feature flag values: {boolFeatureFlag ? "true" : "false"} ({typeof boolFeatureFlag}), {stringFeatureFlag} ({typeof stringFeatureFlag})</div>);
return (<div>Feature flag values: {boolFeatureFlag ? "true" : "false"} ({typeof boolFeatureFlag}), {stringFeatureFlag} ({typeof stringFeatureFlag})</div>);
};

const queryStringProvider: IQueryStringProvider & { currentValue?: string } = {
const queryStringProvider = {
currentValue: "?stringDefaultCat;str=TRUE&boolDefaultFalse=TRUE"
};
} satisfies IQueryStringProvider;

const options: IReactAutoPollOptions = {
flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, "", queryStringProvider)
Expand All @@ -106,12 +132,12 @@ describe("Flag Overrides", () => {
it("Query string override should work - handles query string edge cases", async () => {
const TestComponent = () => {
const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
return (< div>Feature flag value: {featureFlag}</div>);
return (<div>Feature flag value: {featureFlag}</div>);
};

const queryStringProvider: IQueryStringProvider & { currentValue?: string } = {
const queryStringProvider = {
currentValue: "?&some&=garbage&&cc-stringDefaultCat=OVERRIDE_CAT&=cc-stringDefaultCat&cc-stringDefaultCat"
};
} satisfies IQueryStringProvider;

const options: IReactAutoPollOptions = {
flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, void 0, queryStringProvider)
Expand Down
140 changes: 105 additions & 35 deletions src/FlagOverrides.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { type FlagOverrides, OverrideBehaviour, type SettingValue, createFlagOverridesFromMap } from "configcat-common";

const DEFAULT_PARAM_PREFIX = "cc-";
const FORCE_STRING_VALUE_SUFFIX = ";str";

export interface IQueryStringProvider {
readonly currentValue?: string;
readonly currentValue?: string | { [key: string]: string | ReadonlyArray<string> };
}

class DefaultQueryStringProvider implements IQueryStringProvider {
Expand All @@ -10,83 +13,150 @@ class DefaultQueryStringProvider implements IQueryStringProvider {

let defaultQueryStringProvider: DefaultQueryStringProvider | undefined;

type SettingMap = { [name: string]: Setting };

export class QueryParamsOverrideDataSource implements IOverrideDataSource {
private readonly watchChanges?: boolean;
private readonly paramPrefix: string;
private readonly queryStringProvider: IQueryStringProvider;
private queryString: string | undefined;
private settings: { [name: string]: Setting };
private settings: SettingMap;

constructor(watchChanges?: boolean, paramPrefix?: string, queryStringProvider?: IQueryStringProvider) {
this.watchChanges = watchChanges;
this.paramPrefix = paramPrefix ?? "cc-";
this.paramPrefix = paramPrefix ?? DEFAULT_PARAM_PREFIX;

queryStringProvider ??= defaultQueryStringProvider ??= new DefaultQueryStringProvider();
this.queryStringProvider = queryStringProvider;

const currentQueryString = queryStringProvider.currentValue;
this.queryString = currentQueryString;
this.settings = extractSettingsFromQueryString(currentQueryString, this.paramPrefix);
const currentQueryStringOrParams = queryStringProvider.currentValue;
this.settings = extractSettings(currentQueryStringOrParams, this.paramPrefix);
this.queryString = getQueryString(currentQueryStringOrParams);
}

getOverrides(): Promise<{ [name: string]: Setting }> {
getOverrides(): Promise<SettingMap> {
return Promise.resolve(this.getOverridesSync());
}

getOverridesSync(): { [name: string]: Setting } {
getOverridesSync(): SettingMap {
if (this.watchChanges) {
const currentQueryString = this.queryStringProvider.currentValue;
if (currentQueryString !== this.queryString) {
const currentQueryStringOrParams = this.queryStringProvider.currentValue;
const currentQueryString = getQueryString(currentQueryStringOrParams);
if (this.queryString !== currentQueryString) {
this.settings = extractSettings(currentQueryStringOrParams, this.paramPrefix);
this.queryString = currentQueryString;
this.settings = extractSettingsFromQueryString(currentQueryString, this.paramPrefix);
}
}

return this.settings;
}
}

function extractSettingsFromQueryString(queryString: string | undefined, paramPrefix: string) {
const settings: { [name: string]: Setting } = {};
function getQueryString(queryStringOrParams: string | { [key: string]: string | ReadonlyArray<string> } | undefined) {
if (queryStringOrParams == null) {
return "";
}

if (typeof queryStringOrParams === "string") {
return queryStringOrParams;
}

let queryString = "", separator = "?";

for (const key in queryStringOrParams) {
if (!Object.prototype.hasOwnProperty.call(queryStringOrParams, key)) continue;

const values = queryStringOrParams[key] as string | string[];
let value: string, length: number;

if (!Array.isArray(values)) value = values, length = 1;
else if (values.length) value = values[0], length = values.length;
else continue;

for (let i = 0; ;) {
queryString += separator + encodeURIComponent(key) + "=" + encodeURIComponent(value);
if (++i >= length) break;
separator = "&";
value = values[i];
}
}

return queryString;
}

function extractSettings(queryStringOrParams: string | { [key: string]: string | ReadonlyArray<string> } | undefined, paramPrefix: string) {
const settings: SettingMap = {};

if (typeof queryStringOrParams === "string") {
extractSettingFromQueryString(queryStringOrParams, paramPrefix, settings);
}
else if (queryStringOrParams != null) {
extractSettingsFromQueryParams(queryStringOrParams, paramPrefix, settings);
}

return settings;
}

function extractSettingsFromQueryParams(queryParams: { [key: string]: string | ReadonlyArray<string> } | undefined, paramPrefix: string, settings: SettingMap) {
for (const key in queryParams) {
if (!Object.prototype.hasOwnProperty.call(queryParams, key)) continue;

const values = queryParams[key] as string | string[];
let value: string, length: number;

if (!Array.isArray(values)) value = values, length = 1;
else if (values.length) value = values[0], length = values.length;
else continue;

for (let i = 0; ;) {
extractSettingFromQueryParam(key, value, paramPrefix, settings);
if (++i >= length) break;
value = values[i];
}
}
}

function extractSettingFromQueryString(queryString: string, paramPrefix: string, settings: SettingMap) {
if (!queryString
|| queryString.lastIndexOf("?", 0) < 0) { // identical to `queryString.startsWith("?")`
return settings;
|| queryString.lastIndexOf("?", 0) < 0) { // identical to `!queryString.startsWith("?")`
return;
}

const parts = queryString.substring(1).split("&");
for (let part of parts) {
part = part.replace(/\+/g, " ");
const index = part.indexOf("=");

let key = decodeURIComponent(index >= 0 ? part.substring(0, index) : part);
if (!key
|| key.length <= paramPrefix.length
|| key.lastIndexOf(paramPrefix, 0) < 0) { // identical to `!key.startsWith(paramPrefix)`
continue;
}
key = key.substring(paramPrefix.length);
const key = decodeURIComponent(index >= 0 ? part.substring(0, index) : part);
const value = index >= 0 ? decodeURIComponent(part.substring(index + 1)) : "";

const strSuffix = ";str";
const forceInterpretValueAsString = key.length > strSuffix.length
&& key.indexOf(strSuffix, key.length - strSuffix.length) >= 0; // identical to `key.endsWith(strSuffix)`
extractSettingFromQueryParam(key, value, paramPrefix, settings);
}
}

let value: boolean | string | number = index > -1 ? decodeURIComponent(part.substring(index + 1)) : "";
function extractSettingFromQueryParam(key: string, value: string, paramPrefix: string, settings: SettingMap) {
if (!key
|| key.length <= paramPrefix.length
|| key.lastIndexOf(paramPrefix, 0) < 0) { // identical to `!key.startsWith(paramPrefix)`
return;
}

if (forceInterpretValueAsString) {
key = key.substring(0, key.length - strSuffix.length);
}
else {
value = parseQueryStringValue(value);
}
key = key.substring(paramPrefix.length);

const interpretValueAsString = key.length > FORCE_STRING_VALUE_SUFFIX.length
&& key.indexOf(FORCE_STRING_VALUE_SUFFIX, key.length - FORCE_STRING_VALUE_SUFFIX.length) >= 0; // identical to `key.endsWith(strSuffix)`

settings[key] = settingConstuctor.fromValue(value);
if (interpretValueAsString) {
key = key.substring(0, key.length - FORCE_STRING_VALUE_SUFFIX.length);
}
else {
value = parseSettingValue(value) as unknown as string;
}

return settings;
settings[key] = settingConstuctor.fromValue(value);
}

function parseQueryStringValue(value: string): boolean | string | number {
function parseSettingValue(value: string): NonNullable<SettingValue> {
switch (value.toLowerCase()) {
case "false":
return false;
Expand Down

0 comments on commit eafc018

Please sign in to comment.