Merge pull request #5825 from backstage/mob/evolution-v2

core: split into three new separate packages
This commit is contained in:
Patrik Oldsberg
2021-06-01 17:29:06 +02:00
committed by GitHub
455 changed files with 34132 additions and 46 deletions
@@ -14,8 +14,4 @@
* limitations under the License.
*/
// This folder contains implementations for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
export { UrlPatternDiscovery } from './UrlPatternDiscovery';
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
// TODO: add prop types to JS and remove
'react/prop-types': 0,
'jest/expect-expect': 0,
},
};
+22
View File
@@ -0,0 +1,22 @@
# @backstage/core-app-api
This package provides the core API used by Backstage apps.
## Installation
Install the package via Yarn or npm:
```sh
$ yarn add @backstage/core-app-api
```
or
```sh
$ npm install --save @backstage/core-app-api
```
## Documentation
- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
- [Backstage Documentation](https://backstage.io/docs)
+398
View File
@@ -0,0 +1,398 @@
## API Report File for "@backstage/core-app-api"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AlertApi } from '@backstage/core-plugin-api';
import { AlertMessage } from '@backstage/core-plugin-api';
import { AnyApiFactory } from '@backstage/core-plugin-api';
import { AnyApiRef } from '@backstage/core-plugin-api';
import { ApiFactory } from '@backstage/core-plugin-api';
import { ApiHolder } from '@backstage/core-plugin-api';
import { ApiRef } from '@backstage/core-plugin-api';
import { AppConfig } from '@backstage/config';
import { AppTheme } from '@backstage/core-plugin-api';
import { AppThemeApi } from '@backstage/core-plugin-api';
import { auth0AuthApiRef } from '@backstage/core-plugin-api';
import { AuthProvider } from '@backstage/core-plugin-api';
import { AuthRequester } from '@backstage/core-plugin-api';
import { AuthRequesterOptions } from '@backstage/core-plugin-api';
import { AuthRequestOptions } from '@backstage/core-plugin-api';
import { BackstageIdentity } from '@backstage/core-plugin-api';
import { BackstageIdentityApi } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { ConfigReader } from '@backstage/config';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { ErrorApi } from '@backstage/core-plugin-api';
import { ErrorContext } from '@backstage/core-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FeatureFlag } from '@backstage/core-plugin-api';
import { FeatureFlagsApi } from '@backstage/core-plugin-api';
import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api';
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
import { googleAuthApiRef } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
import { OAuthApi } from '@backstage/core-plugin-api';
import { OAuthRequestApi } from '@backstage/core-plugin-api';
import { Observable } from '@backstage/core-plugin-api';
import { oktaAuthApiRef } from '@backstage/core-plugin-api';
import { oneloginAuthApiRef } from '@backstage/core-plugin-api';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { PendingAuthRequest } from '@backstage/core-plugin-api';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import PropTypes from 'prop-types';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { SessionApi } from '@backstage/core-plugin-api';
import { SessionState } from '@backstage/core-plugin-api';
import { StorageApi } from '@backstage/core-plugin-api';
import { StorageValueChange } from '@backstage/core-plugin-api';
import { SubRouteRef } from '@backstage/core-plugin-api';
// @public
export class AlertApiForwarder implements AlertApi {
// (undocumented)
alert$(): Observable<AlertMessage>;
// (undocumented)
post(alert: AlertMessage): void;
}
// @public (undocumented)
export type ApiFactoryHolder = {
get<T>(api: ApiRef<T>): ApiFactory<T, T, {
[key in string]: unknown;
}> | undefined;
};
// @public
export class ApiFactoryRegistry implements ApiFactoryHolder {
// (undocumented)
get<T>(api: ApiRef<T>): ApiFactory<T, T, {
[x: string]: unknown;
}> | undefined;
// (undocumented)
getAllApis(): Set<AnyApiRef>;
register<Api, Impl extends Api, Deps extends {
[name in string]: unknown;
}>(scope: ApiFactoryScope, factory: ApiFactory<Api, Impl, Deps>): boolean;
}
// @public (undocumented)
export const ApiProvider: {
({ apis, children, }: PropsWithChildren<ApiProviderProps>): JSX.Element;
propTypes: {
apis: PropTypes.Validator<PropTypes.InferProps<{
get: PropTypes.Validator<(...args: any[]) => any>;
}>>;
children: PropTypes.Requireable<PropTypes.ReactNodeLike>;
};
};
// @public (undocumented)
export class ApiRegistry implements ApiHolder {
constructor(apis: Map<string, unknown>);
// (undocumented)
static builder(): ApiRegistryBuilder;
// (undocumented)
static from(apis: ApiImpl[]): ApiRegistry;
// (undocumented)
get<T>(api: ApiRef<T>): T | undefined;
static with<T>(api: ApiRef<T>, impl: T): ApiRegistry;
with<T>(api: ApiRef<T>, impl: T): ApiRegistry;
}
// @public (undocumented)
export class ApiResolver implements ApiHolder {
constructor(factories: ApiFactoryHolder);
// (undocumented)
get<T>(ref: ApiRef<T>): T | undefined;
static validateFactories(factories: ApiFactoryHolder, apis: Iterable<AnyApiRef>): void;
}
// @public (undocumented)
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
SignInPage?: ComponentType<SignInPageProps>;
};
// @public
export type AppConfigLoader = () => Promise<AppConfig[]>;
// @public (undocumented)
export type AppContext = {
getPlugins(): BackstagePlugin<any, any>[];
getSystemIcon(key: string): IconComponent | undefined;
getComponents(): AppComponents;
};
// @public (undocumented)
export type AppOptions = {
apis?: Iterable<AnyApiFactory>;
icons?: AppIcons & {
[key in string]: IconComponent;
};
plugins?: BackstagePlugin<any, any>[];
components?: Partial<AppComponents>;
themes?: AppTheme[];
configLoader?: AppConfigLoader;
bindRoutes?(context: {
bind: AppRouteBinder;
}): void;
};
// @public (undocumented)
export type AppRouteBinder = <ExternalRoutes extends {
[name: string]: ExternalRouteRef;
}>(externalRoutes: ExternalRoutes, targetRoutes: PartialKeys<TargetRouteMap<ExternalRoutes>, KeysWithType<ExternalRoutes, ExternalRouteRef<any, true>>>) => void;
// @public (undocumented)
export class AppThemeSelector implements AppThemeApi {
constructor(themes: AppTheme[]);
// (undocumented)
activeThemeId$(): Observable<string | undefined>;
// (undocumented)
static createWithStorage(themes: AppTheme[]): AppThemeSelector;
// (undocumented)
getActiveThemeId(): string | undefined;
// (undocumented)
getInstalledThemes(): AppTheme[];
// (undocumented)
setActiveThemeId(themeId?: string): void;
}
// @public (undocumented)
export class Auth0Auth {
// (undocumented)
static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T;
}
// @public (undocumented)
export type BackstageApp = {
getPlugins(): BackstagePlugin<any, any>[];
getSystemIcon(key: string): IconComponent | undefined;
getProvider(): ComponentType<{}>;
getRouter(): ComponentType<{}>;
};
// @public (undocumented)
export type BootErrorPageProps = {
step: 'load-config' | 'load-chunk';
error: Error;
};
export { ConfigReader }
// @public
export function createApp(options?: AppOptions): PrivateAppImpl;
// @public
export class ErrorAlerter implements ErrorApi {
constructor(alertApi: AlertApi, errorApi: ErrorApi);
// (undocumented)
error$(): Observable<{
error: {
name: string;
message: string;
stack?: string | undefined;
};
context?: ErrorContext | undefined;
}>;
// (undocumented)
post(error: Error, context?: ErrorContext): void;
}
// @public
export class ErrorApiForwarder implements ErrorApi {
// (undocumented)
error$(): Observable<{
error: Error;
context?: ErrorContext;
}>;
// (undocumented)
post(error: Error, context?: ErrorContext): void;
}
// @public (undocumented)
export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null;
// @public (undocumented)
export class GithubAuth implements OAuthApi, SessionApi {
constructor(sessionManager: SessionManager<GithubSession>);
// (undocumented)
static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): GithubAuth;
// (undocumented)
getAccessToken(scope?: string, options?: AuthRequestOptions): Promise<string>;
// (undocumented)
getBackstageIdentity(options?: AuthRequestOptions): Promise<BackstageIdentity | undefined>;
// (undocumented)
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
// (undocumented)
static normalizeScope(scope?: string): Set<string>;
// (undocumented)
sessionState$(): Observable<SessionState>;
// (undocumented)
signIn(): Promise<void>;
// (undocumented)
signOut(): Promise<void>;
}
// @public (undocumented)
export type GithubSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
// @public (undocumented)
export class GitlabAuth {
// (undocumented)
static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T;
}
// @public (undocumented)
export class GoogleAuth {
// (undocumented)
static create({ discoveryApi, oauthRequestApi, environment, provider, defaultScopes, }: OAuthApiCreateOptions): typeof googleAuthApiRef.T;
}
// @public
export class LocalStorageFeatureFlags implements FeatureFlagsApi {
// (undocumented)
getRegisteredFlags(): FeatureFlag[];
// (undocumented)
isActive(name: string): boolean;
// (undocumented)
registerFlag(flag: FeatureFlag): void;
// (undocumented)
save(options: FeatureFlagsSaveOptions): void;
}
// @public (undocumented)
export class MicrosoftAuth {
// (undocumented)
static create({ environment, provider, oauthRequestApi, discoveryApi, defaultScopes, }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T;
}
// @public (undocumented)
export class OAuth2 implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, BackstageIdentityApi, SessionApi {
constructor(options: Options);
// (undocumented)
static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, scopeTransform, }: CreateOptions): OAuth2;
// (undocumented)
getAccessToken(scope?: string | string[], options?: AuthRequestOptions): Promise<string>;
// (undocumented)
getBackstageIdentity(options?: AuthRequestOptions): Promise<BackstageIdentity | undefined>;
// (undocumented)
getIdToken(options?: AuthRequestOptions): Promise<string>;
// (undocumented)
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
// (undocumented)
sessionState$(): Observable<SessionState>;
// (undocumented)
signIn(): Promise<void>;
// (undocumented)
signOut(): Promise<void>;
}
// @public (undocumented)
export type OAuth2Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
// @public
export class OAuthRequestManager implements OAuthRequestApi {
// (undocumented)
authRequest$(): Observable<PendingAuthRequest[]>;
// (undocumented)
createAuthRequester<T>(options: AuthRequesterOptions<T>): AuthRequester<T>;
}
// @public (undocumented)
export class OktaAuth {
// (undocumented)
static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T;
}
// @public (undocumented)
export class OneLoginAuth {
// (undocumented)
static create({ discoveryApi, environment, provider, oauthRequestApi, }: CreateOptions_2): typeof oneloginAuthApiRef.T;
}
// @public (undocumented)
export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
constructor(sessionManager: SessionManager<SamlSession>);
// (undocumented)
static create({ discoveryApi, environment, provider, }: AuthApiCreateOptions): SamlAuth;
// (undocumented)
getBackstageIdentity(options?: AuthRequestOptions): Promise<BackstageIdentity | undefined>;
// (undocumented)
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
// (undocumented)
sessionState$(): Observable<SessionState>;
// (undocumented)
signIn(): Promise<void>;
// (undocumented)
signOut(): Promise<void>;
}
// @public (undocumented)
export type SignInPageProps = {
onResult(result: SignInResult): void;
};
// @public (undocumented)
export type SignInResult = {
userId: string;
profile: ProfileInfo;
getIdToken?: () => Promise<string>;
signOut?: () => Promise<void>;
};
// @public
export class UrlPatternDiscovery implements DiscoveryApi {
static compile(pattern: string): UrlPatternDiscovery;
// (undocumented)
getBaseUrl(pluginId: string): Promise<string>;
}
// @public (undocumented)
export class WebStorage implements StorageApi {
constructor(namespace: string, errorApi: ErrorApi);
// (undocumented)
static create(options: CreateStorageApiOptions): WebStorage;
// (undocumented)
forBucket(name: string): WebStorage;
// (undocumented)
get<T>(key: string): T | undefined;
// (undocumented)
observe$<T>(key: string): Observable<StorageValueChange<T>>;
// (undocumented)
remove(key: string): Promise<void>;
// (undocumented)
set<T>(key: string, data: T): Promise<void>;
}
// (No @packageDocumentation comment for this package)
```
+113
View File
@@ -0,0 +1,113 @@
/*
* 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.
*/
export interface Config {
/**
* Generic frontend configuration.
*/
app: {
/**
* The public absolute root URL that the frontend.
* @visibility frontend
*/
baseUrl: string;
/**
* The title of the app.
* @visibility frontend
*/
title?: string;
/**
* Information about support of this Backstage instance and how to contact the integrator team.
*/
support?: {
/**
* The primary support url.
* @visibility frontend
*/
url: string;
/**
* A list of categorized support item groupings.
*/
items: {
/**
* The title of the support item grouping.
* @visibility frontend
*/
title: string;
/**
* An optional icon for the support item grouping.
* @visibility frontend
*/
icon?: string;
/**
* A list of support links for the Backstage instance.
*/
links: {
/** @visibility frontend */
url: string;
/** @visibility frontend */
title?: string;
}[];
}[];
};
};
/**
* Generic backend configuration.
*/
backend: {
/**
* The public absolute root URL that the backend is reachable at.
* @visibility frontend
*/
baseUrl: string;
};
/**
* Configuration that provides information about the organization that the app is for.
*/
organization?: {
/**
* The name of the organization that the app belongs to.
* @visibility frontend
*/
name?: string;
};
homepage?: {
clocks?: {
/** @visibility frontend */
label: string;
/** @visibility frontend */
timezone: string;
}[];
};
/**
* Configuration that provides information on available authentication providers configured for app
*/
auth?: {
/**
* The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`.
* default value: 'development'
* optional values: 'development' | 'production'
* @visibility frontend
*/
environment?: string;
};
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@backstage/core-app-api",
"description": "Core app API used by Backstage apps",
"version": "0.1.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/core-app-api"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core-components": "^0.1.0",
"@backstage/config": "^0.1.3",
"@backstage/core-plugin-api": "^0.1.0",
"@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
"@types/prop-types": "^15.7.3",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.6.3",
"@backstage/test-utils": "^0.1.8",
"@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/zen-observable": "^0.8.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.3"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,32 @@
/*
* 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 { AlertApi, AlertMessage, Observable } from '@backstage/core-plugin-api';
import { PublishSubject } from '../../../lib/subjects';
/**
* Base implementation for the AlertApi that simply forwards alerts to consumers.
*/
export class AlertApiForwarder implements AlertApi {
private readonly subject = new PublishSubject<AlertMessage>();
post(alert: AlertMessage) {
this.subject.next(alert);
}
alert$(): Observable<AlertMessage> {
return this.subject;
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { AlertApiForwarder } from './AlertApiForwarder';
@@ -0,0 +1,85 @@
/*
* 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 { AppTheme } from '@backstage/core-plugin-api';
import { AppThemeSelector } from './AppThemeSelector';
describe('AppThemeSelector', () => {
it('should should select new themes', async () => {
const selector = new AppThemeSelector([]);
expect(selector.getInstalledThemes()).toEqual([]);
const subFn = jest.fn();
selector.activeThemeId$().subscribe(subFn);
expect(selector.getActiveThemeId()).toBe(undefined);
await 'wait a tick';
expect(subFn).toHaveBeenLastCalledWith(undefined);
selector.setActiveThemeId('x');
expect(subFn).toHaveBeenLastCalledWith('x');
expect(selector.getActiveThemeId()).toBe('x');
selector.setActiveThemeId(undefined);
expect(subFn).toHaveBeenLastCalledWith(undefined);
expect(selector.getActiveThemeId()).toBe(undefined);
});
it('should return a new array of themes', () => {
const themes = new Array<AppTheme>();
const selector = new AppThemeSelector(themes);
expect(selector.getInstalledThemes()).toEqual(themes);
expect(selector.getInstalledThemes()).not.toBe(themes);
});
it('should store theme in local storage', async () => {
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
localStorage.setItem('theme', 'x');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x');
localStorage.removeItem('theme');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
const addListenerSpy = jest.spyOn(window, 'addEventListener');
const selector = AppThemeSelector.createWithStorage([]);
expect(addListenerSpy).toHaveBeenCalledTimes(1);
expect(addListenerSpy).toHaveBeenCalledWith(
'storage',
expect.any(Function),
);
selector.setActiveThemeId('y');
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe('y');
selector.setActiveThemeId(undefined);
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe(null);
localStorage.setItem('theme', 'z');
expect(selector.getActiveThemeId()).toBe(undefined);
const listener = addListenerSpy.mock.calls[0][1] as EventListener;
listener({ key: 'theme' } as StorageEvent);
expect(selector.getActiveThemeId()).toBe('z');
});
});
@@ -0,0 +1,74 @@
/*
* 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 { AppThemeApi, AppTheme, Observable } from '@backstage/core-plugin-api';
import { BehaviorSubject } from '../../../lib/subjects';
const STORAGE_KEY = 'theme';
export class AppThemeSelector implements AppThemeApi {
static createWithStorage(themes: AppTheme[]) {
const selector = new AppThemeSelector(themes);
if (!window.localStorage) {
return selector;
}
const initialThemeId =
window.localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(initialThemeId);
selector.activeThemeId$().subscribe(themeId => {
if (themeId) {
window.localStorage.setItem(STORAGE_KEY, themeId);
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
});
window.addEventListener('storage', event => {
if (event.key === STORAGE_KEY) {
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(themeId);
}
});
return selector;
}
private activeThemeId: string | undefined;
private readonly subject = new BehaviorSubject<string | undefined>(undefined);
constructor(private readonly themes: AppTheme[]) {}
getInstalledThemes(): AppTheme[] {
return this.themes.slice();
}
activeThemeId$(): Observable<string | undefined> {
return this.subject;
}
getActiveThemeId(): string | undefined {
return this.activeThemeId;
}
setActiveThemeId(themeId?: string): void {
this.activeThemeId = themeId;
this.subject.next(themeId);
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './AppThemeSelector';
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { ConfigReader } from '@backstage/config';
@@ -0,0 +1,84 @@
/*
* 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 { UrlPatternDiscovery } from './UrlPatternDiscovery';
describe('UrlPatternDiscovery', () => {
it('should not require interpolation', async () => {
const discoveryApi = UrlPatternDiscovery.compile('http://example.com');
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'http://example.com',
);
});
it('should use a plain pattern', async () => {
const discoveryApi = UrlPatternDiscovery.compile(
'http://localhost:7000/{{ pluginId }}',
);
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'http://localhost:7000/my-plugin',
);
});
it('should allow for multiple interpolation points', async () => {
const discoveryApi = UrlPatternDiscovery.compile(
'https://{{pluginId }}.example.com/api/{{ pluginId}}',
);
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'https://my-plugin.example.com/api/my-plugin',
);
});
it('should validate that the pattern is a valid URL', () => {
expect(() => {
UrlPatternDiscovery.compile('example.com');
}).toThrow('Invalid discovery URL pattern, Invalid URL: example.com');
expect(() => {
UrlPatternDiscovery.compile('http://');
}).toThrow('Invalid discovery URL pattern, Invalid URL: http://');
expect(() => {
UrlPatternDiscovery.compile('abc123');
}).toThrow('Invalid discovery URL pattern, Invalid URL: abc123');
expect(() => {
UrlPatternDiscovery.compile('http://example.com:{{pluginId}}');
}).toThrow(
'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId',
);
expect(() => {
UrlPatternDiscovery.compile('/{{pluginId}}');
}).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden');
}).toThrow('Invalid discovery URL pattern, URL must not have a query');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden');
}).toThrow('Invalid discovery URL pattern, URL must not have a hash');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/');
}).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/');
}).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
});
});
@@ -0,0 +1,58 @@
/*
* 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 { DiscoveryApi } from '@backstage/core-plugin-api';
/**
* UrlPatternDiscovery is a lightweight DiscoveryApi implementation.
* It uses a single template string to construct URLs for each plugin.
*/
export class UrlPatternDiscovery implements DiscoveryApi {
/**
* Creates a new UrlPatternDiscovery given a template. The the only
* interpolation done for the template is to replace instances of `{{pluginId}}`
* with the ID of the plugin being requested.
*
* Example pattern: `http://localhost:7000/api/{{ pluginId }}`
*/
static compile(pattern: string): UrlPatternDiscovery {
const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/);
try {
const urlStr = parts.join('pluginId');
const url = new URL(urlStr);
if (url.hash) {
throw new Error('URL must not have a hash');
}
if (url.search) {
throw new Error('URL must not have a query');
}
if (urlStr.endsWith('/')) {
throw new Error('URL must not end with a slash');
}
} catch (error) {
throw new Error(`Invalid discovery URL pattern, ${error.message}`);
}
return new UrlPatternDiscovery(parts);
}
private constructor(private readonly parts: string[]) {}
async getBaseUrl(pluginId: string): Promise<string> {
return this.parts.join(pluginId);
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { UrlPatternDiscovery } from './UrlPatternDiscovery';
@@ -0,0 +1,39 @@
/*
* 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 { ErrorApi, ErrorContext, AlertApi } from '@backstage/core-plugin-api';
/**
* Decorates an ErrorApi by also forwarding error messages
* to the alertApi with an 'error' severity.
*/
export class ErrorAlerter implements ErrorApi {
constructor(
private readonly alertApi: AlertApi,
private readonly errorApi: ErrorApi,
) {}
post(error: Error, context?: ErrorContext) {
if (!context?.hidden) {
this.alertApi.post({ message: error.message, severity: 'error' });
}
return this.errorApi.post(error, context);
}
error$() {
return this.errorApi.error$();
}
}
@@ -0,0 +1,35 @@
/*
* 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 { ErrorApi, ErrorContext, Observable } from '@backstage/core-plugin-api';
import { PublishSubject } from '../../../lib/subjects';
/**
* Base implementation for the ErrorApi that simply forwards errors to consumers.
*/
export class ErrorApiForwarder implements ErrorApi {
private readonly subject = new PublishSubject<{
error: Error;
context?: ErrorContext;
}>();
post(error: Error, context?: ErrorContext) {
this.subject.next({ error, context });
}
error$(): Observable<{ error: Error; context?: ErrorContext }> {
return this.subject;
}
}
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { ErrorAlerter } from './ErrorAlerter';
export { ErrorApiForwarder } from './ErrorApiForwarder';
@@ -0,0 +1,222 @@
/*
* 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 { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
import { FeatureFlagState, FeatureFlagsApi } from '@backstage/core-plugin-api';
describe('FeatureFlags', () => {
beforeEach(() => {
window.localStorage.clear();
});
describe('getFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
featureFlags = new LocalStorageFeatureFlags();
});
it('returns no flags', () => {
expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
it('loads flags from local storage', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
'feature-flag-four': 2,
'feature-flag-five': 'not-valid',
}),
);
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
expect(featureFlags.isActive('feature-flag-four')).toBe(false);
expect(featureFlags.isActive('feature-flag-five')).toBe(false);
});
it('sets the correct values', () => {
featureFlags.save({
states: {
'feature-flag-zero': FeatureFlagState.Active,
},
});
expect(featureFlags.isActive('feature-flag-zero')).toBe(true);
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,
'feature-flag-tree': 1,
'feature-flag-four': 0,
}),
);
featureFlags.save({
states: {
'feature-flag-one': FeatureFlagState.None,
'feature-flag-two': FeatureFlagState.Active,
},
});
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-two":1}',
);
});
it('clears all values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
}),
);
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
featureFlags.save({ states: {} });
expect(featureFlags.isActive('feature-flag-one')).toBe(false);
expect(featureFlags.isActive('feature-flag-two')).toBe(false);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
});
});
describe('getRegisteredFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
featureFlags = new LocalStorageFeatureFlags();
featureFlags.registerFlag({
name: 'registered-flag-1',
pluginId: 'plugin-one',
});
featureFlags.registerFlag({
name: 'registered-flag-2',
pluginId: 'plugin-one',
});
featureFlags.registerFlag({
name: 'registered-flag-3',
pluginId: 'plugin-two',
});
});
it('should return an empty list', () => {
featureFlags = new LocalStorageFeatureFlags();
expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
it('should return an valid list', () => {
expect(featureFlags.getRegisteredFlags()).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
});
it('should provide a copy of the list of flags', () => {
const flags = featureFlags.getRegisteredFlags();
expect(flags).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
flags.splice(2, 1);
expect(flags).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
]);
expect(featureFlags.getRegisteredFlags()).toEqual([
{ 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: string) =>
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('throws an error if length is less than three characters', () => {
expect(() =>
featureFlags.registerFlag({
name: 'ab',
pluginId: 'plugin-three',
}),
).toThrow(/minimum length of three characters/i);
});
it('throws an error if length is greater than 150 characters', () => {
expect(() =>
featureFlags.registerFlag({
name:
'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
pluginId: 'plugin-three',
}),
).toThrow(/not exceed 150 characters/i);
});
it('throws an error if name does not start with a lowercase letter', () => {
expect(() =>
featureFlags.registerFlag({
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', () => {
expect(() =>
featureFlags.registerFlag({
name: 'Invalid_Feature_Flag',
pluginId: 'plugin-three',
}),
).toThrow(/only contain lowercase letters, numbers and hyphens/i);
});
});
});
@@ -0,0 +1,109 @@
/*
* 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 {
FeatureFlagState,
FeatureFlagsApi,
FeatureFlag,
FeatureFlagsSaveOptions,
} from '@backstage/core-plugin-api';
export function validateFlagName(name: string): 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',
);
}
}
/**
* Create the FeatureFlags implementation based on the API.
*/
export class LocalStorageFeatureFlags implements FeatureFlagsApi {
private registeredFeatureFlags: FeatureFlag[] = [];
private flags?: Map<string, FeatureFlagState>;
registerFlag(flag: FeatureFlag) {
validateFlagName(flag.name);
this.registeredFeatureFlags.push(flag);
}
getRegisteredFlags(): FeatureFlag[] {
return this.registeredFeatureFlags.slice();
}
isActive(name: string): boolean {
if (!this.flags) {
this.flags = this.load();
}
return this.flags.get(name) === FeatureFlagState.Active;
}
save(options: FeatureFlagsSaveOptions): void {
if (!this.flags) {
this.flags = this.load();
}
if (!options.merge) {
this.flags.clear();
}
for (const [name, state] of Object.entries(options.states)) {
this.flags.set(name, state);
}
const enabled = Array.from(this.flags.entries()).filter(
([, state]) => state === FeatureFlagState.Active,
);
window.localStorage.setItem(
'featureFlags',
JSON.stringify(Object.fromEntries(enabled)),
);
}
private load(): Map<string, FeatureFlagState> {
try {
const jsonStr = window.localStorage.getItem('featureFlags');
if (!jsonStr) {
return new Map();
}
const json = JSON.parse(jsonStr) as unknown;
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
return new Map();
}
const entries = Object.entries(json).filter(([name, value]) => {
validateFlagName(name);
return value === FeatureFlagState.Active;
});
return new Map(entries);
} catch {
return new Map();
}
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
@@ -0,0 +1,101 @@
/*
* 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 MockOAuthApi from './MockOAuthApi';
import PowerIcon from '@material-ui/icons/Power';
describe('MockOAuthApi', () => {
it('should trigger all requests', async () => {
const authResult = { is: 'done' };
const mock = new MockOAuthApi();
const authHandler1 = jest.fn().mockImplementation(() => authResult);
const requester1 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn().mockResolvedValue('other');
const requester2 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler2,
});
const promises = [
requester1(new Set(['a'])),
requester1(new Set(['b'])),
requester2(new Set(['a', 'b'])),
requester2(new Set(['b', 'c'])),
requester2(new Set(['c', 'a'])),
];
await expect(
Promise.race([Promise.all(promises), 'waiting']),
).resolves.toBe('waiting');
await mock.triggerAll();
await expect(Promise.all(promises)).resolves.toEqual([
authResult,
authResult,
'other',
'other',
'other',
]);
expect(authHandler1).toHaveBeenCalledTimes(1);
expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b']));
expect(authHandler2).toHaveBeenCalledTimes(1);
expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c']));
});
it('should reject all requests', async () => {
const mock = new MockOAuthApi();
const authHandler1 = jest.fn();
const requester1 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn();
const requester2 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler2,
});
const promises = [
requester1(new Set(['a'])),
requester1(new Set(['b'])),
requester2(new Set(['a', 'b'])),
requester2(new Set(['b', 'c'])),
requester2(new Set(['c', 'a'])),
];
await expect(
Promise.race([Promise.all(promises), 'waiting']),
).resolves.toBe('waiting');
await mock.rejectAll();
for (const promise of promises) {
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
}
expect(authHandler1).not.toHaveBeenCalled();
expect(authHandler2).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
/*
* 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 {
OAuthRequestApi,
AuthRequesterOptions,
} from '@backstage/core-plugin-api';
import { OAuthRequestManager } from './OAuthRequestManager';
export default class MockOAuthApi implements OAuthRequestApi {
private readonly real = new OAuthRequestManager();
createAuthRequester<T>(options: AuthRequesterOptions<T>) {
return this.real.createAuthRequester(options);
}
authRequest$() {
return this.real.authRequest$();
}
async triggerAll() {
await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
return new Promise<void>(resolve => {
const subscription = this.authRequest$().subscribe(requests => {
subscription.unsubscribe();
Promise.all(requests.map(request => request.trigger())).then(() =>
resolve(),
);
});
});
}
async rejectAll() {
await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
return new Promise<void>(resolve => {
const subscription = this.authRequest$().subscribe(requests => {
subscription.unsubscribe();
requests.map(request => request.reject());
resolve();
});
});
}
}
@@ -0,0 +1,96 @@
/*
* 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 { waitFor } from '@testing-library/react';
import { OAuthPendingRequests } from './OAuthPendingRequests';
describe('OAuthPendingRequests', () => {
it('notifies new observers about current state', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const input = new Set(['a', 'b']);
target.pending().subscribe({ next, error });
target.request(input);
await waitFor(() => expect(next).toBeCalledTimes(2));
expect(next.mock.calls[0][0].scopes).toBeUndefined();
expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString());
expect(error.mock.calls.length).toBe(0);
});
it('resolves requests and notifies observers', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const request1 = target.request(new Set(['a']));
const request2 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
target.resolve(new Set(['a']), 'session1');
target.resolve(new Set(['a']), 'session2');
await expect(request1).resolves.toBe('session1');
await expect(request2).resolves.toBe('session1');
expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve
expect(error).toBeCalledTimes(0);
});
it('can resolve through the observable', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn(pendingRequest => pendingRequest.resolve('done'));
const error = jest.fn();
const request1 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
await expect(request1).resolves.toBe('done');
expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution
expect(error).toBeCalledTimes(0);
});
it('rejects requests and notifies observers only once', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const rejection = new Error('eek');
const request1 = target.request(new Set(['a']));
const request2 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
target.reject(rejection);
target.resolve(new Set(['a']), 'session');
await expect(request1).rejects.toBe(rejection);
await expect(request2).rejects.toBe(rejection);
expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve
expect(error).toBeCalledTimes(0);
});
it('can reject through the observable', async () => {
const target = new OAuthPendingRequests<string>();
const rejection = new Error('nope');
const next = jest.fn(pendingRequest => pendingRequest.reject(rejection));
const error = jest.fn();
const request1 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
await expect(request1).rejects.toBe(rejection);
expect(next).toBeCalledTimes(2);
});
});
@@ -0,0 +1,126 @@
/*
* 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 { Observable } from '@backstage/core-plugin-api';
import { BehaviorSubject } from '../../../lib/subjects';
type RequestQueueEntry<ResultType> = {
scopes: Set<string>;
resolve: (value: ResultType | PromiseLike<ResultType>) => void;
reject: (reason: Error) => void;
};
export type PendingRequest<ResultType> = {
scopes: Set<string> | undefined;
resolve: (value: ResultType) => void;
reject: (reason: Error) => void;
};
export function hasScopes(
searched: Set<string>,
searchFor: Set<string>,
): boolean {
for (const scope of searchFor) {
if (!searched.has(scope)) {
return false;
}
}
return true;
}
export function joinScopes(
scopes: Set<string>,
...moreScopess: Set<string>[]
): Set<string> {
const result = new Set(scopes);
for (const moreScopes of moreScopess) {
for (const scope of moreScopes) {
result.add(scope);
}
}
return result;
}
/**
* The OAuthPendingRequests class is a utility for managing and observing
* a stream of requests for oauth scopes for a single provider, and resolving
* them correctly once requests are fulfilled.
*/
export class OAuthPendingRequests<ResultType> {
private requests: RequestQueueEntry<ResultType>[] = [];
private subject = new BehaviorSubject<PendingRequest<ResultType>>(
this.getCurrentPending(),
);
request(scopes: Set<string>): Promise<ResultType> {
return new Promise((resolve, reject) => {
this.requests.push({ scopes, resolve, reject });
this.subject.next(this.getCurrentPending());
});
}
resolve(scopes: Set<string>, result: ResultType): void {
this.requests = this.requests.filter(request => {
if (hasScopes(scopes, request.scopes)) {
request.resolve(result);
return false;
}
return true;
});
this.subject.next(this.getCurrentPending());
}
reject(error: Error) {
this.requests.forEach(request => request.reject(error));
this.requests = [];
this.subject.next(this.getCurrentPending());
}
pending(): Observable<PendingRequest<ResultType>> {
return this.subject;
}
private getCurrentPending(): PendingRequest<ResultType> {
const currentScopes =
this.requests.length === 0
? undefined
: this.requests
.slice(1)
.reduce(
(acc, current) => joinScopes(acc, current.scopes),
this.requests[0].scopes,
);
return {
scopes: currentScopes,
resolve: (value: ResultType) => {
if (currentScopes) {
this.resolve(currentScopes, value);
}
},
reject: (reason: Error) => {
if (currentScopes) {
this.reject(reason);
}
},
};
}
}
@@ -0,0 +1,59 @@
/*
* 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 ProviderIcon from '@material-ui/icons/AcUnit';
import { OAuthRequestManager } from './OAuthRequestManager';
describe('OAuthRequestManager', () => {
it('should forward a requests', async () => {
const manager = new OAuthRequestManager();
const reqSpy = jest.fn();
manager.authRequest$().subscribe(reqSpy);
const requester = manager.createAuthRequester({
provider: {
title: 'My Provider',
icon: ProviderIcon,
},
onAuthRequest: async () => 'hello',
});
expect(reqSpy).toHaveBeenCalledTimes(0);
await 'a tick';
expect(reqSpy).toHaveBeenCalledTimes(2);
expect(reqSpy).toHaveBeenLastCalledWith([]);
const req = requester(new Set(['my-scope']));
expect(reqSpy).toHaveBeenCalledTimes(3);
expect(reqSpy).toHaveBeenLastCalledWith([
expect.objectContaining({
reject: expect.any(Function),
trigger: expect.any(Function),
}),
]);
await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe(
'not yet',
);
const [request] = reqSpy.mock.calls[2][0];
request.trigger();
await expect(req).resolves.toBe('hello');
});
});
@@ -0,0 +1,92 @@
/*
* 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 {
OAuthRequestApi,
PendingAuthRequest,
AuthRequester,
AuthRequesterOptions,
Observable,
} from '@backstage/core-plugin-api';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
import { BehaviorSubject } from '../../../lib/subjects';
/**
* The OAuthRequestManager is an implementation of the OAuthRequestApi.
*
* The purpose of this class and the API is to read a stream of incoming requests
* of OAuth access tokens from different providers with varying scope, and funnel
* them all together into a single request for each OAuth provider.
*/
export class OAuthRequestManager implements OAuthRequestApi {
private readonly subject = new BehaviorSubject<PendingAuthRequest[]>([]);
private currentRequests: PendingAuthRequest[] = [];
private handlerCount = 0;
createAuthRequester<T>(options: AuthRequesterOptions<T>): AuthRequester<T> {
const handler = new OAuthPendingRequests<T>();
const index = this.handlerCount;
this.handlerCount++;
handler.pending().subscribe({
next: scopeRequest => {
const newRequests = this.currentRequests.slice();
const request = this.makeAuthRequest(scopeRequest, options);
if (!request) {
delete newRequests[index];
} else {
newRequests[index] = request;
}
this.currentRequests = newRequests;
// Convert from sparse array to array of present items only
this.subject.next(newRequests.filter(Boolean));
},
});
return scopes => {
return handler.request(scopes);
};
}
// Converts the pending request and popup options into a popup request that we can forward to subscribers.
private makeAuthRequest(
request: PendingRequest<any>,
options: AuthRequesterOptions<any>,
): PendingAuthRequest | undefined {
const { scopes } = request;
if (!scopes) {
return undefined;
}
return {
provider: options.provider,
trigger: async () => {
const result = await options.onAuthRequest(scopes);
request.resolve(result);
},
reject: () => {
const error = new Error('Login failed, rejected by user');
error.name = 'RejectedError';
request.reject(error);
},
};
}
authRequest$(): Observable<PendingAuthRequest[]> {
return this.subject;
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { OAuthRequestManager } from './OAuthRequestManager';
@@ -0,0 +1,173 @@
/*
* 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 { CreateStorageApiOptions, WebStorage } from './WebStorage';
import { StorageApi } from '@backstage/core-plugin-api';
describe('WebStorage Storage API', () => {
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
const createWebStorage = (
args?: Partial<CreateStorageApiOptions>,
): StorageApi => {
return WebStorage.create({
errorApi: mockErrorApi,
...args,
});
};
it('should return undefined for values which are unset', async () => {
const storage = createWebStorage();
expect(storage.get('myfakekey')).toBeUndefined();
});
it('should allow the setting and getting of the simple data structures', async () => {
const storage = createWebStorage();
await storage.set('myfakekey', 'helloimastring');
await storage.set('mysecondfakekey', 1234);
await storage.set('mythirdfakekey', true);
expect(storage.get('myfakekey')).toBe('helloimastring');
expect(storage.get('mysecondfakekey')).toBe(1234);
expect(storage.get('mythirdfakekey')).toBe(true);
});
it('should allow setting of complex datastructures', async () => {
const storage = createWebStorage();
const mockData = {
something: 'here',
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
};
await storage.set('myfakekey', mockData);
expect(storage.get('myfakekey')).toEqual(mockData);
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
await new Promise<void>(resolve => {
storage.observe$<String>('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
storage.set('correctKey', mockData);
await new Promise<void>(resolve => {
storage.observe$('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: undefined,
});
});
it('should be able to create different buckets for different uses', async () => {
const rootStorage = createWebStorage();
const firstStorage = rootStorage.forBucket('userSettings');
const secondStorage = rootStorage.forBucket('profileSettings');
const keyName = 'blobby';
await firstStorage.set(keyName, 'boop');
await secondStorage.set(keyName, 'deerp');
expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName));
expect(firstStorage.get(keyName)).toBe('boop');
expect(secondStorage.get(keyName)).toBe('deerp');
});
it('should not clash with other namesapces when creating buckets', async () => {
const rootStorage = createWebStorage();
// when getting key test2 it will translate to /profile/something/deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to /profile/something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
expect(secondStorage.get('deep/test2')).toBe(undefined);
});
it('should call the error api when the json can not be parsed in local storage', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing',
});
localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}');
const value = rootStorage.get('key');
expect(value).toBe(undefined);
expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error));
expect(mockErrorApi.post).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Error when parsing JSON config from storage for: key',
}),
);
});
it('should return a singleton for the same namespace and same bucket', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing/Thing ',
});
expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test'));
});
});
@@ -0,0 +1,98 @@
/*
* 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 {
StorageApi,
StorageValueChange,
ErrorApi,
Observable,
} from '@backstage/core-plugin-api';
import ObservableImpl from 'zen-observable';
const buckets = new Map<string, WebStorage>();
export type CreateStorageApiOptions = {
errorApi: ErrorApi;
namespace?: string;
};
export class WebStorage implements StorageApi {
constructor(
private readonly namespace: string,
private readonly errorApi: ErrorApi,
) {}
static create(options: CreateStorageApiOptions): WebStorage {
return new WebStorage(options.namespace ?? '', options.errorApi);
}
get<T>(key: string): T | undefined {
try {
const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!);
return storage ?? undefined;
} catch (e) {
this.errorApi.post(
new Error(`Error when parsing JSON config from storage for: ${key}`),
);
}
return undefined;
}
forBucket(name: string): WebStorage {
const bucketPath = `${this.namespace}/${name}`;
if (!buckets.has(bucketPath)) {
buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi));
}
return buckets.get(bucketPath)!;
}
async set<T>(key: string, data: T): Promise<void> {
localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2));
this.notifyChanges({ key, newValue: data });
}
async remove(key: string): Promise<void> {
localStorage.removeItem(this.getKeyName(key));
this.notifyChanges({ key, newValue: undefined });
}
observe$<T>(key: string): Observable<StorageValueChange<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
private getKeyName(key: string) {
return `${this.namespace}/${encodeURIComponent(key)}`;
}
private notifyChanges<T>(message: StorageValueChange<T>) {
for (const subscription of this.subscribers) {
subscription.next(message);
}
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueChange>
>();
private readonly observable = new ObservableImpl<StorageValueChange>(
subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
},
);
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { WebStorage } from './WebStorage';
@@ -0,0 +1,46 @@
/*
* 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 Auth0Icon from '@material-ui/icons/AcUnit';
import { auth0AuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'auth0',
title: 'Auth0',
icon: Auth0Icon,
};
class Auth0Auth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', `email`, `profile`],
}: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default Auth0Auth;
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { default as Auth0Auth } from './Auth0Auth';
@@ -0,0 +1,29 @@
/*
* 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 GithubAuth from './GithubAuth';
describe('GithubAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const githubAuth = new GithubAuth({ getSession } as any);
expect(await githubAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
});
@@ -0,0 +1,140 @@
/*
* 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 GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
OAuthApi,
SessionApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
Observable,
} from '@backstage/core-plugin-api';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { OAuthApiCreateOptions } from '../types';
export type GithubAuthResponse = {
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'github',
title: 'GitHub',
icon: GithubIcon,
};
class GithubAuth implements OAuthApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read:user'],
}: OAuthApiCreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GithubAuthResponse): GithubSession {
return {
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
const authSessionStore = new AuthSessionStore<GithubSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
return new GithubAuth(authSessionStore);
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async signIn() {
await this.getAccessToken();
}
async signOut() {
await this.sessionManager.removeSession();
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: GithubAuth.normalizeScope(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default GithubAuth;
@@ -0,0 +1,18 @@
/*
* 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.
*/
export * from './types';
export { default as GithubAuth } from './GithubAuth';
@@ -0,0 +1,27 @@
/*
* 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 { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
export type GithubSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -0,0 +1,50 @@
/*
* 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 MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
import GitlabAuth from './GitlabAuth';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GitlabAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
[
'read_user api write_repository',
['read_user', 'api', 'write_repository'],
],
['read_repository sudo', ['read_repository', 'sudo']],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const gitlabAuth = GitlabAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
gitlabAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -0,0 +1,45 @@
/*
* 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 GitlabIcon from '@material-ui/icons/AcUnit';
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'GitLab',
icon: GitlabIcon,
};
class GitlabAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read_user'],
}: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default GitlabAuth;
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { default as GitlabAuth } from './GitlabAuth';
@@ -0,0 +1,69 @@
/*
* 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 GoogleAuth from './GoogleAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'https://www.googleapis.com/auth/';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GoogleAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
['email', [`${PREFIX}userinfo.email`]],
['profile', [`${PREFIX}userinfo.profile`]],
['openid', ['openid']],
['userinfo.email', [`${PREFIX}userinfo.email`]],
[
'userinfo.profile email',
[`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
],
[
`profile ${PREFIX}userinfo.email`,
[`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
],
[`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]],
['a', [`${PREFIX}a`]],
['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]],
[`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]],
[`${PREFIX}a`, [`${PREFIX}a`]],
// Some incorrect scopes that we don't try to fix
[`${PREFIX}email`, [`${PREFIX}email`]],
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const googleAuth = GoogleAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
googleAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -0,0 +1,68 @@
/*
* 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 GoogleIcon from '@material-ui/icons/AcUnit';
import { googleAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'google',
title: 'Google',
icon: GoogleIcon,
};
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth {
static create({
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
defaultScopes = [
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
],
}: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
scopeTransform(scopes: string[]) {
return scopes.map(scope => {
if (scope === 'openid') {
return scope;
}
if (scope === 'profile' || scope === 'email') {
return `${SCOPE_PREFIX}userinfo.${scope}`;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default GoogleAuth;
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { default as GoogleAuth } from './GoogleAuth';
@@ -0,0 +1,25 @@
/*
* 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.
*/
export * from './github';
export * from './gitlab';
export * from './google';
export * from './oauth2';
export * from './okta';
export * from './saml';
export * from './auth0';
export * from './microsoft';
export * from './onelogin';
@@ -0,0 +1,52 @@
/*
* 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 MicrosoftIcon from '@material-ui/icons/AcUnit';
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'microsoft',
title: 'Microsoft',
icon: MicrosoftIcon,
};
class MicrosoftAuth {
static create({
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
defaultScopes = [
'openid',
'offline_access',
'profile',
'email',
'User.Read',
],
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default MicrosoftAuth;
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { default as MicrosoftAuth } from './MicrosoftAuth';
@@ -0,0 +1,152 @@
/*
* 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 OAuth2 from './OAuth2';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
const PREFIX = 'https://www.googleapis.com/auth/';
const scopeTransform = (x: string[]) => x;
describe('OAuth2', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe(
'access-token',
);
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-scope', 'my-scope2']),
);
});
it('should transform scopes', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
});
expect(await oauth2.getAccessToken('my-scope')).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-prefix/my-scope']),
);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(oauth2.getAccessToken()).resolves.toBe('access-token');
const promise1 = oauth2.getAccessToken('more');
const promise2 = oauth2.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Grab the expired session first
await expect(oauth2.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = oauth2.getIdToken();
const promise2 = oauth2.getIdToken();
const promise3 = oauth2.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
});
});
@@ -0,0 +1,179 @@
/*
* 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 OAuth2Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
OpenIdConnectApi,
ProfileInfo,
ProfileInfoApi,
SessionState,
SessionApi,
BackstageIdentityApi,
Observable,
} from '@backstage/core-plugin-api';
import { OAuth2Session } from './types';
import { OAuthApiCreateOptions } from '../types';
type Options = {
sessionManager: SessionManager<OAuth2Session>;
scopeTransform: (scopes: string[]) => string[];
};
type CreateOptions = OAuthApiCreateOptions & {
scopeTransform?: (scopes: string[]) => string[];
};
export type OAuth2Response = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'oauth2',
title: 'Your Identity Provider',
icon: OAuth2Icon,
};
class OAuth2
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OAuth2Response): OAuth2Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(
scopeTransform,
res.providerInfo.scope,
),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: OAuth2Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OAuth2({ sessionManager, scopeTransform });
}
private readonly sessionManager: SessionManager<OAuth2Session>;
private readonly scopeTransform: (scopes: string[]) => string[];
constructor(options: Options) {
this.sessionManager = options.sessionManager;
this.scopeTransform = options.scopeTransform;
}
async signIn() {
await this.getAccessToken();
}
async signOut() {
await this.sessionManager.removeSession();
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
private static normalizeScopes(
scopeTransform: (scopes: string[]) => string[],
scopes?: string | string[],
): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s|,]/).filter(Boolean);
return new Set(scopeTransform(scopeList));
}
}
export default OAuth2;
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { default as OAuth2 } from './OAuth2';
export * from './types';
@@ -0,0 +1,28 @@
/*
* 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 { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
export type OAuth2Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -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 OktaAuth from './OktaAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'okta.';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('OktaAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
['openid', ['openid']],
['profile email', ['profile', 'email']],
[`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]],
['groups.read', [`${PREFIX}groups.read`]],
[
`${PREFIX}groups.manage groups.read, openid`,
[`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'],
],
[`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]],
// Some incorrect scopes that we don't try to fix
[`${PREFIX}email`, [`${PREFIX}email`]],
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const auth = OktaAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
auth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -0,0 +1,71 @@
/*
* 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 OktaIcon from '@material-ui/icons/AcUnit';
import { oktaAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'okta',
title: 'Okta',
icon: OktaIcon,
};
const OKTA_OIDC_SCOPES: Set<String> = new Set([
'openid',
'profile',
'email',
'phone',
'address',
'groups',
'offline_access',
]);
const OKTA_SCOPE_PREFIX: string = 'okta.';
class OktaAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
}: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
scopeTransform(scopes) {
return scopes.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
return scope;
}
return `${OKTA_SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default OktaAuth;
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { default as OktaAuth } from './OktaAuth';
@@ -0,0 +1,82 @@
/*
* 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 OneLoginIcon from '@material-ui/icons/AcUnit';
import {
oneloginAuthApiRef,
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
const DEFAULT_PROVIDER = {
id: 'onelogin',
title: 'onelogin',
icon: OneLoginIcon,
};
const OIDC_SCOPES: Set<String> = new Set([
'openid',
'profile',
'email',
'phone',
'address',
'groups',
'offline_access',
]);
const SCOPE_PREFIX: string = 'onelogin.';
class OneLoginAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions): typeof oneloginAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
scopeTransform(scopes) {
return scopes.map(scope => {
if (OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default OneLoginAuth;
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { default as OneLoginAuth } from './OneLoginAuth';
@@ -0,0 +1,98 @@
/*
* 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 SamlIcon from '@material-ui/icons/AcUnit';
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
ProfileInfo,
BackstageIdentity,
SessionState,
AuthRequestOptions,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi,
Observable,
} from '@backstage/core-plugin-api';
import { SamlSession } from './types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { AuthApiCreateOptions } from '../types';
export type SamlAuthResponse = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'saml',
title: 'SAML',
icon: SamlIcon,
};
class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
}: AuthApiCreateOptions) {
const connector = new DirectAuthConnector<SamlSession>({
discoveryApi,
environment,
provider,
});
const sessionManager = new StaticAuthSessionManager<SamlSession>({
connector,
});
const authSessionStore = new AuthSessionStore<SamlSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
});
return new SamlAuth(authSessionStore);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<SamlSession>) {}
async signIn() {
await this.getBackstageIdentity({});
}
async signOut() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
}
export default SamlAuth;
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { default as SamlAuth } from './SamlAuth';
@@ -0,0 +1,22 @@
/*
* 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 { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
export type SamlSession = {
userId: string;
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -0,0 +1,32 @@
/*
* 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 {
AuthProvider,
DiscoveryApi,
OAuthRequestApi,
} from '@backstage/core-plugin-api';
export type OAuthApiCreateOptions = AuthApiCreateOptions & {
oauthRequestApi: OAuthRequestApi;
defaultScopes?: string[];
};
export type AuthApiCreateOptions = {
discoveryApi: DiscoveryApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
@@ -0,0 +1,30 @@
/*
* 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.
*/
// This folder contains implementations for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export * from './system';
export * from './implementations';
@@ -0,0 +1,46 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';
import { ApiAggregator } from './ApiAggregator';
import { ApiRegistry } from './ApiRegistry';
describe('ApiAggregator', () => {
const apiARef = createApiRef<number>({ id: 'a' });
const apiBRef = createApiRef<number>({ id: 'b' });
it('should forward implementations', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 5],
[apiBRef, 10],
]),
);
expect(agg.get(apiARef)).toBe(5);
expect(agg.get(apiBRef)).toBe(10);
});
it('should return the first implementation', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 1],
[apiARef, 2],
]),
);
expect(agg.get(apiARef)).toBe(2);
expect(agg.get(apiBRef)).toBe(undefined);
});
});
@@ -0,0 +1,39 @@
/*
* 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, ApiHolder } from '@backstage/core-plugin-api';
/**
* An ApiHolder that queries multiple other holders from for
* an Api implementation, returning the first one encountered..
*/
export class ApiAggregator implements ApiHolder {
private readonly holders: ApiHolder[];
constructor(...holders: ApiHolder[]) {
this.holders = holders;
}
get<T>(apiRef: ApiRef<T>): T | undefined {
for (const holder of this.holders) {
const api = holder.get(apiRef);
if (api) {
return api;
}
}
return undefined;
}
}
@@ -0,0 +1,91 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';
import { ApiFactoryRegistry } from './ApiFactoryRegistry';
const aRef = createApiRef<number>({ id: 'a' });
const aFactory1 = { api: aRef, deps: {}, factory: () => 1 };
const aFactory2 = { api: aRef, deps: {}, factory: () => 2 };
const bRef = createApiRef<string>({ id: 'b' });
const bFactory = { api: bRef, deps: {}, factory: () => 'x' };
const cRef = createApiRef<string>({ id: 'c' });
const cFactory = { api: cRef, deps: {}, factory: () => 'y' };
describe('ApiFactoryRegistry', () => {
it('should be empty when created', () => {
const registry = new ApiFactoryRegistry();
expect(registry.getAllApis()).toEqual(new Set());
});
it('should register a factory', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('default', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.getAllApis()).toEqual(new Set([aRef]));
});
it('should prioritize factories based on scope', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('default', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('default', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('app', aFactory2)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory2);
expect(registry.register('default', aFactory1)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory2);
expect(registry.register('static', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('static', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('app', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.getAllApis()).toEqual(new Set([aRef]));
});
it('should register multiple factories without conflict', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('static', aFactory1)).toBe(true);
expect(registry.register('default', bFactory)).toBe(true);
expect(registry.register('app', cFactory)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.get(bRef)).toBe(bFactory);
expect(registry.get(cRef)).toBe(cFactory);
expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef]));
});
it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => {
const ref1 = createApiRef<number>({ id: 'a' });
const ref2 = createApiRef<number>({ id: 'a' });
const factory1 = { api: ref1, deps: {}, factory: () => 3 };
const factory2 = { api: ref2, deps: {}, factory: () => 3 };
const registry = new ApiFactoryRegistry();
expect(registry.register('default', factory1)).toBe(true);
expect(registry.register('default', factory2)).toBe(false);
expect(registry.get(ref1)).toEqual(factory1);
expect(registry.get(ref2)).toEqual(factory1);
expect(registry.getAllApis()).toEqual(new Set([ref1]));
expect(registry.register('app', factory2)).toBe(true);
expect(registry.get(ref1)).toEqual(factory2);
expect(registry.get(ref2)).toEqual(factory2);
expect(Array.from(registry.getAllApis())[0]).toBe(ref2);
expect(Array.from(registry.getAllApis())[0]).not.toBe(ref1);
});
});
@@ -0,0 +1,89 @@
/*
* 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 { ApiFactoryHolder } from './types';
import {
ApiRef,
ApiFactory,
AnyApiRef,
AnyApiFactory,
} from '@backstage/core-plugin-api';
type ApiFactoryScope =
| 'default' // Default factories registered by core and plugins
| 'app' // Factories registered in the app, overriding default ones
| 'static'; // APIs that can't be overridden, e.g. config
enum ScopePriority {
default = 10,
app = 50,
static = 100,
}
type FactoryTuple = {
priority: number;
factory: AnyApiFactory;
};
/**
* ApiFactoryRegistry is an ApiFactoryHolder implementation that enables
* registration of API Factories with different scope.
*
* Each scope has an assigned priority, where factories registered with
* higher priority scopes override ones with lower priority.
*/
export class ApiFactoryRegistry implements ApiFactoryHolder {
private readonly factories = new Map<string, FactoryTuple>();
/**
* Register a new API factory. Returns true if the factory was added
* to the registry.
*
* A factory will not be added to the registry if there is already
* an existing factory with the same or higher priority.
*/
register<Api, Impl extends Api, Deps extends { [name in string]: unknown }>(
scope: ApiFactoryScope,
factory: ApiFactory<Api, Impl, Deps>,
) {
const priority = ScopePriority[scope];
const existing = this.factories.get(factory.api.id);
if (existing && existing.priority >= priority) {
return false;
}
this.factories.set(factory.api.id, { priority, factory });
return true;
}
get<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [x: string]: unknown }> | undefined {
const tuple = this.factories.get(api.id);
if (!tuple) {
return undefined;
}
return tuple.factory as ApiFactory<T, T, { [x: string]: unknown }>;
}
getAllApis(): Set<AnyApiRef> {
const refs = new Set<AnyApiRef>();
for (const { factory } of this.factories.values()) {
refs.add(factory.api);
}
return refs;
}
}
@@ -0,0 +1,217 @@
/*
* 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 React, { Context, useContext } from 'react';
import {
useApi,
createApiRef,
withApis,
ApiHolder,
ApiRef,
} from '@backstage/core-plugin-api';
import { ApiProvider } from './ApiProvider';
import { ApiRegistry } from './ApiRegistry';
import { render } from '@testing-library/react';
import { withLogCollector } from '@backstage/test-utils-core';
import { getGlobalSingleton } from '../../lib/globalObject';
import { VersionedValue } from '../../lib/versionedValues';
describe('ApiProvider', () => {
type Api = () => string;
const apiRef = createApiRef<Api>({ id: 'x' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumer = () => {
const api = useApi(apiRef);
return <p>hook message: {api()}</p>;
};
const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => {
return <p>hoc message: {getMessage()}</p>;
});
it('should provide apis', () => {
const renderedHook = render(
<ApiProvider apis={registry}>
<MyHookConsumer />
</ApiProvider>,
);
renderedHook.getByText('hook message: hello');
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should provide nested access to apis', () => {
const aRef = createApiRef<string>({ id: 'a' });
const bRef = createApiRef<string>({ id: 'b' });
const MyComponent = () => {
const a = useApi(aRef);
const b = useApi(bRef);
return (
<div>
a={a} b={b}
</div>
);
};
const renderedHook = render(
<ApiProvider
apis={ApiRegistry.from([
[aRef, 'x'],
[bRef, 'y'],
])}
>
<ApiProvider apis={ApiRegistry.from([[aRef, 'z']])}>
<MyComponent />
</ApiProvider>
</ApiProvider>,
);
renderedHook.getByText('a=z b=y');
});
it('should ignore deps in prototype', () => {
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
const xRef = createApiRef<number>({ id: 'x' });
const proto = { x: xRef };
const props = { getMessage: { enumerable: true, value: apiRef } };
const obj = Object.create(proto, props) as {
getMessage: typeof apiRef;
x: typeof xRef;
};
const MyWeirdHocConsumer = withApis(obj)(({ getMessage }) => {
return <p>hoc message: {getMessage()}</p>;
});
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyWeirdHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should error if no provider is available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHookConsumer />);
}).toThrow(/^No provider available for api-context context/);
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No provider available for api-context context/,
),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHocConsumer />);
}).toThrow(/^No provider available for api-context context/);
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No provider available for api-context context/,
),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
),
]);
});
it('should error if api is not available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHookConsumer />
</ApiProvider>,
);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHocConsumer />
</ApiProvider>,
);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
),
]);
});
});
describe('v1 consumer', () => {
const ApiContext = getGlobalSingleton<
Context<VersionedValue<{ 1: ApiHolder }>>
>('api-context');
function useMockApiV1<T>(apiRef: ApiRef<T>): T {
const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef);
if (!impl) {
throw new Error('no impl');
}
return impl;
}
type Api = () => string;
const apiRef = createApiRef<Api>({ id: 'x' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumerV1 = () => {
const api = useMockApiV1(apiRef);
return <p>hook message: {api()}</p>;
};
it('should provide apis', () => {
const renderedHook = render(
<ApiProvider apis={registry}>
<MyHookConsumerV1 />
</ApiProvider>,
);
renderedHook.getByText('hook message: hello');
});
});
@@ -0,0 +1,60 @@
/*
* 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 React, {
createContext,
useContext,
ReactNode,
PropsWithChildren,
} from 'react';
import PropTypes from 'prop-types';
import { ApiHolder } from '@backstage/core-plugin-api';
import { ApiAggregator } from './ApiAggregator';
import { getOrCreateGlobalSingleton } from '../../lib/globalObject';
import {
VersionedValue,
createVersionedValueMap,
} from '../../lib/versionedValues';
type ApiProviderProps = {
apis: ApiHolder;
children: ReactNode;
};
type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined;
const ApiContext = getOrCreateGlobalSingleton('api-context', () =>
createContext<ApiContextType>(undefined),
);
export const ApiProvider = ({
apis,
children,
}: PropsWithChildren<ApiProviderProps>) => {
const parentHolder = useContext(ApiContext)?.atVersion(1);
const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis;
return (
<ApiContext.Provider
value={createVersionedValueMap({ 1: holder })}
children={children}
/>
);
};
ApiProvider.propTypes = {
apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired,
children: PropTypes.node,
};
@@ -0,0 +1,75 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';
import { ApiRegistry } from './ApiRegistry';
describe('ApiRegistry', () => {
const x1Ref = createApiRef<number>({ id: 'x1' });
const x1DuplicateRef = createApiRef<number>({ id: 'x1' });
const x2Ref = createApiRef<string>({ id: 'x2' });
it('should be created', () => {
const registry = ApiRegistry.from([]);
expect(registry.get(x1Ref)).toBe(undefined);
});
it('should be created with APIs', () => {
const registry = ApiRegistry.from([
[x1Ref, 3],
[x2Ref, 'y'],
]);
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x1DuplicateRef)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
it('should be built', () => {
const registry = ApiRegistry.builder().build();
expect(registry.get(x1Ref)).toBe(undefined);
expect(registry.get(x1DuplicateRef)).toBe(undefined);
});
it('should be built with APIs', () => {
const builder = ApiRegistry.builder();
builder.add(x1Ref, 3);
builder.add(x2Ref, 'y');
const registry = builder.build();
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x1DuplicateRef)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
it('should be created with API', () => {
const reg1 = ApiRegistry.with(x1Ref, 3);
const reg2 = reg1.with(x2Ref, 'y');
const reg3 = reg2.with(x2Ref, 'z');
const reg4 = reg3.with(x1Ref, 2);
const reg5 = reg3.with(x1DuplicateRef, 4);
expect(reg1.get(x1Ref)).toBe(3);
expect(reg1.get(x2Ref)).toBe(undefined);
expect(reg2.get(x1Ref)).toBe(3);
expect(reg2.get(x2Ref)).toBe('y');
expect(reg3.get(x1Ref)).toBe(3);
expect(reg3.get(x2Ref)).toBe('z');
expect(reg4.get(x1Ref)).toBe(2);
expect(reg4.get(x2Ref)).toBe('z');
expect(reg5.get(x1Ref)).toBe(4);
expect(reg5.get(x2Ref)).toBe('z');
});
});
@@ -0,0 +1,69 @@
/*
* 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, ApiHolder } from '@backstage/core-plugin-api';
type ApiImpl<T = unknown> = readonly [ApiRef<T>, T];
class ApiRegistryBuilder {
private apis: [string, unknown][] = [];
add<T, I extends T>(api: ApiRef<T>, impl: I): I {
this.apis.push([api.id, impl]);
return impl;
}
build(): ApiRegistry {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return new ApiRegistry(new Map(this.apis));
}
}
export class ApiRegistry implements ApiHolder {
static builder() {
return new ApiRegistryBuilder();
}
static from(apis: ApiImpl[]) {
return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl])));
}
/**
* Creates a new ApiRegistry with a single API implementation.
*
* @param api ApiRef for the API to add
* @param impl Implementation of the API to add
*/
static with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([[api.id, impl]]));
}
constructor(private readonly apis: Map<string, unknown>) {}
/**
* Returns a new ApiRegistry with the provided API added to the existing ones.
*
* @param api ApiRef for the API to add
* @param impl Implementation of the API to add
*/
with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([...this.apis, [api.id, impl]]));
}
get<T>(api: ApiRef<T>): T | undefined {
return this.apis.get(api.id) as T | undefined;
}
}
@@ -0,0 +1,263 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';
import { ApiResolver } from './ApiResolver';
import { ApiFactoryRegistry } from './ApiFactoryRegistry';
const aRef = createApiRef<number>({ id: 'a' });
const otherARef = createApiRef<number>({ id: 'a' });
const bRef = createApiRef<string>({ id: 'b' });
const otherBRef = createApiRef<string>({ id: 'b' });
const cRef = createApiRef<{ x: string }>({ id: 'c' });
const otherCRef = createApiRef<{ x: string }>({ id: 'c' });
function createRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: {},
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: {},
factory: () => 'b',
});
registry.register('default', {
api: cRef,
deps: { b: otherBRef },
factory: ({ b }) => ({ x: 'x', b }),
});
return registry;
}
function createSelfCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { a: aRef },
factory: () => 1,
});
return registry;
}
function createShortCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { b: bRef },
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: { a: aRef },
factory: () => 'x',
});
return registry;
}
function createShortCyclicRegistryWithOther() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { b: bRef },
factory: () => 1,
});
registry.register('default', {
api: otherBRef,
deps: { a: otherARef },
factory: () => 'x',
});
return registry;
}
function createLongCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { b: otherBRef },
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: { c: cRef },
factory: () => 'b',
});
registry.register('default', {
api: cRef,
deps: { a: aRef },
factory: () => ({ x: 'x' }),
});
return registry;
}
describe('ApiResolver', () => {
it('should be created empty', () => {
const resolver = new ApiResolver(new ApiFactoryRegistry());
expect(resolver.get(aRef)).toBe(undefined);
expect(resolver.get(bRef)).toBe(undefined);
expect(resolver.get(otherBRef)).toBe(undefined);
expect(resolver.get(cRef)).toBe(undefined);
});
it('should instantiate APIs', () => {
const resolver = new ApiResolver(createRegistry());
expect(resolver.get(aRef)).toBe(1);
expect(resolver.get(otherARef)).toBe(1);
expect(resolver.get(bRef)).toBe('b');
expect(resolver.get(otherBRef)).toBe('b');
expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' });
expect(resolver.get(cRef)).toBe(resolver.get(otherCRef));
});
it('should detect self dependency cycles', () => {
const resolver = new ApiResolver(createSelfCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
});
it('should detect short dependency cycles', () => {
const resolver = new ApiResolver(createShortCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
});
it('should detect short dependency cycles with other refs', () => {
const resolver = new ApiResolver(createShortCyclicRegistryWithOther());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(otherARef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(otherBRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
});
it('should detect long dependency cycles', () => {
const resolver = new ApiResolver(createLongCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
// Second call for same ref should still throw
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(otherBRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(cRef)).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should validate a factory holder', () => {
expect(() => {
ApiResolver.validateFactories(createRegistry(), [
aRef,
bRef,
otherBRef,
cRef,
]);
}).not.toThrow();
});
it('should find self cycles with validation', () => {
const self = createSelfCyclicRegistry();
expect(() => ApiResolver.validateFactories(self, [aRef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => ApiResolver.validateFactories(self, [otherARef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
});
it('should find dependency cycles with validation', () => {
const short = createShortCyclicRegistry();
expect(() => ApiResolver.validateFactories(short, [aRef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => ApiResolver.validateFactories(short, [otherARef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(short, [otherBRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
const shortOther = createShortCyclicRegistryWithOther();
expect(() => ApiResolver.validateFactories(shortOther, [aRef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() =>
ApiResolver.validateFactories(shortOther, [otherARef]),
).toThrow('Circular dependency of api factory for apiRef{a}');
expect(() => ApiResolver.validateFactories(shortOther, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() =>
ApiResolver.validateFactories(shortOther, [otherBRef]),
).toThrow('Circular dependency of api factory for apiRef{b}');
const long = createLongCyclicRegistry();
expect(() =>
ApiResolver.validateFactories(long, long.getAllApis()),
).toThrow('Circular dependency of api factory for apiRef{a}');
expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(long, [otherBRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should only call factory func once', () => {
const registry = new ApiFactoryRegistry();
const factory = jest.fn().mockReturnValue(2);
registry.register('default', {
api: aRef,
deps: {},
factory,
});
const resolver = new ApiResolver(registry);
expect(factory).toHaveBeenCalledTimes(0);
expect(resolver.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
expect(resolver.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
expect(resolver.get(otherARef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,110 @@
/*
* 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,
ApiHolder,
AnyApiRef,
TypesToApiRefs,
} from '@backstage/core-plugin-api';
import { ApiFactoryHolder } from './types';
export class ApiResolver implements ApiHolder {
/**
* Validate factories by making sure that each of the apis can be created
* without hitting any circular dependencies.
*/
static validateFactories(
factories: ApiFactoryHolder,
apis: Iterable<AnyApiRef>,
) {
for (const api of apis) {
const heap = [api];
const allDeps = new Set<AnyApiRef>();
while (heap.length) {
const apiRef = heap.shift()!;
const factory = factories.get(apiRef);
if (!factory) {
continue;
}
for (const dep of Object.values(factory.deps)) {
if (dep.id === api.id) {
throw new Error(`Circular dependency of api factory for ${api}`);
}
if (!allDeps.has(dep)) {
allDeps.add(dep);
heap.push(dep);
}
}
}
}
}
private readonly apis = new Map<string, unknown>();
constructor(private readonly factories: ApiFactoryHolder) {}
get<T>(ref: ApiRef<T>): T | undefined {
return this.load(ref);
}
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
const impl = this.apis.get(ref.id);
if (impl) {
return impl as T;
}
const factory = this.factories.get(ref);
if (!factory) {
return undefined;
}
if (loading.includes(factory.api)) {
throw new Error(`Circular dependency of api factory for ${factory.api}`);
}
const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]);
const api = factory.factory(deps);
this.apis.set(ref.id, api);
return api as T;
}
private loadDeps<T>(
dependent: ApiRef<unknown>,
apis: TypesToApiRefs<T>,
loading: AnyApiRef[],
): T {
const impls = {} as T;
for (const key in apis) {
if (apis.hasOwnProperty(key)) {
const ref = apis[key];
const api = this.load(ref, loading);
if (!api) {
throw new Error(
`No API factory available for dependency ${ref} of dependent ${dependent}`,
);
}
impls[key] = api;
}
}
return impls;
}
}
@@ -0,0 +1,21 @@
/*
* 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.
*/
export { ApiProvider } from './ApiProvider';
export { ApiRegistry } from './ApiRegistry';
export { ApiResolver } from './ApiResolver';
export { ApiFactoryRegistry } from './ApiFactoryRegistry';
export * from './types';
@@ -0,0 +1,23 @@
/*
* 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 { ApiFactory, ApiRef } from '@backstage/core-plugin-api';
export type ApiFactoryHolder = {
get<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [key in string]: unknown }> | undefined;
};
+373
View File
@@ -0,0 +1,373 @@
/*
* 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 { LocalStorageFeatureFlags } from '../apis';
import { renderWithEffects, withLogCollector } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { render, screen } from '@testing-library/react';
import React, { PropsWithChildren } from 'react';
import { BrowserRouter, Routes } from 'react-router-dom';
import { createRoutableExtension } from '../extensions';
import { defaultAppIcons } from './icons';
import {
configApiRef,
createApiFactory,
featureFlagsApiRef,
createPlugin,
useRouteRef,
createExternalRouteRef,
createRouteRef,
createSubRouteRef,
} from '@backstage/core-plugin-api';
import { generateBoundRoutes, PrivateAppImpl } from './App';
describe('generateBoundRoutes', () => {
it('runs happy path', () => {
const external = { myRoute: createExternalRouteRef({ id: '1' }) };
const ref = createRouteRef({ id: 'ref-1' });
const result = generateBoundRoutes(({ bind }) => {
bind(external, { myRoute: ref });
});
expect(result.get(external.myRoute)).toBe(ref);
});
it('throws on unknown keys', () => {
const external = { myRoute: createExternalRouteRef({ id: '2' }) };
const ref = createRouteRef({ id: 'ref-2' });
expect(() =>
generateBoundRoutes(({ bind }) => {
bind(external, { someOtherRoute: ref } as any);
}),
).toThrow('Key someOtherRoute is not an existing external route');
});
});
describe('Integration Test', () => {
const plugin1RouteRef = createRouteRef({ id: 'ref-1' });
const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] });
const subRouteRef1 = createSubRouteRef({
id: 'sub1',
path: '/sub1',
parent: plugin1RouteRef,
});
const subRouteRef2 = createSubRouteRef({
id: 'sub2',
path: '/sub2/:x',
parent: plugin1RouteRef,
});
const subRouteRef3 = createSubRouteRef({
id: 'sub3',
path: '/sub3',
parent: plugin2RouteRef,
});
const subRouteRef4 = createSubRouteRef({
id: 'sub4',
path: '/sub4/:y',
parent: plugin2RouteRef,
});
const extRouteRef1 = createExternalRouteRef({ id: 'extRouteRef1' });
const extRouteRef2 = createExternalRouteRef({
id: 'extRouteRef2',
params: ['x'],
});
const extRouteRef3 = createExternalRouteRef({
id: 'extRouteRef3',
optional: true,
});
const extRouteRef4 = createExternalRouteRef({
id: 'extRouteRef4',
optional: true,
params: ['x'],
});
const plugin1 = createPlugin({
id: 'blob',
// Both absolute and sub route refs should be assignable to the plugin routes
routes: {
ref1: plugin1RouteRef,
ref2: plugin2RouteRef,
ref3: subRouteRef1,
},
externalRoutes: {
extRouteRef1,
extRouteRef2,
extRouteRef3,
extRouteRef4,
},
});
const plugin2 = createPlugin({
id: 'plugin2',
});
const HiddenComponent = plugin2.provide(
createRoutableExtension({
component: () => Promise.resolve((_: { path?: string }) => <div />),
mountPoint: plugin2RouteRef,
}),
);
const ExposedComponent = plugin1.provide(
createRoutableExtension({
component: () =>
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
const link1 = useRouteRef(plugin1RouteRef);
const link2 = useRouteRef(plugin2RouteRef);
const subLink1 = useRouteRef(subRouteRef1);
const subLink2 = useRouteRef(subRouteRef2);
const subLink3 = useRouteRef(subRouteRef3);
const subLink4 = useRouteRef(subRouteRef4);
const extLink1 = useRouteRef(extRouteRef1);
const extLink2 = useRouteRef(extRouteRef2);
const extLink3 = useRouteRef(extRouteRef3);
const extLink4 = useRouteRef(extRouteRef4);
return (
<div>
<span>link1: {link1()}</span>
<span>link2: {link2({ x: 'a' })}</span>
<span>subLink1: {subLink1()}</span>
<span>subLink2: {subLink2({ x: 'a' })}</span>
<span>subLink3: {subLink3({ x: 'b' })}</span>
<span>subLink4: {subLink4({ x: 'c', y: 'd' })}</span>
<span>extLink1: {extLink1()}</span>
<span>extLink2: {extLink2({ x: 'a' })}</span>
<span>extLink3: {extLink3?.() ?? '<none>'}</span>
<span>extLink4: {extLink4?.({ x: 'b' }) ?? '<none>'}</span>
</div>
);
}),
mountPoint: plugin1RouteRef,
}),
);
const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
Progress: () => null,
Router: BrowserRouter,
};
it('runs happy paths', async () => {
const app = new PrivateAppImpl({
apis: [],
defaultApis: [],
themes: [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
},
],
icons: defaultAppIcons,
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
extRouteRef3: subRouteRef1,
extRouteRef4: plugin2RouteRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<ExposedComponent path="/" />
<HiddenComponent path="/foo/:x" />
</Routes>
</Router>
</Provider>,
);
expect(screen.getByText('link1: /')).toBeInTheDocument();
expect(screen.getByText('link2: /foo/a')).toBeInTheDocument();
expect(screen.getByText('subLink1: /sub1')).toBeInTheDocument();
expect(screen.getByText('subLink2: /sub2/a')).toBeInTheDocument();
expect(screen.getByText('subLink3: /foo/b/sub3')).toBeInTheDocument();
expect(screen.getByText('subLink4: /foo/c/sub4/d')).toBeInTheDocument();
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument();
expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument();
expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument();
// Plugins should be discovered through element tree
expect(app.getPlugins()).toEqual([plugin1, plugin2]);
});
it('runs happy paths without optional routes', async () => {
const app = new PrivateAppImpl({
apis: [],
defaultApis: [],
themes: [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
},
],
icons: defaultAppIcons,
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<ExposedComponent path="/" />
<HiddenComponent path="/foo" />
</Routes>
</Router>
</Provider>,
);
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
expect(screen.getByText('extLink2: /foo')).toBeInTheDocument();
expect(screen.getByText('extLink3: <none>')).toBeInTheDocument();
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
});
it('should wait for the config to load before calling feature flags', async () => {
const storageFlags = new LocalStorageFeatureFlags();
jest.spyOn(storageFlags, 'registerFlag');
const apis = [
createApiFactory({
api: featureFlagsApiRef,
deps: { configApi: configApiRef },
factory() {
return storageFlags;
},
}),
];
const app = new PrivateAppImpl({
apis,
defaultApis: [],
themes: [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
},
],
icons: defaultAppIcons,
plugins: [
createPlugin({
id: 'test',
register: p => p.featureFlags.register('name'),
}),
],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<ExposedComponent path="/" />
<HiddenComponent path="/foo" />
</Routes>
</Router>
</Provider>,
);
expect(storageFlags.registerFlag).toHaveBeenCalledWith({
name: 'name',
pluginId: 'test',
});
});
it('should throw some error when the route has duplicate params', () => {
const app = new PrivateAppImpl({
apis: [],
defaultApis: [],
themes: [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
},
],
icons: defaultAppIcons,
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
const { error: errorLogs } = withLogCollector(() => {
expect(() =>
render(
<Provider>
<Router>
<Routes>
<ExposedComponent path="/test/:thing">
<HiddenComponent path="/some/:thing" />
</ExposedComponent>
</Routes>
</Router>
</Provider>,
),
).toThrow(
'Parameter :thing is duplicated in path /test/:thing/some/:thing',
);
});
expect(errorLogs).toEqual([
expect.stringContaining(
'Parameter :thing is duplicated in path /test/:thing/some/:thing',
),
expect.stringContaining(
'The above error occurred in the <Provider> component',
),
]);
});
});
+451
View File
@@ -0,0 +1,451 @@
/*
* 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 { Config } from '@backstage/config';
import React, {
ComponentType,
PropsWithChildren,
ReactElement,
useEffect,
useMemo,
useState,
} from 'react';
import { Route, Routes } from 'react-router-dom';
import { useAsync } from 'react-use';
import {
ApiProvider,
ApiRegistry,
AppThemeSelector,
ConfigReader,
LocalStorageFeatureFlags,
} from '../apis';
import {
useApi,
AnyApiFactory,
ApiHolder,
IconComponent,
AppTheme,
appThemeApiRef,
configApiRef,
AppThemeApi,
ConfigApi,
featureFlagsApiRef,
identityApiRef,
BackstagePlugin,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} from '@backstage/core-plugin-api';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
childDiscoverer,
routeElementDiscoverer,
traverseElementTree,
} from '../extensions/traversal';
import { pluginCollector } from '../plugins/collectors';
import {
routeObjectCollector,
routeParentCollector,
routePathCollector,
} from '../routing/collectors';
import { RoutingProvider } from '../routing/RoutingProvider';
import { validateRoutes } from '../routing/validation';
import { AppContextProvider } from './AppContext';
import { AppIdentity } from './AppIdentity';
import { AppThemeProvider } from './AppThemeProvider';
import {
AppComponents,
AppConfigLoader,
AppContext,
AppOptions,
AppRouteBinder,
BackstageApp,
SignInPageProps,
SignInResult,
} from './types';
export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) {
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
if (bindRoutes) {
const bind: AppRouteBinder = (
externalRoutes,
targetRoutes: { [name: string]: RouteRef | SubRouteRef },
) => {
for (const [key, value] of Object.entries(targetRoutes)) {
const externalRoute = externalRoutes[key];
if (!externalRoute) {
throw new Error(`Key ${key} is not an existing external route`);
}
if (!value && !externalRoute.optional) {
throw new Error(
`External route ${key} is required but was undefined`,
);
}
if (value) {
result.set(externalRoute, value);
}
}
};
bindRoutes({ bind });
}
return result;
}
type FullAppOptions = {
apis: Iterable<AnyApiFactory>;
icons: NonNullable<AppOptions['icons']>;
plugins: BackstagePlugin<any, any>[];
components: AppComponents;
themes: AppTheme[];
configLoader?: AppConfigLoader;
defaultApis: Iterable<AnyApiFactory>;
bindRoutes?: AppOptions['bindRoutes'];
};
function useConfigLoader(
configLoader: AppConfigLoader | undefined,
components: AppComponents,
appThemeApi: AppThemeApi,
): { api: ConfigApi } | { node: JSX.Element } {
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(configLoader);
const config = useAsync(configLoader || (() => Promise.resolve([])));
let noConfigNode = undefined;
if (hasConfig && config.loading) {
const { Progress } = components;
noConfigNode = <Progress />;
} else if (config.error) {
const { BootErrorPage } = components;
noConfigNode = <BootErrorPage step="load-config" error={config.error} />;
}
// Before the config is loaded we can't use a router, so exit early
if (noConfigNode) {
return {
node: (
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
</ApiProvider>
),
};
}
const configReader = ConfigReader.fromConfigs(config.value ?? []);
return { api: configReader };
}
class AppContextImpl implements AppContext {
constructor(private readonly app: PrivateAppImpl) {}
getPlugins(): BackstagePlugin<any, any>[] {
return this.app.getPlugins();
}
getSystemIcon(key: string): IconComponent | undefined {
return this.app.getSystemIcon(key);
}
getComponents(): AppComponents {
return this.app.getComponents();
}
}
export class PrivateAppImpl implements BackstageApp {
private apiHolder?: ApiHolder;
private configApi?: ConfigApi;
private readonly apis: Iterable<AnyApiFactory>;
private readonly icons: NonNullable<AppOptions['icons']>;
private readonly plugins: Set<BackstagePlugin<any, any>>;
private readonly components: AppComponents;
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
private readonly defaultApis: Iterable<AnyApiFactory>;
private readonly bindRoutes: AppOptions['bindRoutes'];
private readonly identityApi = new AppIdentity();
constructor(options: FullAppOptions) {
this.apis = options.apis;
this.icons = options.icons;
this.plugins = new Set(options.plugins);
this.components = options.components;
this.themes = options.themes;
this.configLoader = options.configLoader;
this.defaultApis = options.defaultApis;
this.bindRoutes = options.bindRoutes;
}
getPlugins(): BackstagePlugin<any, any>[] {
return Array.from(this.plugins);
}
getSystemIcon(key: string): IconComponent | undefined {
return this.icons[key];
}
getComponents(): AppComponents {
return this.components;
}
getProvider(): ComponentType<{}> {
const appContext = new AppContextImpl(this);
const Provider = ({ children }: PropsWithChildren<{}>) => {
const appThemeApi = useMemo(
() => AppThemeSelector.createWithStorage(this.themes),
[],
);
const { routePaths, routeParents, routeObjects } = useMemo(() => {
const result = traverseElementTree({
root: children,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routePaths: routePathCollector,
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
collectedPlugins: pluginCollector,
},
});
validateRoutes(result.routePaths, result.routeParents);
// TODO(Rugvip): Restructure the public API so that we can get an immediate view of
// the app, rather than having to wait for the provider to render.
// For now we need to push the additional plugins we find during
// collection and then make sure we initialize things afterwards.
result.collectedPlugins.forEach(plugin => this.plugins.add(plugin));
this.verifyPlugins(this.plugins);
// Initialize APIs once all plugins are available
this.getApiHolder();
return result;
}, [children]);
const loadedConfig = useConfigLoader(
this.configLoader,
this.components,
appThemeApi,
);
const hasConfigApi = 'api' in loadedConfig;
if (hasConfigApi) {
const { api } = loadedConfig as { api: Config };
this.configApi = api;
}
useEffect(() => {
if (hasConfigApi) {
const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!;
for (const plugin of this.plugins.values()) {
for (const output of plugin.output()) {
switch (output.type) {
case 'feature-flag': {
featureFlagsApi.registerFlag({
name: output.name,
pluginId: plugin.getId(),
});
break;
}
default:
break;
}
}
}
}
}, [hasConfigApi, loadedConfig]);
if ('node' in loadedConfig) {
// Loading or error
return loadedConfig.node;
}
return (
<ApiProvider apis={this.getApiHolder()}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider
routePaths={routePaths}
routeParents={routeParents}
routeObjects={routeObjects}
routeBindings={generateBoundRoutes(this.bindRoutes)}
>
{children}
</RoutingProvider>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
};
return Provider;
}
getRouter(): ComponentType<{}> {
const {
Router: RouterComponent,
SignInPage: SignInPageComponent,
} = this.components;
// This wraps the sign-in page and waits for sign-in to be completed before rendering the app
const SignInPageWrapper = ({
component: Component,
children,
}: {
component: ComponentType<SignInPageProps>;
children: ReactElement;
}) => {
const [result, setResult] = useState<SignInResult>();
if (result) {
this.identityApi.setSignInResult(result);
return children;
}
return <Component onResult={setResult} />;
};
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
const configApi = useApi(configApiRef);
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://dummy.dev', // baseUrl can be specified as just a path
);
if (pathname.endsWith('/')) {
pathname = pathname.replace(/\/$/, '');
}
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
});
return (
<RouterComponent>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</RouterComponent>
);
}
return (
<RouterComponent>
<SignInPageWrapper component={SignInPageComponent}>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</SignInPageWrapper>
</RouterComponent>
);
};
return AppRouter;
}
private getApiHolder(): ApiHolder {
if (this.apiHolder) {
return this.apiHolder;
}
const registry = new ApiFactoryRegistry();
registry.register('static', {
api: appThemeApiRef,
deps: {},
factory: () => AppThemeSelector.createWithStorage(this.themes),
});
registry.register('static', {
api: configApiRef,
deps: {},
factory: () => {
if (!this.configApi) {
throw new Error(
'Tried to access config API before config was loaded',
);
}
return this.configApi;
},
});
registry.register('static', {
api: identityApiRef,
deps: {},
factory: () => this.identityApi,
});
// It's possible to replace the feature flag API, but since we must have at least
// one implementation we add it here directly instead of through the defaultApis.
registry.register('default', {
api: featureFlagsApiRef,
deps: {},
factory: () => new LocalStorageFeatureFlags(),
});
for (const factory of this.defaultApis) {
registry.register('default', factory);
}
for (const plugin of this.plugins) {
for (const factory of plugin.getApis()) {
if (!registry.register('default', factory)) {
throw new Error(
`Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${
factory.api
}`,
);
}
}
}
for (const factory of this.apis) {
if (!registry.register('app', factory)) {
throw new Error(
`Duplicate or forbidden API factory for ${factory.api} in app`,
);
}
}
ApiResolver.validateFactories(registry, registry.getAllApis());
this.apiHolder = new ApiResolver(registry);
return this.apiHolder;
}
private verifyPlugins(plugins: Iterable<BackstagePlugin>) {
const pluginIds = new Set<string>();
for (const plugin of plugins) {
const id = plugin.getId();
if (pluginIds.has(id)) {
throw new Error(`Duplicate plugin found '${id}'`);
}
pluginIds.add(id);
}
}
}
@@ -0,0 +1,64 @@
/*
* 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 React, { useContext, Context } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { VersionedValue } from '../lib/versionedValues';
import { getGlobalSingleton } from '../lib/globalObject';
import { AppContext as AppContextV1 } from './types';
import { AppContextProvider } from './AppContext';
describe('v1 consumer', () => {
const AppContext = getGlobalSingleton<
Context<VersionedValue<{ 1: AppContextV1 }>>
>('app-context');
function useMockAppV1(): AppContextV1 {
const impl = useContext(AppContext)?.atVersion(1);
if (!impl) {
throw new Error('no impl');
}
return impl;
}
it('should provide an app context', () => {
const mockContext: AppContextV1 = {
getPlugins: jest.fn(),
getComponents: jest.fn(),
getSystemIcon: jest.fn(),
};
const renderedHook = renderHook(() => useMockAppV1(), {
wrapper: ({ children }) => (
<AppContextProvider appContext={mockContext} children={children} />
),
});
const result = renderedHook.result.current;
expect(mockContext.getPlugins).toHaveBeenCalledTimes(0);
result.getPlugins();
expect(mockContext.getPlugins).toHaveBeenCalledTimes(1);
expect(mockContext.getComponents).toHaveBeenCalledTimes(0);
result.getComponents();
expect(mockContext.getComponents).toHaveBeenCalledTimes(1);
expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(0);
result.getSystemIcon('icon');
expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(1);
expect(mockContext.getSystemIcon).toHaveBeenCalledWith('icon');
});
});
@@ -0,0 +1,41 @@
/*
* 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 React, { createContext, PropsWithChildren } from 'react';
import {
VersionedValue,
createVersionedValueMap,
} from '../lib/versionedValues';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
import { AppContext as AppContextV1 } from './types';
type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined;
const AppContext = getOrCreateGlobalSingleton('app-context', () =>
createContext<AppContextType | undefined>(undefined),
);
type Props = {
appContext: AppContextV1;
};
export const AppContextProvider = ({
appContext,
children,
}: PropsWithChildren<Props>) => {
const versionedValue = createVersionedValueMap({ 1: appContext });
return <AppContext.Provider value={versionedValue} children={children} />;
};
@@ -0,0 +1,85 @@
/*
* 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 { IdentityApi, ProfileInfo } from '@backstage/core-plugin-api';
import { SignInResult } from './types';
/**
* Implementation of the connection between the App-wide IdentityApi
* and sign-in page.
*/
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private profile?: ProfileInfo;
private idTokenFunc?: () => Promise<string>;
private signOutFunc?: () => Promise<void>;
getUserId(): string {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi userId before app was loaded',
);
}
return this.userId!;
}
getProfile(): ProfileInfo {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi profile before app was loaded',
);
}
return this.profile!;
}
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idTokenFunc?.();
}
async signOut(): Promise<void> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi signOutFunc before app was loaded',
);
}
await this.signOutFunc?.();
location.reload();
}
// This is indirectly called by the sign-in page to continue into the app.
setSignInResult(result: SignInResult) {
if (this.hasIdentity) {
return;
}
if (!result.userId) {
throw new Error('Invalid sign-in result, userId not set');
}
if (!result.profile) {
throw new Error('Invalid sign-in result, profile not set');
}
this.hasIdentity = true;
this.userId = result.userId;
this.profile = result.profile;
this.idTokenFunc = result.getIdToken;
this.signOutFunc = result.signOut;
}
}
@@ -0,0 +1,97 @@
/*
* 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 React, { useMemo, useEffect, useState, PropsWithChildren } from 'react';
import { ThemeProvider, CssBaseline } from '@material-ui/core';
import { useApi, appThemeApiRef, AppTheme } from '@backstage/core-plugin-api';
import { useObservable } from 'react-use';
// This tries to find the most accurate match, but also falls back to less
// accurate results in order to avoid errors.
function resolveTheme(
themeId: string | undefined,
shouldPreferDark: boolean,
themes: AppTheme[],
) {
if (themeId !== undefined) {
const selectedTheme = themes.find(theme => theme.id === themeId);
if (selectedTheme) {
return selectedTheme;
}
}
if (shouldPreferDark) {
const darkTheme = themes.find(theme => theme.variant === 'dark');
if (darkTheme) {
return darkTheme;
}
}
const lightTheme = themes.find(theme => theme.variant === 'light');
if (lightTheme) {
return lightTheme;
}
return themes[0];
}
const useShouldPreferDarkTheme = () => {
const mediaQuery = useMemo(
() => window.matchMedia('(prefers-color-scheme: dark)'),
[],
);
const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches);
useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setPrefersDark(event.matches);
};
mediaQuery.addListener(listener);
return () => {
mediaQuery.removeListener(listener);
};
}, [mediaQuery]);
return shouldPreferDark;
};
export function AppThemeProvider({ children }: PropsWithChildren<{}>) {
const appThemeApi = useApi(appThemeApiRef);
const themeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
// Browser feature detection won't change over time, so ignore lint rule
const shouldPreferDark = Boolean(window.matchMedia)
? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks
: false;
const appTheme = resolveTheme(
themeId,
shouldPreferDark,
appThemeApi.getInstalledThemes(),
);
if (!appTheme) {
throw new Error('App has no themes');
}
return (
<ThemeProvider theme={appTheme.theme}>
<CssBaseline>{children}</CssBaseline>
</ThemeProvider>
);
}
@@ -0,0 +1,117 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { defaultConfigLoader, OptionallyWrapInRouter } from './createApp';
(process as any).env = { NODE_ENV: 'test' };
const anyEnv = process.env as any;
const anyWindow = window as any;
describe('defaultConfigLoader', () => {
afterEach(() => {
delete anyEnv.APP_CONFIG;
delete anyWindow.__APP_CONFIG__;
});
it('loads static config', async () => {
anyEnv.APP_CONFIG = [
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
];
const configs = await defaultConfigLoader();
expect(configs).toEqual([
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
]);
});
it('loads runtime config', async () => {
anyEnv.APP_CONFIG = [
{ data: { my: 'override-config' }, context: 'a' },
{ data: { my: 'config' }, context: 'b' },
];
const configs = await (defaultConfigLoader as any)(
'{"my":"runtime-config"}',
);
expect(configs).toEqual([
{ data: { my: 'override-config' }, context: 'a' },
{ data: { my: 'config' }, context: 'b' },
{ data: { my: 'runtime-config' }, context: 'env' },
]);
});
it('fails to load invalid missing config', async () => {
await expect(defaultConfigLoader()).rejects.toThrow(
'No static configuration provided',
);
});
it('fails to load invalid static config', async () => {
anyEnv.APP_CONFIG = { my: 'invalid-config' };
await expect(defaultConfigLoader()).rejects.toThrow(
'Static configuration has invalid format',
);
});
it('fails to load bad runtime config', async () => {
anyEnv.APP_CONFIG = [{ data: { my: 'config' }, context: 'a' }];
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0',
);
});
it('loads config from window.__APP_CONFIG__', async () => {
anyEnv.APP_CONFIG = [
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
];
const windowConfig = { app: { configKey: 'config-value' } };
anyWindow.__APP_CONFIG__ = windowConfig;
const configs = await defaultConfigLoader();
expect(configs).toEqual([
...anyEnv.APP_CONFIG,
{ context: 'window', data: windowConfig },
]);
});
});
describe('OptionallyWrapInRouter', () => {
it('should wrap with router if not yet inside a router', async () => {
const { getByText } = render(
<OptionallyWrapInRouter>Test</OptionallyWrapInRouter>,
);
expect(getByText('Test')).toBeInTheDocument();
});
it('should not wrap with router if already inside a router', async () => {
const { getByText } = render(
<MemoryRouter>
<OptionallyWrapInRouter>Test</OptionallyWrapInRouter>
</MemoryRouter>,
);
expect(getByText('Test')).toBeInTheDocument();
});
});
+157
View File
@@ -0,0 +1,157 @@
/*
* 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 React, { PropsWithChildren } from 'react';
import { AppOptions, BootErrorPageProps, AppConfigLoader } from './types';
import { defaultAppIcons } from './icons';
import {
BrowserRouter,
MemoryRouter,
useInRouterContext,
} from 'react-router-dom';
import LightIcon from '@material-ui/icons/WbSunny';
import DarkIcon from '@material-ui/icons/Brightness2';
import { ErrorPage, Progress } from '@backstage/core-components';
import { defaultApis } from './defaultApis';
import { lightTheme, darkTheme } from '@backstage/theme';
import { AppConfig, JsonObject } from '@backstage/config';
import { PrivateAppImpl } from './App';
/**
* The default config loader, which expects that config is available at compile-time
* in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as
* returned by the config loader.
*
* It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string,
* which can be rewritten at runtime to contain an additional JSON config object.
* If runtime config is present, it will be placed first in the config array, overriding
* other config values.
*/
export const defaultConfigLoader: AppConfigLoader = async (
// This string may be replaced at runtime to provide additional config.
// It should be replaced by a JSON-serialized config object.
// It's a param so we can test it, but at runtime this will always fall back to default.
runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__',
) => {
const appConfig = process.env.APP_CONFIG;
if (!appConfig) {
throw new Error('No static configuration provided');
}
if (!Array.isArray(appConfig)) {
throw new Error('Static configuration has invalid format');
}
const configs = (appConfig.slice() as unknown) as AppConfig[];
// Avoiding this string also being replaced at runtime
if (
runtimeConfigJson !==
'__app_injected_runtime_config__'.toLocaleUpperCase('en-US')
) {
try {
const data = JSON.parse(runtimeConfigJson) as JsonObject;
if (Array.isArray(data)) {
configs.push(...data);
} else {
configs.push({ data, context: 'env' });
}
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
}
const windowAppConfig = (window as any).__APP_CONFIG__;
if (windowAppConfig) {
configs.push({
context: 'window',
data: windowAppConfig,
});
}
return configs;
};
// createApp is defined in core, and not core-api, since we need access
// to the components inside core to provide defaults.
// The actual implementation of the app class still lives in core-api,
// as it needs to be used by dev- and test-utils.
export function OptionallyWrapInRouter({ children }: PropsWithChildren<{}>) {
if (useInRouterContext()) {
return <>{children}</>;
}
return <MemoryRouter>{children}</MemoryRouter>;
}
/**
* Creates a new Backstage App.
*/
export function createApp(options?: AppOptions) {
const DefaultNotFoundPage = () => (
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
);
const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => {
let message = '';
if (step === 'load-config') {
message = `The configuration failed to load, someone should have a look at this error: ${error.message}`;
} else if (step === 'load-chunk') {
message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`;
}
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
return (
<OptionallyWrapInRouter>
<ErrorPage status="501" statusMessage={message} />
</OptionallyWrapInRouter>
);
};
const apis = options?.apis ?? [];
const icons = { ...defaultAppIcons, ...options?.icons };
const plugins = options?.plugins ?? [];
const components = {
NotFoundErrorPage: DefaultNotFoundPage,
BootErrorPage: DefaultBootErrorPage,
Progress: Progress,
Router: BrowserRouter,
...options?.components,
};
const themes = options?.themes ?? [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
icon: <LightIcon />,
},
{
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
theme: darkTheme,
icon: <DarkIcon />,
},
];
const configLoader = options?.configLoader ?? defaultConfigLoader;
return new PrivateAppImpl({
apis,
icons,
plugins,
components,
themes,
configLoader,
defaultApis,
bindRoutes: options?.bindRoutes,
});
}
@@ -0,0 +1,223 @@
/*
* 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 {
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
GithubAuth,
OAuth2,
OktaAuth,
GitlabAuth,
Auth0Auth,
MicrosoftAuth,
OAuthRequestManager,
WebStorage,
UrlPatternDiscovery,
SamlAuth,
OneLoginAuth,
} from '../apis';
import {
createApiFactory,
alertApiRef,
errorApiRef,
discoveryApiRef,
oauthRequestApiRef,
googleAuthApiRef,
githubAuthApiRef,
oauth2ApiRef,
oktaAuthApiRef,
gitlabAuthApiRef,
auth0AuthApiRef,
microsoftAuthApiRef,
storageApiRef,
configApiRef,
samlAuthApiRef,
oneloginAuthApiRef,
oidcAuthApiRef,
} from '@backstage/core-plugin-api';
import OAuth2Icon from '@material-ui/icons/AcUnit';
export const defaultApis = [
createApiFactory({
api: discoveryApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
UrlPatternDiscovery.compile(
`${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`,
),
}),
createApiFactory(alertApiRef, new AlertApiForwarder()),
createApiFactory({
api: errorApiRef,
deps: { alertApi: alertApiRef },
factory: ({ alertApi }) =>
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
}),
createApiFactory({
api: storageApiRef,
deps: { errorApi: errorApiRef },
factory: ({ errorApi }) => WebStorage.create({ errorApi }),
}),
createApiFactory(oauthRequestApiRef, new OAuthRequestManager()),
createApiFactory({
api: googleAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
GoogleAuth.create({
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: microsoftAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
MicrosoftAuth.create({
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: githubAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
GithubAuth.create({
discoveryApi,
oauthRequestApi,
defaultScopes: ['read:user'],
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: oktaAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
OktaAuth.create({
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: gitlabAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
GitlabAuth.create({
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: auth0AuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
Auth0Auth.create({
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: oauth2ApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
OAuth2.create({
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: samlAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, configApi }) =>
SamlAuth.create({
discoveryApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: oneloginAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
OneLoginAuth.create({
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
}),
}),
createApiFactory({
api: oidcAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
OAuth2.create({
discoveryApi,
oauthRequestApi,
provider: {
id: 'oidc',
title: 'Your Identity Provider',
icon: OAuth2Icon,
},
environment: configApi.getOptionalString('auth.environment'),
}),
}),
];
+58
View File
@@ -0,0 +1,58 @@
/*
* 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 { IconComponent } from '@backstage/core-plugin-api';
import MuiMenuBookIcon from '@material-ui/icons/MenuBook';
import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage';
import MuiChatIcon from '@material-ui/icons/Chat';
import MuiDashboardIcon from '@material-ui/icons/Dashboard';
import MuiEmailIcon from '@material-ui/icons/Email';
import MuiGitHubIcon from '@material-ui/icons/GitHub';
import MuiHelpIcon from '@material-ui/icons/Help';
import MuiPeopleIcon from '@material-ui/icons/People';
import MuiPersonIcon from '@material-ui/icons/Person';
import MuiWarningIcon from '@material-ui/icons/Warning';
import MuiDocsIcon from '@material-ui/icons/Description';
type AppIconsKey =
| 'brokenImage'
| 'catalog'
| 'chat'
| 'dashboard'
| 'docs'
| 'email'
| 'github'
| 'group'
| 'help'
| 'user'
| 'warning';
export type AppIcons = { [key in AppIconsKey]: IconComponent };
export const defaultAppIcons: AppIcons = {
brokenImage: MuiBrokenImageIcon,
// To be confirmed: see https://github.com/backstage/backstage/issues/4970
catalog: MuiMenuBookIcon,
chat: MuiChatIcon,
dashboard: MuiDashboardIcon,
docs: MuiDocsIcon,
email: MuiEmailIcon,
github: MuiGitHubIcon,
group: MuiPeopleIcon,
help: MuiHelpIcon,
user: MuiPersonIcon,
warning: MuiWarningIcon,
};
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { createApp } from './createApp';
export * from './types';
+244
View File
@@ -0,0 +1,244 @@
/*
* 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 { ComponentType } from 'react';
import {
AnyApiFactory,
AppTheme,
ProfileInfo,
IconComponent,
BackstagePlugin,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} from '@backstage/core-plugin-api';
import { AppConfig } from '@backstage/config';
import { AppIcons } from './icons';
export type BootErrorPageProps = {
step: 'load-config' | 'load-chunk';
error: Error;
};
export type SignInResult = {
/**
* User ID that will be returned by the IdentityApi
*/
userId: string;
profile: ProfileInfo;
/**
* Function used to retrieve an ID token for the signed in user.
*/
getIdToken?: () => Promise<string>;
/**
* Sign out handler that will be called if the user requests to sign out.
*/
signOut?: () => Promise<void>;
};
export type SignInPageProps = {
/**
* Set the sign-in result for the app. This should only be called once.
*/
onResult(result: SignInResult): void;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
/**
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
*
* If a sign-in page is set, it will always be shown before the app, and it is up
* to the sign-in page to handle e.g. saving of login methods for subsequent visits.
*
* The sign-in page will be displayed until it has passed up a result to the parent,
* and which point the AppRouter and all of its children will be rendered instead.
*/
SignInPage?: ComponentType<SignInPageProps>;
};
/**
* A function that loads in the App config that will be accessible via the ConfigApi.
*
* If multiple config objects are returned in the array, values in the earlier configs
* will override later ones.
*/
export type AppConfigLoader = () => Promise<AppConfig[]>;
/**
* Extracts a union of the keys in a map whose value extends the given type
*/
type KeysWithType<Obj extends { [key in string]: any }, Type> = {
[key in keyof Obj]: Obj[key] extends Type ? key : never;
}[keyof Obj];
/**
* Takes a map Map required values and makes all keys matching Keys optional
*/
type PartialKeys<
Map extends { [name in string]: any },
Keys extends keyof Map
> = Partial<Pick<Map, Keys>> & Required<Omit<Map, Keys>>;
/**
* Creates a map of target routes with matching parameters based on a map of external routes.
*/
type TargetRouteMap<
ExternalRoutes extends { [name: string]: ExternalRouteRef }
> = {
[name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef<
infer Params,
any
>
? RouteRef<Params> | SubRouteRef<Params>
: never;
};
export type AppRouteBinder = <
ExternalRoutes extends { [name: string]: ExternalRouteRef }
>(
externalRoutes: ExternalRoutes,
targetRoutes: PartialKeys<
TargetRouteMap<ExternalRoutes>,
KeysWithType<ExternalRoutes, ExternalRouteRef<any, true>>
>,
) => void;
export type AppOptions = {
/**
* A collection of ApiFactories to register in the application to either
* add add new ones, or override factories provided by default or by plugins.
*/
apis?: Iterable<AnyApiFactory>;
/**
* Supply icons to override the default ones.
*/
icons?: AppIcons & { [key in string]: IconComponent };
/**
* A list of all plugins to include in the app.
*/
plugins?: BackstagePlugin<any, any>[];
/**
* Supply components to the app to override the default ones.
*/
components?: Partial<AppComponents>;
/**
* Themes provided as a part of the app. By default two themes are included, one
* light variant of the default backstage theme, and one dark.
*
* This is the default config:
*
* ```
* [{
* id: 'light',
* title: 'Light Theme',
* variant: 'light',
* theme: lightTheme,
* icon: <LightIcon />,
* }, {
* id: 'dark',
* title: 'Dark Theme',
* variant: 'dark',
* theme: darkTheme,
* icon: <DarkIcon />,
* }]
* ```
*/
themes?: AppTheme[];
/**
* A function that loads in App configuration that will be accessible via
* the ConfigApi.
*
* Defaults to an empty config.
*
* TODO(Rugvip): Omitting this should instead default to loading in configuration
* that was packaged by the backstage-cli and default docker container boot script.
*/
configLoader?: AppConfigLoader;
/**
* A function that is used to register associations between cross-plugin route
* references, enabling plugins to navigate between each other.
*
* The `bind` function that is passed in should be used to bind all external
* routes of all used plugins.
*
* ```ts
* bindRoutes({ bind }) {
* bind(docsPlugin.externalRoutes, {
* homePage: managePlugin.routes.managePage,
* })
* bind(homePagePlugin.externalRoutes, {
* settingsPage: settingsPlugin.routes.settingsPage,
* })
* }
* ```
*/
bindRoutes?(context: { bind: AppRouteBinder }): void;
};
export type BackstageApp = {
/**
* Returns all plugins registered for the app.
*/
getPlugins(): BackstagePlugin<any, any>[];
/**
* Get a common or custom icon for this app.
*/
getSystemIcon(key: string): IconComponent | undefined;
/**
* Provider component that should wrap the Router created with getRouter()
* and any other components that need to be within the app context.
*/
getProvider(): ComponentType<{}>;
/**
* Router component that should wrap the App Routes create with getRoutes()
* and any other components that should only be available while signed in.
*/
getRouter(): ComponentType<{}>;
};
export type AppContext = {
/**
* Get a list of all plugins that are installed in the app.
*/
getPlugins(): BackstagePlugin<any, any>[];
/**
* Get a common or custom icon for this app.
*/
getSystemIcon(key: string): IconComponent | undefined;
/**
* Get the components registered for various purposes in the app.
*/
getComponents(): AppComponents;
};
@@ -0,0 +1,118 @@
/*
* 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 React from 'react';
import { attachComponentData, getComponentData } from './componentData';
describe('elementData', () => {
it('should attach a single piece of data', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should attach several distinct pieces of data', () => {
const data1 = { foo: 'bar' };
const data2 = { test: 'value' };
const Component = () => null;
attachComponentData(Component, 'my-data', data1);
attachComponentData(Component, 'second', data2);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data1);
expect(getComponentData(element, 'second')).toBe(data2);
});
it('returns undefined for missing data', () => {
const data = { foo: 'bar' };
const Component1 = () => null;
const Component2 = () => null;
attachComponentData(Component2, 'my-data', data);
const element1 = <Component1 />;
const element2 = <Component2 />;
expect(getComponentData(element1, 'missing')).toBeUndefined();
expect(getComponentData(element2, 'missing')).toBeUndefined();
});
it('should throw when attempting to overwrite data', () => {
const data = { foo: 'bar' };
const MyComponent = () => null;
attachComponentData(MyComponent, 'my-data', data);
expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow(
'Attempted to attach duplicate data "my-data" to component "MyComponent"',
);
});
describe('works across versions', () => {
function getDataSymbol() {
const Component = () => null;
attachComponentData(Component, 'my-data', {});
const [symbol] = Object.getOwnPropertySymbols(Component);
return symbol;
}
it('should should be able to get data from older versions', () => {
const symbol = getDataSymbol();
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect((element as any).type[symbol].map.get('my-data')).toBe(data);
});
it('should should be able to attach data for older versions', () => {
const symbol = getDataSymbol();
const data = { foo: 'bar' };
const Component = () => null;
(Component as any)[symbol] = {
map: new Map([['my-data', data]]),
};
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should be able to get data from newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
const container = (global as any)[
'__@backstage/component-data-store__'
].get(element.type);
expect(container.map.get('my-data')).toBe(data);
});
it('should should be able to attach data for newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
(global as any)['__@backstage/component-data-store__'].set(Component, {
map: new Map([['my-data', data]]),
});
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
});
});
@@ -0,0 +1,84 @@
/*
* 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 { ComponentType, ReactNode } from 'react';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x
const DATA_KEY = Symbol('backstage-component-data');
type ComponentWithData<P> = ComponentType<P> & {
[DATA_KEY]?: DataContainer;
};
type DataContainer = {
map: Map<string, unknown>;
};
type MaybeComponentNode = ReactNode & {
type?: ComponentType<any> & { [DATA_KEY]?: DataContainer };
};
// The store is bridged across versions using the global object
const store = getOrCreateGlobalSingleton(
'component-data-store',
() => new WeakMap<ComponentType<any>, DataContainer>(),
);
export function attachComponentData<P>(
component: ComponentType<P>,
type: string,
data: unknown,
) {
const dataComponent = component as ComponentWithData<P>;
let container = store.get(component) || dataComponent[DATA_KEY];
if (!container) {
container = { map: new Map() };
store.set(component, container);
dataComponent[DATA_KEY] = container;
}
if (container.map.has(type)) {
const name = component.displayName || component.name;
throw new Error(
`Attempted to attach duplicate data "${type}" to component "${name}"`,
);
}
container.map.set(type, data);
}
export function getComponentData<T>(
node: ReactNode,
type: string,
): T | undefined {
if (!node) {
return undefined;
}
const component = (node as MaybeComponentNode).type;
if (!component) {
return undefined;
}
const container = store.get(component) || component[DATA_KEY];
if (!container) {
return undefined;
}
return container.map.get(type) as T | undefined;
}
@@ -0,0 +1,75 @@
/*
* 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 React from 'react';
import { createPlugin, createRouteRef } from '@backstage/core-plugin-api';
import { getComponentData } from './componentData';
import {
createComponentExtension,
createReactExtension,
createRoutableExtension,
} from './extensions';
const plugin = createPlugin({
id: 'my-plugin',
});
describe('extensions', () => {
it('should create a react extension with component data', () => {
const Component = () => <div />;
const extension = createReactExtension({
component: {
sync: Component,
},
data: {
myData: { foo: 'bar' },
},
});
const ExtensionComponent = plugin.provide(extension);
const element = <ExtensionComponent />;
expect(getComponentData(element, 'core.plugin')).toBe(plugin);
expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' });
});
it('should create react extensions of different types', () => {
const Component = () => <div />;
const routeRef = createRouteRef({ id: 'foo' });
const extension1 = createComponentExtension({
component: {
sync: Component,
},
});
const extension2 = createRoutableExtension({
component: () => Promise.resolve(Component),
mountPoint: routeRef,
});
const ExtensionComponent1 = plugin.provide(extension1);
const ExtensionComponent2 = plugin.provide(extension2);
const element1 = <ExtensionComponent1 />;
const element2 = <ExtensionComponent2 />;
expect(getComponentData(element1, 'core.plugin')).toBe(plugin);
expect(getComponentData(element2, 'core.plugin')).toBe(plugin);
expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef);
});
});
@@ -0,0 +1,145 @@
/*
* 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 React, { lazy, Suspense } from 'react';
import { attachComponentData } from './componentData';
import {
Extension,
BackstagePlugin,
RouteRef,
useRouteRef,
useApp,
} from '@backstage/core-plugin-api';
type ComponentLoader<T> =
| {
lazy: () => Promise<T>;
}
| {
sync: T;
};
// We do not use ComponentType as the return type, since it doesn't let us convey the children prop.
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
// making it impossible to make the usage of children type safe.
export function createRoutableExtension<
T extends (props: any) => JSX.Element | null
>(options: {
component: () => Promise<T>;
mountPoint: RouteRef;
}): Extension<T> {
const { component, mountPoint } = options;
return createReactExtension({
component: {
lazy: () =>
component().then(
InnerComponent => {
const RoutableExtensionWrapper: any = (props: any) => {
// Validate that the routing is wired up correctly in the App.tsx
try {
useRouteRef(mountPoint);
} catch (error) {
if (error?.message.startsWith('No path for ')) {
throw new Error(
`Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` +
'Routable extension components may not be rendered by other components and must be ' +
'directly available as an element within the App provider component.',
);
}
throw error;
}
return <InnerComponent {...props} />;
};
const componentName =
(InnerComponent as { displayName?: string }).displayName ||
InnerComponent.name ||
'LazyComponent';
RoutableExtensionWrapper.displayName = `RoutableExtension(${componentName})`;
return RoutableExtensionWrapper as T;
},
error => {
const RoutableExtensionWrapper: any = (_: any) => {
const app = useApp();
const { BootErrorPage } = app.getComponents();
return <BootErrorPage step="load-chunk" error={error} />;
};
return RoutableExtensionWrapper;
},
),
},
data: {
'core.mountPoint': mountPoint,
},
});
}
// We do not use ComponentType as the return type, since it doesn't let us convey the children prop.
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
// making it impossible to make the usage of children type safe.
export function createComponentExtension<
T extends (props: any) => JSX.Element | null
>(options: { component: ComponentLoader<T> }): Extension<T> {
const { component } = options;
return createReactExtension({ component });
}
// We do not use ComponentType as the return type, since it doesn't let us convey the children prop.
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
// making it impossible to make the usage of children type safe.
export function createReactExtension<
T extends (props: any) => JSX.Element | null
>(options: {
component: ComponentLoader<T>;
data?: Record<string, unknown>;
}): Extension<T> {
const { data = {} } = options;
let Component: T;
if ('lazy' in options.component) {
const lazyLoader = options.component.lazy;
Component = (lazy(() =>
lazyLoader().then(component => ({ default: component })),
) as unknown) as T;
} else {
Component = options.component.sync;
}
const componentName =
(Component as { displayName?: string }).displayName ||
Component.name ||
'Component';
return {
expose(plugin: BackstagePlugin<any, any>) {
const Result: any = (props: any) => (
<Suspense fallback="...">
<Component {...props} />
</Suspense>
);
attachComponentData(Result, 'core.plugin', plugin);
for (const [key, value] of Object.entries(data)) {
attachComponentData(Result, key, value);
}
Result.displayName = `Extension(${componentName})`;
return Result;
},
};
}
@@ -0,0 +1,22 @@
/*
* 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.
*/
export { attachComponentData, getComponentData } from './componentData';
export {
createReactExtension,
createRoutableExtension,
createComponentExtension,
} from './extensions';
@@ -0,0 +1,104 @@
/*
* 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 React, { Children, isValidElement } from 'react';
import {
childDiscoverer,
createCollector,
traverseElementTree,
} from './traversal';
describe('discovery', () => {
it('should collect element names', () => {
const root = (
<main>
<div>
<h1>Title</h1>
<p>Text</p>
</div>
<hr />
<div>
<h2>Title</h2>
<span>Text</span>
</div>
</main>
);
const { names } = traverseElementTree({
root,
discoverers: [childDiscoverer],
collectors: {
names: createCollector(
() => Array<string>(),
(acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
},
),
},
});
expect(names).toEqual([
'main',
'div',
'hr',
'div',
'h1',
'p',
'h2',
'span',
]);
});
it('should collect element names while skipping one level of children', () => {
const root = (
<main>
<div>
<h1>Title</h1>
<p>Text</p>
</div>
<hr />
<div>
<h2>Title</h2>
<span>Text</span>
</div>
</main>
);
const { names } = traverseElementTree({
root,
discoverers: [
el =>
Children.toArray(el.props.children).flatMap(child =>
isValidElement(child) ? child?.props?.children : [],
),
],
collectors: {
names: createCollector(
() => Array<string>(),
(acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
},
),
},
});
expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']);
});
});
@@ -0,0 +1,129 @@
/*
* 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 { isValidElement, ReactNode, ReactElement, Children } from 'react';
export type Discoverer = (element: ReactElement) => ReactNode;
export type Collector<Result, Context> = () => {
accumulator: Result;
visit(
accumulator: Result,
element: ReactElement,
parent: ReactElement | undefined,
context: Context,
): Context;
};
/**
* A function that allows you to traverse a tree of React elements using
* varying methods to discover child nodes and collect data along the way.
*/
export function traverseElementTree<Results>(options: {
root: ReactNode;
discoverers: Discoverer[];
collectors: { [name in keyof Results]: Collector<Results[name], any> };
}): Results {
const collectors: {
[name in string]: ReturnType<Collector<any, any>>;
} = {};
// Bootstrap all collectors, initializing the accumulators and providing the visitor function
for (const name in options.collectors) {
if (options.collectors.hasOwnProperty(name)) {
collectors[name] = options.collectors[name]();
}
}
// Internal representation of an element in the tree that we're iterating over
type QueueItem = {
node: ReactNode;
parent: ReactElement | undefined;
contexts: { [name in string]: unknown };
};
const queue = [
{
node: Children.toArray(options.root),
parent: undefined,
contexts: {},
} as QueueItem,
];
while (queue.length !== 0) {
const { node, parent, contexts } = queue.shift()!;
// While the parent and the element we pass on to collectors and discoverers
// have been validated and are known to be React elements, the child nodes
// emitted by the discoverers are not.
Children.forEach(node, element => {
if (!isValidElement(element)) {
return;
}
const nextContexts: QueueItem['contexts'] = {};
// Collectors populate their result data using the current node, and compute
// context for the next iteration
for (const name in collectors) {
if (collectors.hasOwnProperty(name)) {
const collector = collectors[name];
nextContexts[name] = collector.visit(
collector.accumulator,
element,
parent,
contexts[name],
);
}
}
// Discoverers provide ways to continue the traversal from the current element
for (const discoverer of options.discoverers) {
const children = discoverer(element);
if (children) {
queue.push({
node: children,
parent: element,
contexts: nextContexts,
});
}
}
});
}
return Object.fromEntries(
Object.entries(collectors).map(([name, c]) => [name, c.accumulator]),
) as Results;
}
export function createCollector<Result, Context>(
accumulatorFactory: () => Result,
visit: ReturnType<Collector<Result, Context>>['visit'],
): Collector<Result, Context> {
return () => ({ accumulator: accumulatorFactory(), visit });
}
export function childDiscoverer(element: ReactElement): ReactNode {
return element.props?.children;
}
export function routeElementDiscoverer(element: ReactElement): ReactNode {
if (element.props?.path && element.props?.element) {
return element.props?.element;
}
return undefined;
}
+54
View File
@@ -0,0 +1,54 @@
/*
* 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 * as index from '.';
describe('index', () => {
it('exports the app api', () => {
expect(index).toEqual({
// Public API
createApp: expect.any(Function),
ApiProvider: expect.any(Function),
// TODO(Rugvip): Figure out if we need these
ApiFactoryRegistry: expect.any(Function),
ApiResolver: expect.any(Function),
ApiRegistry: expect.any(Function),
// Components
FlatRoutes: expect.any(Function),
// Utility API Implementations
AlertApiForwarder: expect.any(Function),
AppThemeSelector: expect.any(Function),
Auth0Auth: expect.any(Function),
ConfigReader: expect.any(Function),
ErrorAlerter: expect.any(Function),
ErrorApiForwarder: expect.any(Function),
GithubAuth: expect.any(Function),
GitlabAuth: expect.any(Function),
GoogleAuth: expect.any(Function),
LocalStorageFeatureFlags: expect.any(Function),
MicrosoftAuth: expect.any(Function),
OAuth2: expect.any(Function),
OAuthRequestManager: expect.any(Function),
OktaAuth: expect.any(Function),
OneLoginAuth: expect.any(Function),
SamlAuth: expect.any(Function),
UrlPatternDiscovery: expect.any(Function),
WebStorage: expect.any(Function),
});
});
});
+19
View File
@@ -0,0 +1,19 @@
/*
* 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.
*/
export * from './apis';
export * from './app';
export * from './routing';
@@ -0,0 +1,182 @@
/*
* 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 ProviderIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from './DefaultAuthConnector';
import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi';
import * as loginPopup from '../loginPopup';
import { UrlPatternDiscovery } from '../../apis';
import { msw } from '@backstage/test-utils';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const defaultOptions = {
discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'),
environment: 'production',
provider: {
id: 'my-provider',
title: 'My Provider',
icon: ProviderIcon,
},
oauthRequestApi: new MockOAuthApi(),
sessionTransform: ({ expiresInSeconds, ...res }: any) => ({
...res,
scopes: new Set(res.scopes.split(' ')),
expiresAt: new Date(Date.now() + expiresInSeconds * 1000),
}),
};
describe('DefaultAuthConnector', () => {
const server = setupServer();
msw.setupDefaultHandlers(server);
afterEach(() => {
jest.resetAllMocks();
});
it('should refresh a session', async () => {
server.use(
rest.get('*', (_req, res, ctx) =>
res(
ctx.json({
idToken: 'mock-id-token',
accessToken: 'mock-access-token',
scopes: 'a b c',
expiresInSeconds: '60',
}),
),
),
);
const helper = new DefaultAuthConnector<any>(defaultOptions);
const session = await helper.refreshSession();
expect(session.idToken).toBe('mock-id-token');
expect(session.accessToken).toBe('mock-access-token');
expect(session.scopes).toEqual(new Set(['a', 'b', 'c']));
expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000);
expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000);
});
it('should handle failure to refresh session', async () => {
server.use(
rest.get('*', (_req, res, ctx) =>
res(ctx.status(500, 'Error: Network NOPE')),
),
);
const helper = new DefaultAuthConnector(defaultOptions);
await expect(helper.refreshSession()).rejects.toThrow(
'Auth refresh request failed, Error: Network NOPE',
);
});
it('should handle failure response when refreshing session', async () => {
server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE'))));
const helper = new DefaultAuthConnector(defaultOptions);
await expect(helper.refreshSession()).rejects.toThrow(
'Auth refresh request failed, NOPE',
);
});
it('should fail if popup was rejected', async () => {
const mockOauth = new MockOAuthApi();
const helper = new DefaultAuthConnector({
...defaultOptions,
oauthRequestApi: mockOauth,
});
const promise = helper.createSession({ scopes: new Set(['a', 'b']) });
await mockOauth.rejectAll();
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
});
it('should create a session', async () => {
const mockOauth = new MockOAuthApi();
const popupSpy = jest
.spyOn(loginPopup, 'showLoginPopup')
.mockResolvedValue({
idToken: 'my-id-token',
accessToken: 'my-access-token',
scopes: 'a b',
expiresInSeconds: 3600,
});
const helper = new DefaultAuthConnector({
...defaultOptions,
oauthRequestApi: mockOauth,
});
const sessionPromise = helper.createSession({
scopes: new Set(['a', 'b']),
});
await mockOauth.triggerAll();
expect(popupSpy).toBeCalledTimes(1);
expect(popupSpy.mock.calls[0][0]).toMatchObject({
url:
'http://my-host/api/auth/my-provider/start?scope=a%20b&env=production',
});
await expect(sessionPromise).resolves.toEqual({
idToken: 'my-id-token',
accessToken: 'my-access-token',
scopes: expect.any(Set),
expiresAt: expect.any(Date),
});
});
it('should instantly show popup if option is set', async () => {
const popupSpy = jest
.spyOn(loginPopup, 'showLoginPopup')
.mockResolvedValue('my-session');
const helper = new DefaultAuthConnector({
...defaultOptions,
oauthRequestApi: new MockOAuthApi(),
sessionTransform: str => str,
});
const sessionPromise = helper.createSession({
scopes: new Set(),
instantPopup: true,
});
await expect(sessionPromise).resolves.toBe('my-session');
expect(popupSpy).toBeCalledTimes(1);
});
it('should use join func to join scopes', async () => {
const mockOauth = new MockOAuthApi();
const popupSpy = jest
.spyOn(loginPopup, 'showLoginPopup')
.mockResolvedValue({ scopes: '' });
const helper = new DefaultAuthConnector({
...defaultOptions,
joinScopes: scopes => `-${[...scopes].join('')}-`,
oauthRequestApi: mockOauth,
});
helper.createSession({ scopes: new Set(['a', 'b']) });
await mockOauth.triggerAll();
expect(popupSpy).toBeCalledTimes(1);
expect(popupSpy.mock.calls[0][0]).toMatchObject({
url:
'http://my-host/api/auth/my-provider/start?scope=-ab-&env=production',
});
});
});
@@ -0,0 +1,204 @@
/*
* 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 {
AuthRequester,
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '@backstage/core-plugin-api';
import { showLoginPopup } from '../loginPopup';
import { AuthConnector, CreateSessionOptions } from './types';
type Options<AuthSession> = {
/**
* DiscoveryApi instance used to locate the auth backend endpoint.
*/
discoveryApi: DiscoveryApi;
/**
* Environment hint passed on to auth backend, for example 'production' or 'development'
*/
environment: string;
/**
* Information about the auth provider to be shown to the user.
* The ID Must match the backend auth plugin configuration, for example 'google'.
*/
provider: AuthProvider & { id: string };
/**
* API used to instantiate an auth requester.
*/
oauthRequestApi: OAuthRequestApi;
/**
* Function used to join together a set of scopes, defaults to joining with a space character.
*/
joinScopes?: (scopes: Set<string>) => string;
/**
* Function used to transform an auth response into the session type.
*/
sessionTransform?(response: any): AuthSession | Promise<AuthSession>;
};
function defaultJoinScopes(scopes: Set<string>) {
return [...scopes].join(' ');
}
/**
* DefaultAuthConnector is the default auth connector in Backstage. It talks to the
* backend auth plugin through the standardized API, and requests user permission
* via the OAuthRequestApi.
*/
export class DefaultAuthConnector<AuthSession>
implements AuthConnector<AuthSession> {
private readonly discoveryApi: DiscoveryApi;
private readonly environment: string;
private readonly provider: AuthProvider & { id: string };
private readonly joinScopesFunc: (scopes: Set<string>) => string;
private readonly authRequester: AuthRequester<AuthSession>;
private readonly sessionTransform: (response: any) => Promise<AuthSession>;
constructor(options: Options<AuthSession>) {
const {
discoveryApi,
environment,
provider,
joinScopes = defaultJoinScopes,
oauthRequestApi,
sessionTransform = id => id,
} = options;
this.authRequester = oauthRequestApi.createAuthRequester({
provider,
onAuthRequest: scopes => this.showPopup(scopes),
});
this.discoveryApi = discoveryApi;
this.environment = environment;
this.provider = provider;
this.joinScopesFunc = joinScopes;
this.sessionTransform = sessionTransform;
}
async createSession(options: CreateSessionOptions): Promise<AuthSession> {
if (options.instantPopup) {
return this.showPopup(options.scopes);
}
return this.authRequester(options.scopes);
}
async refreshSession(): Promise<any> {
const res = await fetch(
await this.buildUrl('/refresh', { optional: true }),
{
headers: {
'x-requested-with': 'XMLHttpRequest',
},
credentials: 'include',
},
).catch(error => {
throw new Error(`Auth refresh request failed, ${error}`);
});
if (!res.ok) {
const error: any = new Error(
`Auth refresh request failed, ${res.statusText}`,
);
error.status = res.status;
throw error;
}
const authInfo = await res.json();
if (authInfo.error) {
const error = new Error(authInfo.error.message);
if (authInfo.error.name) {
error.name = authInfo.error.name;
}
throw error;
}
return await this.sessionTransform(authInfo);
}
async removeSession(): Promise<void> {
const res = await fetch(await this.buildUrl('/logout'), {
method: 'POST',
headers: {
'x-requested-with': 'XMLHttpRequest',
},
credentials: 'include',
}).catch(error => {
throw new Error(`Logout request failed, ${error}`);
});
if (!res.ok) {
const error: any = new Error(`Logout request failed, ${res.statusText}`);
error.status = res.status;
throw error;
}
}
private async showPopup(scopes: Set<string>): Promise<AuthSession> {
const scope = this.joinScopesFunc(scopes);
const popupUrl = await this.buildUrl('/start', { scope });
const payload = await showLoginPopup({
url: popupUrl,
name: `${this.provider.title} Login`,
origin: new URL(popupUrl).origin,
width: 450,
height: 730,
});
return await this.sessionTransform(payload);
}
private async buildUrl(
path: string,
query?: { [key: string]: string | boolean | undefined },
): Promise<string> {
const baseUrl = await this.discoveryApi.getBaseUrl('auth');
const queryString = this.buildQueryString({
...query,
env: this.environment,
});
return `${baseUrl}/${this.provider.id}${path}${queryString}`;
}
private buildQueryString(query?: {
[key: string]: string | boolean | undefined;
}): string {
if (!query) {
return '';
}
const queryString = Object.entries<string | boolean | undefined>(query)
.map(([key, value]) => {
if (typeof value === 'string') {
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
} else if (value) {
return encodeURIComponent(key);
}
return undefined;
})
.filter(Boolean)
.join('&');
if (!queryString) {
return '';
}
return `?${queryString}`;
}
}
@@ -0,0 +1,77 @@
/*
* 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 { AuthProvider, DiscoveryApi } from '@backstage/core-plugin-api';
import { showLoginPopup } from '../loginPopup';
type Options = {
discoveryApi: DiscoveryApi;
environment?: string;
provider: AuthProvider & { id: string };
};
export class DirectAuthConnector<DirectAuthResponse> {
private readonly discoveryApi: DiscoveryApi;
private readonly environment: string | undefined;
private readonly provider: AuthProvider & { id: string };
constructor(options: Options) {
const { discoveryApi, environment, provider } = options;
this.discoveryApi = discoveryApi;
this.environment = environment;
this.provider = provider;
}
async createSession(): Promise<DirectAuthResponse> {
const popupUrl = await this.buildUrl('/start');
const payload = await showLoginPopup({
url: popupUrl,
name: `${this.provider.title} Login`,
origin: new URL(popupUrl).origin,
width: 450,
height: 730,
});
return {
...payload,
id: payload.profile.email,
};
}
async refreshSession(): Promise<any> {}
async removeSession(): Promise<void> {
const res = await fetch(await this.buildUrl('/logout'), {
method: 'POST',
headers: {
'x-requested-with': 'XMLHttpRequest',
},
credentials: 'include',
}).catch(error => {
throw new Error(`Logout request failed, ${error}`);
});
if (!res.ok) {
const error: any = new Error(`Logout request failed, ${res.statusText}`);
error.status = res.status;
throw error;
}
}
private async buildUrl(path: string): Promise<string> {
const baseUrl = await this.discoveryApi.getBaseUrl('auth');
return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`;
}
}
@@ -0,0 +1,35 @@
/*
* 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 { MockAuthConnector, mockAccessToken } from './MockAuthConnector';
describe('MockAuthConnector', () => {
it('should return mock tokens', async () => {
const helper = new MockAuthConnector();
await expect(helper.createSession()).resolves.toEqual({
accessToken: mockAccessToken,
expiresAt: expect.any(Date),
scopes: expect.any(String),
});
await expect(helper.refreshSession()).resolves.toEqual({
accessToken: mockAccessToken,
expiresAt: expect.any(Date),
scopes: expect.any(String),
});
});
});
@@ -0,0 +1,45 @@
/*
* 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 { AuthConnector } from './types';
export const mockAccessToken = 'mock-access-token';
type MockSession = {
accessToken: string;
expiresAt: Date;
scopes: string;
};
const defaultMockSession: MockSession = {
accessToken: mockAccessToken,
expiresAt: new Date(),
scopes: 'profile email',
};
export class MockAuthConnector implements AuthConnector<MockSession> {
constructor(private readonly mockSession: MockSession = defaultMockSession) {}
async createSession() {
return this.mockSession;
}
async refreshSession() {
return this.mockSession;
}
async removeSession() {}
}
@@ -0,0 +1,19 @@
/*
* 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.
*/
export { DefaultAuthConnector } from './DefaultAuthConnector';
export { DirectAuthConnector } from './DirectAuthConnector';
export * from './types';

Some files were not shown because too many files have changed in this diff Show More