diff --git a/src/FlagOverrides.test.tsx b/src/FlagOverrides.test.tsx
index 87e0c14..405c3ba 100644
--- a/src/FlagOverrides.test.tsx
+++ b/src/FlagOverrides.test.tsx
@@ -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});
+ return (
Feature flag value: {featureFlag}
);
};
- 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)
@@ -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});
+ return (Feature flag value: {featureFlag}
);
};
- 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)
@@ -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 (Feature flag value: {featureFlag}
);
+ };
+
+ const queryStringProvider = {
+ currentValue: { "cc-stringDefaultCat": "OVERRIDE_CAT" as string | ReadonlyArray }
+ } satisfies IQueryStringProvider;
+
+ const options: IReactAutoPollOptions = {
+ flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, true, void 0, queryStringProvider)
+ };
+
+ const ui = ;
+
+ 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});
+ return (Feature flag value: {featureFlag}
);
};
- 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)
@@ -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}));
+ return (Feature flag values: {boolFeatureFlag ? "true" : "false"} ({typeof boolFeatureFlag}), {stringFeatureFlag} ({typeof stringFeatureFlag})
);
};
- 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)
@@ -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});
+ return (Feature flag value: {featureFlag}
);
};
- 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)
diff --git a/src/FlagOverrides.ts b/src/FlagOverrides.ts
index abddf7f..30be838 100644
--- a/src/FlagOverrides.ts
+++ b/src/FlagOverrides.ts
@@ -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 };
}
class DefaultQueryStringProvider implements IQueryStringProvider {
@@ -10,35 +13,38 @@ 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 {
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);
}
}
@@ -46,12 +52,74 @@ export class QueryParamsOverrideDataSource implements IOverrideDataSource {
}
}
-function extractSettingsFromQueryString(queryString: string | undefined, paramPrefix: string) {
- const settings: { [name: string]: Setting } = {};
+function getQueryString(queryStringOrParams: string | { [key: string]: string | ReadonlyArray } | 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 } | 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 } | 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("&");
@@ -59,34 +127,36 @@ function extractSettingsFromQueryString(queryString: string | undefined, paramPr
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 {
switch (value.toLowerCase()) {
case "false":
return false;