Merge pull request #391 from spotify/bil/feature-flags

Add Feature Flags API 🎚
This commit is contained in:
Stefan Ålund
2020-03-30 20:07:15 +02:00
committed by GitHub
16 changed files with 747 additions and 8 deletions
+9 -2
View File
@@ -14,13 +14,20 @@
* limitations under the License.
*/
import { ApiHolder, ApiRegistry, errorApiRef } from '@backstage/core';
import {
ApiHolder,
ApiRegistry,
errorApiRef,
featureFlagsApiRef,
FeatureFlags,
} from '@backstage/core';
import { ErrorDisplayForwarder } from './components/ErrorDisplay/ErrorDisplay';
const builder = ApiRegistry.builder();
export const errorDialogForwarder = new ErrorDisplayForwarder();
builder.add(errorApiRef, errorDialogForwarder);
builder.add(featureFlagsApiRef, new FeatureFlags());
export default builder.build() as ApiHolder;
@@ -0,0 +1,61 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ApiRef from '../ApiRef';
import {
UserFlags,
FeatureFlagsRegistry,
FeatureFlagsRegistryItem,
} from '../../app/FeatureFlags';
/**
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
*
* Plugins can use this API to register feature flags that they have available
* for users to enable/disable, and this API will centralize the current user's
* state of which feature flags they would like to enable.
*
* This is ideal for Backstage plugins, as well as your own App, to trial incomplete
* or unstable upcoming features. Although there will be a common interface for users
* to enable and disable feature flags, this API acts as another way to enable/disable.
*/
export enum FeatureFlagState {
Off = 0,
On = 1,
}
export interface FeatureFlagsApi {
/**
* Store a list of registered feature flags.
*/
registeredFeatureFlags: FeatureFlagsRegistryItem[];
/**
* Get a list of all feature flags from the current user.
*/
getFlags(): UserFlags;
/**
* Get a list of all registered flags.
*/
getRegisteredFlags(): FeatureFlagsRegistry;
}
export const featureFlagsApiRef = new ApiRef<FeatureFlagsApi>({
id: 'core.featureflags',
description: 'Used to toggle functionality in features across Backstage',
});
@@ -21,3 +21,4 @@
// If you think some API definition is missing, please open an Issue or send a PR!
export * from './error';
export * from './featureFlags';
+15
View File
@@ -19,6 +19,8 @@ import { Route, Switch, Redirect } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { App } from './types';
import BackstagePlugin from '../plugin/Plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions/featureFlags';
import {
IconComponent,
SystemIcons,
@@ -62,6 +64,7 @@ export default class AppBuilder {
const app = new AppImpl(this.systemIcons);
const routes = new Array<JSX.Element>();
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
for (const plugin of this.plugins.values()) {
for (const output of plugin.output()) {
@@ -87,12 +90,24 @@ export default class AppBuilder {
);
break;
}
case 'feature-flag': {
registeredFeatureFlags.push({
pluginId: plugin.getId(),
name: output.name,
});
break;
}
default:
break;
}
}
}
const FeatureFlags = this.apis && this.apis.get(featureFlagsApiRef);
if (FeatureFlags) {
FeatureFlags.registeredFeatureFlags = registeredFeatureFlags;
}
routes.push(
<Route key="login" path="/login" component={LoginPage} exact />,
);
@@ -0,0 +1,246 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags';
import { FeatureFlagState } from '../apis/definitions/featureFlags';
describe('FeatureFlags', () => {
beforeEach(() => {
window.localStorage.clear();
});
describe('#getFlags', () => {
let featureFlags;
beforeEach(() => {
featureFlags = new FeatureFlagsImpl();
});
it('returns no flags', () => {
expect(featureFlags.getFlags().toObject()).toMatchObject({});
});
it('returns the correct flags', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
}),
);
featureFlags = new FeatureFlagsImpl();
expect(featureFlags.getFlags().toObject()).toMatchObject({
'feature-flag-one': FeatureFlagState.On,
'feature-flag-two': FeatureFlagState.On,
'feature-flag-three': FeatureFlagState.Off,
});
});
it('gets the correct values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 0,
}),
);
featureFlags = new FeatureFlagsImpl();
expect(featureFlags.getFlags().get('feature-flag-one')).toEqual(
FeatureFlagState.On,
);
expect(featureFlags.getFlags().get('feature-flag-two')).toEqual(
FeatureFlagState.Off,
);
expect(featureFlags.getFlags().get('feature-flag-three')).toEqual(
FeatureFlagState.Off,
);
});
it('sets the correct values', () => {
const flags = featureFlags.getFlags();
flags.set('feature-flag-zero', FeatureFlagState.On);
expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.On);
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-zero":1}',
);
});
it('deletes the correct values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 0,
}),
);
featureFlags = new FeatureFlagsImpl();
const flags = featureFlags.getFlags();
flags.delete('feature-flag-one');
expect(flags.get('feature-flag-one')).toEqual(FeatureFlagState.Off);
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-two":0}',
);
});
it('clears all values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
}),
);
const flags = featureFlags.getFlags();
flags.clear();
expect(flags.toObject()).toEqual({});
expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
});
});
describe('#getRegisteredFlags', () => {
let featureFlags;
beforeEach(() => {
featureFlags = new FeatureFlagsImpl();
featureFlags.registeredFeatureFlags = [
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
];
});
it('should return an empty list', () => {
featureFlags.registeredFeatureFlags = [];
expect(featureFlags.getRegisteredFlags().toObject()).toEqual([]);
});
it('should return an valid list', () => {
expect(featureFlags.getRegisteredFlags().toObject()).toMatchObject([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
});
it('should get the correct values', () => {
const getByName = name =>
featureFlags.getRegisteredFlags().find(flag => flag.name === name);
expect(getByName('registered-flag-0')).toBeUndefined();
expect(getByName('registered-flag-1')).toEqual({
name: 'registered-flag-1',
pluginId: 'plugin-one',
});
expect(getByName('registered-flag-2')).toEqual({
name: 'registered-flag-2',
pluginId: 'plugin-one',
});
expect(getByName('registered-flag-3')).toEqual({
name: 'registered-flag-3',
pluginId: 'plugin-two',
});
});
it('should append the correct value', () => {
const flags = featureFlags.getRegisteredFlags();
flags.push({
name: 'registered-flag-4',
pluginId: 'plugin-three',
});
expect(flags.toObject()).toMatchObject([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
{ name: 'registered-flag-4', pluginId: 'plugin-three' },
]);
});
it('should concat the correct values', () => {
const flags = featureFlags.getRegisteredFlags();
const concatValues = flags.concat([
{
name: 'registered-flag-4',
pluginId: 'plugin-three',
},
{
name: 'registered-flag-5',
pluginId: 'plugin-four',
},
]);
expect(concatValues).toMatchObject([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
{ name: 'registered-flag-4', pluginId: 'plugin-three' },
{ name: 'registered-flag-5', pluginId: 'plugin-four' },
]);
});
it('throws an error if length is less than three characters', () => {
const flags = featureFlags.getRegisteredFlags();
expect(() =>
flags.push({
name: 'ab',
pluginId: 'plugin-three',
}),
).toThrow(/minimum length of three characters/i);
});
it('throws an error if length is greater than 150 characters', () => {
const flags = featureFlags.getRegisteredFlags();
expect(() =>
flags.push({
name:
'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
pluginId: 'plugin-three',
}),
).toThrow(/not exceed 150 characters/i);
});
it('throws an error if name does not start with a lowercase letter', () => {
const flags = featureFlags.getRegisteredFlags();
expect(() =>
flags.push({
name: '123456789',
pluginId: 'plugin-three',
}),
).toThrow(/start with a lowercase letter/i);
});
it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => {
const flags = featureFlags.getRegisteredFlags();
expect(() =>
flags.push({
name: 'Invalid_Feature_Flag',
pluginId: 'plugin-three',
}),
).toThrow(/only contain lowercase letters, numbers and hyphens/i);
});
});
});
+181
View File
@@ -0,0 +1,181 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FeatureFlagName } from '../plugin/types';
import {
FeatureFlagState,
FeatureFlagsApi,
} from '../apis/definitions/featureFlags';
/**
* Helper method for validating compatibility and flag name.
*/
export function validateBrowserCompat(): void {
if (!('localStorage' in window)) {
throw new Error(
'Feature Flags are not supported on browsers without the Local Storage API',
);
}
}
export function validateFlagName(name: FeatureFlagName): void {
if (name.length < 3) {
throw new Error(
`The '${name}' feature flag must have a minimum length of three characters.`,
);
}
if (name.length > 150) {
throw new Error(
`The '${name}' feature flag must not exceed 150 characters.`,
);
}
if (!name.match(/^[a-z]+[a-z0-9-]+$/)) {
throw new Error(
`The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` +
'Examples: feature-flag-one, alpha, release-2020',
);
}
}
/**
* The UserFlags class.
*
* This acts as a data structure for the user's feature flags. You
* can use this to retrieve, add, edit, delete, clear and save the user's
* feature flags to the local browser for persisted storage.
*/
export class UserFlags extends Map<FeatureFlagName, FeatureFlagState> {
static load(): UserFlags {
validateBrowserCompat();
try {
const jsonString = window.localStorage.getItem('featureFlags') as string;
const json = JSON.parse(jsonString);
return new this(Object.entries(json));
} catch (err) {
return new this([]);
}
}
get(name: FeatureFlagName): FeatureFlagState {
return super.get(name) || FeatureFlagState.Off;
}
set(name: FeatureFlagName, state: FeatureFlagState): this {
validateFlagName(name);
const output = super.set(name, state);
this.save();
return output;
}
delete(name: FeatureFlagName): boolean {
const output = super.delete(name);
this.save();
return output;
}
clear(): void {
super.clear();
this.save();
}
save(): void {
window.localStorage.setItem(
'featureFlags',
JSON.stringify(this.toObject()),
);
}
toObject() {
return Array.from(this.entries()).reduce(
(obj, [key, value]) => ({ ...obj, [key]: value }),
{},
);
}
toJSON() {
return JSON.stringify(this.toObject());
}
toString() {
return this.toJSON();
}
}
/**
* The FeatureFlagsRegistry class.
*
* This acts as a holding data structure for feature flags
* that plugins wish to register for use in Backstage.
*/
export interface FeatureFlagsRegistryItem {
pluginId: string;
name: FeatureFlagName;
}
export class FeatureFlagsRegistry extends Array<FeatureFlagsRegistryItem> {
static from(entries: FeatureFlagsRegistryItem[]) {
Array.from(entries).forEach(entry => validateFlagName(entry.name));
return new FeatureFlagsRegistry(...entries);
}
push(...entries: FeatureFlagsRegistryItem[]): number {
Array.from(entries).forEach(entry => validateFlagName(entry.name));
return super.push(...entries);
}
concat(
...entries: (
| FeatureFlagsRegistryItem
| ConcatArray<FeatureFlagsRegistryItem>
)[]
): FeatureFlagsRegistryItem[] {
const _concat = super.concat(...entries);
Array.from(_concat).forEach(entry => validateFlagName(entry.name));
return _concat;
}
toObject() {
return [...this.values()];
}
toJSON() {
return JSON.stringify(this.toObject());
}
toString() {
return this.toJSON();
}
}
/**
* Create the FeatureFlags implementation based on the API.
*/
export class FeatureFlags implements FeatureFlagsApi {
public registeredFeatureFlags: FeatureFlagsRegistryItem[] = [];
private userFlags: UserFlags | undefined;
getFlags(): UserFlags {
if (!this.userFlags) this.userFlags = UserFlags.load();
return this.userFlags;
}
getRegisteredFlags(): FeatureFlagsRegistry {
return FeatureFlagsRegistry.from(this.registeredFeatureFlags);
}
}
+1
View File
@@ -16,4 +16,5 @@
export * from './api';
export * from './apis';
export { FeatureFlags } from './app/FeatureFlags';
export { useApp } from './app/AppContext';
+23 -1
View File
@@ -15,7 +15,13 @@
*/
import { ComponentType } from 'react';
import { PluginOutput, RoutePath, RouteOptions } from './types';
import {
PluginOutput,
RoutePath,
RouteOptions,
FeatureFlagName,
} from './types';
import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags';
import { Widget } from '../widgetView/types';
export type PluginConfig = {
@@ -26,6 +32,7 @@ export type PluginConfig = {
export type PluginHooks = {
router: RouterHooks;
widgets: WidgetHooks;
featureFlags: FeatureFlagsHooks;
};
export type RouterHooks = {
@@ -46,6 +53,10 @@ export type WidgetHooks = {
add(widget: Widget): void;
};
export type FeatureFlagsHooks = {
register(name: FeatureFlagName): void;
};
export const registerSymbol = Symbol('plugin-register');
export const outputSymbol = Symbol('plugin-output');
@@ -54,6 +65,10 @@ export default class Plugin {
constructor(private readonly config: PluginConfig) {}
getId(): string {
return this.config.id;
}
output(): PluginOutput[] {
if (this.storedOutput) {
return this.storedOutput;
@@ -78,6 +93,13 @@ export default class Plugin {
outputs.push({ type: 'widget', widget });
},
},
featureFlags: {
register(name) {
validateBrowserCompat();
validateFlagName(name);
outputs.push({ type: 'feature-flag', name });
},
},
});
this.storedOutput = outputs;
+12 -1
View File
@@ -43,4 +43,15 @@ export type WidgetOutput = {
widget: Widget;
};
export type PluginOutput = RouteOutput | RedirectRouteOutput | WidgetOutput;
export type FeatureFlagName = string;
export type FeatureFlagOutput = {
type: 'feature-flag';
name: FeatureFlagName;
};
export type PluginOutput =
| RouteOutput
| RedirectRouteOutput
| WidgetOutput
| FeatureFlagOutput;