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:
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
@@ -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';
|
||||
+222
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+109
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+96
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user