diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts index 60a5b815e7..24468fdcb6 100644 --- a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts +++ b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts @@ -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'; diff --git a/packages/core-app-api/.eslintrc.js b/packages/core-app-api/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/core-app-api/.eslintrc.js @@ -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, + }, +}; diff --git a/packages/core-app-api/README.md b/packages/core-app-api/README.md new file mode 100644 index 0000000000..28f206cc39 --- /dev/null +++ b/packages/core-app-api/README.md @@ -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) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md new file mode 100644 index 0000000000..36c4f7419b --- /dev/null +++ b/packages/core-app-api/api-report.md @@ -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; + // (undocumented) + post(alert: AlertMessage): void; + } + +// @public (undocumented) +export type ApiFactoryHolder = { + get(api: ApiRef): ApiFactory | undefined; +}; + +// @public +export class ApiFactoryRegistry implements ApiFactoryHolder { + // (undocumented) + get(api: ApiRef): ApiFactory | undefined; + // (undocumented) + getAllApis(): Set; + register(scope: ApiFactoryScope, factory: ApiFactory): boolean; +} + +// @public (undocumented) +export const ApiProvider: { + ({ apis, children, }: PropsWithChildren): JSX.Element; + propTypes: { + apis: PropTypes.Validator any>; + }>>; + children: PropTypes.Requireable; + }; +}; + +// @public (undocumented) +export class ApiRegistry implements ApiHolder { + constructor(apis: Map); + // (undocumented) + static builder(): ApiRegistryBuilder; + // (undocumented) + static from(apis: ApiImpl[]): ApiRegistry; + // (undocumented) + get(api: ApiRef): T | undefined; + static with(api: ApiRef, impl: T): ApiRegistry; + with(api: ApiRef, impl: T): ApiRegistry; +} + +// @public (undocumented) +export class ApiResolver implements ApiHolder { + constructor(factories: ApiFactoryHolder); + // (undocumented) + get(ref: ApiRef): T | undefined; + static validateFactories(factories: ApiFactoryHolder, apis: Iterable): void; +} + +// @public (undocumented) +export type AppComponents = { + NotFoundErrorPage: ComponentType<{}>; + BootErrorPage: ComponentType; + Progress: ComponentType<{}>; + Router: ComponentType<{}>; + SignInPage?: ComponentType; +}; + +// @public +export type AppConfigLoader = () => Promise; + +// @public (undocumented) +export type AppContext = { + getPlugins(): BackstagePlugin[]; + getSystemIcon(key: string): IconComponent | undefined; + getComponents(): AppComponents; +}; + +// @public (undocumented) +export type AppOptions = { + apis?: Iterable; + icons?: AppIcons & { + [key in string]: IconComponent; + }; + plugins?: BackstagePlugin[]; + components?: Partial; + themes?: AppTheme[]; + configLoader?: AppConfigLoader; + bindRoutes?(context: { + bind: AppRouteBinder; + }): void; +}; + +// @public (undocumented) +export type AppRouteBinder = (externalRoutes: ExternalRoutes, targetRoutes: PartialKeys, KeysWithType>>) => void; + +// @public (undocumented) +export class AppThemeSelector implements AppThemeApi { + constructor(themes: AppTheme[]); + // (undocumented) + activeThemeId$(): Observable; + // (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[]; + 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); + // (undocumented) + static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): GithubAuth; + // (undocumented) + getAccessToken(scope?: string, options?: AuthRequestOptions): Promise; + // (undocumented) + getBackstageIdentity(options?: AuthRequestOptions): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + static normalizeScope(scope?: string): Set; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; +} + +// @public (undocumented) +export type GithubSession = { + providerInfo: { + accessToken: string; + scopes: Set; + 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; + // (undocumented) + getBackstageIdentity(options?: AuthRequestOptions): Promise; + // (undocumented) + getIdToken(options?: AuthRequestOptions): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; +} + +// @public (undocumented) +export type OAuth2Session = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +// @public +export class OAuthRequestManager implements OAuthRequestApi { + // (undocumented) + authRequest$(): Observable; + // (undocumented) + createAuthRequester(options: AuthRequesterOptions): AuthRequester; + } + +// @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); + // (undocumented) + static create({ discoveryApi, environment, provider, }: AuthApiCreateOptions): SamlAuth; + // (undocumented) + getBackstageIdentity(options?: AuthRequestOptions): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; +} + +// @public (undocumented) +export type SignInPageProps = { + onResult(result: SignInResult): void; +}; + +// @public (undocumented) +export type SignInResult = { + userId: string; + profile: ProfileInfo; + getIdToken?: () => Promise; + signOut?: () => Promise; +}; + +// @public +export class UrlPatternDiscovery implements DiscoveryApi { + static compile(pattern: string): UrlPatternDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + } + +// @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(key: string): T | undefined; + // (undocumented) + observe$(key: string): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/core-app-api/config.d.ts b/packages/core-app-api/config.d.ts new file mode 100644 index 0000000000..9e43c6f3ad --- /dev/null +++ b/packages/core-app-api/config.d.ts @@ -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; + }; +} diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json new file mode 100644 index 0000000000..296a80ce94 --- /dev/null +++ b/packages/core-app-api/package.json @@ -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" +} diff --git a/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts new file mode 100644 index 0000000000..28cb5bc068 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.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(); + + post(alert: AlertMessage) { + this.subject.next(alert); + } + + alert$(): Observable { + return this.subject; + } +} diff --git a/packages/core-app-api/src/apis/implementations/AlertApi/index.ts b/packages/core-app-api/src/apis/implementations/AlertApi/index.ts new file mode 100644 index 0000000000..12ab8bc60c --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AlertApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts new file mode 100644 index 0000000000..8d36e6b10e --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts new file mode 100644 index 0000000000..3bd4d764ea --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -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(undefined); + + constructor(private readonly themes: AppTheme[]) {} + + getInstalledThemes(): AppTheme[] { + return this.themes.slice(); + } + + activeThemeId$(): Observable { + return this.subject; + } + + getActiveThemeId(): string | undefined { + return this.activeThemeId; + } + + setActiveThemeId(themeId?: string): void { + this.activeThemeId = themeId; + this.subject.next(themeId); + } +} diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts new file mode 100644 index 0000000000..cb42c0f875 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts b/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts new file mode 100644 index 0000000000..7c7f88a3e5 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts new file mode 100644 index 0000000000..9597443b98 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts @@ -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'); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts new file mode 100644 index 0000000000..a9decd9ef9 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts @@ -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 { + return this.parts.join(pluginId); + } +} diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts new file mode 100644 index 0000000000..24468fdcb6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts new file mode 100644 index 0000000000..73d5042c11 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts @@ -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$(); + } +} diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts new file mode 100644 index 0000000000..875d07c0a3 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -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; + } +} diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts new file mode 100644 index 0000000000..757dfd0d8f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx new file mode 100644 index 0000000000..a100f01a52 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx @@ -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); + }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx new file mode 100644 index 0000000000..800ae98f24 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx @@ -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; + + 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 { + 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(); + } + } +} diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts new file mode 100644 index 0000000000..33990584f3 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts new file mode 100644 index 0000000000..32170acc6f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts @@ -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(); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts new file mode 100644 index 0000000000..46d620c329 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts @@ -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(options: AuthRequesterOptions) { + 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(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(resolve => { + const subscription = this.authRequest$().subscribe(requests => { + subscription.unsubscribe(); + requests.map(request => request.reject()); + resolve(); + }); + }); + } +} diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts new file mode 100644 index 0000000000..280321daa0 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts new file mode 100644 index 0000000000..370ba9a3a1 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -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 = { + scopes: Set; + resolve: (value: ResultType | PromiseLike) => void; + reject: (reason: Error) => void; +}; + +export type PendingRequest = { + scopes: Set | undefined; + resolve: (value: ResultType) => void; + reject: (reason: Error) => void; +}; + +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +export function joinScopes( + scopes: Set, + ...moreScopess: Set[] +): Set { + 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 { + private requests: RequestQueueEntry[] = []; + private subject = new BehaviorSubject>( + this.getCurrentPending(), + ); + + request(scopes: Set): Promise { + return new Promise((resolve, reject) => { + this.requests.push({ scopes, resolve, reject }); + + this.subject.next(this.getCurrentPending()); + }); + } + + resolve(scopes: Set, 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> { + return this.subject; + } + + private getCurrentPending(): PendingRequest { + 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); + } + }, + }; + } +} diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts new file mode 100644 index 0000000000..46a5362f35 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts @@ -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'); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts new file mode 100644 index 0000000000..e50c18d271 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -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([]); + private currentRequests: PendingAuthRequest[] = []; + private handlerCount = 0; + + createAuthRequester(options: AuthRequesterOptions): AuthRequester { + const handler = new OAuthPendingRequests(); + + 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, + options: AuthRequesterOptions, + ): 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 { + return this.subject; + } +} diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts new file mode 100644 index 0000000000..0fe9bfae0f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts new file mode 100644 index 0000000000..da5b2509db --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -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, + ): 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(resolve => { + storage.observe$('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(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')); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts new file mode 100644 index 0000000000..3f49b644f6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -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(); + +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(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(key: string, data: T): Promise { + localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2)); + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + localStorage.removeItem(this.getKeyName(key)); + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts new file mode 100644 index 0000000000..33b0094551 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts new file mode 100644 index 0000000000..391a709935 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -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; diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts new file mode 100644 index 0000000000..dda27d0fa3 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts new file mode 100644 index 0000000000..3ff29cc6ef --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts @@ -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); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts new file mode 100644 index 0000000000..2ab0e5390f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -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({ + manager: sessionManager, + storageKey: `${provider.id}Session`, + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + return new GithubAuth(authSessionStore); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + + sessionState$(): Observable { + 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 { + 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 { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) + ? scope + : scope.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} +export default GithubAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/index.ts b/packages/core-app-api/src/apis/implementations/auth/github/index.ts new file mode 100644 index 0000000000..9e1722f4a4 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/types.ts b/packages/core-app-api/src/apis/implementations/auth/github/types.ts new file mode 100644 index 0000000000..95beef3668 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/types.ts @@ -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; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts new file mode 100644 index 0000000000..6a592c7fbe --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -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) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts new file mode 100644 index 0000000000..fbb9509e7d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -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; diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts new file mode 100644 index 0000000000..42d7210551 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts new file mode 100644 index 0000000000..9e8569c5cf --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts @@ -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) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts new file mode 100644 index 0000000000..1074a192ef --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -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; diff --git a/packages/core-app-api/src/apis/implementations/auth/google/index.ts b/packages/core-app-api/src/apis/implementations/auth/google/index.ts new file mode 100644 index 0000000000..2521d46046 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/google/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts new file mode 100644 index 0000000000..7ef78d19cf --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts new file mode 100644 index 0000000000..490e74b532 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -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; diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts new file mode 100644 index 0000000000..77328d8557 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts new file mode 100644 index 0000000000..93db2c732d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -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 + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts new file mode 100644 index 0000000000..c6c251a4f5 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -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; + 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; + 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 { + 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 { + 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 { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeTransform(scopeList)); + } +} + +export default OAuth2; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts new file mode 100644 index 0000000000..52bcb1df2c --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts new file mode 100644 index 0000000000..ade0f1a6c6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts @@ -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; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts new file mode 100644 index 0000000000..d6b1a07d9d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -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) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts new file mode 100644 index 0000000000..a86e3f272d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -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 = 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; diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/index.ts b/packages/core-app-api/src/apis/implementations/auth/okta/index.ts new file mode 100644 index 0000000000..4cc774b26b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/okta/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts new file mode 100644 index 0000000000..2e8c7ac2e8 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -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 = 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; diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts new file mode 100644 index 0000000000..1d163207db --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts new file mode 100644 index 0000000000..7f747d6977 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -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({ + discoveryApi, + environment, + provider, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + }); + + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: `${provider.id}Session`, + }); + + return new SamlAuth(authSessionStore); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getBackstageIdentity({}); + } + async signOut() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + 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; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts new file mode 100644 index 0000000000..c2436ab435 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/types.ts b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts new file mode 100644 index 0000000000..b62826b2ea --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts @@ -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; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/types.ts b/packages/core-app-api/src/apis/implementations/auth/types.ts new file mode 100644 index 0000000000..a752e68eba --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/types.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 { + 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 }; +}; diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts new file mode 100644 index 0000000000..d0df2760ab --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/index.ts b/packages/core-app-api/src/apis/index.ts new file mode 100644 index 0000000000..5652dadf79 --- /dev/null +++ b/packages/core-app-api/src/apis/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/system/ApiAggregator.test.ts b/packages/core-app-api/src/apis/system/ApiAggregator.test.ts new file mode 100644 index 0000000000..c38aa08fad --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiAggregator.test.ts @@ -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({ id: 'a' }); + const apiBRef = createApiRef({ 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); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiAggregator.ts b/packages/core-app-api/src/apis/system/ApiAggregator.ts new file mode 100644 index 0000000000..1299e38da0 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiAggregator.ts @@ -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(apiRef: ApiRef): T | undefined { + for (const holder of this.holders) { + const api = holder.get(apiRef); + if (api) { + return api; + } + } + return undefined; + } +} diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts new file mode 100644 index 0000000000..160019604e --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts @@ -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({ id: 'a' }); +const aFactory1 = { api: aRef, deps: {}, factory: () => 1 }; +const aFactory2 = { api: aRef, deps: {}, factory: () => 2 }; +const bRef = createApiRef({ id: 'b' }); +const bFactory = { api: bRef, deps: {}, factory: () => 'x' }; +const cRef = createApiRef({ 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({ id: 'a' }); + const ref2 = createApiRef({ 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); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts new file mode 100644 index 0000000000..fe17f5a600 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts @@ -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(); + + /** + * 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( + scope: ApiFactoryScope, + factory: ApiFactory, + ) { + 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( + api: ApiRef, + ): ApiFactory | undefined { + const tuple = this.factories.get(api.id); + if (!tuple) { + return undefined; + } + return tuple.factory as ApiFactory; + } + + getAllApis(): Set { + const refs = new Set(); + for (const { factory } of this.factories.values()) { + refs.add(factory.api); + } + return refs; + } +} diff --git a/packages/core-app-api/src/apis/system/ApiProvider.test.tsx b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx new file mode 100644 index 0000000000..7d38ec3cf9 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx @@ -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({ id: 'x' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumer = () => { + const api = useApi(apiRef); + return

hook message: {api()}

; + }; + + const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => { + return

hoc message: {getMessage()}

; + }); + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should provide nested access to apis', () => { + const aRef = createApiRef({ id: 'a' }); + const bRef = createApiRef({ id: 'b' }); + + const MyComponent = () => { + const a = useApi(aRef); + const b = useApi(bRef); + return ( +
+ a={a} b={b} +
+ ); + }; + + const renderedHook = render( + + + + + , + ); + renderedHook.getByText('a=z b=y'); + }); + + it('should ignore deps in prototype', () => { + // 100% coverage + happy typescript = hasOwnProperty + this atrocity + const xRef = createApiRef({ 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

hoc message: {getMessage()}

; + }); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should error if no provider is available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).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 component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).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 component/, + ), + ]); + }); + + it('should error if api is not available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).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 component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).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 component/, + ), + ]); + }); +}); + +describe('v1 consumer', () => { + const ApiContext = getGlobalSingleton< + Context> + >('api-context'); + + function useMockApiV1(apiRef: ApiRef): T { + const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + type Api = () => string; + const apiRef = createApiRef({ id: 'x' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumerV1 = () => { + const api = useMockApiV1(apiRef); + return

hook message: {api()}

; + }; + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiProvider.tsx b/packages/core-app-api/src/apis/system/ApiProvider.tsx new file mode 100644 index 0000000000..93d20b12fc --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiProvider.tsx @@ -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(undefined), +); + +export const ApiProvider = ({ + apis, + children, +}: PropsWithChildren) => { + const parentHolder = useContext(ApiContext)?.atVersion(1); + const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; + + return ( + + ); +}; + +ApiProvider.propTypes = { + apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired, + children: PropTypes.node, +}; diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.test.ts b/packages/core-app-api/src/apis/system/ApiRegistry.test.ts new file mode 100644 index 0000000000..0931e2c103 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiRegistry.test.ts @@ -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({ id: 'x1' }); + const x1DuplicateRef = createApiRef({ id: 'x1' }); + const x2Ref = createApiRef({ 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'); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.ts b/packages/core-app-api/src/apis/system/ApiRegistry.ts new file mode 100644 index 0000000000..4571d52cce --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiRegistry.ts @@ -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 = readonly [ApiRef, T]; + +class ApiRegistryBuilder { + private apis: [string, unknown][] = []; + + add(api: ApiRef, 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(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([[api.id, impl]])); + } + + constructor(private readonly apis: Map) {} + + /** + * 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(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([...this.apis, [api.id, impl]])); + } + + get(api: ApiRef): T | undefined { + return this.apis.get(api.id) as T | undefined; + } +} diff --git a/packages/core-app-api/src/apis/system/ApiResolver.test.ts b/packages/core-app-api/src/apis/system/ApiResolver.test.ts new file mode 100644 index 0000000000..af8dbc28c6 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiResolver.test.ts @@ -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({ id: 'a' }); +const otherARef = createApiRef({ id: 'a' }); +const bRef = createApiRef({ id: 'b' }); +const otherBRef = createApiRef({ 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); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiResolver.ts b/packages/core-app-api/src/apis/system/ApiResolver.ts new file mode 100644 index 0000000000..1fbce4871a --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiResolver.ts @@ -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, + ) { + for (const api of apis) { + const heap = [api]; + const allDeps = new Set(); + + 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(); + + constructor(private readonly factories: ApiFactoryHolder) {} + + get(ref: ApiRef): T | undefined { + return this.load(ref); + } + + private load(ref: ApiRef, 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( + dependent: ApiRef, + apis: TypesToApiRefs, + 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; + } +} diff --git a/packages/core-app-api/src/apis/system/index.ts b/packages/core-app-api/src/apis/system/index.ts new file mode 100644 index 0000000000..dd7c081f62 --- /dev/null +++ b/packages/core-app-api/src/apis/system/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/system/types.ts b/packages/core-app-api/src/apis/system/types.ts new file mode 100644 index 0000000000..f1ba82c21e --- /dev/null +++ b/packages/core-app-api/src/apis/system/types.ts @@ -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( + api: ApiRef, + ): ApiFactory | undefined; +}; diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx new file mode 100644 index 0000000000..c282ba6b7f --- /dev/null +++ b/packages/core-app-api/src/app/App.test.tsx @@ -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 }) =>
), + 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 ( +
+ link1: {link1()} + link2: {link2({ x: 'a' })} + subLink1: {subLink1()} + subLink2: {subLink2({ x: 'a' })} + subLink3: {subLink3({ x: 'b' })} + subLink4: {subLink4({ x: 'c', y: 'd' })} + extLink1: {extLink1()} + extLink2: {extLink2({ x: 'a' })} + extLink3: {extLink3?.() ?? ''} + extLink4: {extLink4?.({ x: 'b' }) ?? ''} +
+ ); + }), + 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( + + + + + + + + , + ); + + 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( + + + + + + + + , + ); + + expect(screen.getByText('extLink1: /')).toBeInTheDocument(); + expect(screen.getByText('extLink2: /foo')).toBeInTheDocument(); + expect(screen.getByText('extLink3: ')).toBeInTheDocument(); + expect(screen.getByText('extLink4: ')).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( + + + + + + + + , + ); + + 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( + + + + + + + + + , + ), + ).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 component', + ), + ]); + }); +}); diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx new file mode 100644 index 0000000000..a5ba5c4bca --- /dev/null +++ b/packages/core-app-api/src/app/App.tsx @@ -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(); + + 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; + icons: NonNullable; + plugins: BackstagePlugin[]; + components: AppComponents; + themes: AppTheme[]; + configLoader?: AppConfigLoader; + defaultApis: Iterable; + 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 = ; + } else if (config.error) { + const { BootErrorPage } = components; + noConfigNode = ; + } + + // Before the config is loaded we can't use a router, so exit early + if (noConfigNode) { + return { + node: ( + + {noConfigNode} + + ), + }; + } + + const configReader = ConfigReader.fromConfigs(config.value ?? []); + + return { api: configReader }; +} + +class AppContextImpl implements AppContext { + constructor(private readonly app: PrivateAppImpl) {} + + getPlugins(): BackstagePlugin[] { + 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; + private readonly icons: NonNullable; + private readonly plugins: Set>; + private readonly components: AppComponents; + private readonly themes: AppTheme[]; + private readonly configLoader?: AppConfigLoader; + private readonly defaultApis: Iterable; + 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[] { + 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 ( + + + + + {children} + + + + + ); + }; + 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; + children: ReactElement; + }) => { + const [result, setResult] = useState(); + + if (result) { + this.identityApi.setSignInResult(result); + return children; + } + + return ; + }; + + 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 ( + + + {children}} /> + + + ); + } + + return ( + + + + {children}} /> + + + + ); + }; + + 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) { + const pluginIds = new Set(); + + for (const plugin of plugins) { + const id = plugin.getId(); + if (pluginIds.has(id)) { + throw new Error(`Duplicate plugin found '${id}'`); + } + pluginIds.add(id); + } + } +} diff --git a/packages/core-app-api/src/app/AppContext.test.tsx b/packages/core-app-api/src/app/AppContext.test.tsx new file mode 100644 index 0000000000..224499b148 --- /dev/null +++ b/packages/core-app-api/src/app/AppContext.test.tsx @@ -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> + >('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 }) => ( + + ), + }); + 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'); + }); +}); diff --git a/packages/core-app-api/src/app/AppContext.tsx b/packages/core-app-api/src/app/AppContext.tsx new file mode 100644 index 0000000000..9cdd4cdc13 --- /dev/null +++ b/packages/core-app-api/src/app/AppContext.tsx @@ -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(undefined), +); + +type Props = { + appContext: AppContextV1; +}; + +export const AppContextProvider = ({ + appContext, + children, +}: PropsWithChildren) => { + const versionedValue = createVersionedValueMap({ 1: appContext }); + + return ; +}; diff --git a/packages/core-app-api/src/app/AppIdentity.ts b/packages/core-app-api/src/app/AppIdentity.ts new file mode 100644 index 0000000000..b2b4a0bcdc --- /dev/null +++ b/packages/core-app-api/src/app/AppIdentity.ts @@ -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; + private signOutFunc?: () => Promise; + + 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 { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi idToken before app was loaded', + ); + } + return this.idTokenFunc?.(); + } + + async signOut(): Promise { + 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; + } +} diff --git a/packages/core-app-api/src/app/AppThemeProvider.tsx b/packages/core-app-api/src/app/AppThemeProvider.tsx new file mode 100644 index 0000000000..d297b05ced --- /dev/null +++ b/packages/core-app-api/src/app/AppThemeProvider.tsx @@ -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 ( + + {children} + + ); +} diff --git a/packages/core-app-api/src/app/createApp.test.tsx b/packages/core-app-api/src/app/createApp.test.tsx new file mode 100644 index 0000000000..a800c11ea6 --- /dev/null +++ b/packages/core-app-api/src/app/createApp.test.tsx @@ -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( + Test, + ); + + expect(getByText('Test')).toBeInTheDocument(); + }); + + it('should not wrap with router if already inside a router', async () => { + const { getByText } = render( + + Test + , + ); + + expect(getByText('Test')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx new file mode 100644 index 0000000000..c11ac840f9 --- /dev/null +++ b/packages/core-app-api/src/app/createApp.tsx @@ -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 {children}; +} + +/** + * Creates a new Backstage App. + */ +export function createApp(options?: AppOptions) { + const DefaultNotFoundPage = () => ( + + ); + 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 ( + + + + ); + }; + + 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: , + }, + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: darkTheme, + icon: , + }, + ]; + const configLoader = options?.configLoader ?? defaultConfigLoader; + + return new PrivateAppImpl({ + apis, + icons, + plugins, + components, + themes, + configLoader, + defaultApis, + bindRoutes: options?.bindRoutes, + }); +} diff --git a/packages/core-app-api/src/app/defaultApis.ts b/packages/core-app-api/src/app/defaultApis.ts new file mode 100644 index 0000000000..5d84e27f89 --- /dev/null +++ b/packages/core-app-api/src/app/defaultApis.ts @@ -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'), + }), + }), +]; diff --git a/packages/core-app-api/src/app/icons.tsx b/packages/core-app-api/src/app/icons.tsx new file mode 100644 index 0000000000..9ec278ba7f --- /dev/null +++ b/packages/core-app-api/src/app/icons.tsx @@ -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, +}; diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts new file mode 100644 index 0000000000..a7cdf22a43 --- /dev/null +++ b/packages/core-app-api/src/app/index.ts @@ -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'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts new file mode 100644 index 0000000000..85be0c5f53 --- /dev/null +++ b/packages/core-app-api/src/app/types.ts @@ -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; + + /** + * Sign out handler that will be called if the user requests to sign out. + */ + signOut?: () => Promise; +}; + +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; + 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; +}; + +/** + * 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; + +/** + * Extracts a union of the keys in a map whose value extends the given type + */ +type KeysWithType = { + [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> & Required>; + +/** + * 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 | SubRouteRef + : never; +}; + +export type AppRouteBinder = < + ExternalRoutes extends { [name: string]: ExternalRouteRef } +>( + externalRoutes: ExternalRoutes, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, +) => 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; + + /** + * 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[]; + + /** + * Supply components to the app to override the default ones. + */ + components?: Partial; + + /** + * 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: , + * }, { + * id: 'dark', + * title: 'Dark Theme', + * variant: 'dark', + * theme: darkTheme, + * icon: , + * }] + * ``` + */ + 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[]; + + /** + * 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[]; + + /** + * 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; +}; diff --git a/packages/core-app-api/src/extensions/componentData.test.tsx b/packages/core-app-api/src/extensions/componentData.test.tsx new file mode 100644 index 0000000000..417fe07414 --- /dev/null +++ b/packages/core-app-api/src/extensions/componentData.test.tsx @@ -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 = ; + 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 = ; + 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 = ; + const element2 = ; + 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 = ; + 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 = ; + 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 = ; + 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 = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + }); +}); diff --git a/packages/core-app-api/src/extensions/componentData.tsx b/packages/core-app-api/src/extensions/componentData.tsx new file mode 100644 index 0000000000..fc8594039c --- /dev/null +++ b/packages/core-app-api/src/extensions/componentData.tsx @@ -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

= ComponentType

& { + [DATA_KEY]?: DataContainer; +}; + +type DataContainer = { + map: Map; +}; + +type MaybeComponentNode = ReactNode & { + type?: ComponentType & { [DATA_KEY]?: DataContainer }; +}; + +// The store is bridged across versions using the global object +const store = getOrCreateGlobalSingleton( + 'component-data-store', + () => new WeakMap, DataContainer>(), +); + +export function attachComponentData

( + component: ComponentType

, + type: string, + data: unknown, +) { + const dataComponent = component as ComponentWithData

; + + 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( + 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; +} diff --git a/packages/core-app-api/src/extensions/extensions.test.tsx b/packages/core-app-api/src/extensions/extensions.test.tsx new file mode 100644 index 0000000000..e8770dddbb --- /dev/null +++ b/packages/core-app-api/src/extensions/extensions.test.tsx @@ -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 = () =>

; + + const extension = createReactExtension({ + component: { + sync: Component, + }, + data: { + myData: { foo: 'bar' }, + }, + }); + + const ExtensionComponent = plugin.provide(extension); + const element = ; + + expect(getComponentData(element, 'core.plugin')).toBe(plugin); + expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' }); + }); + + it('should create react extensions of different types', () => { + const Component = () =>
; + 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 = ; + const element2 = ; + + expect(getComponentData(element1, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef); + }); +}); diff --git a/packages/core-app-api/src/extensions/extensions.tsx b/packages/core-app-api/src/extensions/extensions.tsx new file mode 100644 index 0000000000..8eab3b905f --- /dev/null +++ b/packages/core-app-api/src/extensions/extensions.tsx @@ -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 = + | { + lazy: () => Promise; + } + | { + 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; + mountPoint: RouteRef; +}): Extension { + 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 ; + }; + + 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 ; + }; + 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 }): Extension { + 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; + data?: Record; +}): Extension { + 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) { + const Result: any = (props: any) => ( + + + + ); + + attachComponentData(Result, 'core.plugin', plugin); + for (const [key, value] of Object.entries(data)) { + attachComponentData(Result, key, value); + } + + Result.displayName = `Extension(${componentName})`; + return Result; + }, + }; +} diff --git a/packages/core-app-api/src/extensions/index.ts b/packages/core-app-api/src/extensions/index.ts new file mode 100644 index 0000000000..26a0c597b1 --- /dev/null +++ b/packages/core-app-api/src/extensions/index.ts @@ -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'; diff --git a/packages/core-app-api/src/extensions/traversal.test.tsx b/packages/core-app-api/src/extensions/traversal.test.tsx new file mode 100644 index 0000000000..38571fdcc7 --- /dev/null +++ b/packages/core-app-api/src/extensions/traversal.test.tsx @@ -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 = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [childDiscoverer], + collectors: { + names: createCollector( + () => Array(), + (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 = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [ + el => + Children.toArray(el.props.children).flatMap(child => + isValidElement(child) ? child?.props?.children : [], + ), + ], + collectors: { + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), + }, + }); + + expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']); + }); +}); diff --git a/packages/core-app-api/src/extensions/traversal.ts b/packages/core-app-api/src/extensions/traversal.ts new file mode 100644 index 0000000000..4431bbd24c --- /dev/null +++ b/packages/core-app-api/src/extensions/traversal.ts @@ -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 = () => { + 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(options: { + root: ReactNode; + discoverers: Discoverer[]; + collectors: { [name in keyof Results]: Collector }; +}): Results { + const collectors: { + [name in string]: ReturnType>; + } = {}; + + // 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( + accumulatorFactory: () => Result, + visit: ReturnType>['visit'], +): Collector { + 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; +} diff --git a/packages/core-app-api/src/index.test.ts b/packages/core-app-api/src/index.test.ts new file mode 100644 index 0000000000..bec176fda8 --- /dev/null +++ b/packages/core-app-api/src/index.test.ts @@ -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), + }); + }); +}); diff --git a/packages/core-app-api/src/index.ts b/packages/core-app-api/src/index.ts new file mode 100644 index 0000000000..816e788a2b --- /dev/null +++ b/packages/core-app-api/src/index.ts @@ -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'; diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts new file mode 100644 index 0000000000..391b86952d --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -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(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', + }); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts new file mode 100644 index 0000000000..1498657281 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -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 = { + /** + * 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; + /** + * Function used to transform an auth response into the session type. + */ + sessionTransform?(response: any): AuthSession | Promise; +}; + +function defaultJoinScopes(scopes: Set) { + 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 + implements AuthConnector { + private readonly discoveryApi: DiscoveryApi; + private readonly environment: string; + private readonly provider: AuthProvider & { id: string }; + private readonly joinScopesFunc: (scopes: Set) => string; + private readonly authRequester: AuthRequester; + private readonly sessionTransform: (response: any) => Promise; + + constructor(options: Options) { + 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 { + if (options.instantPopup) { + return this.showPopup(options.scopes); + } + return this.authRequester(options.scopes); + } + + async refreshSession(): Promise { + 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 { + 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): Promise { + 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 { + 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(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}`; + } +} diff --git a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts new file mode 100644 index 0000000000..e5b3ca1219 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -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 { + 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 { + 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 {} + + async removeSession(): Promise { + 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 { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; + } +} diff --git a/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.test.ts new file mode 100644 index 0000000000..cd7986ffd0 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.test.ts @@ -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), + }); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.ts new file mode 100644 index 0000000000..9134fd0773 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.ts @@ -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 { + constructor(private readonly mockSession: MockSession = defaultMockSession) {} + + async createSession() { + return this.mockSession; + } + + async refreshSession() { + return this.mockSession; + } + + async removeSession() {} +} diff --git a/packages/core-app-api/src/lib/AuthConnector/index.ts b/packages/core-app-api/src/lib/AuthConnector/index.ts new file mode 100644 index 0000000000..388619e2c1 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/index.ts @@ -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'; diff --git a/packages/core-app-api/src/lib/AuthConnector/types.ts b/packages/core-app-api/src/lib/AuthConnector/types.ts new file mode 100644 index 0000000000..46175a265f --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/types.ts @@ -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. + */ + +export type CreateSessionOptions = { + scopes: Set; + instantPopup?: boolean; +}; + +/** + * An AuthConnector is responsible for realizing auth session actions + * by for example communicating with a backend or interacting with the user. + */ +export type AuthConnector = { + createSession(options: CreateSessionOptions): Promise; + refreshSession(): Promise; + removeSession(): Promise; +}; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts new file mode 100644 index 0000000000..5c960f7876 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { AuthSessionStore } from './AuthSessionStore'; +import { SessionManager } from './types'; + +const defaultOptions = { + storageKey: 'my-key', + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +class LocalStorage { + private store: Record = {}; + + getItem(key: string) { + return this.store[key] || null; + } + setItem(key: string, value: string) { + this.store[key] = value.toString(); + } + removeItem(key: string) { + delete this.store[key]; + } +} + +class MockManager implements SessionManager { + setSession = jest.fn(); + getSession = jest.fn(); + removeSession = jest.fn(); + sessionState$ = jest.fn(); +} + +describe('GheAuth AuthSessionStore', () => { + beforeEach(() => { + delete (window as any).localStorage; + (window as any).localStorage = new LocalStorage(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should load session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('a b c'); + expect(manager.getSession).not.toHaveBeenCalled(); + expect(manager.setSession).toHaveBeenCalledWith('a b c'); + }); + + it('should not use session without enough scope', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('a b c d'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({ scopes: new Set(['d']) })).resolves.toBe( + 'a b c d', + ); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should not use expired session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ + manager, + ...defaultOptions, + sessionShouldRefresh: () => true, + }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should not load missing session', async () => { + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + + expect(localStorage.getItem('my-key')).toBe('"123"'); + }); + + it('should ignore bad session values', async () => { + localStorage.setItem('my-key', 'derp'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should clear session', () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + store.removeSession(); + + expect(localStorage.getItem('my-key')).toBe(null); + expect(manager.removeSession).toHaveBeenCalled(); + }); + + it('should forward sessionState calls', () => { + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + store.sessionState$(); + expect(manager.sessionState$).toHaveBeenCalled(); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts new file mode 100644 index 0000000000..224036d283 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -0,0 +1,131 @@ +/* + * 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 { + SessionManager, + MutableSessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, + GetSessionOptions, +} from './types'; +import { SessionScopeHelper } from './common'; + +type Options = { + /** The connector used for acting on the auth session */ + manager: MutableSessionManager; + /** Storage key to use to store sessions */ + storageKey: string; + /** Used to get the scope of the session */ + sessionScopes?: SessionScopesFunc; + /** Used to check if the session needs to be refreshed, defaults to never refresh */ + sessionShouldRefresh?: SessionShouldRefreshFunc; +}; + +/** + * AuthSessionStore decorates another SessionManager with a functionality + * to store the session in local storage. + * + * Session is serialized to JSON with special support for following types: Set. + */ +export class AuthSessionStore implements SessionManager { + private readonly manager: MutableSessionManager; + private readonly storageKey: string; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; + private readonly helper: SessionScopeHelper; + + constructor(options: Options) { + const { + manager, + storageKey, + sessionScopes, + sessionShouldRefresh = () => false, + } = options; + + this.manager = manager; + this.storageKey = storageKey; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ + sessionScopes, + defaultScopes: new Set(), + }); + } + + async getSession(options: GetSessionOptions): Promise { + const { scopes } = options; + const session = this.loadSession(); + + if (this.helper.sessionExistsAndHasScope(session, scopes)) { + const shouldRefresh = this.sessionShouldRefreshFunc(session!); + + if (!shouldRefresh) { + this.manager.setSession(session!); + return session!; + } + } + + const newSession = await this.manager.getSession(options); + this.saveSession(newSession); + return newSession; + } + + async removeSession() { + localStorage.removeItem(this.storageKey); + await this.manager.removeSession(); + } + + sessionState$() { + return this.manager.sessionState$(); + } + + private loadSession(): T | undefined { + try { + const sessionJson = localStorage.getItem(this.storageKey); + if (sessionJson) { + const session = JSON.parse(sessionJson, (_key, value) => { + if (value?.__type === 'Set') { + return new Set(value.__value); + } + return value; + }); + return session; + } + + return undefined; + } catch (error) { + localStorage.removeItem(this.storageKey); + return undefined; + } + } + + private saveSession(session: T | undefined) { + if (session === undefined) { + localStorage.removeItem(this.storageKey); + } else { + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); + } + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts new file mode 100644 index 0000000000..ce273847cd --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -0,0 +1,168 @@ +/* + * 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 { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +import { SessionState } from '@backstage/core-plugin-api'; + +const defaultOptions = { + sessionScopes: (session: { scopes: Set }) => session.scopes, + sessionShouldRefresh: (session: { expired: boolean }) => session.expired, +}; + +describe('RefreshingAuthSessionManager', () => { + it('should save result from createSession', async () => { + const createSession = jest.fn().mockResolvedValue({ expired: false }); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const removeSession = jest.fn(); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession, removeSession }, + ...defaultOptions, + } as any); + const stateSubscriber = jest.fn(); + manager.sessionState$().subscribe(stateSubscriber); + + await Promise.resolve(); // Wait a tick for observer to post a value + + expect(stateSubscriber.mock.calls).toEqual([[SessionState.SignedOut]]); + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + ]); + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + expect(refreshSession).toBeCalledTimes(1); + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + ]); + + expect(removeSession).toHaveBeenCalledTimes(0); + await manager.removeSession(); + expect(removeSession).toHaveBeenCalledTimes(1); + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + [SessionState.SignedOut], + ]); + }); + + it('should ask consent only if scopes have changed', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: false, + }); + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['b']) }); + expect(createSession).toBeCalledTimes(2); + }); + + it('should check for session expiry', async () => { + const createSession = jest.fn(); + const refreshSession = jest + .fn() + .mockRejectedValueOnce(new Error('NOPE')) + .mockResolvedValue({ scopes: new Set(['a']) }); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: true, + }); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(2); + }); + + it('should handle user closed popup', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockRejectedValueOnce(new Error('some error')); + await expect( + manager.getSession({ scopes: new Set(['a']) }), + ).rejects.toThrow('some error'); + }); + + it('should not get optional session', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ optional: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(0); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should forward option to instantly show auth popup and not attempt refresh', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ instantPopup: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(1); + expect(createSession).toHaveBeenCalledWith({ + scopes: new Set(), + instantPopup: true, + }); + expect(refreshSession).toBeCalledTimes(0); + }); + + it('should remove session straight away', async () => { + const removeSession = jest.fn(); + const manager = new RefreshingAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts new file mode 100644 index 0000000000..a098b384f9 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -0,0 +1,148 @@ +/* + * 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 { + SessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, + GetSessionOptions, +} from './types'; +import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper, hasScopes } from './common'; +import { SessionStateTracker } from './SessionStateTracker'; + +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes: SessionScopesFunc; + /** Used to check if the session needs to be refreshed */ + sessionShouldRefresh: SessionShouldRefreshFunc; + /** The default scopes that should always be present in a session, defaults to none. */ + defaultScopes?: Set; +}; + +/** + * RefreshingAuthSessionManager manages an underlying session that has + * and expiration time and needs to be refreshed periodically. + */ +export class RefreshingAuthSessionManager implements SessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + private readonly sessionScopesFunc: SessionScopesFunc; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; + private readonly stateTracker = new SessionStateTracker(); + + private refreshPromise?: Promise; + private currentSession: T | undefined; + + constructor(options: Options) { + const { + connector, + defaultScopes = new Set(), + sessionScopes, + sessionShouldRefresh, + } = options; + + this.connector = connector; + this.sessionScopesFunc = sessionScopes; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); + } + + async getSession(options: GetSessionOptions): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { + const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!); + if (!shouldRefresh) { + return this.currentSession!; + } + + try { + const refreshedSession = await this.collapsedSessionRefresh(); + const currentScopes = this.sessionScopesFunc(this.currentSession!); + const refreshedScopes = this.sessionScopesFunc(refreshedSession); + if (hasScopes(refreshedScopes, currentScopes)) { + this.currentSession = refreshedSession; + } + return refreshedSession; + } catch (error) { + if (options.optional) { + return undefined; + } + throw error; + } + } + + // The user may still have a valid refresh token in their cookies. Attempt to + // initiate a fresh session through the backend using that refresh token. + // + // We skip this check if an instant login popup is requested, as we need to + // stay in a synchronous call stack from the user interaction. The downside + // is that that the user will sometimes be requested to log in even if they + // already had an existing session. + if (!this.currentSession && !options.instantPopup) { + try { + const newSession = await this.collapsedSessionRefresh(); + this.currentSession = newSession; + // The session might not have the scopes requested so go back and check again + return this.getSession(options); + } catch { + // If the refresh attempt fails we assume we don't have a session, so continue to create one. + } + } + + // If we continue here we will show a popup, so exit if this is an optional session request. + if (options.optional) { + return undefined; + } + + // We can call authRequester multiple times, the returned session will contain all requested scopes. + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); + this.stateTracker.setIsSignedIn(true); + return this.currentSession; + } + + async removeSession() { + this.currentSession = undefined; + await this.connector.removeSession(); + this.stateTracker.setIsSignedIn(false); + } + + sessionState$() { + return this.stateTracker.sessionState$(); + } + + private async collapsedSessionRefresh(): Promise { + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.connector.refreshSession(); + + try { + const session = await this.refreshPromise; + this.stateTracker.setIsSignedIn(true); + return session; + } finally { + delete this.refreshPromise; + } + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-app-api/src/lib/AuthSessionManager/SessionStateTracker.ts new file mode 100644 index 0000000000..72ca789cc9 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -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 { BehaviorSubject } from '../subjects'; +import { Observable, SessionState } from '@backstage/core-plugin-api'; + +export class SessionStateTracker { + private readonly subject = new BehaviorSubject( + SessionState.SignedOut, + ); + + private signedIn: boolean = false; + + setIsSignedIn(isSignedIn: boolean) { + if (this.signedIn !== isSignedIn) { + this.signedIn = isSignedIn; + this.subject.next( + this.signedIn ? SessionState.SignedIn : SessionState.SignedOut, + ); + } + } + + sessionState$(): Observable { + return this.subject; + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts new file mode 100644 index 0000000000..6280750875 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -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 { StaticAuthSessionManager } from './StaticAuthSessionManager'; + +const defaultOptions = { + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +describe('StaticAuthSessionManager', () => { + const baseConnector = { + refreshSession() { + throw new Error('refreshSession should not be called'); + }, + removeSession() { + throw new Error('removeSession should not be called'); + }, + }; + + it('should get session by creating session once', async () => { + const createSession = jest.fn().mockResolvedValue('my-session'); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + }); + + it('should fail to get session if user rejects the request', async () => { + const createSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).rejects.toThrow('NOPE'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ optional: true })).resolves.toBe( + undefined, + ); + }); + + it('should only request auth once for same scopes', async () => { + const createSession = jest + .fn() + .mockImplementation(({ scopes }) => [...scopes].join(' ')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['b']) })).resolves.toBe( + 'a b', + ); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it('should remove session and reload', async () => { + const removeSession = jest.fn(); + const manager = new StaticAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts new file mode 100644 index 0000000000..e4f144b0a9 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -0,0 +1,83 @@ +/* + * 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 { MutableSessionManager, GetSessionOptions } from './types'; +import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper } from './common'; +import { SessionStateTracker } from './SessionStateTracker'; + +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes?: (session: T) => Set; + /** The default scopes that should always be present in a session, defaults to none. */ + defaultScopes?: Set; +}; + +/** + * StaticAuthSessionManager manages an underlying session that does not expire. + */ +export class StaticAuthSessionManager implements MutableSessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + private readonly stateTracker = new SessionStateTracker(); + + private currentSession: T | undefined; + + constructor(options: Options) { + const { connector, defaultScopes = new Set(), sessionScopes } = options; + + this.connector = connector; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); + } + + setSession(session: T | undefined): void { + this.currentSession = session; + this.stateTracker.setIsSignedIn(Boolean(session)); + } + + async getSession(options: GetSessionOptions): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { + return this.currentSession; + } + + // If we continue here we will show a popup, so exit if this is an optional session request. + if (options.optional) { + return undefined; + } + + // We can call authRequester multiple times, the returned session will contain all requested scopes. + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); + this.stateTracker.setIsSignedIn(true); + return this.currentSession; + } + + async removeSession() { + this.currentSession = undefined; + await this.connector.removeSession(); + this.stateTracker.setIsSignedIn(false); + } + + sessionState$() { + return this.stateTracker.sessionState$(); + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/common.ts b/packages/core-app-api/src/lib/AuthSessionManager/common.ts new file mode 100644 index 0000000000..ff2897535d --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/common.ts @@ -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 { SessionScopesFunc } from './types'; + +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +type ScopeHelperOptions = { + sessionScopes: SessionScopesFunc | undefined; + defaultScopes?: Set; +}; + +export class SessionScopeHelper { + constructor(private readonly options: ScopeHelperOptions) {} + + sessionExistsAndHasScope( + session: T | undefined, + scopes?: Set, + ): boolean { + if (!session) { + return false; + } + if (!scopes) { + return true; + } + if (this.options.sessionScopes === undefined) { + return true; + } + const sessionScopes = this.options.sessionScopes(session); + return hasScopes(sessionScopes, scopes); + } + + getExtendedScope(session: T | undefined, scopes?: Set) { + const newScope = new Set(this.options.defaultScopes); + if (session && this.options.sessionScopes !== undefined) { + const sessionScopes = this.options.sessionScopes(session); + for (const scope of sessionScopes) { + newScope.add(scope); + } + } + if (scopes) { + for (const scope of scopes) { + newScope.add(scope); + } + } + return newScope; + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/index.ts b/packages/core-app-api/src/lib/AuthSessionManager/index.ts new file mode 100644 index 0000000000..5f4dde8662 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +export { StaticAuthSessionManager } from './StaticAuthSessionManager'; +export { AuthSessionStore } from './AuthSessionStore'; +export * from './types'; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/types.ts b/packages/core-app-api/src/lib/AuthSessionManager/types.ts new file mode 100644 index 0000000000..0bed895fba --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/types.ts @@ -0,0 +1,56 @@ +/* + * 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, SessionState } from '@backstage/core-plugin-api'; + +export type GetSessionOptions = { + optional?: boolean; + instantPopup?: boolean; + scopes?: Set; +}; + +/** + * A sessions manager keeps track of the current session and makes sure that + * multiple simultaneous requests for sessions with different scope are handled + * in a correct way. + */ +export type SessionManager = { + getSession(options: GetSessionOptions): Promise; + + removeSession(): Promise; + + sessionState$(): Observable; +}; + +/** + * An extension of the session manager where the session can also be pushed from the manager. + */ +export interface MutableSessionManager extends SessionManager { + setSession(session: T | undefined): void; +} + +/** + * A function called to determine the scopes of a session. + */ +export type SessionScopesFunc = (session: T) => Set; + +/** + * A function called to determine whether it's time for a session to refresh. + * + * This should return true before the session expires, for example, if a session + * expires after 60 minutes, you could return true if the session is older than 45 minutes. + */ +export type SessionShouldRefreshFunc = (session: T) => boolean; diff --git a/packages/core-app-api/src/lib/globalObject.test.ts b/packages/core-app-api/src/lib/globalObject.test.ts new file mode 100644 index 0000000000..e72f027b46 --- /dev/null +++ b/packages/core-app-api/src/lib/globalObject.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2021 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 { + getGlobalSingleton, + getOrCreateGlobalSingleton, + setGlobalSingleton, +} from './globalObject'; + +const anyGlobal = global as any; + +describe('getGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + const myOtherThing = {}; + + anyGlobal['__@backstage/my-thing__'] = myThing; + expect(getGlobalSingleton('my-thing')).toBe(myThing); + expect(getGlobalSingleton('my-thing')).toBe(myThing); + anyGlobal['__@backstage/my-thing__'] = myOtherThing; + expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); + }); + + it('should throw if the value is not set', () => { + expect(() => getGlobalSingleton('my-thing')).toThrow( + 'Global my-thing is not set', + ); + }); +}); + +describe('getOrCreateGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + anyGlobal['__@backstage/my-thing__'] = myThing; + + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + }); + + it('should should create a new value', () => { + const myNewThing = {}; + + expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); + expect(getOrCreateGlobalSingleton('my-thing', () => myNewThing)).toBe( + myNewThing, + ); + expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); + }); +}); + +describe('setGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should set a global value', () => { + setGlobalSingleton('my-thing', 'global value'); + + expect(anyGlobal['__@backstage/my-thing__']).toBe('global value'); + }); + + it('should throw if global value is set', () => { + anyGlobal['__@backstage/my-thing__'] = 'already defined'; + expect(() => setGlobalSingleton('my-thing', () => 'global value')).toThrow( + 'Global my-thing is already se', + ); + }); +}); diff --git a/packages/core-app-api/src/lib/globalObject.ts b/packages/core-app-api/src/lib/globalObject.ts new file mode 100644 index 0000000000..87be58499d --- /dev/null +++ b/packages/core-app-api/src/lib/globalObject.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 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. + */ + +// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 +function getGlobalObject() { + if (typeof window !== 'undefined' && window.Math === Math) { + return window; + } + if (typeof self !== 'undefined' && self.Math === Math) { + return self; + } + // eslint-disable-next-line no-new-func + return Function('return this')(); +} + +const globalObject = getGlobalObject(); + +const makeKey = (id: string) => `__@backstage/${id}__`; + +/** + * Used to provide a global singleton value, failing if it is already set. + */ +export function setGlobalSingleton(id: string, value: unknown): void { + const key = makeKey(id); + if (key in globalObject) { + throw new Error(`Global ${id} is already set`); // TODO some sort of special build err + } + globalObject[key] = value; +} + +/** + * Used to access a global singleton value, failing if it is not already set. + */ +export function getGlobalSingleton(id: string): T { + const key = makeKey(id); + if (!(key in globalObject)) { + throw new Error(`Global ${id} is not set`); // TODO some sort of special build err + } + + return globalObject[key]; +} + +/** + * Serializes access to a global singleton value, with the first caller creating the value. + */ +export function getOrCreateGlobalSingleton( + id: string, + supplier: () => T, +): T { + const key = makeKey(id); + + let value = globalObject[key]; + if (value) { + return value; + } + + value = supplier(); + globalObject[key] = value; + return value; +} diff --git a/packages/core-app-api/src/lib/index.ts b/packages/core-app-api/src/lib/index.ts new file mode 100644 index 0000000000..10f213b50f --- /dev/null +++ b/packages/core-app-api/src/lib/index.ts @@ -0,0 +1,20 @@ +/* + * 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 './subjects'; +export * from './loginPopup'; +export * from './AuthConnector'; +export * from './AuthSessionManager'; diff --git a/packages/core-app-api/src/lib/loginPopup.test.ts b/packages/core-app-api/src/lib/loginPopup.test.ts new file mode 100644 index 0000000000..98541c268e --- /dev/null +++ b/packages/core-app-api/src/lib/loginPopup.test.ts @@ -0,0 +1,218 @@ +/* + * 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 { showLoginPopup } from './loginPopup'; + +describe('showLoginPopup', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should show an auth popup', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + name: 'test-popup', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toBe( + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + ); + expect(openSpy.mock.calls[0][1]).toBe('test-popup'); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + listener({} as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + // None of these should be accepted + listener({ source: popupMock } as MessageEvent); + listener({ origin: 'my-origin' } as MessageEvent); + listener({ data: { type: 'authorization_response' } } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: {}, + } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: { type: 'not-auth-result', response: {} }, + } as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + const myResponse = {}; + + // This should be accepted as a valid sessions response + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'authorization_response', + response: myResponse, + }, + } as MessageEvent); + + await expect(payloadPromise).resolves.toBe(myResponse); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup returns error', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'authorization_response', + error: { + message: 'NOPE', + name: 'NopeError', + }, + }, + } as MessageEvent); + + await expect(payloadPromise).rejects.toThrow({ + name: 'NopeError', + message: 'NOPE', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup is closed', async () => { + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + listener({ + source: popupMock, + origin: 'origin', + data: { + type: 'config_info', + targetOrigin: 'http://localhost', + }, + } as MessageEvent); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, popup was closed', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should indicate if origin does not match', async () => { + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + listener({ + source: popupMock, + origin: 'origin', + data: { + type: 'config_info', + targetOrigin: 'http://differenthost', + }, + } as MessageEvent); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, Incorrect app origin, expected http://differenthost', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); +}); diff --git a/packages/core-app-api/src/lib/loginPopup.ts b/packages/core-app-api/src/lib/loginPopup.ts new file mode 100644 index 0000000000..716c4f9651 --- /dev/null +++ b/packages/core-app-api/src/lib/loginPopup.ts @@ -0,0 +1,143 @@ +/* + * 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. + */ + +/** + * Options used to open a login popup. + */ +export type LoginPopupOptions = { + /** + * The URL that the auth popup should point to + */ + url: string; + + /** + * The name of the popup, as in second argument to window.open + */ + name: string; + + /** + * The origin of the final popup page that will post a message to this window. + */ + origin: string; + + /** + * The width of the popup in pixels, defaults to 500 + */ + width?: number; + + /** + * The height of the popup in pixels, defaults to 700 + */ + height?: number; +}; + +type AuthResult = + | { + type: 'authorization_response'; + response: unknown; + } + | { + type: 'authorization_response'; + error: { + name: string; + message: string; + }; + }; + +/** + * Show a popup pointing to a URL that starts an auth flow. Implementing the receiving + * end of the postMessage mechanism outlined in https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00 + * + * The redirect handler of the flow should use postMessage to communicate back + * to the app window. The message posted to the app must match the AuthResult type. + * + * The returned promise resolves to the response of the message that was posted from the auth popup. + */ +export function showLoginPopup(options: LoginPopupOptions): Promise { + return new Promise((resolve, reject) => { + const width = options.width || 500; + const height = options.height || 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + options.url, + options.name, + `menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`, + ); + + let targetOrigin = ''; + + if (!popup || typeof popup.closed === 'undefined' || popup.closed) { + const error = new Error('Failed to open auth popup.'); + error.name = 'PopupRejectedError'; + reject(error); + return; + } + + const messageListener = (event: MessageEvent) => { + if (event.source !== popup) { + return; + } + if (event.origin !== options.origin) { + return; + } + const { data } = event; + + if (data.type === 'config_info') { + targetOrigin = data.targetOrigin; + return; + } + + if (data.type !== 'authorization_response') { + return; + } + const authResult = data as AuthResult; + + if ('error' in authResult) { + const error = new Error(authResult.error.message); + error.name = authResult.error.name; + // TODO: proper error type + // error.extra = authResult.error.extra; + reject(error); + } else { + resolve(authResult.response); + } + done(); + }; + + const intervalId = setInterval(() => { + if (popup.closed) { + const errMessage = `Login failed, ${ + targetOrigin && targetOrigin !== window.location.origin + ? `Incorrect app origin, expected ${targetOrigin}` + : 'popup was closed' + }`; + const error = new Error(errMessage); + error.name = 'PopupClosedError'; + reject(error); + done(); + } + }, 100); + + function done() { + window.removeEventListener('message', messageListener); + clearInterval(intervalId); + } + + window.addEventListener('message', messageListener); + }); +} diff --git a/packages/core-app-api/src/lib/subjects.test.ts b/packages/core-app-api/src/lib/subjects.test.ts new file mode 100644 index 0000000000..31ed26d97d --- /dev/null +++ b/packages/core-app-api/src/lib/subjects.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { PublishSubject, BehaviorSubject } from './subjects'; + +function observerSpy() { + return { + next: jest.fn(), + error: jest.fn(), + complete: jest.fn(), + }; +} + +describe('PublishSubject', () => { + it('should be completed', async () => { + const subj = new PublishSubject(); + subj.complete(); + + const spy = observerSpy(); + subj.subscribe(spy); + await 'a tick'; + + expect(spy.next).not.toHaveBeenCalled(); + expect(spy.error).not.toHaveBeenCalled(); + expect(spy.complete).toHaveBeenCalledTimes(1); + + expect(() => subj.next(1)).toThrow('PublishSubject is closed'); + expect(() => subj.error(new Error())).toThrow('PublishSubject is closed'); + expect(() => subj.complete()).toThrow('PublishSubject is closed'); + + expect(subj.closed).toBe(true); + }); + + it('should forward values to all subscribers', async () => { + const subj = new PublishSubject(); + + const spy1 = observerSpy(); + const spy2 = observerSpy(); + subj.subscribe(spy1); + const sub = subj.subscribe(spy2); + + subj.next(42); + + expect(spy1.next).toHaveBeenCalledTimes(1); + expect(spy1.next).toHaveBeenCalledWith(42); + expect(spy2.next).toHaveBeenCalledTimes(1); + expect(spy2.next).toHaveBeenCalledWith(42); + + sub.unsubscribe(); + + subj.next(1337); + + expect(spy1.next).toHaveBeenCalledTimes(2); + expect(spy1.next).toHaveBeenCalledWith(1337); + expect(spy2.next).toHaveBeenCalledTimes(1); + + expect(spy1.error).not.toHaveBeenCalled(); + expect(spy2.error).not.toHaveBeenCalled(); + expect(spy1.complete).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(false); + }); + + it('should forward errors', async () => { + const subj = new PublishSubject(); + + const spy1 = observerSpy(); + subj.subscribe(spy1); + + const error = new Error('NOPE'); + subj.error(error); + expect(spy1.error).toHaveBeenCalledWith(error); + expect(spy1.next).not.toHaveBeenCalled(); + expect(spy1.complete).not.toHaveBeenCalled(); + + const spy2 = observerSpy(); + subj.subscribe(spy2); + await 'a tick'; + expect(spy2.error).toHaveBeenCalledWith(error); + expect(spy2.next).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(true); + }); +}); + +describe('BehaviorSubject', () => { + it('should be completed', async () => { + const subj = new BehaviorSubject(0); + subj.complete(); + + const next = jest.fn(); + const complete = jest.fn(); + subj.subscribe({ next, complete }); + await 'a tick'; + + expect(complete).toHaveBeenCalledTimes(1); + + expect(() => subj.next(1)).toThrow('BehaviorSubject is closed'); + expect(() => subj.error(new Error())).toThrow('BehaviorSubject is closed'); + expect(() => subj.complete()).toThrow('BehaviorSubject is closed'); + + expect(subj.closed).toBe(true); + }); + + it('should forward values to all subscribers', async () => { + const subj = new BehaviorSubject(0); + + const obs1 = jest.fn(); + const obs2 = jest.fn(); + subj.subscribe(obs1); + const sub = subj.subscribe(obs2); + await 'a tick'; + + expect(obs1).toHaveBeenCalledTimes(1); + expect(obs1).toHaveBeenCalledWith(0); + expect(obs2).toHaveBeenCalledTimes(1); + expect(obs2).toHaveBeenCalledWith(0); + + subj.next(42); + + expect(obs1).toHaveBeenCalledTimes(2); + expect(obs1).toHaveBeenCalledWith(42); + expect(obs2).toHaveBeenCalledTimes(2); + expect(obs2).toHaveBeenCalledWith(42); + + sub.unsubscribe(); + + subj.next(1337); + + expect(obs1).toHaveBeenCalledTimes(3); + expect(obs1).toHaveBeenCalledWith(1337); + expect(obs2).toHaveBeenCalledTimes(2); + + expect(subj.closed).toBe(false); + }); + + it('should forward errors', async () => { + const subj = new BehaviorSubject(0); + + const spy1 = observerSpy(); + subj.subscribe(spy1); + await 'a tick'; + + expect(spy1.error).not.toHaveBeenCalled(); + expect(spy1.next).toHaveBeenCalledWith(0); + + const error = new Error('NOPE'); + subj.error(error); + expect(spy1.error).toHaveBeenCalledWith(error); + expect(spy1.next).toHaveBeenCalledWith(0); + expect(spy1.next).toHaveBeenCalledTimes(1); + expect(spy1.complete).not.toHaveBeenCalled(); + + const spy2 = observerSpy(); + subj.subscribe(spy2); + await 'a tick'; + expect(spy2.error).toHaveBeenCalledWith(error); + expect(spy2.next).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(true); + }); +}); diff --git a/packages/core-app-api/src/lib/subjects.ts b/packages/core-app-api/src/lib/subjects.ts new file mode 100644 index 0000000000..52870538f0 --- /dev/null +++ b/packages/core-app-api/src/lib/subjects.ts @@ -0,0 +1,210 @@ +/* + * 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 ObservableImpl from 'zen-observable'; + +// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects. +// If we add a more complete Observables library they should be replaced. + +/** + * A basic implementation of ReactiveX publish subjects. + * + * A subject is a convenient way to create an observable when you want + * to fan out a single value to all subscribers. + * + * See http://reactivex.io/documentation/subject.html + */ +export class PublishSubject + implements Observable, ZenObservable.SubscriptionObserver { + private isClosed = false; + private terminatingError?: Error; + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; + } + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + [Symbol.observable]() { + return this; + } + + get closed() { + return this.isClosed; + } + + next(value: T) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + error(error: Error) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.isClosed = true; + this.terminatingError = error; + this.subscribers.forEach(subscriber => subscriber.error(error)); + } + + complete() { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.isClosed = true; + this.subscribers.forEach(subscriber => subscriber.complete()); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription; + subscribe( + onNext: ZenObservable.Observer | ((value: T) => void), + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription { + const observer = + typeof onNext === 'function' + ? { + next: onNext, + error: onError, + complete: onComplete, + } + : onNext; + + return this.observable.subscribe(observer); + } +} + +/** + * A basic implementation of ReactiveX behavior subjects. + * + * A subject is a convenient way to create an observable when you want + * to fan out a single value to all subscribers. + * + * The BehaviorSubject will emit the most recently emitted value or error + * whenever a new observer subscribes to the subject. + * + * See http://reactivex.io/documentation/subject.html + */ +export class BehaviorSubject + implements Observable, ZenObservable.SubscriptionObserver { + private isClosed = false; + private currentValue: T; + private terminatingError?: Error; + + constructor(value: T) { + this.currentValue = value; + } + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; + } + + subscriber.next(this.currentValue); + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + [Symbol.observable]() { + return this; + } + + get closed() { + return this.isClosed; + } + + next(value: T) { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.currentValue = value; + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + error(error: Error) { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.isClosed = true; + this.terminatingError = error; + this.subscribers.forEach(subscriber => subscriber.error(error)); + } + + complete() { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.isClosed = true; + this.subscribers.forEach(subscriber => subscriber.complete()); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription; + subscribe( + onNext: ZenObservable.Observer | ((value: T) => void), + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription { + const observer = + typeof onNext === 'function' + ? { + next: onNext, + error: onError, + complete: onComplete, + } + : onNext; + + return this.observable.subscribe(observer); + } +} diff --git a/packages/core-app-api/src/lib/versionedValues.test.ts b/packages/core-app-api/src/lib/versionedValues.test.ts new file mode 100644 index 0000000000..6ba7db4970 --- /dev/null +++ b/packages/core-app-api/src/lib/versionedValues.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 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 { createVersionedValueMap } from './versionedValues'; + +describe('createVersionedValueMap', () => { + it('should be empty', () => { + const map = createVersionedValueMap({}); + + // @ts-expect-error + expect(map.atVersion(1 as any)).toBe(undefined); + }); + + it('should access values by version', () => { + const map = createVersionedValueMap({ 1: 'v1', 2: 'v2' }); + + expect(map.atVersion(1)).toBe('v1'); + expect(map.atVersion(2)).toBe('v2'); + + // @ts-expect-error + expect(map.atVersion(0)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(NaN)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(Infinity)).toBe(undefined); + }); +}); diff --git a/packages/core-app-api/src/lib/versionedValues.ts b/packages/core-app-api/src/lib/versionedValues.ts new file mode 100644 index 0000000000..88e4e90084 --- /dev/null +++ b/packages/core-app-api/src/lib/versionedValues.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 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. + */ + +/** + * The versioned value interface is a container for a set of values that + * can be looked up by version. It is intended to be used as a container + * for values that can be versioned independently of package versions. + */ +export type VersionedValue = { + atVersion( + version: Version, + ): Versions[Version] | undefined; +}; + +/** + * Creates a container for a map of versioned values that implements VersionedValue. + */ +export function createVersionedValueMap< + Versions extends { [version: number]: any } +>(versions: Versions): VersionedValue { + Object.freeze(versions); + return { + atVersion(version) { + return versions[version]; + }, + }; +} diff --git a/packages/core-app-api/src/plugins/collectors.test.tsx b/packages/core-app-api/src/plugins/collectors.test.tsx new file mode 100644 index 0000000000..220d89690f --- /dev/null +++ b/packages/core-app-api/src/plugins/collectors.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { createPlugin, createRouteRef } from '@backstage/core-plugin-api'; +import { + createRoutableExtension, + createComponentExtension, +} from '../extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { pluginCollector } from './collectors'; + +const mockConfig = () => ({ id: 'foo' }); +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const pluginA = createPlugin({ id: 'my-plugin-a' }); +const pluginB = createPlugin({ id: 'my-plugin-b' }); +const pluginC = createPlugin({ id: 'my-plugin-c' }); + +const ref1 = createRouteRef(mockConfig()); +const ref2 = createRouteRef(mockConfig()); + +const Extension1 = pluginA.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = pluginB.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = pluginA.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension4 = pluginB.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension5 = pluginC.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); + +describe('collection', () => { + it('should collect the plugins', () => { + const root = ( + + + +
+ +
+
+ {[]} + Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { plugins } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + plugins: pluginCollector, + }, + }); + + expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC])); + }); +}); diff --git a/packages/core-app-api/src/plugins/collectors.ts b/packages/core-app-api/src/plugins/collectors.ts new file mode 100644 index 0000000000..8e5b623164 --- /dev/null +++ b/packages/core-app-api/src/plugins/collectors.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ +/* + * 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 { BackstagePlugin } from '@backstage/core-plugin-api'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +export const pluginCollector = createCollector( + () => new Set>(), + (acc, node) => { + const plugin = getComponentData>( + node, + 'core.plugin', + ); + if (plugin) { + acc.add(plugin); + } + }, +); diff --git a/packages/core-app-api/src/plugins/index.ts b/packages/core-app-api/src/plugins/index.ts new file mode 100644 index 0000000000..95794a7487 --- /dev/null +++ b/packages/core-app-api/src/plugins/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 { pluginCollector } from './collectors'; diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx new file mode 100644 index 0000000000..a9b83d1016 --- /dev/null +++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx @@ -0,0 +1,111 @@ +/* + * 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, RenderResult } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom'; +import { AppContext } from '../app'; +import { AppContextProvider } from '../app/AppContext'; +import { FlatRoutes } from './FlatRoutes'; + +function makeRouteRenderer(node: ReactNode) { + let rendered: RenderResult | undefined = undefined; + return (path: string) => { + const content = ( + ({ + NotFoundErrorPage: () => <>Not Found, + }), + } as unknown) as AppContext + } + > + + + ); + if (rendered) { + rendered.unmount(); + rendered.rerender(content); + } else { + rendered = render(content); + } + return rendered; + }; +} + +describe('FlatRoutes', () => { + it('renders some routes', () => { + const renderRoute = makeRouteRenderer( + + a} /> + b} /> + , + ); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('b')).toBeInTheDocument(); + expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument(); + expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument(); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + }); + + it('is not sensitive to ordering and overlapping routes', () => { + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + a-1} /> + a} /> + a-2} /> + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument(); + renderRoute('').unmount(); + + // This uses regular Routes from react-router, not that a-2 renders a, which is the behavior we're working around + const renderBadRoute = makeRouteRenderer({routes}); + expect(renderBadRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderBadRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderBadRoute('/a-2').getByText('a')).toBeInTheDocument(); + }); + + it('renders children straight as outlets', () => { + const MyPage = () => { + return <>Outlet: {useOutlet()}; + }; + + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + }> + a + + }> + a-b + + }> + b + + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument(); + expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx new file mode 100644 index 0000000000..54ef092965 --- /dev/null +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -0,0 +1,87 @@ +/* + * 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, { ReactNode, Children, isValidElement, Fragment } from 'react'; +import { useRoutes } from 'react-router-dom'; +import { useApp } from '@backstage/core-plugin-api'; + +type RouteObject = { + path: string; + element: JSX.Element; + children?: RouteObject[]; +}; + +// Similar to the same function from react-router, this collects routes from the +// children, but only the first level of routes +function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { + return Children.toArray(childrenNode).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + const { children } = child.props; + + if (child.type === Fragment) { + return createRoutesFromChildren(children); + } + + let path = child.props.path as string | undefined; + + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; + + return [ + { + path, + element: child, + children: children && [ + { + path: '/*', + element: children, + }, + ], + }, + ]; + }); +} + +type FlatRoutesProps = { + children: ReactNode; +}; + +export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + const routes = createRoutesFromChildren(props.children) + // Routes are sorted to work around a bug where prefixes are unexpectedly matched + .sort((a, b) => b.path.localeCompare(a.path)) + // We make sure all routes have '/*' appended, except '/' + .map(obj => { + obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; + return obj; + }); + + // TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop + routes.push({ + element: , + path: '/*', + }); + + return useRoutes(routes); +}; diff --git a/packages/core-app-api/src/routing/RouteResolver.test.ts b/packages/core-app-api/src/routing/RouteResolver.test.ts new file mode 100644 index 0000000000..f75617e7a5 --- /dev/null +++ b/packages/core-app-api/src/routing/RouteResolver.test.ts @@ -0,0 +1,297 @@ +/* + * 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 { + createRouteRef, + createSubRouteRef, + createExternalRouteRef, + ExternalRouteRef, + RouteRef, + SubRouteRef, +} from '@backstage/core-plugin-api'; +import { RouteResolver } from './RouteResolver'; +import { MATCH_ALL_ROUTE } from './collectors'; + +const element = () => null; +const rest = { element, caseSensitive: false, children: [MATCH_ALL_ROUTE] }; + +const ref1 = createRouteRef({ id: 'rr1' }); +const ref2 = createRouteRef({ id: 'rr2', params: ['x'] }); +const ref3 = createRouteRef({ id: 'rr3', params: ['y'] }); +const subRef1 = createSubRouteRef({ + id: 'srr1', + parent: ref1, + path: '/foo', +}); +const subRef2 = createSubRouteRef({ + id: 'srr2', + parent: ref1, + path: '/foo/:a', +}); +const subRef3 = createSubRouteRef({ + id: 'srr3', + parent: ref2, + path: '/bar', +}); +const subRef4 = createSubRouteRef({ + id: 'srr4', + parent: ref2, + path: '/bar/:a', +}); +const externalRef1 = createExternalRouteRef({ id: 'err1' }); +const externalRef2 = createExternalRouteRef({ + id: 'err2', + optional: true, +}); +const externalRef3 = createExternalRouteRef({ id: 'err3', params: ['x'] }); +const externalRef4 = createExternalRouteRef({ + id: 'err4', + optional: true, + params: ['x'], +}); + +describe('RouteResolver', () => { + it('should not resolve anything with an empty resolver', () => { + const r = new RouteResolver(new Map(), new Map(), [], new Map()); + + expect(r.resolve(ref1, '/')?.()).toBe(undefined); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe(undefined); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(undefined); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route', () => { + const r = new RouteResolver( + new Map([[ref1, '/my-route']]), + new Map(), + [{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }], + new Map(), + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route with a param and with a parent', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/my-route'], + [ref2, '/my-parent/:x'], + ]), + new Map([[ref2, ref1]]), + [ + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/my-route/my-parent/1x'); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe( + '/my-route/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe( + '/my-route/my-parent/4x/bar/4a', + ); + expect(r.resolve(externalRef1, '/')?.()).toBe('/my-route'); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe( + '/my-route/my-parent/5x', + ); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe( + '/my-route/my-parent/6x/bar', + ); + }); + + it('should resolve the most specific match', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/deep'], + [ref2, '/root/:x'], + [ref3, '/sub/:y'], + ]), + new Map([ + [ref3, ref2], + [ref1, ref3], + ]), + [ + { + routeRefs: new Set([ref2]), + path: '/root/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref3]), + path: '/sub/:y', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref1]), + path: '/deep', + ...rest, + }, + ], + }, + ], + }, + ], + new Map(), + ); + + expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x'); + expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y'); + + expect(() => r.resolve(ref1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(() => r.resolve(ref1, '/root/x')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(ref1, '/root/x/sub/y')?.()).toBe('/root/x/sub/y/deep'); + // Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here + expect(r.resolve(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe( + '/root/x/sub/y/deep', + ); + }); + + it('should resolve an absolute route with multiple parents', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/my-route'], + [ref2, '/my-parent/:x'], + [ref3, '/my-grandparent/:y'], + ]), + new Map([ + [ref1, ref2], + [ref2, ref3], + ]), + [ + { + routeRefs: new Set([ref3]), + path: '/my-grandparent/:y', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + ); + + const l = '/my-grandparent/my-y/my-parent/my-x'; + expect(r.resolve(ref1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(ref1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo', + ); + expect(() => r.resolve(subRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a', + ); + expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, l)?.({ x: '4x', a: '4a' })).toBe( + '/my-grandparent/my-y/my-parent/4x/bar/4a', + ); + expect( + r.resolve(subRef4, '/my-grandparent/my-y')?.({ x: '4x', a: '4a' }), + ).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a'); + expect(r.resolve(externalRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(externalRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef2, l)?.()).toBe(undefined); + expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe( + '/my-grandparent/my-y/my-parent/5x', + ); + expect(() => r.resolve(externalRef3, '/')?.({ x: '5x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe( + '/my-grandparent/my-y/my-parent/6x/bar', + ); + expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + }); +}); diff --git a/packages/core-app-api/src/routing/RouteResolver.ts b/packages/core-app-api/src/routing/RouteResolver.ts new file mode 100644 index 0000000000..38158f750e --- /dev/null +++ b/packages/core-app-api/src/routing/RouteResolver.ts @@ -0,0 +1,225 @@ +/* + * 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 { generatePath, matchRoutes } from 'react-router-dom'; +import { + AnyRouteRef, + BackstageRouteObject, + AnyParams, + RouteFunc, + routeRefType, + isRouteRef, + isSubRouteRef, + isExternalRouteRef, +} from './types'; +import { + RouteRef, + ExternalRouteRef, + SubRouteRef, +} from '@backstage/core-plugin-api'; + +// Joins a list of paths together, avoiding trailing and duplicate slashes +function joinPaths(...paths: string[]): string { + const normalized = paths.join('/').replace(/\/\/+/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + return normalized.slice(0, -1); + } + return normalized; +} + +/** + * Resolves the absolute route ref that our target route ref is pointing pointing to, as well + * as the relative target path. + * + * Returns an undefined target ref if one could not be fully resolved. + */ +function resolveTargetRef( + anyRouteRef: AnyRouteRef, + routePaths: Map, + routeBindings: Map, +): readonly [RouteRef | undefined, string] { + // First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append. + // For sub routes it will be the parent path, while for external routes it will be the bound route. + let targetRef: RouteRef; + let subRoutePath = ''; + if (isRouteRef(anyRouteRef)) { + targetRef = anyRouteRef; + } else if (isSubRouteRef(anyRouteRef)) { + targetRef = anyRouteRef.parent; + subRoutePath = anyRouteRef.path; + } else if (isExternalRouteRef(anyRouteRef)) { + const resolvedRoute = routeBindings.get(anyRouteRef); + if (!resolvedRoute) { + return [undefined, '']; + } + if (isRouteRef(resolvedRoute)) { + targetRef = resolvedRoute; + } else if (isSubRouteRef(resolvedRoute)) { + targetRef = resolvedRoute.parent; + subRoutePath = resolvedRoute.path; + } else { + throw new Error( + `ExternalRouteRef was bound to invalid target, ${resolvedRoute}`, + ); + } + } else if (anyRouteRef[routeRefType]) { + throw new Error( + `Unknown or invalid route ref type, ${anyRouteRef[routeRefType]}`, + ); + } else { + throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`); + } + + // Bail if no absolute path could be resolved + if (!targetRef) { + return [undefined, '']; + } + + // Find the path that our target route is bound to + const resolvedPath = routePaths.get(targetRef); + if (!resolvedPath) { + return [undefined, '']; + } + + // SubRouteRefs join the path from the parent route with its own path + const targetPath = joinPaths(resolvedPath, subRoutePath); + return [targetRef, targetPath]; +} + +/** + * Resolves the complete base path for navigating to the target RouteRef. + */ +function resolveBasePath( + targetRef: RouteRef, + sourceLocation: Parameters[1], + routePaths: Map, + routeParents: Map, + routeObjects: BackstageRouteObject[], +) { + // While traversing the app element tree we build up the routeObjects structure + // used here. It is the same kind of structure that react-router creates, with the + // addition that associated route refs are stored throughout the tree. This lets + // us look up all route refs that can be reached from our source location. + // Because of the similar route object structure, we can use `matchRoutes` from + // react-router to do the lookup of our current location. + const match = matchRoutes(routeObjects, sourceLocation) ?? []; + + // While we search for a common routing root between our current location and + // the target route, we build a list of all route refs we find that we need + // to traverse to reach the target. + const refDiffList = Array(); + + let matchIndex = -1; + for ( + let targetSearchRef: RouteRef | undefined = targetRef; + targetSearchRef; + targetSearchRef = routeParents.get(targetSearchRef) + ) { + // The match contains a list of all ancestral route refs present at our current location + // Starting at the desired target ref and traversing back through its parents, we search + // for a target ref that is present in the match for our current location. When a match + // is found it means we have found a common base to resolve the route from. + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(targetSearchRef!), + ); + if (matchIndex !== -1) { + break; + } + + // Every time we move a step up in the ancestry of the target ref, we add the current ref + // to the diff list, which ends up being the list of route refs to traverse form the common base + // in order to reach our target. + refDiffList.unshift(targetSearchRef); + } + + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (refDiffList.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the caller would have no way of knowing + // what parameters those are. + const diffPath = joinPaths( + ...refDiffList.slice(0, -1).map(ref => { + const path = routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${targetRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }), + ); + + return parentPath + diffPath; +} + +export class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + private readonly routeBindings: Map< + ExternalRouteRef, + RouteRef | SubRouteRef + >, + ) {} + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + sourceLocation: Parameters[1], + ): RouteFunc | undefined { + // First figure out what our target absolute ref is, as well as our target path. + const [targetRef, targetPath] = resolveTargetRef( + anyRouteRef, + this.routePaths, + this.routeBindings, + ); + if (!targetRef) { + return undefined; + } + + // Next we figure out the base path, which is the combination of the common parent path + // between our current location and our target location, as well as the additional path + // that is the difference between the parent path and the base of our target location. + const basePath = resolveBasePath( + targetRef, + sourceLocation, + this.routePaths, + this.routeParents, + this.routeObjects, + ); + + const routeFunc: RouteFunc = (...[params]) => { + return basePath + generatePath(targetPath, params); + }; + return routeFunc; + } +} diff --git a/packages/core-app-api/src/routing/RoutingProvider.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.test.tsx new file mode 100644 index 0000000000..39528fe3a9 --- /dev/null +++ b/packages/core-app-api/src/routing/RoutingProvider.test.tsx @@ -0,0 +1,369 @@ +/* + * 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, + ReactElement, + useContext, + Context, +} from 'react'; +import { MemoryRouter, Routes } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { VersionedValue } from '../lib/versionedValues'; +import { getGlobalSingleton } from '../lib/globalObject'; +import { createRoutableExtension } from '../extensions'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { + createPlugin, + useRouteRef, + createRouteRef, + createExternalRouteRef, + RouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; +import { RoutingProvider } from './RoutingProvider'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; +import { validateRoutes } from './validation'; +import { RouteResolver } from './RouteResolver'; +import { AnyRouteRef, RouteFunc } from './types'; + +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef({ id: 'ref1' }); +const ref2 = createRouteRef({ id: 'ref2' }); +const ref3 = createRouteRef({ id: 'ref3' }); +const ref4 = createRouteRef({ id: 'ref4' }); +const ref5 = createRouteRef({ id: 'ref5', params: ['x'] }); +const eRefA = createExternalRouteRef({ id: '1' }); +const eRefB = createExternalRouteRef({ id: '2' }); +const eRefC = createExternalRouteRef({ id: '3', params: ['y'] }); +const eRefD = createExternalRouteRef({ id: '4', optional: true }); +const eRefE = createExternalRouteRef({ + id: '5', + optional: true, + params: ['z'], +}); + +const MockRouteSource = (props: { + path?: string; + name: string; + routeRef: AnyRouteRef; + params?: T; +}) => { + try { + const routeFunc = useRouteRef(props.routeRef as any) as + | RouteFunc + | undefined; + return ( +
+ Path at {props.name}: {routeFunc?.(props.params) ?? ''} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}: {ex.message} +
+ ); + } +}; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +function withRoutingProvider( + root: ReactElement, + routeBindings: [ExternalRouteRef, RouteRef][] = [], +) { + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + return ( + + {root} + + ); +} + +describe('discovery', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { + const root = ( + + + + + + + + + + + + + + + ); + + const rendered = render( + withRoutingProvider(root, [ + [eRefA, ref3], + [eRefB, ref1], + [eRefC, ref2], + [eRefD, ref1], + ]), + ); + + await expect( + rendered.findByText('Path at inside: /foo/bar'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at insideExternal: /baz'), + ).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal1: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal2: /foo/bar'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal3: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal4: '), + ).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', async () => { + const root = ( + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/bar/bleb'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); + }); + + it('should handle relative routing within parameterized routePaths', async () => { + const root = ( + + + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/blob/baz'), + ).resolves.toBeInTheDocument(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText( + `Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + expect( + rendered.getByText( + `Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + }); + + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { + const root = ( + + + + + + + + ); + + const { routePaths, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + }, + }); + + expect(() => validateRoutes(routePaths, routeParents)).toThrow( + 'Parameter :id is duplicated in path /foo/:id/bar/:id', + ); + }); +}); + +describe('v1 consumer', () => { + const RoutingContext = getGlobalSingleton< + Context> + >('routing-context'); + + function useMockRouteRefV1( + routeRef: AnyRouteRef, + location: string, + ): RouteFunc | undefined { + const resolver = useContext(RoutingContext)?.atVersion(1); + if (!resolver) { + throw new Error('no impl'); + } + return resolver.resolve(routeRef, location); + } + + it('should resolve routes', () => { + const routeRef1 = createRouteRef({ id: 'ref1' }); + const routeRef2 = createRouteRef({ id: 'ref2' }); + const routeRef3 = createRouteRef({ id: 'ref3', params: ['x'] }); + + const renderedHook = renderHook( + ({ routeRef }) => useMockRouteRefV1(routeRef, '/'), + { + initialProps: { + routeRef: routeRef1 as AnyRouteRef, + }, + wrapper: ({ children }) => ( + , string>([ + [routeRef2, '/foo'], + [routeRef3, '/bar/:x'], + ]) + } + routeParents={new Map()} + routeObjects={[]} + routeBindings={new Map()} + children={children} + /> + ), + }, + ); + + expect(renderedHook.result.current).toBe(undefined); + renderedHook.rerender({ routeRef: routeRef2 }); + expect(renderedHook.result.current?.()).toBe('/foo'); + renderedHook.rerender({ routeRef: routeRef3 }); + expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/bar/my-x'); + }); +}); diff --git a/packages/core-app-api/src/routing/RoutingProvider.tsx b/packages/core-app-api/src/routing/RoutingProvider.tsx new file mode 100644 index 0000000000..86507d2ff4 --- /dev/null +++ b/packages/core-app-api/src/routing/RoutingProvider.tsx @@ -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, { createContext, ReactNode } from 'react'; +import { + ExternalRouteRef, + RouteRef, + SubRouteRef, +} from '@backstage/core-plugin-api'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; +import { + createVersionedValueMap, + VersionedValue, +} from '../lib/versionedValues'; +import { RouteResolver } from './RouteResolver'; +import { BackstageRouteObject } from './types'; + +type RoutingContextType = VersionedValue<{ 1: RouteResolver }> | undefined; +const RoutingContext = getOrCreateGlobalSingleton('routing-context', () => + createContext(undefined), +); + +type ProviderProps = { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; + routeBindings: Map; + children: ReactNode; +}; + +export const RoutingProvider = ({ + routePaths, + routeParents, + routeObjects, + routeBindings, + children, +}: ProviderProps) => { + const resolver = new RouteResolver( + routePaths, + routeParents, + routeObjects, + routeBindings, + ); + + const versionedValue = createVersionedValueMap({ 1: resolver }); + return ( + + {children} + + ); +}; diff --git a/packages/core-app-api/src/routing/collectors.test.tsx b/packages/core-app-api/src/routing/collectors.test.tsx new file mode 100644 index 0000000000..40b57bf25a --- /dev/null +++ b/packages/core-app-api/src/routing/collectors.test.tsx @@ -0,0 +1,376 @@ +/* + * 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 { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; + +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { + createRouteRef, + createPlugin, + RouteRef, +} from '@backstage/core-plugin-api'; +import { attachComponentData, createRoutableExtension } from '../extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef({ id: 'ref1' }); +const ref2 = createRouteRef({ id: 'ref2' }); +const ref3 = createRouteRef({ id: 'ref3' }); +const ref4 = createRouteRef({ id: 'ref4' }); +const ref5 = createRouteRef({ id: 'ref5' }); +const refOrder = [ref1, ref2, ref3, ref4, ref5]; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +const AggregationComponent = ({ + children, +}: PropsWithChildren<{ + path: string; +}>) => <>{children}; + +attachComponentData(AggregationComponent, 'core.gatherMountPoints', true); + +function sortedEntries(map: Map): [RouteRef, T][] { + return Array.from(map).sort( + ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), + ); +} + +function routeObj( + path: string, + refs: RouteRef[], + children: any[] = [], + type: 'mounted' | 'gathered' = 'mounted', +) { + return { + path: path, + caseSensitive: false, + element: type, + routeRefs: new Set(refs), + children: [ + { + path: '/*', + caseSensitive: false, + element: 'match-all', + routeRefs: new Set(), + }, + ...children, + ], + }; +} + +describe('discovery', () => { + it('should collect routes', () => { + const list = [ +
, +
, +
+ +
, + ]; + + const root = ( + + + +
+ +
+
+ Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {list} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref2], + [ref4, undefined], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj('/bar/:id', [ref2], [routeObj('/baz', [ref3])]), + routeObj('/blop', [ref5]), + ], + ), + routeObj('/divsoup', [ref4]), + ]); + }); + + it('should handle all react router Route patterns', () => { + const root = ( + + + + + + + + } + /> + }> + } /> + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should use the route aggregator key to bind child routes to the same path', () => { + const root = ( + + + + +
+ +
+ HELLO +
+ + + + + + + +
+
+ ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/foo'], + [ref3, '/bar'], + [ref4, '/baz'], + [ref5, '/baz'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + expect(routeObjects).toEqual([ + routeObj('/foo', [ref1, ref2], [], 'gathered'), + routeObj( + '/bar', + [ref3], + [routeObj('/baz', [ref4, ref5], [], 'gathered')], + ), + ]); + }); + + it('should use the route aggregator but stop when encountering explicit path', () => { + const root = ( + + + + + + + + + + + + + + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar'], + [ref3, '/baz'], + [ref4, '/blop'], + [ref5, '/bar'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref1], + [ref4, ref3], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj( + '/bar', + [ref2, ref5], + [routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])], + 'gathered', + ), + ], + ), + ]); + }); + + it('should stop gathering mount points after encountering explicit path', () => { + const root = ( + + + + + + + + + + + + ); + + expect(() => { + traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + }).toThrow('Mounted routable extension must have a path'); + }); +}); diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx new file mode 100644 index 0000000000..e940a2cada --- /dev/null +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -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 { isValidElement, ReactElement, ReactNode } from 'react'; +import { RouteRef } from '@backstage/core-plugin-api'; +import { BackstageRouteObject } from './types'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +function getMountPoint(node: ReactElement): RouteRef | undefined { + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + + return routeRef; +} + +export const routePathCollector = createCollector( + () => new Map(), + (acc, node, parent, ctxPath: string | undefined) => { + // The context path is used during mount point gathering to assign the same path + // to all discovered mount points + let currentCtxPath = ctxPath; + + if (parent?.props.element === node) { + return currentCtxPath; + } + + // Start gathering mount points when we encounter a mount point gathering flag + if (getComponentData(node, 'core.gatherMountPoints')) { + const path: string | undefined = node.props?.path; + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + currentCtxPath = path; + } + + const routeRef = getMountPoint(node); + if (routeRef) { + let path: string | undefined = node.props?.path; + // If we're gathering mount points we use the context path as out path, unless + // the element has its own path, in which case we use that instead and stop gathering + if (currentCtxPath) { + if (path) { + currentCtxPath = undefined; + } else { + path = currentCtxPath; + } + } + if (!path) { + throw new Error('Mounted routable extension must have a path'); + } + acc.set(routeRef, path); + } + return currentCtxPath; + }, +); + +export const routeParentCollector = createCollector( + () => new Map(), + (acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => { + if (parent?.props.element === node) { + return parentRouteRef; + } + + let nextParent = parentRouteRef; + + const routeRef = getMountPoint(node); + if (routeRef) { + // "sticky" route ref is when we've encountered a mount point gatherer, and we want a + // mount points beneath it to have the same parent, regardless of internal structure + if (parentRouteRef && 'sticky' in parentRouteRef) { + acc.set(routeRef, parentRouteRef.sticky); + + // When we encounter a mount point with an explicit path, we stop gathering + // mount points withing the children and remove the sticky state + if (node.props?.path) { + nextParent = routeRef; + } else { + nextParent = parentRouteRef; + } + } else { + acc.set(routeRef, parentRouteRef); + nextParent = routeRef; + } + } + + // Mount point gatherers are marked as "sticky" + if (getComponentData(node, 'core.gatherMountPoints')) { + return { sticky: nextParent }; + } + + return nextParent; + }, +); + +// We always add a child that matches all subroutes but without any route refs. This makes +// sure that we're always able to match each route no matter how deep the navigation goes. +// The route resolver then takes care of selecting the most specific match in order to find +// mount points that are as deep in the routing tree as possible. +export const MATCH_ALL_ROUTE: BackstageRouteObject = { + caseSensitive: false, + path: '/*', + element: 'match-all', // These elements aren't used, so we add in a bit of debug information + routeRefs: new Set(), +}; + +export const routeObjectCollector = createCollector( + () => Array(), + (acc, node, parent, parentObj: BackstageRouteObject | undefined) => { + const parentChildren = parentObj?.children ?? acc; + if (parent?.props.element === node) { + return parentObj; + } + + const path: string | undefined = node.props?.path; + const caseSensitive: boolean = Boolean(node.props?.caseSensitive); + + const routeRef = getMountPoint(node); + if (routeRef) { + if (path) { + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: 'mounted', + routeRefs: new Set([routeRef]), + children: [MATCH_ALL_ROUTE], + }; + parentChildren.push(newObject); + return newObject; + } + + parentObj?.routeRefs.add(routeRef); + } + + const isGatherer = getComponentData( + node, + 'core.gatherMountPoints', + ); + if (isGatherer) { + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: 'gathered', + routeRefs: new Set(), + children: [MATCH_ALL_ROUTE], + }; + parentChildren.push(newObject); + return newObject; + } + + return parentObj; + }, +); diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts new file mode 100644 index 0000000000..7982333f4b --- /dev/null +++ b/packages/core-app-api/src/routing/index.ts @@ -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 { FlatRoutes } from './FlatRoutes'; diff --git a/packages/core-app-api/src/routing/types.ts b/packages/core-app-api/src/routing/types.ts new file mode 100644 index 0000000000..53128642f9 --- /dev/null +++ b/packages/core-app-api/src/routing/types.ts @@ -0,0 +1,86 @@ +/* + * 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 { + RouteRef, + SubRouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; + +type RouteRefType = Exclude< + keyof RouteRef, + 'params' | 'path' | 'title' | 'icon' +>; +export const routeRefType: RouteRefType = getOrCreateGlobalSingleton( + 'route-ref-type', + () => Symbol('route-ref-type'), +); + +export type AnyParams = { [param in string]: string } | undefined; + +export type AnyRouteRef = + | RouteRef + | SubRouteRef + | ExternalRouteRef; + +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] +) => string; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRefs: Set; +} + +export function isRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is RouteRef { + return routeRef[routeRefType] === 'absolute'; +} + +export function isSubRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is SubRouteRef { + return routeRef[routeRefType] === 'sub'; +} + +export function isExternalRouteRef< + Params extends AnyParams, + Optional extends boolean +>( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is ExternalRouteRef { + return routeRef[routeRefType] === 'external'; +} diff --git a/packages/core-app-api/src/routing/validation.ts b/packages/core-app-api/src/routing/validation.ts new file mode 100644 index 0000000000..2d32471a14 --- /dev/null +++ b/packages/core-app-api/src/routing/validation.ts @@ -0,0 +1,56 @@ +/* + * 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 { AnyRouteRef } from './types'; + +export function validateRoutes( + routePaths: Map, + routeParents: Map, +) { + const notLeafRoutes = new Set(routeParents.values()); + notLeafRoutes.delete(undefined); + + for (const route of routeParents.keys()) { + if (notLeafRoutes.has(route)) { + continue; + } + + let currentRouteRef: AnyRouteRef | undefined = route; + + let fullPath = ''; + while (currentRouteRef) { + const path = routePaths.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = routeParents.get(currentRouteRef); + } + + const params = fullPath.match(/:(\w+)/g); + if (params) { + for (let j = 0; j < params.length; j++) { + for (let i = j + 1; i < params.length; i++) { + if (params[i] === params[j]) { + throw new Error( + `Parameter ${params[i]} is duplicated in path ${fullPath}`, + ); + } + } + } + } + } +} diff --git a/packages/core-app-api/src/setupTests.ts b/packages/core-app-api/src/setupTests.ts new file mode 100644 index 0000000000..aea2220869 --- /dev/null +++ b/packages/core-app-api/src/setupTests.ts @@ -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. + */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/packages/core-components/.eslintrc.js b/packages/core-components/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/core-components/.eslintrc.js @@ -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, + }, +}; diff --git a/packages/core-components/.npmrc b/packages/core-components/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/packages/core-components/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/packages/core-components/README.md b/packages/core-components/README.md new file mode 100644 index 0000000000..e8d4b607d3 --- /dev/null +++ b/packages/core-components/README.md @@ -0,0 +1,22 @@ +# @backstage/core-components + +This package provides the core components used by Backstage plugins and apps + +## Installation + +Install the package via Yarn or npm: + +```sh +$ yarn add @backstage/core-components +``` + +or + +```sh +$ npm install --save @backstage/core-components +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/core-components/package.json b/packages/core-components/package.json new file mode 100644 index 0000000000..003b4bdac6 --- /dev/null +++ b/packages/core-components/package.json @@ -0,0 +1,92 @@ +{ + "name": "@backstage/core-components", + "description": "Core components used by Backstage plugins and 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-components" + }, + "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/config": "^0.1.5", + "@backstage/core-plugin-api": "^0.1.0", + "@backstage/errors": "^0.1.1", + "@backstage/theme": "^0.2.7", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@testing-library/react-hooks": "^3.4.2", + "@types/dagre": "^0.7.44", + "@types/prop-types": "^15.7.3", + "@types/react": "^16.9", + "@types/react-sparklines": "^1.7.0", + "@types/react-text-truncate": "^0.14.0", + "classnames": "^2.2.6", + "clsx": "^1.1.0", + "d3-selection": "^2.0.0", + "d3-shape": "^2.0.0", + "d3-zoom": "^2.0.0", + "dagre": "^0.8.5", + "immer": "^9.0.1", + "lodash": "^4.17.15", + "material-table": "^1.69.1", + "pluralize": "^8.0.0", + "prop-types": "^15.7.2", + "qs": "^6.9.4", + "rc-progress": "^3.0.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "react-helmet": "6.1.0", + "react-hook-form": "^6.15.4", + "react-markdown": "^5.0.2", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-sparklines": "^1.7.0", + "react-syntax-highlighter": "^15.4.3", + "react-text-truncate": "^0.16.0", + "react-use": "^17.2.4", + "remark-gfm": "^1.0.0", + "zen-observable": "^0.8.15" + }, + "devDependencies": { + "@backstage/core-app-api": "^0.1.0", + "@backstage/cli": "^0.6.11", + "@backstage/test-utils": "^0.1.11", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/classnames": "^2.2.9", + "@types/d3-selection": "^2.0.0", + "@types/d3-shape": "^2.0.0", + "@types/d3-zoom": "^2.0.0", + "@types/google-protobuf": "^3.7.2", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-helmet": "^6.1.0", + "@types/zen-observable": "^0.8.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx new file mode 100644 index 0000000000..624337c49b --- /dev/null +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 { AlertDisplay } from './AlertDisplay'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { + ApiProvider, + ApiRegistry, + AlertApiForwarder, +} from '@backstage/core-app-api'; +import Observable from 'zen-observable'; +import { renderInTestApp } from '@backstage/test-utils'; + +const TEST_MESSAGE = 'TEST_MESSAGE'; + +describe('', () => { + it('renders without exploding', async () => { + const apiRegistry = ApiRegistry.from([ + [alertApiRef, new AlertApiForwarder()], + ]); + + const { queryByText } = await renderInTestApp( + + + , + ); + expect(queryByText(TEST_MESSAGE)).not.toBeInTheDocument(); + }); + + it('renders with message', async () => { + const apiRegistry = ApiRegistry.from([ + [ + alertApiRef, + { + post() {}, + alert$() { + return Observable.of({ message: TEST_MESSAGE }); + }, + }, + ], + ]); + + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText(TEST_MESSAGE)).toBeInTheDocument(); + }); + + describe('with multiple messages', () => { + let apiRegistry: ApiRegistry; + + beforeEach(() => { + apiRegistry = ApiRegistry.from([ + [ + alertApiRef, + { + post() {}, + alert$() { + return Observable.of( + { message: 'message one' }, + { message: 'message two' }, + { message: 'message three' }, + ); + }, + }, + ], + ]); + }); + + it('renders first message', async () => { + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText('message one')).toBeInTheDocument(); + }); + + it('renders a count of remaining messages', async () => { + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText('(2 older messages)')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx new file mode 100644 index 0000000000..c841d234f6 --- /dev/null +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx @@ -0,0 +1,76 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { Snackbar, IconButton } from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import { Alert } from '@material-ui/lab'; +import { AlertMessage, useApi, alertApiRef } from '@backstage/core-plugin-api'; +import pluralize from 'pluralize'; + +// TODO: improve on this and promote to a shared component for use by all apps. +export const AlertDisplay = () => { + const [messages, setMessages] = useState>([]); + const alertApi = useApi(alertApiRef); + + useEffect(() => { + const subscription = alertApi + .alert$() + .subscribe(message => setMessages(msgs => msgs.concat(message))); + + return () => { + subscription.unsubscribe(); + }; + }, [alertApi]); + + if (messages.length === 0) { + return null; + } + + const [firstMessage] = messages; + + const handleClose = () => { + setMessages(msgs => msgs.filter(msg => msg !== firstMessage)); + }; + + return ( + + + + + } + severity={firstMessage.severity} + > + + {firstMessage.message.toString()} + {messages.length > 1 && ( + {` (${messages.length - 1} older ${pluralize( + 'message', + messages.length - 1, + )})`} + )} + + + + ); +}; diff --git a/packages/core-components/src/components/AlertDisplay/index.ts b/packages/core-components/src/components/AlertDisplay/index.ts new file mode 100644 index 0000000000..72aa1c5ad8 --- /dev/null +++ b/packages/core-components/src/components/AlertDisplay/index.ts @@ -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 { AlertDisplay } from './AlertDisplay'; diff --git a/packages/core-components/src/components/Avatar/Avatar.stories.tsx b/packages/core-components/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 0000000000..5ac628d72b --- /dev/null +++ b/packages/core-components/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,42 @@ +/* + * 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 { Avatar } from './Avatar'; + +export default { + title: 'Data Display/Avatar', + component: Avatar, +}; + +export const Default = () => ( + +); + +export const NameFallback = () => ; + +export const Empty = () => ; + +export const CustomStyling = () => ( + +); diff --git a/packages/core-components/src/components/Avatar/Avatar.test.tsx b/packages/core-components/src/components/Avatar/Avatar.test.tsx new file mode 100644 index 0000000000..da6ca8f42e --- /dev/null +++ b/packages/core-components/src/components/Avatar/Avatar.test.tsx @@ -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 { render } from '@testing-library/react'; +import React from 'react'; +import { Avatar } from './Avatar'; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render(); + + expect(getByText('JD')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-components/src/components/Avatar/Avatar.tsx b/packages/core-components/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..95aa4a8ced --- /dev/null +++ b/packages/core-components/src/components/Avatar/Avatar.tsx @@ -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 React, { CSSProperties } from 'react'; +import { + Avatar as MaterialAvatar, + createStyles, + makeStyles, + Theme, +} from '@material-ui/core'; +import { extractInitials, stringToColor } from './utils'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + avatar: { + width: '4rem', + height: '4rem', + color: '#fff', + fontWeight: theme.typography.fontWeightBold, + letterSpacing: '1px', + textTransform: 'uppercase', + }, + }), +); + +export type AvatarProps = { + displayName?: string; + picture?: string; + customStyles?: CSSProperties; +}; + +export const Avatar = ({ displayName, picture, customStyles }: AvatarProps) => { + const classes = useStyles(); + return ( + + {displayName && extractInitials(displayName)} + + ); +}; diff --git a/packages/core-components/src/components/Avatar/index.ts b/packages/core-components/src/components/Avatar/index.ts new file mode 100644 index 0000000000..962414634e --- /dev/null +++ b/packages/core-components/src/components/Avatar/index.ts @@ -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 { Avatar } from './Avatar'; diff --git a/packages/core-components/src/components/Avatar/util.test.ts b/packages/core-components/src/components/Avatar/util.test.ts new file mode 100644 index 0000000000..94de957e8e --- /dev/null +++ b/packages/core-components/src/components/Avatar/util.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { extractInitials, stringToColor } from './utils'; + +describe('stringToColor', () => { + it('extract color', async () => { + expect(stringToColor('Jenny Doe')).toEqual('#7809fa'); + }); +}); + +describe('extractInitials', () => { + it('extract initials', async () => { + expect(extractInitials('Jenny Doe')).toEqual('JD'); + }); + + it('extract single letter for short name', async () => { + expect(extractInitials('Doe')).toEqual('D'); + }); + + it('limit the initials to two letters', async () => { + expect(extractInitials('John Jonathan Doe')).toEqual('JJ'); + }); +}); diff --git a/packages/core-components/src/components/Avatar/utils.ts b/packages/core-components/src/components/Avatar/utils.ts new file mode 100644 index 0000000000..5990a72955 --- /dev/null +++ b/packages/core-components/src/components/Avatar/utils.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. + */ + +export function stringToColor(str: string) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.substr(-2); + } + return color; +} + +export function extractInitials(value: string) { + return value.match(/\b\w/g)!.join('').substring(0, 2); +} diff --git a/packages/core-components/src/components/Button/Button.stories.tsx b/packages/core-components/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..0fee0300ab --- /dev/null +++ b/packages/core-components/src/components/Button/Button.stories.tsx @@ -0,0 +1,183 @@ +/* + * 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, { ComponentType } from 'react'; +import { Button } from './Button'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { createRouteRef, useRouteRef } from '@backstage/core-plugin-api'; +import { + Divider, + Link, + List, + ListItem, + ListItemText, + Typography, + Button as MaterialButton, +} from '@material-ui/core'; +// We don't want to export RoutingProvider from core-app-api, but it's way easier to +// use here. This hack only works in storybook stories. +// TODO: Export a nicer to user routing provider, perhaps from test-utils +// eslint-disable-next-line monorepo/no-internal-import +import { RoutingProvider } from '@backstage/core-app-api/src/routing/RoutingProvider'; + +const routeRef = createRouteRef({ + id: 'storybook.test-route', +}); + +const Location = () => { + const location = useLocation(); + return
Current location: {location.pathname}
; +}; + +export default { + title: 'Inputs/Button', + component: Button, + decorators: [ + (Story: ComponentType<{}>) => ( + <> + + A collection of buttons that should be used in the Backstage + interface. These leverage the properties inherited from{' '} + + Material-UI Button + + , but include an opinionated set that align to the Backstage design. + + + + + + +
+
+ +
+ +
+
+
+ + ), + ], +}; + +export const Default = () => { + const link = useRouteRef(routeRef); + // Design Permutations: + // color = default | primary | secondary + // variant = contained | outlined | text + return ( + + + + Default Button: + This is the default button design which should be used in most cases. +
+
color="primary" variant="contained"
+
+ + +
+ + + Secondary Button: + Used for actions that cancel, skip, and in general perform negative + functions, etc. +
+
color="secondary" variant="contained"
+
+ + +
+ + + Tertiary Button: + Used commonly in a ButtonGroup and when the button function itself is + not a primary function on a page. +
+
color="default" variant="outlined"
+
+ + +
+
+ ); +}; + +export const ButtonLinks = () => { + const link = useRouteRef(routeRef); + + const handleClick = () => { + return 'Your click worked!'; + }; + + return ( + <> + + { + // TODO: Refactor to use new routing mechanisms + } + + +   has props for both Material-UI's component as well as for + react-router-dom's Route object. + + + + +   links to a statically defined route. In general, this should be + avoided. + + + + + View URL + +   links to a defined URL using Material-UI's Button. + + + + + Trigger Event + +   triggers an onClick event using Material-UI's Button. + + + + ); +}; diff --git a/packages/core-components/src/components/Button/Button.test.tsx b/packages/core-components/src/components/Button/Button.test.tsx new file mode 100644 index 0000000000..8bae5f2767 --- /dev/null +++ b/packages/core-components/src/components/Button/Button.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 { render, fireEvent, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Button } from './Button'; +import { Route, Routes } from 'react-router'; + +describe(' + , + ), + ); + + expect(() => getByText(testString)).toThrow(); + await act(async () => { + fireEvent.click(getByText(buttonLabel)); + }); + expect(getByText(testString)).toBeInTheDocument(); + }); +}); diff --git a/packages/core-components/src/components/Button/Button.tsx b/packages/core-components/src/components/Button/Button.tsx new file mode 100644 index 0000000000..ca45b3da7f --- /dev/null +++ b/packages/core-components/src/components/Button/Button.tsx @@ -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. + */ + +import React, { ComponentProps } from 'react'; +import { Button as MaterialButton } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = ComponentProps & + ComponentProps; + +/** + * Thin wrapper on top of material-ui's Button component + * Makes the Button to utilise react-router + */ +export const Button = React.forwardRef((props, ref) => ( + +)); diff --git a/packages/core-components/src/components/Button/index.ts b/packages/core-components/src/components/Button/index.ts new file mode 100644 index 0000000000..7b584ed799 --- /dev/null +++ b/packages/core-components/src/components/Button/index.ts @@ -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 { Button } from './Button'; diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx new file mode 100644 index 0000000000..3416e94260 --- /dev/null +++ b/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx @@ -0,0 +1,105 @@ +/* + * 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, { useState } from 'react'; +import { CheckboxTree } from './CheckboxTree'; + +const CHECKBOX_TREE_ITEMS = [ + { + label: 'Generic subcategory name 1', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, + { + label: 'Generic subcategory name 2', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, + { + label: 'Generic subcategory name 3', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, +]; + +export default { + title: 'Inputs/CheckboxTree', + component: CheckboxTree, +}; + +export const Default = () => ( + {}} + label="default" + subCategories={CHECKBOX_TREE_ITEMS} + /> +); + +export const DynamicTree = () => { + function generateTree(showMore: boolean = false) { + const t = [ + { + label: 'Show more', + options: [], + }, + ]; + + if (showMore) { + t.push({ + label: 'More', + options: [], + }); + } + + return t; + } + + const [tree, setTree] = useState(generateTree()); + + return ( + { + setTree(generateTree(state.some(c => c.category === 'Show more'))); + }} + label="default" + subCategories={tree} + /> + ); +}; diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx new file mode 100644 index 0000000000..0750867ff3 --- /dev/null +++ b/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx @@ -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 { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { CheckboxTree } from './CheckboxTree'; + +const CHECKBOX_TREE_ITEMS = [ + { + label: 'Generic subcategory name 1', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, +]; + +const minProps = { + onChange: jest.fn(), + label: 'Default', + subCategories: CHECKBOX_TREE_ITEMS, +}; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText, getByTestId } = render(); + + expect(getByText('Generic subcategory name 1')).toBeInTheDocument(); + const checkbox = await getByTestId('expandable'); + + // Simulate click on expandable arrow + fireEvent.click(checkbox); + + // Simulate click on option + const option = getByText('Option 1'); + expect(getByText('Option 1')).toBeInTheDocument(); + fireEvent.click(option); + expect(minProps.onChange).toHaveBeenCalled(); + }); +}); diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx new file mode 100644 index 0000000000..d6d3410913 --- /dev/null +++ b/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx @@ -0,0 +1,358 @@ +/* + * 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. + */ +/* eslint-disable guard-for-in */ +import { + Checkbox, + Collapse, + List, + ListItem, + ListItemIcon, + ListItemText, + Typography, +} from '@material-ui/core'; +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import ExpandLess from '@material-ui/icons/ExpandLess'; +import ExpandMore from '@material-ui/icons/ExpandMore'; +import produce from 'immer'; +import { isEqual } from 'lodash'; +import React, { useEffect, useReducer } from 'react'; +import { usePrevious } from 'react-use'; + +type IndexedObject = { + [key: string]: T; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + minWidth: 10, + maxWidth: 360, + backgroundColor: 'transparent', + '&:hover': { + backgroundColor: 'transparent', + }, + '&:active': { + animation: 'none', + transform: 'none', + }, + }, + nested: { + paddingLeft: theme.spacing(5), + height: '32px', + '&:hover': { + backgroundColor: 'transparent', + }, + }, + listItemIcon: { + minWidth: 10, + }, + listItem: { + '&:hover': { + backgroundColor: 'transparent', + }, + }, + text: { + '& span, & svg': { + fontWeight: 'normal', + fontSize: 14, + }, + }, + }), +); + +/* SUB_CATEGORY */ + +type SubCategory = { + label: string; + isChecked?: boolean; + isOpen?: boolean; + options?: Option[]; +}; + +type SubCategoryWithIndexedOptions = { + label: string; + isChecked?: boolean; + isOpen?: boolean; + options: IndexedObject