add clone of core-api package as app-api

Co-authored-by: Juan Lulkin <jmaiz@spotify.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-03-10 16:37:29 +01:00
parent ab50eed6eb
commit 4065a77e22
155 changed files with 13375 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
// TODO: add prop types to JS and remove
'react/prop-types': 0,
'jest/expect-expect': 0,
},
};
+20
View File
@@ -0,0 +1,20 @@
# @backstage/app-api
This package provides the core API used by Backstage plugins and apps.
## Installation
```sh
$ npm install --save @backstage/app-api
```
or
```sh
$ yarn add @backstage/app-api
```
## Documentation
- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@backstage/app-api",
"description": "Core app API 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/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/config": "^0.1.3",
"@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": "^15.3.3",
"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.3.0",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.3"
},
"files": [
"dist"
]
}
@@ -0,0 +1,44 @@
/*
* 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, ApiRef } from '../system';
import { Observable } from '../../types';
export type AlertMessage = {
message: string;
// Severity will default to success since that is what material ui defaults the value to.
severity?: 'success' | 'info' | 'warning' | 'error';
};
/**
* The alert API is used to report alerts to the app, and display them to the user.
*/
export type AlertApi = {
/**
* Post an alert for handling by the application.
*/
post(alert: AlertMessage): void;
/**
* Observe alerts posted by other parts of the application.
*/
alert$(): Observable<AlertMessage>;
};
export const alertApiRef: ApiRef<AlertApi> = createApiRef({
id: 'core.alert',
description: 'Used to report alerts and forward them to the app',
});
@@ -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 { ApiRef, createApiRef } from '../system';
import { BackstageTheme } from '@backstage/theme';
import { Observable } from '../../types';
import { SvgIconProps } from '@material-ui/core';
/**
* Describes a theme provided by the app.
*/
export type AppTheme = {
/**
* ID used to remember theme selections.
*/
id: string;
/**
* Title of the theme
*/
title: string;
/**
* Theme variant
*/
variant: 'light' | 'dark';
/**
* The specialized MaterialUI theme instance.
*/
theme: BackstageTheme;
/**
* An Icon for the theme mode setting.
*/
icon?: React.ReactElement<SvgIconProps>;
};
/**
* The AppThemeApi gives access to the current app theme, and allows switching
* to other options that have been registered as a part of the App.
*/
export type AppThemeApi = {
/**
* Get a list of available themes.
*/
getInstalledThemes(): AppTheme[];
/**
* Observe the currently selected theme. A value of undefined means no specific theme has been selected.
*/
activeThemeId$(): Observable<string | undefined>;
/**
* Get the current theme ID. Returns undefined if no specific theme is selected.
*/
getActiveThemeId(): string | undefined;
/**
* Set a specific theme to use in the app, overriding the default theme selection.
*
* Clear the selection by passing in undefined.
*/
setActiveThemeId(themeId?: string): void;
};
export const appThemeApiRef: ApiRef<AppThemeApi> = createApiRef({
id: 'core.apptheme',
description: 'API Used to configure the app theme, and enumerate options',
});
@@ -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 { ApiRef, createApiRef } from '../system';
import { Config } from '@backstage/config';
/**
* The Config API is used to provide a mechanism to access the
* runtime configuration of the system.
*/
export type ConfigApi = Config;
export const configApiRef: ApiRef<ConfigApi> = createApiRef({
id: 'core.config',
description: 'Used to access runtime configuration',
});
@@ -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.
*/
import { ApiRef, createApiRef } from '../system';
/**
* The discovery API is used to provide a mechanism for plugins to
* discover the endpoint to use to talk to their backend counterpart.
*
* The purpose of the discovery API is to allow for many different deployment
* setups and routing methods through a central configuration, instead
* of letting each individual plugin manage that configuration.
*
* Implementations of the discovery API can be a simple as a URL pattern
* using the pluginId, but could also have overrides for individual plugins,
* or query a separate discovery service.
*/
export type DiscoveryApi = {
/**
* Returns the HTTP base backend URL for a given plugin, without a trailing slash.
*
* This method must always be called just before making a request, as opposed to
* fetching the URL when constructing an API client. That is to ensure that more
* flexible routing patterns can be supported.
*
* For example, asking for the URL for `auth` may return something
* like `https://backstage.example.com/api/auth`
*/
getBaseUrl(pluginId: string): Promise<string>;
};
export const discoveryApiRef: ApiRef<DiscoveryApi> = createApiRef({
id: 'core.discovery',
description: 'Provides service discovery of backend plugins',
});
@@ -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 { ApiRef, createApiRef } from '../system';
import { Observable } from '../../types';
/**
* Mirrors the JavaScript Error class, for the purpose of
* providing documentation and optional fields.
*/
type Error = {
name: string;
message: string;
stack?: string;
};
/**
* Provides additional information about an error that was posted to the application.
*/
export type ErrorContext = {
// If set to true, this error should not be displayed to the user. Defaults to false.
hidden?: boolean;
};
/**
* The error API is used to report errors to the app, and display them to the user.
*
* Plugins can use this API as a method of displaying errors to the user, but also
* to report errors for collection by error reporting services.
*
* If an error can be displayed inline, e.g. as feedback in a form, that should be
* preferred over relying on this API to display the error. The main use of this API
* for displaying errors should be for asynchronous errors, such as a failing background process.
*
* Even if an error is displayed inline, it should still be reported through this API
* if it would be useful to collect or log it for debugging purposes, but with
* the hidden flag set. For example, an error arising from form field validation
* should probably not be reported, while a failed REST call would be useful to report.
*/
export type ErrorApi = {
/**
* Post an error for handling by the application.
*/
post(error: Error, context?: ErrorContext): void;
/**
* Observe errors posted by other parts of the application.
*/
error$(): Observable<{ error: Error; context?: ErrorContext }>;
};
export const errorApiRef: ApiRef<ErrorApi> = createApiRef({
id: 'core.error',
description: 'Used to report errors and forward them to the app',
});
@@ -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 { ApiRef, createApiRef } from '../system';
/**
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
*
* Plugins can use this API to register feature flags that they have available
* for users to enable/disable, and this API will centralize the current user's
* state of which feature flags they would like to enable.
*
* This is ideal for Backstage plugins, as well as your own App, to trial incomplete
* or unstable upcoming features. Although there will be a common interface for users
* to enable and disable feature flags, this API acts as another way to enable/disable.
*/
export type FeatureFlag = {
name: string;
pluginId: string;
};
export enum FeatureFlagState {
None = 0,
Active = 1,
}
/**
* Options to use when saving feature flags.
*/
export type FeatureFlagsSaveOptions = {
/**
* The new feature flag states to save.
*/
states: Record<string, FeatureFlagState>;
/**
* Whether the saves states should be merged into the existing ones, or replace them.
*
* Defaults to false.
*/
merge?: boolean;
};
export type UserFlags = {};
export interface FeatureFlagsApi {
/**
* Registers a new feature flag. Once a feature flag has been registered it
* can be toggled by users, and read back to enable or disable features.
*/
registerFlag(flag: FeatureFlag): void;
/**
* Get a list of all registered flags.
*/
getRegisteredFlags(): FeatureFlag[];
/**
* Whether the feature flag with the given name is currently activated for the user.
*/
isActive(name: string): boolean;
/**
* Save the user's choice of feature flag states.
*/
save(options: FeatureFlagsSaveOptions): void;
}
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> = createApiRef({
id: 'core.featureflags',
description: 'Used to toggle functionality in features across Backstage',
});
@@ -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 { ApiRef, createApiRef } from '../system';
import { ProfileInfo } from './auth';
/**
* The Identity API used to identify and get information about the signed in user.
*/
export type IdentityApi = {
/**
* The ID of the signed in user. This ID is not meant to be presented to the user, but used
* as an opaque string to pass on to backends or use in frontend logic.
*
* TODO: The intention of the user ID is to be able to tie the user to an identity
* that is known by the catalog and/or identity backend. It should for example
* be possible to fetch all owned components using this ID.
*/
getUserId(): string;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
/**
* The profile of the signed in user.
*/
getProfile(): ProfileInfo;
/**
* An OpenID Connect ID Token which proves the identity of the signed in user.
*
* The ID token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*/
getIdToken(): Promise<string | undefined>;
/**
* Sign out the current user
*/
signOut(): Promise<void>;
};
export const identityApiRef: ApiRef<IdentityApi> = createApiRef({
id: 'core.identity',
description: 'Provides access to the identity of the signed in user',
});
@@ -0,0 +1,133 @@
/*
* 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 '../../icons';
import { Observable } from '../../types';
import { ApiRef, createApiRef } from '../system';
/**
* Information about the auth provider that we're requesting a login towards.
*
* This should be shown to the user so that they can be informed about what login is being requested
* before a popup is shown.
*/
export type AuthProvider = {
/**
* Title for the auth provider, for example "GitHub"
*/
title: string;
/**
* Icon for the auth provider.
*/
icon: IconComponent;
};
/**
* Describes how to handle auth requests. Both how to show them to the user, and what to do when
* the user accesses the auth request.
*/
export type AuthRequesterOptions<AuthResponse> = {
/**
* Information about the auth provider, which will be forwarded to auth requests.
*/
provider: AuthProvider;
/**
* Implementation of the auth flow, which will be called synchronously when
* trigger() is called on an auth requests.
*/
onAuthRequest(scopes: Set<string>): Promise<AuthResponse>;
};
/**
* Function used to trigger new auth requests for a set of scopes.
*
* The returned promise will resolve to the same value returned by the onAuthRequest in the
* AuthRequesterOptions. Or rejected, if the request is rejected.
*
* This function can be called multiple times before the promise resolves. All calls
* will be merged into one request, and the scopes forwarded to the onAuthRequest will be the
* union of all requested scopes.
*/
export type AuthRequester<AuthResponse> = (
scopes: Set<string>,
) => Promise<AuthResponse>;
/**
* An pending auth request for a single auth provider. The request will remain in this pending
* state until either reject() or trigger() is called.
*
* Any new requests for the same provider are merged into the existing pending request, meaning
* there will only ever be a single pending request for a given provider.
*/
export type PendingAuthRequest = {
/**
* Information about the auth provider, as given in the AuthRequesterOptions
*/
provider: AuthProvider;
/**
* Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError".
*/
reject: () => void;
/**
* Trigger the auth request to continue the auth flow, by for example showing a popup.
*
* Synchronously calls onAuthRequest with all scope currently in the request.
*/
trigger(): Promise<void>;
};
/**
* Provides helpers for implemented OAuth login flows within Backstage.
*/
export type OAuthRequestApi = {
/**
* A utility for showing login popups or similar things, and merging together multiple requests for
* different scopes into one request that includes all scopes.
*
* The passed in options provide information about the login provider, and how to handle auth requests.
*
* The returned AuthRequester function is used to request login with new scopes. These requests
* are merged together and forwarded to the auth handler, as soon as a consumer of auth requests
* triggers an auth flow.
*
* See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info.
*/
createAuthRequester<AuthResponse>(
options: AuthRequesterOptions<AuthResponse>,
): AuthRequester<AuthResponse>;
/**
* Observers pending auth requests. The returned observable will emit all
* current active auth request, at most one for each created auth requester.
*
* Each request has its own info about the login provider, forwarded from the auth requester options.
*
* Depending on user interaction, the request should either be rejected, or used to trigger the auth handler.
* If the request is rejected, all pending AuthRequester calls will fail with a "RejectedError".
* If a auth is triggered, and the auth handler resolves successfully, then all currently pending
* AuthRequester calls will resolve to the value returned by the onAuthRequest call.
*/
authRequest$(): Observable<PendingAuthRequest[]>;
};
export const oauthRequestApiRef: ApiRef<OAuthRequestApi> = createApiRef({
id: 'core.oauthrequest',
description: 'An API for implementing unified OAuth flows in Backstage',
});
@@ -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 { ApiRef, createApiRef } from '../system';
import { Observable } from '../../types';
import { ErrorApi } from './ErrorApi';
export type StorageValueChange<T = any> = {
key: string;
newValue?: T;
};
export type CreateStorageApiOptions = {
errorApi: ErrorApi;
namespace?: string;
};
export interface StorageApi {
/**
* Create a bucket to store data in.
* @param {String} name Namespace for the storage to be stored under,
* will inherit previous namespaces too
*/
forBucket(name: string): StorageApi;
/**
* Get the current value for persistent data, use observe$ to be notified of updates.
*
* @param {String} key Unique key associated with the data.
* @return {Object} data The data that should is stored.
*/
get<T>(key: string): T | undefined;
/**
* Remove persistent data.
*
* @param {String} key Unique key associated with the data.
*/
remove(key: string): Promise<void>;
/**
* Save persistent data, and emit messages to anyone that is using observe$ for this key
*
* @param {String} key Unique key associated with the data.
*/
set(key: string, data: any): Promise<void>;
/**
* Observe changes on a particular key in the bucket
* @param {String} key Unique key associated with the data
*/
observe$<T>(key: string): Observable<StorageValueChange<T>>;
}
export const storageApiRef: ApiRef<StorageApi> = createApiRef({
id: 'core.storage',
description: 'Provides the ability to store data which is unique to the user',
});
@@ -0,0 +1,347 @@
/*
* 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, createApiRef } from '../system';
import { Observable } from '../../types';
/**
* This file contains declarations for common interfaces of auth-related APIs.
* The declarations should be used to signal which type of authentication and
* authorization methods each separate auth provider supports.
*
* For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect,
* would be declared as follows:
*
* const googleAuthApiRef = createApiRef<OAuthApi & OpenIDConnectApi>({ ... })
*/
/**
* An array of scopes, or a scope string formatted according to the
* auth provider, which is typically a space separated list.
*
* See the documentation for each auth provider for the list of scopes
* supported by each provider.
*/
export type OAuthScope = string | string[];
export type AuthRequestOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty response will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
optional?: boolean;
/**
* If this is set to true, the request will bypass the regular oauth login modal
* and open the login popup directly.
*
* The method must be called synchronously from a user action for this to work in all browsers.
*
* @default false
*/
instantPopup?: boolean;
};
/**
* This API provides access to OAuth 2 credentials. It lets you request access tokens,
* which can be used to act on behalf of the user when talking to APIs.
*/
export type OAuthApi = {
/**
* Requests an OAuth 2 Access Token, optionally with a set of scopes. The access token allows
* you to make requests on behalf of the user, and the copes may grant you broader access, depending
* on the auth provider.
*
* Each auth provider has separate handling of scope, so you need to look at the documentation
* for each one to know what scope you need to request.
*
* This method is cheap and should be called each time an access token is used. Do not for example
* store the access token in React component state, as that could cause the token to expire. Instead
* fetch a new access token for each request.
*
* Be sure to include all required scopes when requesting an access token. When testing your implementation
* it is best to log out the Backstage session and then visit your plugin page directly, as
* you might already have some required scopes in your existing session. Not requesting the correct
* scopes can lead to 403 or other authorization errors, which can be tricky to debug.
*
* If the user has not yet granted access to the provider and the set of requested scopes, the user
* will be prompted to log in. The returned promise will not resolve until the user has
* successfully logged in. The returned promise can be rejected, but only if the user rejects the login request.
*/
getAccessToken(
scope?: OAuthScope,
options?: AuthRequestOptions,
): Promise<string>;
};
/**
* This API provides access to OpenID Connect credentials. It lets you request ID tokens,
* which can be passed to backend services to prove the user's identity.
*/
export type OpenIdConnectApi = {
/**
* Requests an OpenID Connect ID Token.
*
* This method is cheap and should be called each time an ID token is used. Do not for example
* store the id token in React component state, as that could cause the token to expire. Instead
* fetch a new id token for each request.
*
* If the user has not yet logged in to Google inside Backstage, the user will be prompted
* to log in. The returned promise will not resolve until the user has successfully logged in.
* The returned promise can be rejected, but only if the user rejects the login request.
*/
getIdToken(options?: AuthRequestOptions): Promise<string>;
};
/**
* This API provides access to profile information of the user from an auth provider.
*/
export type ProfileInfoApi = {
/**
* Get profile information for the user as supplied by this auth provider.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
};
/**
* This API provides access to the user's identity within Backstage.
*
* An auth provider that implements this interface can be used to sign-in to backstage. It is
* not intended to be used directly from a plugin, but instead serves as a connection between
* this authentication method and the app's @IdentityApi
*/
export type BackstageIdentityApi = {
/**
* Get the user's identity within Backstage. This should normally not be called directly,
* use the @IdentityApi instead.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getBackstageIdentity(
options?: AuthRequestOptions,
): Promise<BackstageIdentity | undefined>;
};
export type BackstageIdentity = {
/**
* The backstage user ID.
*/
id: string;
/**
* An ID token that can be used to authenticate the user within Backstage.
*/
idToken: string;
};
/**
* Profile information of the user.
*/
export type ProfileInfo = {
/**
* Email ID.
*/
email?: string;
/**
* Display name that can be presented to the user.
*/
displayName?: string;
/**
* URL to an avatar image of the user.
*/
picture?: string;
};
/**
* Session state values passed to subscribers of the SessionApi.
*/
export enum SessionState {
SignedIn = 'SignedIn',
SignedOut = 'SignedOut',
}
/**
* The SessionApi provides basic controls for any auth provider that is tied to a persistent session.
*/
export type SessionApi = {
/**
* Sign in with a minimum set of permissions.
*/
signIn(): Promise<void>;
/**
* Sign out from the current session. This will reload the page.
*/
signOut(): Promise<void>;
/**
* Observe the current state of the auth session. Emits the current state on subscription.
*/
sessionState$(): Observable<SessionState>;
};
/**
* Provides authentication towards Google APIs and identities.
*
* See https://developers.google.com/identity/protocols/googlescopes for a full list of supported scopes.
*
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
export const googleAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
});
/**
* Provides authentication towards GitHub APIs.
*
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
* for a full list of supported scopes.
*/
export const githubAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.github',
description: 'Provides authentication towards GitHub APIs',
});
/**
* Provides authentication towards Okta APIs.
*
* See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/
* for a full list of supported scopes.
*/
export const oktaAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.okta',
description: 'Provides authentication towards Okta APIs',
});
/**
* Provides authentication towards GitLab APIs.
*
* See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token
* for a full list of supported scopes.
*/
export const gitlabAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.gitlab',
description: 'Provides authentication towards GitLab APIs',
});
/**
* Provides authentication towards Auth0 APIs.
*
* See https://auth0.com/docs/scopes/current/oidc-scopes
* for a full list of supported scopes.
*/
export const auth0AuthApiRef: ApiRef<
OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.auth0',
description: 'Provides authentication towards Auth0 APIs',
});
/**
* Provides authentication towards Microsoft APIs and identities.
*
* For more info and a full list of supported scopes, see:
* - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent
* - https://docs.microsoft.com/en-us/graph/permissions-reference
*/
export const microsoftAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.microsoft',
description: 'Provides authentication towards Microsoft APIs and identities',
});
/**
* Provides authentication for custom identity providers.
*/
export const oauth2ApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
});
/**
* Provides authentication for custom OpenID Connect identity providers.
*/
export const oidcAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.oidc',
description: 'Example of how to use oidc custom provider',
});
/**
* Provides authentication for saml based identity providers
*/
export const samlAuthApiRef: ApiRef<
ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.saml',
description: 'Example of how to use SAML custom provider',
});
export const oneloginAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.onelogin',
description: 'Provides authentication towards OneLogin APIs and identities',
});
@@ -0,0 +1,33 @@
/*
* 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 definitions for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
//
// If you think some API definition is missing, please open an Issue or send a PR!
export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
@@ -0,0 +1,33 @@
/*
* 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 } from '../..';
import { PublishSubject } from '../../../lib';
import { Observable } from '../../../types';
/**
* Base implementation for the AlertApi that simply forwards alerts to consumers.
*/
export class AlertApiForwarder implements AlertApi {
private readonly subject = new PublishSubject<AlertMessage>();
post(alert: AlertMessage) {
this.subject.next(alert);
}
alert$(): Observable<AlertMessage> {
return this.subject;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { AlertApiForwarder } from './AlertApiForwarder';
@@ -0,0 +1,85 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AppTheme } from '../../definitions';
import { AppThemeSelector } from './AppThemeSelector';
describe('AppThemeSelector', () => {
it('should should select new themes', async () => {
const selector = new AppThemeSelector([]);
expect(selector.getInstalledThemes()).toEqual([]);
const subFn = jest.fn();
selector.activeThemeId$().subscribe(subFn);
expect(selector.getActiveThemeId()).toBe(undefined);
await 'wait a tick';
expect(subFn).toHaveBeenLastCalledWith(undefined);
selector.setActiveThemeId('x');
expect(subFn).toHaveBeenLastCalledWith('x');
expect(selector.getActiveThemeId()).toBe('x');
selector.setActiveThemeId(undefined);
expect(subFn).toHaveBeenLastCalledWith(undefined);
expect(selector.getActiveThemeId()).toBe(undefined);
});
it('should return a new array of themes', () => {
const themes = new Array<AppTheme>();
const selector = new AppThemeSelector(themes);
expect(selector.getInstalledThemes()).toEqual(themes);
expect(selector.getInstalledThemes()).not.toBe(themes);
});
it('should store theme in local storage', async () => {
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
localStorage.setItem('theme', 'x');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x');
localStorage.removeItem('theme');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
const addListenerSpy = jest.spyOn(window, 'addEventListener');
const selector = AppThemeSelector.createWithStorage([]);
expect(addListenerSpy).toHaveBeenCalledTimes(1);
expect(addListenerSpy).toHaveBeenCalledWith(
'storage',
expect.any(Function),
);
selector.setActiveThemeId('y');
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe('y');
selector.setActiveThemeId(undefined);
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe(null);
localStorage.setItem('theme', 'z');
expect(selector.getActiveThemeId()).toBe(undefined);
const listener = addListenerSpy.mock.calls[0][1] as EventListener;
listener({ key: 'theme' } as StorageEvent);
expect(selector.getActiveThemeId()).toBe('z');
});
});
@@ -0,0 +1,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 { AppThemeApi, AppTheme } from '../../definitions';
import { BehaviorSubject } from '../../../lib';
import { Observable } from '../../../types';
const STORAGE_KEY = 'theme';
export class AppThemeSelector implements AppThemeApi {
static createWithStorage(themes: AppTheme[]) {
const selector = new AppThemeSelector(themes);
if (!window.localStorage) {
return selector;
}
const initialThemeId =
window.localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(initialThemeId);
selector.activeThemeId$().subscribe(themeId => {
if (themeId) {
window.localStorage.setItem(STORAGE_KEY, themeId);
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
});
window.addEventListener('storage', event => {
if (event.key === STORAGE_KEY) {
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(themeId);
}
});
return selector;
}
private activeThemeId: string | undefined;
private readonly subject = new BehaviorSubject<string | undefined>(undefined);
constructor(private readonly themes: AppTheme[]) {}
getInstalledThemes(): AppTheme[] {
return this.themes.slice();
}
activeThemeId$(): Observable<string | undefined> {
return this.subject;
}
getActiveThemeId(): string | undefined {
return this.activeThemeId;
}
setActiveThemeId(themeId?: string): void {
this.activeThemeId = themeId;
this.subject.next(themeId);
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './AppThemeSelector';
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ConfigReader } from '@backstage/config';
@@ -0,0 +1,84 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UrlPatternDiscovery } from './UrlPatternDiscovery';
describe('UrlPatternDiscovery', () => {
it('should not require interpolation', async () => {
const discoveryApi = UrlPatternDiscovery.compile('http://example.com');
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'http://example.com',
);
});
it('should use a plain pattern', async () => {
const discoveryApi = UrlPatternDiscovery.compile(
'http://localhost:7000/{{ pluginId }}',
);
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'http://localhost:7000/my-plugin',
);
});
it('should allow for multiple interpolation points', async () => {
const discoveryApi = UrlPatternDiscovery.compile(
'https://{{pluginId }}.example.com/api/{{ pluginId}}',
);
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'https://my-plugin.example.com/api/my-plugin',
);
});
it('should validate that the pattern is a valid URL', () => {
expect(() => {
UrlPatternDiscovery.compile('example.com');
}).toThrow('Invalid discovery URL pattern, Invalid URL: example.com');
expect(() => {
UrlPatternDiscovery.compile('http://');
}).toThrow('Invalid discovery URL pattern, Invalid URL: http://');
expect(() => {
UrlPatternDiscovery.compile('abc123');
}).toThrow('Invalid discovery URL pattern, Invalid URL: abc123');
expect(() => {
UrlPatternDiscovery.compile('http://example.com:{{pluginId}}');
}).toThrow(
'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId',
);
expect(() => {
UrlPatternDiscovery.compile('/{{pluginId}}');
}).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden');
}).toThrow('Invalid discovery URL pattern, URL must not have a query');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden');
}).toThrow('Invalid discovery URL pattern, URL must not have a hash');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/');
}).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/');
}).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
});
});
@@ -0,0 +1,58 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DiscoveryApi } from '../../definitions/DiscoveryApi';
/**
* UrlPatternDiscovery is a lightweight DiscoveryApi implementation.
* It uses a single template string to construct URLs for each plugin.
*/
export class UrlPatternDiscovery implements DiscoveryApi {
/**
* Creates a new UrlPatternDiscovery given a template. The the only
* interpolation done for the template is to replace instances of `{{pluginId}}`
* with the ID of the plugin being requested.
*
* Example pattern: `http://localhost:7000/api/{{ pluginId }}`
*/
static compile(pattern: string): UrlPatternDiscovery {
const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/);
try {
const urlStr = parts.join('pluginId');
const url = new URL(urlStr);
if (url.hash) {
throw new Error('URL must not have a hash');
}
if (url.search) {
throw new Error('URL must not have a query');
}
if (urlStr.endsWith('/')) {
throw new Error('URL must not end with a slash');
}
} catch (error) {
throw new Error(`Invalid discovery URL pattern, ${error.message}`);
}
return new UrlPatternDiscovery(parts);
}
private constructor(private readonly parts: string[]) {}
async getBaseUrl(pluginId: string): Promise<string> {
return this.parts.join(pluginId);
}
}
@@ -0,0 +1,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.
*/
// This folder contains implementations for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
export { UrlPatternDiscovery } from './UrlPatternDiscovery';
@@ -0,0 +1,39 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ErrorApi, ErrorContext, AlertApi } from '../..';
/**
* Decorates an ErrorApi by also forwarding error messages
* to the alertApi with an 'error' severity.
*/
export class ErrorAlerter implements ErrorApi {
constructor(
private readonly alertApi: AlertApi,
private readonly errorApi: ErrorApi,
) {}
post(error: Error, context?: ErrorContext) {
if (!context?.hidden) {
this.alertApi.post({ message: error.message, severity: 'error' });
}
return this.errorApi.post(error, context);
}
error$() {
return this.errorApi.error$();
}
}
@@ -0,0 +1,36 @@
/*
* 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 } from '../..';
import { PublishSubject } from '../../../lib';
import { Observable } from '../../../types';
/**
* Base implementation for the ErrorApi that simply forwards errors to consumers.
*/
export class ErrorApiForwarder implements ErrorApi {
private readonly subject = new PublishSubject<{
error: Error;
context?: ErrorContext;
}>();
post(error: Error, context?: ErrorContext) {
this.subject.next({ error, context });
}
error$(): Observable<{ error: Error; context?: ErrorContext }> {
return this.subject;
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ErrorAlerter } from './ErrorAlerter';
export { ErrorApiForwarder } from './ErrorApiForwarder';
@@ -0,0 +1,222 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
import { FeatureFlagState, FeatureFlagsApi } from '../../definitions';
describe('FeatureFlags', () => {
beforeEach(() => {
window.localStorage.clear();
});
describe('getFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
featureFlags = new LocalStorageFeatureFlags();
});
it('returns no flags', () => {
expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
it('loads flags from local storage', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
'feature-flag-four': 2,
'feature-flag-five': 'not-valid',
}),
);
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
expect(featureFlags.isActive('feature-flag-four')).toBe(false);
expect(featureFlags.isActive('feature-flag-five')).toBe(false);
});
it('sets the correct values', () => {
featureFlags.save({
states: {
'feature-flag-zero': FeatureFlagState.Active,
},
});
expect(featureFlags.isActive('feature-flag-zero')).toBe(true);
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-zero":1}',
);
});
it('deletes the correct values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 0,
'feature-flag-tree': 1,
'feature-flag-four': 0,
}),
);
featureFlags.save({
states: {
'feature-flag-one': FeatureFlagState.None,
'feature-flag-two': FeatureFlagState.Active,
},
});
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-two":1}',
);
});
it('clears all values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
}),
);
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
featureFlags.save({ states: {} });
expect(featureFlags.isActive('feature-flag-one')).toBe(false);
expect(featureFlags.isActive('feature-flag-two')).toBe(false);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
});
});
describe('getRegisteredFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
featureFlags = new LocalStorageFeatureFlags();
featureFlags.registerFlag({
name: 'registered-flag-1',
pluginId: 'plugin-one',
});
featureFlags.registerFlag({
name: 'registered-flag-2',
pluginId: 'plugin-one',
});
featureFlags.registerFlag({
name: 'registered-flag-3',
pluginId: 'plugin-two',
});
});
it('should return an empty list', () => {
featureFlags = new LocalStorageFeatureFlags();
expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
it('should return an valid list', () => {
expect(featureFlags.getRegisteredFlags()).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
});
it('should provide a copy of the list of flags', () => {
const flags = featureFlags.getRegisteredFlags();
expect(flags).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
flags.splice(2, 1);
expect(flags).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
]);
expect(featureFlags.getRegisteredFlags()).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
});
it('should get the correct values', () => {
const getByName = (name: string) =>
featureFlags.getRegisteredFlags().find(flag => flag.name === name);
expect(getByName('registered-flag-0')).toBeUndefined();
expect(getByName('registered-flag-1')).toEqual({
name: 'registered-flag-1',
pluginId: 'plugin-one',
});
expect(getByName('registered-flag-2')).toEqual({
name: 'registered-flag-2',
pluginId: 'plugin-one',
});
expect(getByName('registered-flag-3')).toEqual({
name: 'registered-flag-3',
pluginId: 'plugin-two',
});
});
it('throws an error if length is less than three characters', () => {
expect(() =>
featureFlags.registerFlag({
name: 'ab',
pluginId: 'plugin-three',
}),
).toThrow(/minimum length of three characters/i);
});
it('throws an error if length is greater than 150 characters', () => {
expect(() =>
featureFlags.registerFlag({
name:
'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
pluginId: 'plugin-three',
}),
).toThrow(/not exceed 150 characters/i);
});
it('throws an error if name does not start with a lowercase letter', () => {
expect(() =>
featureFlags.registerFlag({
name: '123456789',
pluginId: 'plugin-three',
}),
).toThrow(/start with a lowercase letter/i);
});
it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => {
expect(() =>
featureFlags.registerFlag({
name: 'Invalid_Feature_Flag',
pluginId: 'plugin-three',
}),
).toThrow(/only contain lowercase letters, numbers and hyphens/i);
});
});
});
@@ -0,0 +1,109 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
FeatureFlagState,
FeatureFlagsApi,
FeatureFlag,
FeatureFlagsSaveOptions,
} from '../../definitions';
export function validateFlagName(name: string): void {
if (name.length < 3) {
throw new Error(
`The '${name}' feature flag must have a minimum length of three characters.`,
);
}
if (name.length > 150) {
throw new Error(
`The '${name}' feature flag must not exceed 150 characters.`,
);
}
if (!name.match(/^[a-z]+[a-z0-9-]+$/)) {
throw new Error(
`The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` +
'Examples: feature-flag-one, alpha, release-2020',
);
}
}
/**
* Create the FeatureFlags implementation based on the API.
*/
export class LocalStorageFeatureFlags implements FeatureFlagsApi {
private registeredFeatureFlags: FeatureFlag[] = [];
private flags?: Map<string, FeatureFlagState>;
registerFlag(flag: FeatureFlag) {
validateFlagName(flag.name);
this.registeredFeatureFlags.push(flag);
}
getRegisteredFlags(): FeatureFlag[] {
return this.registeredFeatureFlags.slice();
}
isActive(name: string): boolean {
if (!this.flags) {
this.flags = this.load();
}
return this.flags.get(name) === FeatureFlagState.Active;
}
save(options: FeatureFlagsSaveOptions): void {
if (!this.flags) {
this.flags = this.load();
}
if (!options.merge) {
this.flags.clear();
}
for (const [name, state] of Object.entries(options.states)) {
this.flags.set(name, state);
}
const enabled = Array.from(this.flags.entries()).filter(
([, state]) => state === FeatureFlagState.Active,
);
window.localStorage.setItem(
'featureFlags',
JSON.stringify(Object.fromEntries(enabled)),
);
}
private load(): Map<string, FeatureFlagState> {
try {
const jsonStr = window.localStorage.getItem('featureFlags');
if (!jsonStr) {
return new Map();
}
const json = JSON.parse(jsonStr) as unknown;
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
return new Map();
}
const entries = Object.entries(json).filter(([name, value]) => {
validateFlagName(name);
return value === FeatureFlagState.Active;
});
return new Map(entries);
} catch {
return new Map();
}
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
@@ -0,0 +1,101 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import MockOAuthApi from './MockOAuthApi';
import PowerIcon from '@material-ui/icons/Power';
describe('MockOAuthApi', () => {
it('should trigger all requests', async () => {
const authResult = { is: 'done' };
const mock = new MockOAuthApi();
const authHandler1 = jest.fn().mockImplementation(() => authResult);
const requester1 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn().mockResolvedValue('other');
const requester2 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler2,
});
const promises = [
requester1(new Set(['a'])),
requester1(new Set(['b'])),
requester2(new Set(['a', 'b'])),
requester2(new Set(['b', 'c'])),
requester2(new Set(['c', 'a'])),
];
await expect(
Promise.race([Promise.all(promises), 'waiting']),
).resolves.toBe('waiting');
await mock.triggerAll();
await expect(Promise.all(promises)).resolves.toEqual([
authResult,
authResult,
'other',
'other',
'other',
]);
expect(authHandler1).toHaveBeenCalledTimes(1);
expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b']));
expect(authHandler2).toHaveBeenCalledTimes(1);
expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c']));
});
it('should reject all requests', async () => {
const mock = new MockOAuthApi();
const authHandler1 = jest.fn();
const requester1 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn();
const requester2 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler2,
});
const promises = [
requester1(new Set(['a'])),
requester1(new Set(['b'])),
requester2(new Set(['a', 'b'])),
requester2(new Set(['b', 'c'])),
requester2(new Set(['c', 'a'])),
];
await expect(
Promise.race([Promise.all(promises), 'waiting']),
).resolves.toBe('waiting');
await mock.rejectAll();
for (const promise of promises) {
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
}
expect(authHandler1).not.toHaveBeenCalled();
expect(authHandler2).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
/*
* 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 '../../definitions';
import { OAuthRequestManager } from './OAuthRequestManager';
export default class MockOAuthApi implements OAuthRequestApi {
private readonly real = new OAuthRequestManager();
createAuthRequester<T>(options: AuthRequesterOptions<T>) {
return this.real.createAuthRequester(options);
}
authRequest$() {
return this.real.authRequest$();
}
async triggerAll() {
await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
return new Promise<void>(resolve => {
const subscription = this.authRequest$().subscribe(requests => {
subscription.unsubscribe();
Promise.all(requests.map(request => request.trigger())).then(() =>
resolve(),
);
});
});
}
async rejectAll() {
await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
return new Promise<void>(resolve => {
const subscription = this.authRequest$().subscribe(requests => {
subscription.unsubscribe();
requests.map(request => request.reject());
resolve();
});
});
}
}
@@ -0,0 +1,96 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { waitFor } from '@testing-library/react';
import { OAuthPendingRequests } from './OAuthPendingRequests';
describe('OAuthPendingRequests', () => {
it('notifies new observers about current state', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const input = new Set(['a', 'b']);
target.pending().subscribe({ next, error });
target.request(input);
await waitFor(() => expect(next).toBeCalledTimes(2));
expect(next.mock.calls[0][0].scopes).toBeUndefined();
expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString());
expect(error.mock.calls.length).toBe(0);
});
it('resolves requests and notifies observers', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const request1 = target.request(new Set(['a']));
const request2 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
target.resolve(new Set(['a']), 'session1');
target.resolve(new Set(['a']), 'session2');
await expect(request1).resolves.toBe('session1');
await expect(request2).resolves.toBe('session1');
expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve
expect(error).toBeCalledTimes(0);
});
it('can resolve through the observable', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn(pendingRequest => pendingRequest.resolve('done'));
const error = jest.fn();
const request1 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
await expect(request1).resolves.toBe('done');
expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution
expect(error).toBeCalledTimes(0);
});
it('rejects requests and notifies observers only once', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const rejection = new Error('eek');
const request1 = target.request(new Set(['a']));
const request2 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
target.reject(rejection);
target.resolve(new Set(['a']), 'session');
await expect(request1).rejects.toBe(rejection);
await expect(request2).rejects.toBe(rejection);
expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve
expect(error).toBeCalledTimes(0);
});
it('can reject through the observable', async () => {
const target = new OAuthPendingRequests<string>();
const rejection = new Error('nope');
const next = jest.fn(pendingRequest => pendingRequest.reject(rejection));
const error = jest.fn();
const request1 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
await expect(request1).rejects.toBe(rejection);
expect(next).toBeCalledTimes(2);
});
});
@@ -0,0 +1,126 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BehaviorSubject } from '../../../lib';
import { Observable } from '../../../types';
type RequestQueueEntry<ResultType> = {
scopes: Set<string>;
resolve: (value: ResultType | PromiseLike<ResultType>) => void;
reject: (reason: Error) => void;
};
export type PendingRequest<ResultType> = {
scopes: Set<string> | undefined;
resolve: (value: ResultType) => void;
reject: (reason: Error) => void;
};
export function hasScopes(
searched: Set<string>,
searchFor: Set<string>,
): boolean {
for (const scope of searchFor) {
if (!searched.has(scope)) {
return false;
}
}
return true;
}
export function joinScopes(
scopes: Set<string>,
...moreScopess: Set<string>[]
): Set<string> {
const result = new Set(scopes);
for (const moreScopes of moreScopess) {
for (const scope of moreScopes) {
result.add(scope);
}
}
return result;
}
/**
* The OAuthPendingRequests class is a utility for managing and observing
* a stream of requests for oauth scopes for a single provider, and resolving
* them correctly once requests are fulfilled.
*/
export class OAuthPendingRequests<ResultType> {
private requests: RequestQueueEntry<ResultType>[] = [];
private subject = new BehaviorSubject<PendingRequest<ResultType>>(
this.getCurrentPending(),
);
request(scopes: Set<string>): Promise<ResultType> {
return new Promise((resolve, reject) => {
this.requests.push({ scopes, resolve, reject });
this.subject.next(this.getCurrentPending());
});
}
resolve(scopes: Set<string>, result: ResultType): void {
this.requests = this.requests.filter(request => {
if (hasScopes(scopes, request.scopes)) {
request.resolve(result);
return false;
}
return true;
});
this.subject.next(this.getCurrentPending());
}
reject(error: Error) {
this.requests.forEach(request => request.reject(error));
this.requests = [];
this.subject.next(this.getCurrentPending());
}
pending(): Observable<PendingRequest<ResultType>> {
return this.subject;
}
private getCurrentPending(): PendingRequest<ResultType> {
const currentScopes =
this.requests.length === 0
? undefined
: this.requests
.slice(1)
.reduce(
(acc, current) => joinScopes(acc, current.scopes),
this.requests[0].scopes,
);
return {
scopes: currentScopes,
resolve: (value: ResultType) => {
if (currentScopes) {
this.resolve(currentScopes, value);
}
},
reject: (reason: Error) => {
if (currentScopes) {
this.reject(reason);
}
},
};
}
}
@@ -0,0 +1,59 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ProviderIcon from '@material-ui/icons/AcUnit';
import { OAuthRequestManager } from './OAuthRequestManager';
describe('OAuthRequestManager', () => {
it('should forward a requests', async () => {
const manager = new OAuthRequestManager();
const reqSpy = jest.fn();
manager.authRequest$().subscribe(reqSpy);
const requester = manager.createAuthRequester({
provider: {
title: 'My Provider',
icon: ProviderIcon,
},
onAuthRequest: async () => 'hello',
});
expect(reqSpy).toHaveBeenCalledTimes(0);
await 'a tick';
expect(reqSpy).toHaveBeenCalledTimes(2);
expect(reqSpy).toHaveBeenLastCalledWith([]);
const req = requester(new Set(['my-scope']));
expect(reqSpy).toHaveBeenCalledTimes(3);
expect(reqSpy).toHaveBeenLastCalledWith([
expect.objectContaining({
reject: expect.any(Function),
trigger: expect.any(Function),
}),
]);
await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe(
'not yet',
);
const [request] = reqSpy.mock.calls[2][0];
request.trigger();
await expect(req).resolves.toBe('hello');
});
});
@@ -0,0 +1,92 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
OAuthRequestApi,
PendingAuthRequest,
AuthRequester,
AuthRequesterOptions,
} from '../../definitions';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
import { BehaviorSubject } from '../../../lib';
import { Observable } from '../../../types';
/**
* The OAuthRequestManager is an implementation of the OAuthRequestApi.
*
* The purpose of this class and the API is to read a stream of incoming requests
* of OAuth access tokens from different providers with varying scope, and funnel
* them all together into a single request for each OAuth provider.
*/
export class OAuthRequestManager implements OAuthRequestApi {
private readonly subject = new BehaviorSubject<PendingAuthRequest[]>([]);
private currentRequests: PendingAuthRequest[] = [];
private handlerCount = 0;
createAuthRequester<T>(options: AuthRequesterOptions<T>): AuthRequester<T> {
const handler = new OAuthPendingRequests<T>();
const index = this.handlerCount;
this.handlerCount++;
handler.pending().subscribe({
next: scopeRequest => {
const newRequests = this.currentRequests.slice();
const request = this.makeAuthRequest(scopeRequest, options);
if (!request) {
delete newRequests[index];
} else {
newRequests[index] = request;
}
this.currentRequests = newRequests;
// Convert from sparse array to array of present items only
this.subject.next(newRequests.filter(Boolean));
},
});
return scopes => {
return handler.request(scopes);
};
}
// Converts the pending request and popup options into a popup request that we can forward to subscribers.
private makeAuthRequest(
request: PendingRequest<any>,
options: AuthRequesterOptions<any>,
): PendingAuthRequest | undefined {
const { scopes } = request;
if (!scopes) {
return undefined;
}
return {
provider: options.provider,
trigger: async () => {
const result = await options.onAuthRequest(scopes);
request.resolve(result);
},
reject: () => {
const error = new Error('Login failed, rejected by user');
error.name = 'RejectedError';
request.reject(error);
},
};
}
authRequest$(): Observable<PendingAuthRequest[]> {
return this.subject;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { OAuthRequestManager } from './OAuthRequestManager';
@@ -0,0 +1,173 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { WebStorage } from './WebStorage';
import { CreateStorageApiOptions, StorageApi } from '../../definitions';
describe('WebStorage Storage API', () => {
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
const createWebStorage = (
args?: Partial<CreateStorageApiOptions>,
): StorageApi => {
return WebStorage.create({
errorApi: mockErrorApi,
...args,
});
};
it('should return undefined for values which are unset', async () => {
const storage = createWebStorage();
expect(storage.get('myfakekey')).toBeUndefined();
});
it('should allow the setting and getting of the simple data structures', async () => {
const storage = createWebStorage();
await storage.set('myfakekey', 'helloimastring');
await storage.set('mysecondfakekey', 1234);
await storage.set('mythirdfakekey', true);
expect(storage.get('myfakekey')).toBe('helloimastring');
expect(storage.get('mysecondfakekey')).toBe(1234);
expect(storage.get('mythirdfakekey')).toBe(true);
});
it('should allow setting of complex datastructures', async () => {
const storage = createWebStorage();
const mockData = {
something: 'here',
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
};
await storage.set('myfakekey', mockData);
expect(storage.get('myfakekey')).toEqual(mockData);
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
await new Promise<void>(resolve => {
storage.observe$<String>('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
storage.set('correctKey', mockData);
await new Promise<void>(resolve => {
storage.observe$('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: undefined,
});
});
it('should be able to create different buckets for different uses', async () => {
const rootStorage = createWebStorage();
const firstStorage = rootStorage.forBucket('userSettings');
const secondStorage = rootStorage.forBucket('profileSettings');
const keyName = 'blobby';
await firstStorage.set(keyName, 'boop');
await secondStorage.set(keyName, 'deerp');
expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName));
expect(firstStorage.get(keyName)).toBe('boop');
expect(secondStorage.get(keyName)).toBe('deerp');
});
it('should not clash with other namesapces when creating buckets', async () => {
const rootStorage = createWebStorage();
// when getting key test2 it will translate to /profile/something/deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to /profile/something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
expect(secondStorage.get('deep/test2')).toBe(undefined);
});
it('should call the error api when the json can not be parsed in local storage', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing',
});
localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}');
const value = rootStorage.get('key');
expect(value).toBe(undefined);
expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error));
expect(mockErrorApi.post).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Error when parsing JSON config from storage for: key',
}),
);
});
it('should return a singleton for the same namespace and same bucket', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing/Thing ',
});
expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test'));
});
});
@@ -0,0 +1,94 @@
/*
* 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,
CreateStorageApiOptions,
} from '../../definitions';
import { Observable } from '../../../types';
import ObservableImpl from 'zen-observable';
const buckets = new Map<string, WebStorage>();
export class WebStorage implements StorageApi {
constructor(
private readonly namespace: string,
private readonly errorApi: ErrorApi,
) {}
static create(options: CreateStorageApiOptions): WebStorage {
return new WebStorage(options.namespace ?? '', options.errorApi);
}
get<T>(key: string): T | undefined {
try {
const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!);
return storage ?? undefined;
} catch (e) {
this.errorApi.post(
new Error(`Error when parsing JSON config from storage for: ${key}`),
);
}
return undefined;
}
forBucket(name: string): WebStorage {
const bucketPath = `${this.namespace}/${name}`;
if (!buckets.has(bucketPath)) {
buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi));
}
return buckets.get(bucketPath)!;
}
async set<T>(key: string, data: T): Promise<void> {
localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2));
this.notifyChanges({ key, newValue: data });
}
async remove(key: string): Promise<void> {
localStorage.removeItem(this.getKeyName(key));
this.notifyChanges({ key, newValue: undefined });
}
observe$<T>(key: string): Observable<StorageValueChange<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
private getKeyName(key: string) {
return `${this.namespace}/${encodeURIComponent(key)}`;
}
private notifyChanges<T>(message: StorageValueChange<T>) {
for (const subscription of this.subscribers) {
subscription.next(message);
}
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueChange>
>();
private readonly observable = new ObservableImpl<StorageValueChange>(
subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
},
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { WebStorage } from './WebStorage';
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Auth0Icon from '@material-ui/icons/AcUnit';
import { auth0AuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'auth0',
title: 'Auth0',
icon: Auth0Icon,
};
class Auth0Auth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', `email`, `profile`],
}: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default Auth0Auth;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as Auth0Auth } from './Auth0Auth';
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import GithubAuth from './GithubAuth';
describe('GithubAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const githubAuth = new GithubAuth({ getSession } as any);
expect(await githubAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
});
@@ -0,0 +1,140 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
OAuthApi,
SessionApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuthApiCreateOptions } from '../types';
export type GithubAuthResponse = {
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'github',
title: 'GitHub',
icon: GithubIcon,
};
class GithubAuth implements OAuthApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read:user'],
}: OAuthApiCreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GithubAuthResponse): GithubSession {
return {
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
const authSessionStore = new AuthSessionStore<GithubSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
return new GithubAuth(authSessionStore);
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async signIn() {
await this.getAccessToken();
}
async signOut() {
await this.sessionManager.removeSession();
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: GithubAuth.normalizeScope(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default GithubAuth;
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types';
export { default as GithubAuth } from './GithubAuth';
@@ -0,0 +1,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 } from '../../..';
import { BackstageIdentity } from '../../../definitions';
export type GithubSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -0,0 +1,50 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
import GitlabAuth from './GitlabAuth';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GitlabAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
[
'read_user api write_repository',
['read_user', 'api', 'write_repository'],
],
['read_repository sudo', ['read_repository', 'sudo']],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const gitlabAuth = GitlabAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
gitlabAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import GitlabIcon from '@material-ui/icons/AcUnit';
import { gitlabAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'GitLab',
icon: GitlabIcon,
};
class GitlabAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read_user'],
}: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default GitlabAuth;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as GitlabAuth } from './GitlabAuth';
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import GoogleAuth from './GoogleAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'https://www.googleapis.com/auth/';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GoogleAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
['email', [`${PREFIX}userinfo.email`]],
['profile', [`${PREFIX}userinfo.profile`]],
['openid', ['openid']],
['userinfo.email', [`${PREFIX}userinfo.email`]],
[
'userinfo.profile email',
[`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
],
[
`profile ${PREFIX}userinfo.email`,
[`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
],
[`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]],
['a', [`${PREFIX}a`]],
['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]],
[`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]],
[`${PREFIX}a`, [`${PREFIX}a`]],
// Some incorrect scopes that we don't try to fix
[`${PREFIX}email`, [`${PREFIX}email`]],
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const googleAuth = GoogleAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
googleAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -0,0 +1,68 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import GoogleIcon from '@material-ui/icons/AcUnit';
import { googleAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'google',
title: 'Google',
icon: GoogleIcon,
};
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth {
static create({
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
defaultScopes = [
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
],
}: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
scopeTransform(scopes: string[]) {
return scopes.map(scope => {
if (scope === 'openid') {
return scope;
}
if (scope === 'profile' || scope === 'email') {
return `${SCOPE_PREFIX}userinfo.${scope}`;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default GoogleAuth;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as GoogleAuth } from './GoogleAuth';
@@ -0,0 +1,25 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './github';
export * from './gitlab';
export * from './google';
export * from './oauth2';
export * from './okta';
export * from './saml';
export * from './auth0';
export * from './microsoft';
export * from './onelogin';
@@ -0,0 +1,52 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import MicrosoftIcon from '@material-ui/icons/AcUnit';
import { microsoftAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'microsoft',
title: 'Microsoft',
icon: MicrosoftIcon,
};
class MicrosoftAuth {
static create({
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
defaultScopes = [
'openid',
'offline_access',
'profile',
'email',
'User.Read',
],
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default MicrosoftAuth;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as MicrosoftAuth } from './MicrosoftAuth';
@@ -0,0 +1,152 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OAuth2 from './OAuth2';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
const PREFIX = 'https://www.googleapis.com/auth/';
const scopeTransform = (x: string[]) => x;
describe('OAuth2', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe(
'access-token',
);
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-scope', 'my-scope2']),
);
});
it('should transform scopes', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
});
expect(await oauth2.getAccessToken('my-scope')).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-prefix/my-scope']),
);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(oauth2.getAccessToken()).resolves.toBe('access-token');
const promise1 = oauth2.getAccessToken('more');
const promise2 = oauth2.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Grab the expired session first
await expect(oauth2.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = oauth2.getIdToken();
const promise2 = oauth2.getIdToken();
const promise3 = oauth2.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
});
});
@@ -0,0 +1,179 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OAuth2Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
OpenIdConnectApi,
ProfileInfo,
ProfileInfoApi,
SessionState,
SessionApi,
BackstageIdentityApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
import { OAuthApiCreateOptions } from '../types';
type Options = {
sessionManager: SessionManager<OAuth2Session>;
scopeTransform: (scopes: string[]) => string[];
};
type CreateOptions = OAuthApiCreateOptions & {
scopeTransform?: (scopes: string[]) => string[];
};
export type OAuth2Response = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'oauth2',
title: 'Your Identity Provider',
icon: OAuth2Icon,
};
class OAuth2
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OAuth2Response): OAuth2Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(
scopeTransform,
res.providerInfo.scope,
),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: OAuth2Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OAuth2({ sessionManager, scopeTransform });
}
private readonly sessionManager: SessionManager<OAuth2Session>;
private readonly scopeTransform: (scopes: string[]) => string[];
constructor(options: Options) {
this.sessionManager = options.sessionManager;
this.scopeTransform = options.scopeTransform;
}
async signIn() {
await this.getAccessToken();
}
async signOut() {
await this.sessionManager.removeSession();
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
private static normalizeScopes(
scopeTransform: (scopes: string[]) => string[],
scopes?: string | string[],
): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s|,]/).filter(Boolean);
return new Set(scopeTransform(scopeList));
}
}
export default OAuth2;
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as OAuth2 } from './OAuth2';
export * from './types';
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type OAuth2Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -0,0 +1,61 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OktaAuth from './OktaAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'okta.';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('OktaAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
['openid', ['openid']],
['profile email', ['profile', 'email']],
[`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]],
['groups.read', [`${PREFIX}groups.read`]],
[
`${PREFIX}groups.manage groups.read, openid`,
[`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'],
],
[`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]],
// Some incorrect scopes that we don't try to fix
[`${PREFIX}email`, [`${PREFIX}email`]],
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const auth = OktaAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
auth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -0,0 +1,71 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OktaIcon from '@material-ui/icons/AcUnit';
import { oktaAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'okta',
title: 'Okta',
icon: OktaIcon,
};
const OKTA_OIDC_SCOPES: Set<String> = new Set([
'openid',
'profile',
'email',
'phone',
'address',
'groups',
'offline_access',
]);
const OKTA_SCOPE_PREFIX: string = 'okta.';
class OktaAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
}: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
scopeTransform(scopes) {
return scopes.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
return scope;
}
return `${OKTA_SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default OktaAuth;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as OktaAuth } from './OktaAuth';
@@ -0,0 +1,82 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OneLoginIcon from '@material-ui/icons/AcUnit';
import { oneloginAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
const DEFAULT_PROVIDER = {
id: 'onelogin',
title: 'onelogin',
icon: OneLoginIcon,
};
const OIDC_SCOPES: Set<String> = new Set([
'openid',
'profile',
'email',
'phone',
'address',
'groups',
'offline_access',
]);
const SCOPE_PREFIX: string = 'onelogin.';
class OneLoginAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions): typeof oneloginAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
scopeTransform(scopes) {
return scopes.map(scope => {
if (OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default OneLoginAuth;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as OneLoginAuth } from './OneLoginAuth';
@@ -0,0 +1,98 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import SamlIcon from '@material-ui/icons/AcUnit';
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import {
ProfileInfo,
BackstageIdentity,
SessionState,
AuthRequestOptions,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi,
} from '../../../definitions/auth';
import { SamlSession } from './types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { AuthApiCreateOptions } from '../types';
export type SamlAuthResponse = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'saml',
title: 'SAML',
icon: SamlIcon,
};
class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
}: AuthApiCreateOptions) {
const connector = new DirectAuthConnector<SamlSession>({
discoveryApi,
environment,
provider,
});
const sessionManager = new StaticAuthSessionManager<SamlSession>({
connector,
});
const authSessionStore = new AuthSessionStore<SamlSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
});
return new SamlAuth(authSessionStore);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<SamlSession>) {}
async signIn() {
await this.getBackstageIdentity({});
}
async signOut() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
}
export default SamlAuth;
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as SamlAuth } from './SamlAuth';
@@ -0,0 +1,22 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type SamlSession = {
userId: string;
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -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 { AuthProvider, DiscoveryApi, OAuthRequestApi } from '../../definitions';
export type OAuthApiCreateOptions = AuthApiCreateOptions & {
oauthRequestApi: OAuthRequestApi;
defaultScopes?: string[];
};
export type AuthApiCreateOptions = {
discoveryApi: DiscoveryApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
@@ -0,0 +1,30 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This folder contains implementations for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './system';
export * from './definitions';
export * from './implementations';
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiAggregator } from './ApiAggregator';
import { createApiRef } from './ApiRef';
import { ApiRegistry } from './ApiRegistry';
describe('ApiAggregator', () => {
const apiARef = createApiRef<number>({ id: 'a', description: '' });
const apiBRef = createApiRef<number>({ id: 'b', description: '' });
it('should forward implementations', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 5],
[apiBRef, 10],
]),
);
expect(agg.get(apiARef)).toBe(5);
expect(agg.get(apiBRef)).toBe(10);
});
it('should return the first implementation', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 1],
[apiARef, 2],
]),
);
expect(agg.get(apiARef)).toBe(2);
expect(agg.get(apiBRef)).toBe(undefined);
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiRef, ApiHolder } from './types';
/**
* An ApiHolder that queries multiple other holders from for
* an Api implementation, returning the first one encountered..
*/
export class ApiAggregator implements ApiHolder {
private readonly holders: ApiHolder[];
constructor(...holders: ApiHolder[]) {
this.holders = holders;
}
get<T>(apiRef: ApiRef<T>): T | undefined {
for (const holder of this.holders) {
const api = holder.get(apiRef);
if (api) {
return api;
}
}
return undefined;
}
}
@@ -0,0 +1,91 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiFactoryRegistry } from './ApiFactoryRegistry';
import { createApiRef } from './ApiRef';
const aRef = createApiRef<number>({ id: 'a', description: '' });
const aFactory1 = { api: aRef, deps: {}, factory: () => 1 };
const aFactory2 = { api: aRef, deps: {}, factory: () => 2 };
const bRef = createApiRef<string>({ id: 'b', description: '' });
const bFactory = { api: bRef, deps: {}, factory: () => 'x' };
const cRef = createApiRef<string>({ id: 'c', description: '' });
const cFactory = { api: cRef, deps: {}, factory: () => 'y' };
describe('ApiFactoryRegistry', () => {
it('should be empty when created', () => {
const registry = new ApiFactoryRegistry();
expect(registry.getAllApis()).toEqual(new Set());
});
it('should register a factory', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('default', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.getAllApis()).toEqual(new Set([aRef]));
});
it('should prioritize factories based on scope', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('default', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('default', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('app', aFactory2)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory2);
expect(registry.register('default', aFactory1)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory2);
expect(registry.register('static', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('static', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('app', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.getAllApis()).toEqual(new Set([aRef]));
});
it('should register multiple factories without conflict', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('static', aFactory1)).toBe(true);
expect(registry.register('default', bFactory)).toBe(true);
expect(registry.register('app', cFactory)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.get(bRef)).toBe(bFactory);
expect(registry.get(cRef)).toBe(cFactory);
expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef]));
});
it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => {
const ref1 = createApiRef<number>({ id: 'a', description: 'ref1' });
const ref2 = createApiRef<number>({ id: 'a', description: 'ref2' });
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(registry.getAllApis()).toEqual(new Set([ref2]));
expect(registry.getAllApis()).not.toEqual(new Set([ref1]));
});
});
@@ -0,0 +1,89 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiRef,
ApiFactoryHolder,
ApiFactory,
AnyApiRef,
AnyApiFactory,
} from './types';
type ApiFactoryScope =
| 'default' // Default factories registered by core and plugins
| 'app' // Factories registered in the app, overriding default ones
| 'static'; // APIs that can't be overridden, e.g. config
enum ScopePriority {
default = 10,
app = 50,
static = 100,
}
type FactoryTuple = {
priority: number;
factory: AnyApiFactory;
};
/**
* ApiFactoryRegistry is an ApiFactoryHolder implementation that enables
* registration of API Factories with different scope.
*
* Each scope has an assigned priority, where factories registered with
* higher priority scopes override ones with lower priority.
*/
export class ApiFactoryRegistry implements ApiFactoryHolder {
private readonly factories = new Map<string, FactoryTuple>();
/**
* Register a new API factory. Returns true if the factory was added
* to the registry.
*
* A factory will not be added to the registry if there is already
* an existing factory with the same or higher priority.
*/
register<Api, Impl extends Api, Deps extends { [name in string]: unknown }>(
scope: ApiFactoryScope,
factory: ApiFactory<Api, Impl, Deps>,
) {
const priority = ScopePriority[scope];
const existing = this.factories.get(factory.api.id);
if (existing && existing.priority >= priority) {
return false;
}
this.factories.set(factory.api.id, { priority, factory });
return true;
}
get<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [x: string]: unknown }> | undefined {
const tuple = this.factories.get(api.id);
if (!tuple) {
return undefined;
}
return tuple.factory as ApiFactory<T, T, { [x: string]: unknown }>;
}
getAllApis(): Set<AnyApiRef> {
const refs = new Set<AnyApiRef>();
for (const { factory } of this.factories.values()) {
refs.add(factory.api);
}
return refs;
}
}
@@ -0,0 +1,212 @@
/*
* 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 { ApiProvider, useApi, withApis } from './ApiProvider';
import { createApiRef } from './ApiRef';
import { ApiRegistry } from './ApiRegistry';
import { render } from '@testing-library/react';
import { withLogCollector } from '@backstage/test-utils-core';
import { getGlobalSingleton } from '../../lib/globalObject';
import { ApiHolder, ApiRef } from './types';
import { VersionedValue } from '../../lib/versionedValues';
describe('ApiProvider', () => {
type Api = () => string;
const apiRef = createApiRef<Api>({ id: 'x', description: '' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumer = () => {
const api = useApi(apiRef);
return <p>hook message: {api()}</p>;
};
const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => {
return <p>hoc message: {getMessage()}</p>;
});
it('should provide apis', () => {
const renderedHook = render(
<ApiProvider apis={registry}>
<MyHookConsumer />
</ApiProvider>,
);
renderedHook.getByText('hook message: hello');
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should provide nested access to apis', () => {
const aRef = createApiRef<string>({ id: 'a', description: '' });
const bRef = createApiRef<string>({ id: 'b', description: '' });
const MyComponent = () => {
const a = useApi(aRef);
const b = useApi(bRef);
return (
<div>
a={a} b={b}
</div>
);
};
const renderedHook = render(
<ApiProvider
apis={ApiRegistry.from([
[aRef, 'x'],
[bRef, 'y'],
])}
>
<ApiProvider apis={ApiRegistry.from([[aRef, 'z']])}>
<MyComponent />
</ApiProvider>
</ApiProvider>,
);
renderedHook.getByText('a=z b=y');
});
it('should ignore deps in prototype', () => {
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
const xRef = createApiRef<number>({ id: 'x', description: '' });
const proto = { x: xRef };
const props = { getMessage: { enumerable: true, value: apiRef } };
const obj = Object.create(proto, props) as {
getMessage: typeof apiRef;
x: typeof xRef;
};
const MyWeirdHocConsumer = withApis(obj)(({ getMessage }) => {
return <p>hoc message: {getMessage()}</p>;
});
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyWeirdHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should error if no provider is available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHookConsumer />);
}).toThrow(/^No ApiProvider available in react context. /);
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No ApiProvider available in react context. /,
),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHocConsumer />);
}).toThrow(/^No ApiProvider available in react context. /);
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No ApiProvider available in react context. /,
),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
),
]);
});
it('should error if api is not available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHookConsumer />
</ApiProvider>,
);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHocConsumer />
</ApiProvider>,
);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
),
]);
});
});
describe('v1 consumer', () => {
const ApiContext = getGlobalSingleton<
Context<VersionedValue<{ 1: ApiHolder }>>
>('api-context');
function useMockApiV1<T>(apiRef: ApiRef<T>): T {
const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef);
if (!impl) {
throw new Error('no impl');
}
return impl;
}
type Api = () => string;
const apiRef = createApiRef<Api>({ id: 'x', description: '' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumerV1 = () => {
const api = useMockApiV1(apiRef);
return <p>hook message: {api()}</p>;
};
it('should provide apis', () => {
const renderedHook = render(
<ApiProvider apis={registry}>
<MyHookConsumerV1 />
</ApiProvider>,
);
renderedHook.getByText('hook message: hello');
});
});
@@ -0,0 +1,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 React, {
createContext,
useContext,
ReactNode,
PropsWithChildren,
Context,
} from 'react';
import PropTypes from 'prop-types';
import { ApiRef, ApiHolder, TypesToApiRefs } from './types';
import { ApiAggregator } from './ApiAggregator';
import { getGlobalSingleton, setGlobalSingleton } from '../../lib/globalObject';
import {
VersionedValue,
createVersionedValueMap,
} from '../../lib/versionedValues';
const missingHolderMessage =
'No ApiProvider available in react context. ' +
'A common cause of this error is that multiple versions of @backstage/core-api are installed. ' +
`You can check if that is the case using 'yarn backstage-cli versions:check', and can in many cases ` +
`fix the issue either with the --fix flag or using 'yarn backstage-cli versions:bump'`;
type ApiProviderProps = {
apis: ApiHolder;
children: ReactNode;
};
type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined;
const ApiContext = createContext<ApiContextType>(undefined);
setGlobalSingleton('api-context', ApiContext);
export const ApiProvider = ({
apis,
children,
}: PropsWithChildren<ApiProviderProps>) => {
const parentHolder = useContext(ApiContext)?.atVersion(1);
const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis;
return (
<ApiContext.Provider
value={createVersionedValueMap({ 1: holder })}
children={children}
/>
);
};
ApiProvider.propTypes = {
apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired,
children: PropTypes.node,
};
export function useApiHolder(): ApiHolder {
const versionedHolder = useContext(
getGlobalSingleton<Context<ApiContextType>>('api-context'),
);
if (!versionedHolder) {
throw new Error(missingHolderMessage);
}
const apiHolder = versionedHolder.atVersion(1);
if (!apiHolder) {
throw new Error('ApiContext v1 not available');
}
return apiHolder;
}
export function useApi<T>(apiRef: ApiRef<T>): T {
const apiHolder = useApiHolder();
const api = apiHolder.get(apiRef);
if (!api) {
throw new Error(`No implementation available for ${apiRef}`);
}
return api;
}
export function withApis<T>(apis: TypesToApiRefs<T>) {
return function withApisWrapper<P extends T>(
WrappedComponent: React.ComponentType<P>,
) {
const Hoc = (props: PropsWithChildren<Omit<P, keyof T>>) => {
const apiHolder = useApiHolder();
const impls = {} as T;
for (const key in apis) {
if (apis.hasOwnProperty(key)) {
const ref = apis[key];
const api = apiHolder.get(ref);
if (!api) {
throw new Error(`No implementation available for ${ref}`);
}
impls[key] = api;
}
}
return <WrappedComponent {...(props as P)} {...impls} />;
};
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
Hoc.displayName = `withApis(${displayName})`;
return Hoc;
};
}
@@ -0,0 +1,51 @@
/*
* 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 './ApiRef';
describe('ApiRef', () => {
it('should be created', () => {
const ref = createApiRef({ id: 'abc', description: '123' });
expect(ref.id).toBe('abc');
expect(ref.description).toBe('123');
expect(String(ref)).toBe('apiRef{abc}');
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
});
it('should reject invalid ids', () => {
for (const id of ['a', 'abc', 'ab-c', 'a.b.c', 'a-b.c', 'abc.a-b-c.abc3']) {
expect(createApiRef({ id, description: '123' }).id).toBe(id);
}
for (const id of [
'123',
'ab-3',
'ab_c',
'.',
'2ac',
'ab.3a',
'.abc',
'abc.',
'ab..s',
'',
'_',
]) {
expect(() => createApiRef({ id, description: '123' }).id).toThrow(
`API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`,
);
}
});
});
@@ -0,0 +1,57 @@
/*
* 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 type { ApiRef } from './types';
export type ApiRefConfig = {
id: string;
description?: string;
};
class ApiRefImpl<T> implements ApiRef<T> {
constructor(private readonly config: ApiRefConfig) {
const valid = config.id
.split('.')
.flatMap(part => part.split('-'))
.every(part => part.match(/^[a-z][a-z0-9]*$/));
if (!valid) {
throw new Error(
`API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`,
);
}
}
get id(): string {
return this.config.id;
}
get description() {
return this.config.description;
}
// Utility for getting type of an api, using `typeof apiRef.T`
get T(): T {
throw new Error(`tried to read ApiRef.T of ${this}`);
}
toString() {
return `apiRef{${this.config.id}}`;
}
}
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> {
return new ApiRefImpl<T>(config);
}
@@ -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 { ApiRegistry } from './ApiRegistry';
import { createApiRef } from './ApiRef';
describe('ApiRegistry', () => {
const x1Ref = createApiRef<number>({ id: 'x1', description: '' });
const x1DuplicateRef = createApiRef<number>({ id: 'x1', description: '' });
const x2Ref = createApiRef<string>({ id: 'x2', description: '' });
it('should be created', () => {
const registry = ApiRegistry.from([]);
expect(registry.get(x1Ref)).toBe(undefined);
});
it('should be created with APIs', () => {
const registry = ApiRegistry.from([
[x1Ref, 3],
[x2Ref, 'y'],
]);
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x1DuplicateRef)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
it('should be built', () => {
const registry = ApiRegistry.builder().build();
expect(registry.get(x1Ref)).toBe(undefined);
expect(registry.get(x1DuplicateRef)).toBe(undefined);
});
it('should be built with APIs', () => {
const builder = ApiRegistry.builder();
builder.add(x1Ref, 3);
builder.add(x2Ref, 'y');
const registry = builder.build();
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x1DuplicateRef)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
it('should be created with API', () => {
const reg1 = ApiRegistry.with(x1Ref, 3);
const reg2 = reg1.with(x2Ref, 'y');
const reg3 = reg2.with(x2Ref, 'z');
const reg4 = reg3.with(x1Ref, 2);
const reg5 = reg3.with(x1DuplicateRef, 4);
expect(reg1.get(x1Ref)).toBe(3);
expect(reg1.get(x2Ref)).toBe(undefined);
expect(reg2.get(x1Ref)).toBe(3);
expect(reg2.get(x2Ref)).toBe('y');
expect(reg3.get(x1Ref)).toBe(3);
expect(reg3.get(x2Ref)).toBe('z');
expect(reg4.get(x1Ref)).toBe(2);
expect(reg4.get(x2Ref)).toBe('z');
expect(reg5.get(x1Ref)).toBe(4);
expect(reg5.get(x2Ref)).toBe('z');
});
});
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiRef, ApiHolder } from './types';
type ApiImpl<T = unknown> = readonly [ApiRef<T>, T];
class ApiRegistryBuilder {
private apis: [string, unknown][] = [];
add<T, I extends T>(api: ApiRef<T>, impl: I): I {
this.apis.push([api.id, impl]);
return impl;
}
build(): ApiRegistry {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return new ApiRegistry(new Map(this.apis));
}
}
export class ApiRegistry implements ApiHolder {
static builder() {
return new ApiRegistryBuilder();
}
static from(apis: ApiImpl[]) {
return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl])));
}
/**
* Creates a new ApiRegistry with a single API implementation.
*
* @param api ApiRef for the API to add
* @param impl Implementation of the API to add
*/
static with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([[api.id, impl]]));
}
constructor(private readonly apis: Map<string, unknown>) {}
/**
* Returns a new ApiRegistry with the provided API added to the existing ones.
*
* @param api ApiRef for the API to add
* @param impl Implementation of the API to add
*/
with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([...this.apis, [api.id, impl]]));
}
get<T>(api: ApiRef<T>): T | undefined {
return this.apis.get(api.id) as T | undefined;
}
}
@@ -0,0 +1,266 @@
/*
* 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 { ApiResolver } from './ApiResolver';
import { createApiRef } from './ApiRef';
import { ApiFactoryRegistry } from './ApiFactoryRegistry';
const aRef = createApiRef<number>({ id: 'a', description: '' });
const otherARef = createApiRef<number>({ id: 'a', description: 'other' });
const bRef = createApiRef<string>({ id: 'b', description: '' });
const otherBRef = createApiRef<string>({ id: 'b', description: 'other' });
const cRef = createApiRef<{ x: string }>({ id: 'c', description: '' });
const otherCRef = createApiRef<{ x: string }>({
id: 'c',
description: 'other',
});
function createRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: {},
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: {},
factory: () => 'b',
});
registry.register('default', {
api: cRef,
deps: { b: otherBRef },
factory: ({ b }) => ({ x: 'x', b }),
});
return registry;
}
function createSelfCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { a: aRef },
factory: () => 1,
});
return registry;
}
function createShortCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { b: bRef },
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: { a: aRef },
factory: () => 'x',
});
return registry;
}
function createShortCyclicRegistryWithOther() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { b: bRef },
factory: () => 1,
});
registry.register('default', {
api: otherBRef,
deps: { a: otherARef },
factory: () => 'x',
});
return registry;
}
function createLongCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { b: otherBRef },
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: { c: cRef },
factory: () => 'b',
});
registry.register('default', {
api: cRef,
deps: { a: aRef },
factory: () => ({ x: 'x' }),
});
return registry;
}
describe('ApiResolver', () => {
it('should be created empty', () => {
const resolver = new ApiResolver(new ApiFactoryRegistry());
expect(resolver.get(aRef)).toBe(undefined);
expect(resolver.get(bRef)).toBe(undefined);
expect(resolver.get(otherBRef)).toBe(undefined);
expect(resolver.get(cRef)).toBe(undefined);
});
it('should instantiate APIs', () => {
const resolver = new ApiResolver(createRegistry());
expect(resolver.get(aRef)).toBe(1);
expect(resolver.get(otherARef)).toBe(1);
expect(resolver.get(bRef)).toBe('b');
expect(resolver.get(otherBRef)).toBe('b');
expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' });
expect(resolver.get(cRef)).toBe(resolver.get(otherCRef));
});
it('should detect self dependency cycles', () => {
const resolver = new ApiResolver(createSelfCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
});
it('should detect short dependency cycles', () => {
const resolver = new ApiResolver(createShortCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
});
it('should detect short dependency cycles with other refs', () => {
const resolver = new ApiResolver(createShortCyclicRegistryWithOther());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(otherARef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(otherBRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
});
it('should detect long dependency cycles', () => {
const resolver = new ApiResolver(createLongCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
// Second call for same ref should still throw
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(otherBRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(cRef)).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should validate a factory holder', () => {
expect(() => {
ApiResolver.validateFactories(createRegistry(), [
aRef,
bRef,
otherBRef,
cRef,
]);
}).not.toThrow();
});
it('should find self cycles with validation', () => {
const self = createSelfCyclicRegistry();
expect(() => ApiResolver.validateFactories(self, [aRef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => ApiResolver.validateFactories(self, [otherARef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
});
it('should find dependency cycles with validation', () => {
const short = createShortCyclicRegistry();
expect(() => ApiResolver.validateFactories(short, [aRef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => ApiResolver.validateFactories(short, [otherARef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(short, [otherBRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
const shortOther = createShortCyclicRegistryWithOther();
expect(() => ApiResolver.validateFactories(shortOther, [aRef])).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() =>
ApiResolver.validateFactories(shortOther, [otherARef]),
).toThrow('Circular dependency of api factory for apiRef{a}');
expect(() => ApiResolver.validateFactories(shortOther, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() =>
ApiResolver.validateFactories(shortOther, [otherBRef]),
).toThrow('Circular dependency of api factory for apiRef{b}');
const long = createLongCyclicRegistry();
expect(() =>
ApiResolver.validateFactories(long, long.getAllApis()),
).toThrow('Circular dependency of api factory for apiRef{a}');
expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(long, [otherBRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should only call factory func once', () => {
const registry = new ApiFactoryRegistry();
const factory = jest.fn().mockReturnValue(2);
registry.register('default', {
api: aRef,
deps: {},
factory,
});
const resolver = new ApiResolver(registry);
expect(factory).toHaveBeenCalledTimes(0);
expect(resolver.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
expect(resolver.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
expect(resolver.get(otherARef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,110 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiRef,
ApiHolder,
ApiFactoryHolder,
AnyApiRef,
TypesToApiRefs,
} from './types';
export class ApiResolver implements ApiHolder {
/**
* Validate factories by making sure that each of the apis can be created
* without hitting any circular dependencies.
*/
static validateFactories(
factories: ApiFactoryHolder,
apis: Iterable<AnyApiRef>,
) {
for (const api of apis) {
const heap = [api];
const allDeps = new Set<AnyApiRef>();
while (heap.length) {
const apiRef = heap.shift()!;
const factory = factories.get(apiRef);
if (!factory) {
continue;
}
for (const dep of Object.values(factory.deps)) {
if (dep.id === api.id) {
throw new Error(`Circular dependency of api factory for ${api}`);
}
if (!allDeps.has(dep)) {
allDeps.add(dep);
heap.push(dep);
}
}
}
}
}
private readonly apis = new Map<string, unknown>();
constructor(private readonly factories: ApiFactoryHolder) {}
get<T>(ref: ApiRef<T>): T | undefined {
return this.load(ref);
}
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
const impl = this.apis.get(ref.id);
if (impl) {
return impl as T;
}
const factory = this.factories.get(ref);
if (!factory) {
return undefined;
}
if (loading.includes(factory.api)) {
throw new Error(`Circular dependency of api factory for ${factory.api}`);
}
const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]);
const api = factory.factory(deps);
this.apis.set(ref.id, api);
return api as T;
}
private loadDeps<T>(
dependent: ApiRef<unknown>,
apis: TypesToApiRefs<T>,
loading: AnyApiRef[],
): T {
const impls = {} as T;
for (const key in apis) {
if (apis.hasOwnProperty(key)) {
const ref = apis[key];
const api = this.load(ref, loading);
if (!api) {
throw new Error(
`No API factory available for dependency ${ref} of dependent ${dependent}`,
);
}
impls[key] = api;
}
}
return impls;
}
}
@@ -0,0 +1,49 @@
/*
* 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, ApiFactory, TypesToApiRefs } from './types';
/**
* Used to infer types for a standalone ApiFactory that isn't immediately passed
* to another function.
* This function doesn't actually do anything, it's only used to infer types.
*/
export function createApiFactory<
Api,
Impl extends Api,
Deps extends { [name in string]: unknown }
>(factory: ApiFactory<Api, Impl, Deps>): ApiFactory<Api, Impl, Deps>;
export function createApiFactory<Api, Impl extends Api>(
api: ApiRef<Api>,
instance: Impl,
): ApiFactory<Api, Impl, {}>;
export function createApiFactory<
Api,
Impl extends Api,
Deps extends { [name in string]: unknown }
>(
factory: ApiFactory<Api, Impl, Deps> | ApiRef<Api>,
instance?: Impl,
): ApiFactory<Api, Impl, Deps> {
if ('id' in factory) {
return {
api: factory,
deps: {} as TypesToApiRefs<Deps>,
factory: () => instance!,
};
}
return factory;
}
+23
View File
@@ -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.
*/
export { ApiProvider, useApi, useApiHolder } from './ApiProvider';
export { ApiRegistry } from './ApiRegistry';
export { ApiResolver } from './ApiResolver';
export { ApiFactoryRegistry } from './ApiFactoryRegistry';
export { createApiRef } from './ApiRef';
export * from './types';
export * from './helpers';
+57
View File
@@ -0,0 +1,57 @@
/*
* 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 ApiRef<T> = {
id: string;
description?: string;
T: T;
};
export type AnyApiRef = ApiRef<unknown>;
export type ApiRefType<T> = T extends ApiRef<infer U> ? U : never;
export type TypesToApiRefs<T> = { [key in keyof T]: ApiRef<T[key]> };
export type ApiRefsToTypes<T extends { [key in string]: ApiRef<unknown> }> = {
[key in keyof T]: ApiRefType<T[key]>;
};
export type ApiHolder = {
get<T>(api: ApiRef<T>): T | undefined;
};
export type ApiFactory<
Api,
Impl extends Api,
Deps extends { [name in string]: unknown }
> = {
api: ApiRef<Api>;
deps: TypesToApiRefs<Deps>;
factory(deps: Deps): Impl;
};
export type AnyApiFactory = ApiFactory<
unknown,
unknown,
{ [key in string]: unknown }
>;
export type ApiFactoryHolder = {
get<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [key in string]: unknown }> | undefined;
};
+299
View File
@@ -0,0 +1,299 @@
/*
* 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 { 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 { defaultSystemIcons } from '../icons';
import { createPlugin } from '../plugin';
import { useRouteRef } from '../routing/hooks';
import {
createExternalRouteRef,
createRouteRef,
createSubRouteRef,
} from '../routing';
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',
externalRoutes: {
extRouteRef1,
extRouteRef2,
extRouteRef3,
extRouteRef4,
},
});
const plugin2 = createPlugin({
id: 'plugin2',
});
const HiddenComponent = plugin2.provide(
createRoutableExtension({
component: () => Promise.resolve((_: { path?: string }) => <div />),
mountPoint: plugin2RouteRef,
}),
);
const ExposedComponent = plugin1.provide(
createRoutableExtension({
component: () =>
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
const link1 = useRouteRef(plugin1RouteRef);
const link2 = useRouteRef(plugin2RouteRef);
const subLink1 = useRouteRef(subRouteRef1);
const subLink2 = useRouteRef(subRouteRef2);
const subLink3 = useRouteRef(subRouteRef3);
const subLink4 = useRouteRef(subRouteRef4);
const extLink1 = useRouteRef(extRouteRef1);
const extLink2 = useRouteRef(extRouteRef2);
const extLink3 = useRouteRef(extRouteRef3);
const extLink4 = useRouteRef(extRouteRef4);
return (
<div>
<span>link1: {link1()}</span>
<span>link2: {link2({ x: 'a' })}</span>
<span>subLink1: {subLink1()}</span>
<span>subLink2: {subLink2({ x: 'a' })}</span>
<span>subLink3: {subLink3({ x: 'b' })}</span>
<span>subLink4: {subLink4({ x: 'c', y: 'd' })}</span>
<span>extLink1: {extLink1()}</span>
<span>extLink2: {extLink2({ x: 'a' })}</span>
<span>extLink3: {extLink3?.() ?? '<none>'}</span>
<span>extLink4: {extLink4?.({ x: 'b' }) ?? '<none>'}</span>
</div>
);
}),
mountPoint: plugin1RouteRef,
}),
);
const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
Progress: () => null,
Router: BrowserRouter,
};
it('runs happy paths', async () => {
const app = new PrivateAppImpl({
apis: [],
defaultApis: [],
themes: [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
},
],
icons: defaultSystemIcons,
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
extRouteRef3: subRouteRef1,
extRouteRef4: plugin2RouteRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<ExposedComponent path="/" />
<HiddenComponent path="/foo/:x" />
</Routes>
</Router>
</Provider>,
);
expect(screen.getByText('link1: /')).toBeInTheDocument();
expect(screen.getByText('link2: /foo/a')).toBeInTheDocument();
expect(screen.getByText('subLink1: /sub1')).toBeInTheDocument();
expect(screen.getByText('subLink2: /sub2/a')).toBeInTheDocument();
expect(screen.getByText('subLink3: /foo/b/sub3')).toBeInTheDocument();
expect(screen.getByText('subLink4: /foo/c/sub4/d')).toBeInTheDocument();
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument();
expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument();
expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument();
});
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: defaultSystemIcons,
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<ExposedComponent path="/" />
<HiddenComponent path="/foo" />
</Routes>
</Router>
</Provider>,
);
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
expect(screen.getByText('extLink2: /foo')).toBeInTheDocument();
expect(screen.getByText('extLink3: <none>')).toBeInTheDocument();
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
});
it('should 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: defaultSystemIcons,
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
const { error: errorLogs } = withLogCollector(() => {
expect(() =>
render(
<Provider>
<Router>
<Routes>
<ExposedComponent path="/test/:thing">
<HiddenComponent path="/some/:thing" />
</ExposedComponent>
</Routes>
</Router>
</Provider>,
),
).toThrow(
'Parameter :thing is duplicated in path /test/:thing/some/:thing',
);
});
expect(errorLogs).toEqual([
expect.stringContaining(
'Parameter :thing is duplicated in path /test/:thing/some/:thing',
),
expect.stringContaining(
'The above error occurred in the <Provider> component',
),
]);
});
});
+501
View File
@@ -0,0 +1,501 @@
/*
* 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,
PropsWithChildren,
ReactElement,
useMemo,
useState,
} from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { useAsync } from 'react-use';
import {
AnyApiFactory,
ApiHolder,
ApiProvider,
ApiRegistry,
AppTheme,
appThemeApiRef,
AppThemeSelector,
configApiRef,
ConfigReader,
LocalStorageFeatureFlags,
useApi,
} from '../apis';
import {
AppThemeApi,
ConfigApi,
featureFlagsApiRef,
identityApiRef,
} from '../apis/definitions';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
childDiscoverer,
routeElementDiscoverer,
traverseElementTree,
} from '../extensions/traversal';
import { IconComponent, IconComponentMap, IconKey } from '../icons';
import { BackstagePlugin } from '../plugin';
import { AnyRoutes } from '../plugin/types';
import { RouteRef, ExternalRouteRef } from '../routing';
import {
routeObjectCollector,
routeParentCollector,
routePathCollector,
} from '../routing/collectors';
import { RoutingProvider } from '../routing/hooks';
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'],
): Map<ExternalRouteRef, RouteRef> {
const result = new Map<ExternalRouteRef, RouteRef>();
if (bindRoutes) {
const bind: AppRouteBinder = (externalRoutes, targetRoutes: AnyRoutes) => {
for (const [key, value] of Object.entries(targetRoutes)) {
const externalRoute = externalRoutes[key];
if (!externalRoute) {
throw new Error(`Key ${key} is not an existing external route`);
}
if (!value && !externalRoute.optional) {
throw new Error(
`External route ${key} is required but was undefined`,
);
}
if (value) {
result.set(externalRoute, value);
}
}
};
bindRoutes({ bind });
}
return result;
}
type FullAppOptions = {
apis: Iterable<AnyApiFactory>;
icons: IconComponentMap;
plugins: BackstagePlugin<any, any>[];
components: AppComponents;
themes: AppTheme[];
configLoader?: AppConfigLoader;
defaultApis: Iterable<AnyApiFactory>;
bindRoutes?: AppOptions['bindRoutes'];
};
function useConfigLoader(
configLoader: AppConfigLoader | undefined,
components: AppComponents,
appThemeApi: AppThemeApi,
): { api: ConfigApi } | { node: JSX.Element } {
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(configLoader);
const config = useAsync(configLoader || (() => Promise.resolve([])));
let noConfigNode = undefined;
if (hasConfig && config.loading) {
const { Progress } = components;
noConfigNode = <Progress />;
} else if (config.error) {
const { BootErrorPage } = components;
noConfigNode = <BootErrorPage step="load-config" error={config.error} />;
}
// Before the config is loaded we can't use a router, so exit early
if (noConfigNode) {
return {
node: (
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
</ApiProvider>
),
};
}
const configReader = ConfigReader.fromConfigs(config.value ?? []);
return { api: configReader };
}
class AppContextImpl implements AppContext {
constructor(private readonly app: PrivateAppImpl) {}
getPlugins(): BackstagePlugin<any, any>[] {
// eslint-disable-next-line no-console
console.warn('appContext.getPlugins() is deprecated and will be removed');
return this.app.getPlugins();
}
getSystemIcon(key: IconKey): IconComponent | undefined {
return this.app.getSystemIcon(key);
}
getComponents(): AppComponents {
return this.app.getComponents();
}
getProvider(): React.ComponentType<{}> {
// eslint-disable-next-line no-console
console.warn('appContext.getProvider() is deprecated and will be removed');
return this.app.getProvider();
}
getRouter(): React.ComponentType<{}> {
// eslint-disable-next-line no-console
console.warn('appContext.getRouter() is deprecated and will be removed');
return this.app.getRouter();
}
getRoutes(): JSX.Element[] {
// eslint-disable-next-line no-console
console.warn('appContext.getRoutes() is deprecated and will be removed');
return this.app.getRoutes();
}
}
export class PrivateAppImpl implements BackstageApp {
private apiHolder?: ApiHolder;
private configApi?: ConfigApi;
private readonly apis: Iterable<AnyApiFactory>;
private readonly icons: IconComponentMap;
private readonly plugins: BackstagePlugin<any, any>[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
private readonly defaultApis: Iterable<AnyApiFactory>;
private readonly bindRoutes: AppOptions['bindRoutes'];
private readonly identityApi = new AppIdentity();
constructor(options: FullAppOptions) {
this.apis = options.apis;
this.icons = options.icons;
this.plugins = options.plugins;
this.components = options.components;
this.themes = options.themes;
this.configLoader = options.configLoader;
this.defaultApis = options.defaultApis;
this.bindRoutes = options.bindRoutes;
}
getPlugins(): BackstagePlugin<any, any>[] {
return this.plugins;
}
getSystemIcon(key: IconKey): IconComponent | undefined {
return this.icons[key];
}
getComponents(): AppComponents {
return this.components;
}
getRoutes(): JSX.Element[] {
const routes = new Array<JSX.Element>();
const { NotFoundErrorPage } = this.components;
for (const plugin of this.plugins.values()) {
for (const output of plugin.output()) {
switch (output.type) {
case 'legacy-route': {
const { path, component: Component } = output;
routes.push(
<Route key={path} path={path} element={<Component />} />,
);
break;
}
case 'route': {
const { target, component: Component } = output;
routes.push(
<Route
key={`${plugin.getId()}-${target.path}`}
path={target.path}
element={<Component />}
/>,
);
break;
}
case 'legacy-redirect-route': {
const { path, target } = output;
routes.push(<Navigate key={path} to={target} />);
break;
}
case 'redirect-route': {
const { from, to } = output;
routes.push(<Navigate key={from.path} to={to.path} />);
break;
}
default:
break;
}
}
}
routes.push(
<Route
key="not-found-error-page"
path="/*"
element={<NotFoundErrorPage />}
/>,
);
return routes;
}
getProvider(): ComponentType<{}> {
const appContext = new AppContextImpl(this);
const apiHolder = this.getApiHolder();
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;
}
}
}
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,
},
});
validateRoutes(result.routePaths, result.routeParents);
return result;
}, [children]);
const loadedConfig = useConfigLoader(
this.configLoader,
this.components,
appThemeApi,
);
if ('node' in loadedConfig) {
// Loading or error
return loadedConfig.node;
}
this.configApi = loadedConfig.api;
return (
<ApiProvider apis={apiHolder}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider
routePaths={routePaths}
routeParents={routeParents}
routeObjects={routeObjects}
routeBindings={generateBoundRoutes(this.bindRoutes)}
>
{children}
</RoutingProvider>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
};
return Provider;
}
getRouter(): ComponentType<{}> {
const {
Router: RouterComponent,
SignInPage: SignInPageComponent,
} = this.components;
// This wraps the sign-in page and waits for sign-in to be completed before rendering the app
const SignInPageWrapper = ({
component: Component,
children,
}: {
component: ComponentType<SignInPageProps>;
children: ReactElement;
}) => {
const [result, setResult] = useState<SignInResult>();
if (result) {
this.identityApi.setSignInResult(result);
return children;
}
return <Component onResult={setResult} />;
};
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
const configApi = useApi(configApiRef);
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://dummy.dev', // baseUrl can be specified as just a path
);
if (pathname.endsWith('/')) {
pathname = pathname.replace(/\/$/, '');
}
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
});
return (
<RouterComponent>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</RouterComponent>
);
}
return (
<RouterComponent>
<SignInPageWrapper component={SignInPageComponent}>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</SignInPageWrapper>
</RouterComponent>
);
};
return AppRouter;
}
private getApiHolder(): ApiHolder {
if (this.apiHolder) {
return this.apiHolder;
}
const registry = new ApiFactoryRegistry();
registry.register('static', {
api: appThemeApiRef,
deps: {},
factory: () => AppThemeSelector.createWithStorage(this.themes),
});
registry.register('static', {
api: configApiRef,
deps: {},
factory: () => {
if (!this.configApi) {
throw new Error(
'Tried to access config API before config was loaded',
);
}
return this.configApi;
},
});
registry.register('static', {
api: identityApiRef,
deps: {},
factory: () => this.identityApi,
});
// It's possible to replace the feature flag API, but since we must have at least
// one implementation we add it here directly instead of through the defaultApis.
registry.register('default', {
api: featureFlagsApiRef,
deps: {},
factory: () => new LocalStorageFeatureFlags(),
});
for (const factory of this.defaultApis) {
registry.register('default', factory);
}
for (const plugin of this.plugins) {
for (const factory of plugin.getApis()) {
if (!registry.register('default', factory)) {
throw new Error(
`Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${
factory.api
}`,
);
}
}
}
for (const factory of this.apis) {
if (!registry.register('app', factory)) {
throw new Error(
`Duplicate or forbidden API factory for ${factory.api} in app`,
);
}
}
ApiResolver.validateFactories(registry, registry.getAllApis());
this.apiHolder = new ApiResolver(registry);
return this.apiHolder;
}
verify() {
const pluginIds = new Set<string>();
for (const plugin of this.plugins) {
const id = plugin.getId();
if (pluginIds.has(id)) {
throw new Error(`Duplicate plugin found '${id}'`);
}
pluginIds.add(id);
}
}
}
@@ -0,0 +1,79 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext, Context } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { VersionedValue } from '../lib/versionedValues';
import { getGlobalSingleton } from '../lib/globalObject';
import { AppContext as AppContextV1 } from './types';
import { AppContextProvider } from './AppContext';
describe('v1 consumer', () => {
const AppContext = getGlobalSingleton<
Context<VersionedValue<{ 1: AppContextV1 }>>
>('app-context');
function useMockAppV1(): AppContextV1 {
const impl = useContext(AppContext)?.atVersion(1);
if (!impl) {
throw new Error('no impl');
}
return impl;
}
it('should provide an app context', () => {
const mockContext: AppContextV1 = {
getComponents: jest.fn(),
getSystemIcon: jest.fn(),
getPlugins: jest.fn(),
getProvider: jest.fn(),
getRouter: jest.fn(),
getRoutes: jest.fn(),
};
const renderedHook = renderHook(() => useMockAppV1(), {
wrapper: ({ children }) => (
<AppContextProvider appContext={mockContext} children={children} />
),
});
const result = renderedHook.result.current;
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');
expect(mockContext.getPlugins).toHaveBeenCalledTimes(0);
result.getPlugins();
expect(mockContext.getPlugins).toHaveBeenCalledTimes(1);
expect(mockContext.getProvider).toHaveBeenCalledTimes(0);
result.getProvider();
expect(mockContext.getProvider).toHaveBeenCalledTimes(1);
expect(mockContext.getRouter).toHaveBeenCalledTimes(0);
result.getRouter();
expect(mockContext.getRouter).toHaveBeenCalledTimes(1);
expect(mockContext.getRoutes).toHaveBeenCalledTimes(0);
result.getRoutes();
expect(mockContext.getRoutes).toHaveBeenCalledTimes(1);
});
});
+60
View File
@@ -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,
PropsWithChildren,
useContext,
Context,
} from 'react';
import {
VersionedValue,
createVersionedValueMap,
} from '../lib/versionedValues';
import { getGlobalSingleton, setGlobalSingleton } from '../lib/globalObject';
import { AppContext as AppContextV1 } from './types';
type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined;
const AppContext = createContext<AppContextType | undefined>(undefined);
setGlobalSingleton('app-context', AppContext);
type Props = {
appContext: AppContextV1;
};
export const AppContextProvider = ({
appContext,
children,
}: PropsWithChildren<Props>) => {
const versionedValue = createVersionedValueMap({ 1: appContext });
return <AppContext.Provider value={versionedValue} children={children} />;
};
export const useApp = (): AppContextV1 => {
const versionedContext = useContext(
getGlobalSingleton<Context<AppContextType>>('app-context'),
);
if (!versionedContext) {
throw new Error('No app context available');
}
const appContext = versionedContext.atVersion(1);
if (!appContext) {
throw new Error('AppContext v1 not available');
}
return appContext;
};
+85
View File
@@ -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 '../apis';
import { SignInResult } from './types';
/**
* Implementation of the connection between the App-wide IdentityApi
* and sign-in page.
*/
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private profile?: ProfileInfo;
private idTokenFunc?: () => Promise<string>;
private signOutFunc?: () => Promise<void>;
getUserId(): string {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi userId before app was loaded',
);
}
return this.userId!;
}
getProfile(): ProfileInfo {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi profile before app was loaded',
);
}
return this.profile!;
}
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idTokenFunc?.();
}
async signOut(): Promise<void> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi signOutFunc before app was loaded',
);
}
await this.signOutFunc?.();
location.reload();
}
// This is indirectly called by the sign-in page to continue into the app.
setSignInResult(result: SignInResult) {
if (this.hasIdentity) {
return;
}
if (!result.userId) {
throw new Error('Invalid sign-in result, userId not set');
}
if (!result.profile) {
throw new Error('Invalid sign-in result, profile not set');
}
this.hasIdentity = true;
this.userId = result.userId;
this.profile = result.profile;
this.idTokenFunc = result.getIdToken;
this.signOutFunc = result.signOut;
}
}
@@ -0,0 +1,97 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useMemo, useEffect, useState, PropsWithChildren } from 'react';
import { ThemeProvider, CssBaseline } from '@material-ui/core';
import { useApi, appThemeApiRef, AppTheme } from '../apis';
import { useObservable } from 'react-use';
// This tries to find the most accurate match, but also falls back to less
// accurate results in order to avoid errors.
function resolveTheme(
themeId: string | undefined,
shouldPreferDark: boolean,
themes: AppTheme[],
) {
if (themeId !== undefined) {
const selectedTheme = themes.find(theme => theme.id === themeId);
if (selectedTheme) {
return selectedTheme;
}
}
if (shouldPreferDark) {
const darkTheme = themes.find(theme => theme.variant === 'dark');
if (darkTheme) {
return darkTheme;
}
}
const lightTheme = themes.find(theme => theme.variant === 'light');
if (lightTheme) {
return lightTheme;
}
return themes[0];
}
const useShouldPreferDarkTheme = () => {
const mediaQuery = useMemo(
() => window.matchMedia('(prefers-color-scheme: dark)'),
[],
);
const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches);
useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setPrefersDark(event.matches);
};
mediaQuery.addListener(listener);
return () => {
mediaQuery.removeListener(listener);
};
}, [mediaQuery]);
return shouldPreferDark;
};
export function AppThemeProvider({ children }: PropsWithChildren<{}>) {
const appThemeApi = useApi(appThemeApiRef);
const themeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
// Browser feature detection won't change over time, so ignore lint rule
const shouldPreferDark = Boolean(window.matchMedia)
? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks
: false;
const appTheme = resolveTheme(
themeId,
shouldPreferDark,
appThemeApi.getInstalledThemes(),
);
if (!appTheme) {
throw new Error('App has no themes');
}
return (
<ThemeProvider theme={appTheme.theme}>
<CssBaseline>{children}</CssBaseline>
</ThemeProvider>
);
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useApp } from './AppContext';
export * from './types';
+257
View File
@@ -0,0 +1,257 @@
/*
* 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 { IconComponent, IconComponentMap, IconKey } from '../icons';
import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types';
import { ExternalRouteRef, RouteRef } from '../routing';
import { AnyApiFactory } from '../apis';
import { AppTheme, ProfileInfo } from '../apis/definitions';
import { AppConfig } from '@backstage/config';
import { SubRouteRef } from '../routing/types';
export type BootErrorPageProps = {
step: 'load-config';
error: Error;
};
export type SignInResult = {
/**
* User ID that will be returned by the IdentityApi
*/
userId: string;
profile: ProfileInfo;
/**
* Function used to retrieve an ID token for the signed in user.
*/
getIdToken?: () => Promise<string>;
/**
* Sign out handler that will be called if the user requests to sign out.
*/
signOut?: () => Promise<void>;
};
export type SignInPageProps = {
/**
* Set the sign-in result for the app. This should only be called once.
*/
onResult(result: SignInResult): void;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
/**
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
*
* If a sign-in page is set, it will always be shown before the app, and it is up
* to the sign-in page to handle e.g. saving of login methods for subsequent visits.
*
* The sign-in page will be displayed until it has passed up a result to the parent,
* and which point the AppRouter and all of its children will be rendered instead.
*/
SignInPage?: ComponentType<SignInPageProps>;
};
/**
* A function that loads in the App config that will be accessible via the ConfigApi.
*
* If multiple config objects are returned in the array, values in the earlier configs
* will override later ones.
*/
export type AppConfigLoader = () => Promise<AppConfig[]>;
/**
* Extracts a union of the keys in a map whose value extends the given type
*/
type KeysWithType<Obj extends { [key in string]: any }, Type> = {
[key in keyof Obj]: Obj[key] extends Type ? key : never;
}[keyof Obj];
/**
* Takes a map Map required values and makes all keys matching Keys optional
*/
type PartialKeys<
Map extends { [name in string]: any },
Keys extends keyof Map
> = Partial<Pick<Map, Keys>> & Required<Omit<Map, Keys>>;
/**
* Creates a map of target routes with matching parameters based on a map of external routes.
*/
type TargetRouteMap<ExternalRoutes extends AnyExternalRoutes> = {
[name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef<
infer Params,
any
>
? RouteRef<Params> | SubRouteRef<Params>
: never;
};
export type AppRouteBinder = <ExternalRoutes extends AnyExternalRoutes>(
externalRoutes: ExternalRoutes,
targetRoutes: PartialKeys<
TargetRouteMap<ExternalRoutes>,
KeysWithType<ExternalRoutes, ExternalRouteRef<any, true>>
>,
) => void;
export type AppOptions = {
/**
* A collection of ApiFactories to register in the application to either
* add add new ones, or override factories provided by default or by plugins.
*/
apis?: Iterable<AnyApiFactory>;
/**
* Supply icons to override the default ones.
*/
icons?: IconComponentMap;
/**
* A list of all plugins to include in the app.
*/
plugins?: BackstagePlugin<any, any>[];
/**
* Supply components to the app to override the default ones.
*/
components?: Partial<AppComponents>;
/**
* Themes provided as a part of the app. By default two themes are included, one
* light variant of the default backstage theme, and one dark.
*
* This is the default config:
*
* ```
* [{
* id: 'light',
* title: 'Light Theme',
* variant: 'light',
* theme: lightTheme,
* icon: <LightIcon />,
* }, {
* id: 'dark',
* title: 'Dark Theme',
* variant: 'dark',
* theme: darkTheme,
* icon: <DarkIcon />,
* }]
* ```
*/
themes?: AppTheme[];
/**
* A function that loads in App configuration that will be accessible via
* the ConfigApi.
*
* Defaults to an empty config.
*
* TODO(Rugvip): Omitting this should instead default to loading in configuration
* that was packaged by the backstage-cli and default docker container boot script.
*/
configLoader?: AppConfigLoader;
/**
* A function that is used to register associations between cross-plugin route
* references, enabling plugins to navigate between each other.
*
* The `bind` function that is passed in should be used to bind all external
* routes of all used plugins.
*
* ```ts
* bindRoutes({ bind }) {
* bind(docsPlugin.externalRoutes, {
* homePage: managePlugin.routes.managePage,
* })
* bind(homePagePlugin.externalRoutes, {
* settingsPage: settingsPlugin.routes.settingsPage,
* })
* }
* ```
*/
bindRoutes?(context: { bind: AppRouteBinder }): void;
};
export type BackstageApp = {
/**
* Returns all plugins registered for the app.
*/
getPlugins(): BackstagePlugin<any, any>[];
/**
* Get a common or custom icon for this app.
*/
getSystemIcon(key: IconKey): 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<{}>;
/**
* Routes component that contains all routes for plugin pages in the app.
*
* @deprecated Registering routes in plugins is deprecated and this method will be removed.
*/
getRoutes(): JSX.Element[];
};
export type AppContext = {
/**
* @deprecated Will be removed
*/
getPlugins(): BackstagePlugin<any, any>[];
/**
* Get a common or custom icon for this app.
*/
getSystemIcon(key: IconKey): IconComponent | undefined;
/**
* Get the components registered for various purposes in the app.
*/
getComponents(): AppComponents;
/**
* @deprecated Will be removed
*/
getProvider(): ComponentType<{}>;
/**
* @deprecated Will be removed
*/
getRouter(): ComponentType<{}>;
/**
* @deprecated Will be removed
*/
getRoutes(): JSX.Element[];
};
@@ -0,0 +1,118 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { attachComponentData, getComponentData } from './componentData';
describe('elementData', () => {
it('should attach a single piece of data', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should attach several distinct pieces of data', () => {
const data1 = { foo: 'bar' };
const data2 = { test: 'value' };
const Component = () => null;
attachComponentData(Component, 'my-data', data1);
attachComponentData(Component, 'second', data2);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data1);
expect(getComponentData(element, 'second')).toBe(data2);
});
it('returns undefined for missing data', () => {
const data = { foo: 'bar' };
const Component1 = () => null;
const Component2 = () => null;
attachComponentData(Component2, 'my-data', data);
const element1 = <Component1 />;
const element2 = <Component2 />;
expect(getComponentData(element1, 'missing')).toBeUndefined();
expect(getComponentData(element2, 'missing')).toBeUndefined();
});
it('should throw when attempting to overwrite data', () => {
const data = { foo: 'bar' };
const MyComponent = () => null;
attachComponentData(MyComponent, 'my-data', data);
expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow(
'Attempted to attach duplicate data "my-data" to component "MyComponent"',
);
});
describe('works across versions', () => {
function getDataSymbol() {
const Component = () => null;
attachComponentData(Component, 'my-data', {});
const [symbol] = Object.getOwnPropertySymbols(Component);
return symbol;
}
it('should should be able to get data from older versions', () => {
const symbol = getDataSymbol();
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect((element as any).type[symbol].map.get('my-data')).toBe(data);
});
it('should should be able to attach data for older versions', () => {
const symbol = getDataSymbol();
const data = { foo: 'bar' };
const Component = () => null;
(Component as any)[symbol] = {
map: new Map([['my-data', data]]),
};
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should be able to get data from newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
const container = (global as any)[
'__@backstage/component-data-store__'
].get(element.type);
expect(container.map.get('my-data')).toBe(data);
});
it('should should be able to attach data for newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
(global as any)['__@backstage/component-data-store__'].set(Component, {
map: new Map([['my-data', data]]),
});
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentType, ReactNode } from 'react';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x
const DATA_KEY = Symbol('backstage-component-data');
type ComponentWithData<P> = ComponentType<P> & {
[DATA_KEY]?: DataContainer;
};
type DataContainer = {
map: Map<string, unknown>;
};
type MaybeComponentNode = ReactNode & {
type?: ComponentType<any> & { [DATA_KEY]?: DataContainer };
};
// The store is bridged across versions using the global object
const store = getOrCreateGlobalSingleton(
'component-data-store',
() => new WeakMap<ComponentType<any>, DataContainer>(),
);
export function attachComponentData<P>(
component: ComponentType<P>,
type: string,
data: unknown,
) {
const dataComponent = component as ComponentWithData<P>;
let container = store.get(component) || dataComponent[DATA_KEY];
if (!container) {
container = { map: new Map() };
store.set(component, container);
dataComponent[DATA_KEY] = container;
}
if (container.map.has(type)) {
const name = component.displayName || component.name;
throw new Error(
`Attempted to attach duplicate data "${type}" to component "${name}"`,
);
}
container.map.set(type, data);
}
export function getComponentData<T>(
node: ReactNode,
type: string,
): T | undefined {
if (!node) {
return undefined;
}
const component = (node as MaybeComponentNode).type;
if (!component) {
return undefined;
}
const container = store.get(component) || component[DATA_KEY];
if (!container) {
return undefined;
}
return container.map.get(type) as T | undefined;
}
@@ -0,0 +1,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 from 'react';
import { createPlugin } from '../plugin';
import { createRouteRef } from '../routing';
import { getComponentData } from './componentData';
import {
createComponentExtension,
createReactExtension,
createRoutableExtension,
} from './extensions';
const plugin = createPlugin({
id: 'my-plugin',
});
describe('extensions', () => {
it('should create a react extension with component data', () => {
const Component = () => <div />;
const extension = createReactExtension({
component: {
sync: Component,
},
data: {
myData: { foo: 'bar' },
},
});
const ExtensionComponent = plugin.provide(extension);
const element = <ExtensionComponent />;
expect(getComponentData(element, 'core.plugin')).toBe(plugin);
expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' });
});
it('should create react extensions of different types', () => {
const Component = () => <div />;
const routeRef = createRouteRef({ path: '/foo', title: 'Foo' });
const extension1 = createComponentExtension({
component: {
sync: Component,
},
});
const extension2 = createRoutableExtension({
component: () => Promise.resolve(Component),
mountPoint: routeRef,
});
const ExtensionComponent1 = plugin.provide(extension1);
const ExtensionComponent2 = plugin.provide(extension2);
const element1 = <ExtensionComponent1 />;
const element2 = <ExtensionComponent2 />;
expect(getComponentData(element1, 'core.plugin')).toBe(plugin);
expect(getComponentData(element2, 'core.plugin')).toBe(plugin);
expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef);
});
});
@@ -0,0 +1,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 React, { lazy, Suspense } from 'react';
import { RouteRef, useRouteRef } from '../routing';
import { attachComponentData } from './componentData';
import { Extension, BackstagePlugin } from '../plugin/types';
type ComponentLoader<T> =
| {
lazy: () => Promise<T>;
}
| {
sync: T;
};
export function createRoutableExtension<
T extends (props: any) => JSX.Element
>(options: {
component: () => Promise<T>;
mountPoint: RouteRef;
}): Extension<T> {
const { component, mountPoint } = options;
return createReactExtension({
component: {
lazy: () =>
component().then(InnerComponent => {
const RoutableExtensionWrapper = ((props: any) => {
// Validate that the routing is wired up correctly in the App.tsx
try {
useRouteRef(mountPoint);
} catch {
throw new Error(
'Routable extension component 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.',
);
}
return <InnerComponent {...props} />;
}) as T;
return RoutableExtensionWrapper;
}),
},
data: {
'core.mountPoint': mountPoint,
},
});
}
export function createComponentExtension<
T extends (props: any) => JSX.Element
>(options: { component: ComponentLoader<T> }): Extension<T> {
const { component } = options;
return createReactExtension({ component });
}
export function createReactExtension<
T extends (props: any) => JSX.Element
>(options: {
component: ComponentLoader<T>;
data?: Record<string, unknown>;
}): Extension<T> {
const { data = {} } = options;
let Component: T;
if ('lazy' in options.component) {
const lazyLoader = options.component.lazy;
Component = (lazy(() =>
lazyLoader().then(component => ({ default: component })),
) as unknown) as T;
} else {
Component = options.component.sync;
}
const componentName =
(Component as { displayName?: string }).displayName ||
Component.name ||
'Component';
return {
expose(plugin: BackstagePlugin<any, any>) {
const Result: any = (props: any) => (
<Suspense fallback="...">
<Component {...props} />
</Suspense>
);
attachComponentData(Result, 'core.plugin', plugin);
for (const [key, value] of Object.entries(data)) {
attachComponentData(Result, key, value);
}
Result.displayName = `Extension(${componentName})`;
return Result;
},
};
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { attachComponentData, getComponentData } from './componentData';
export {
createReactExtension,
createRoutableExtension,
createComponentExtension,
} from './extensions';
@@ -0,0 +1,104 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { Children, isValidElement } from 'react';
import {
childDiscoverer,
createCollector,
traverseElementTree,
} from './traversal';
describe('discovery', () => {
it('should collect element names', () => {
const root = (
<main>
<div>
<h1>Title</h1>
<p>Text</p>
</div>
<hr />
<div>
<h2>Title</h2>
<span>Text</span>
</div>
</main>
);
const { names } = traverseElementTree({
root,
discoverers: [childDiscoverer],
collectors: {
names: createCollector(
() => Array<string>(),
(acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
},
),
},
});
expect(names).toEqual([
'main',
'div',
'hr',
'div',
'h1',
'p',
'h2',
'span',
]);
});
it('should collect element names while skipping one level of children', () => {
const root = (
<main>
<div>
<h1>Title</h1>
<p>Text</p>
</div>
<hr />
<div>
<h2>Title</h2>
<span>Text</span>
</div>
</main>
);
const { names } = traverseElementTree({
root,
discoverers: [
el =>
Children.toArray(el.props.children).flatMap(child =>
isValidElement(child) ? child?.props?.children : [],
),
],
collectors: {
names: createCollector(
() => Array<string>(),
(acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
},
),
},
});
expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']);
});
});
@@ -0,0 +1,138 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isValidElement, ReactNode, ReactElement, Children } from 'react';
export type Discoverer = (element: ReactElement) => ReactNode;
export type Collector<Result, Context> = () => {
accumulator: Result;
visit(
accumulator: Result,
element: ReactElement,
parent: ReactElement | undefined,
context: Context,
): Context;
};
/**
* A function that allows you to traverse a tree of React elements using
* varying methods to discover child nodes and collect data along the way.
*/
export function traverseElementTree<Results>(options: {
root: ReactNode;
discoverers: Discoverer[];
collectors: { [name in keyof Results]: Collector<Results[name], any> };
}): Results {
const visited = new Set();
const collectors: {
[name in string]: ReturnType<Collector<any, any>>;
} = {};
// Bootstrap all collectors, initializing the accumulators and providing the visitor function
for (const name in options.collectors) {
if (options.collectors.hasOwnProperty(name)) {
collectors[name] = options.collectors[name]();
}
}
// Internal representation of an element in the tree that we're iterating over
type QueueItem = {
node: ReactNode;
parent: ReactElement | undefined;
contexts: { [name in string]: unknown };
};
const queue = [
{
node: Children.toArray(options.root),
parent: undefined,
contexts: {},
} as QueueItem,
];
while (queue.length !== 0) {
const { node, parent, contexts } = queue.shift()!;
// While the parent and the element we pass on to collectors and discoverers
// have been validated and are known to be React elements, the child nodes
// emitted by the discoverers are not.
Children.forEach(node, element => {
if (!isValidElement(element)) {
return;
}
if (visited.has(element)) {
const anyType = element?.type as
| { displayName?: string; name?: string }
| undefined;
const name = anyType?.displayName || anyType?.name || String(anyType);
throw new Error(`Visited element ${name} twice`);
}
visited.add(element);
const nextContexts: QueueItem['contexts'] = {};
// Collectors populate their result data using the current node, and compute
// context for the next iteration
for (const name in collectors) {
if (collectors.hasOwnProperty(name)) {
const collector = collectors[name];
nextContexts[name] = collector.visit(
collector.accumulator,
element,
parent,
contexts[name],
);
}
}
// Discoverers provide ways to continue the traversal from the current element
for (const discoverer of options.discoverers) {
const children = discoverer(element);
if (children) {
queue.push({
node: children,
parent: element,
contexts: nextContexts,
});
}
}
});
}
return Object.fromEntries(
Object.entries(collectors).map(([name, c]) => [name, c.accumulator]),
) as Results;
}
export function createCollector<Result, Context>(
accumulatorFactory: () => Result,
visit: ReturnType<Collector<Result, Context>>['visit'],
): Collector<Result, Context> {
return () => ({ accumulator: accumulatorFactory(), visit });
}
export function childDiscoverer(element: ReactElement): ReactNode {
return element.props?.children;
}
export function routeElementDiscoverer(element: ReactElement): ReactNode {
if (element.props?.path && element.props?.element) {
return element.props?.element;
}
return undefined;
}
+60
View File
@@ -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 { SvgIconProps } from '@material-ui/core';
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 React from 'react';
import { useApp } from '../app/AppContext';
import { IconComponent, IconComponentMap, SystemIconKey } from './types';
export const defaultSystemIcons: IconComponentMap = {
brokenImage: MuiBrokenImageIcon,
chat: MuiChatIcon,
dashboard: MuiDashboardIcon,
email: MuiEmailIcon,
github: MuiGitHubIcon,
group: MuiPeopleIcon,
help: MuiHelpIcon,
user: MuiPersonIcon,
warning: MuiWarningIcon,
};
const overridableSystemIcon = (key: SystemIconKey): IconComponent => {
const Component = (props: SvgIconProps) => {
const app = useApp();
const Icon = app.getSystemIcon(key);
return Icon ? <Icon {...props} /> : <MuiBrokenImageIcon {...props} />;
};
return Component;
};
export const BrokenImageIcon = overridableSystemIcon('brokenImage');
export const ChatIcon = overridableSystemIcon('chat');
export const DashboardIcon = overridableSystemIcon('dashboard');
export const EmailIcon = overridableSystemIcon('email');
export const GitHubIcon = overridableSystemIcon('github');
export const GroupIcon = overridableSystemIcon('group');
export const HelpIcon = overridableSystemIcon('help');
export const UserIcon = overridableSystemIcon('user');
export const WarningIcon = overridableSystemIcon('warning');

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