Merge branch 'master' of github.com:spotify/backstage into ndudnik/use-catalog-backend

# Conflicts:
#	plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
#	plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
#	plugins/catalog/src/components/ComponentPage/ComponentPage.tsx
#	plugins/catalog/src/data/mock-factory.ts
#	plugins/catalog/src/plugin.ts
This commit is contained in:
Nikita Nek Dudnik
2020-05-28 14:28:00 +02:00
200 changed files with 2982 additions and 644 deletions
-1
View File
@@ -8,7 +8,6 @@ Contributions are welcome, and they are greatly appreciated! Every little bit he
Backstage is released under the Apache2.0 License, and original creations contributed to this repo are accepted under the same license.
# Types of Contributions
## Report bugs
+1 -1
View File
@@ -85,7 +85,7 @@ Take a look at the [Getting Started](docs/getting-started/README.md) guide to le
- [Architecture](docs/architecture-terminology.md)
- [API references](docs/reference/README.md)
- [Designing for Backstage](docs/design.md)
- [Storybook - UI components](http://storybook.backstage.io) ([WIP](https://github.com/spotify/backstage/milestone/9))
- [Storybook - UI components](http://storybook.backstage.io)
- [Contributing to Storybook](docs/getting-started/contributing-to-storybook.md)
- Using Backstage components (TODO)
@@ -0,0 +1,71 @@
# ADR004: Module Export Structure
| Created | Status |
| ---------- | ------ |
| 2020-05-27 | Open |
## Context
With a growing number of exports of packages like `@backstage/core`, it is becoming more and more difficult to answer questions such as
> Is the export in this module also exported by the package?
or
> What is exported from this directory?
We currently do not use any pattern for how to structure exports. There is a mix of package-level re-exports deep into the directory tree, shallow re-exports for each directory, exports using `*` and explicit lists of each symbol, etc.
The mix and lack of predictability makes it difficult to reason about the boundaries of a module, and for example knowing whether is is safe to export a symbol in a given file.
## Decision
We will make each exported symbol traceable through index files all the way down to the root of the package, `src/index.ts`. Each index file will only re-export from its own immediate directory children, and only index files will have re-exports. This gives a file tree similar to this:
```text
index.ts
components/index.ts
/ComponentX/index.ts
/ComponentX.tsx
/SubComponentY.tsx
lib/index.ts
/UtilityX/index.ts
/UtilityX.ts
/helper.ts
```
To check whether for example `SubComponentY` is exported from the package, it should be possible to traverse the index files towards the root, starting at the adjacent one. If there is any index file that doesn't export the previous one, the symbol is not publicly exported. For example, if `components/ComponentX/index.ts` exports `SubComponentY`, but `components/index.ts` does not re-export `./ComponentX`, one should be certain that `SubComponentY` is not exported outside the package. This rule would be broken if for example the root `index.ts` re-exports `./components/ComponentX`
In addition, index files that are re-exporting other index files should always use wildcard form, that is:
```ts
// in components/index.ts
export * from './ComponentX';
```
Index files that are re-exporting symbols from non-index files should always enumerate all exports, that is:
```ts
// in components/ComponentX/index.ts
export { ComponentX } from './ComponentX';
export type { ComponentXProps } from './ComponentX';
```
Internal cross-directory imports are allowed from non-index modules to index modules, for example:
```ts
// in components/ComponentX/ComponentX.tsx
import { UtilityX } from '../../lib/UtilityX';
```
Imports that bypass an index file are discouraged, but may sometimes be necessary, for example:
```ts
// in components/ComponentX/ComponentX.tsx
import { helperFunc } from '../../lib/UtilityX/helper';
```
## Consequences
We will actively work to rework the export structure in our codebase, prioritizing the library packages such as `@backstage/core` and `@backstage/backend-common`.
If possible, we will add tools, such as lint rules, to help enforce the export structure.
+3 -2
View File
@@ -55,15 +55,16 @@ import {
errorApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
} from '@backstage/core';
const builder = ApiRegistry.builder();
// The alert API is a self-contained implementation that shows alerts to the user.
builder.add(alertApiRef, new AlertApiForwarder());
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
// The error API uses the alert API to send error notifications to the user.
builder.add(errorApiRef, new ErrorApiForwarder(alertApiForwarder));
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
const app = createApp({
apis: apiBuilder.build(),
-1
View File
@@ -18,7 +18,6 @@
"@backstage/plugin-sentry": "^0.1.1-alpha.6",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
+10 -5
View File
@@ -14,13 +14,17 @@
* limitations under the License.
*/
import { createApp, OAuthRequestDialog } from '@backstage/core';
import {
createApp,
AlertDisplay,
OAuthRequestDialog,
LoginPage,
} from '@backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import Root from './components/Root';
import AlertDisplay from './components/AlertDisplay';
import * as plugins from './plugins';
import apis, { alertApiForwarder } from './apis';
import apis from './apis';
const app = createApp({
apis,
@@ -32,10 +36,11 @@ const AppComponent = app.getRootComponent();
const App: FC<{}> = () => (
<AppProvider>
<AlertDisplay forwarder={alertApiForwarder} />
<AlertDisplay />
<OAuthRequestDialog />
<Router>
<Root>
<Route key="login" path="/login" component={LoginPage} exact />
<AppComponent />
</Root>
</Router>
+3 -4
View File
@@ -21,6 +21,7 @@ import {
errorApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
featureFlagsApiRef,
FeatureFlags,
GoogleAuth,
@@ -41,11 +42,9 @@ import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
const builder = ApiRegistry.builder();
export const alertApiForwarder = new AlertApiForwarder();
builder.add(alertApiRef, alertApiForwarder);
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
export const errorApiForwarder = new ErrorApiForwarder(alertApiForwarder);
builder.add(errorApiRef, errorApiForwarder);
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
builder.add(circleCIApiRef, new CircleCIApi());
builder.add(featureFlagsApiRef, new FeatureFlags());
File diff suppressed because one or more lines are too long
@@ -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 React, { FC } from 'react';
import { makeStyles } from '@material-ui/core';
const useStyles = makeStyles({
svg: {
width: 'auto',
height: 28,
},
path: {
fill: '#7df3e1',
},
});
const LogoIcon: FC<{}> = () => {
const classes = useStyles();
return (
<svg
className={classes.svg}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 337.46 428.5"
>
<path
className={classes.path}
d="M303,166.05a80.69,80.69,0,0,0,13.45-10.37c.79-.77,1.55-1.53,2.3-2.3a83.12,83.12,0,0,0,7.93-9.38A63.69,63.69,0,0,0,333,133.23a48.58,48.58,0,0,0,4.35-16.4c1.49-19.39-10-38.67-35.62-54.22L198.56,0,78.3,115.23,0,190.25l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.69,19.16-18.36,25.52-42.12,13.7-61.87a49.22,49.22,0,0,0-6.8-8.87A89.17,89.17,0,0,0,259,178.29h.15a85.08,85.08,0,0,0,31-5.79A80.88,80.88,0,0,0,303,166.05ZM202.45,225.86c-19.32,18.51-50.4,21.23-75.7,5.9L51.61,186.15l67.45-64.64,76.41,46.38C223,184.58,221.49,207.61,202.45,225.86Zm8.93-82.22-70.65-42.89L205.14,39,274.51,81.1c25.94,15.72,29.31,37,10.55,55A60.69,60.69,0,0,1,211.38,143.64Zm29.86,190c-19.57,18.75-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,282.52v24.67L108.6,373.1a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A87.27,87.27,0,0,1,241.24,333.68Zm0-39c-19.57,18.75-46.17,29.08-74.88,29.08a123.81,123.81,0,0,1-64.1-18.19L0,243.53v24.68l108.6,65.91a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.5v-1.78A87.27,87.27,0,0,1,241.24,294.7Zm0-39c-19.57,18.76-46.17,29.09-74.88,29.09a123.81,123.81,0,0,1-64.1-18.19L0,204.55v24.68l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.68,12.88-12.35,20-27.13,19.68-41.5v-1.82A86.09,86.09,0,0,1,241.24,255.71Zm83.7,25.74a94.15,94.15,0,0,1-60.2,25.86h0V334a81.6,81.6,0,0,0,51.74-22.37c14-13.38,21.14-28.11,21-42.64v-2.19A94.92,94.92,0,0,1,324.94,281.45Zm-83.7,91.21c-19.57,18.76-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,321.5v24.68l108.6,65.9a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.8,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A86.29,86.29,0,0,1,241.24,372.66ZM327,162.45c-.68.69-1.35,1.38-2.05,2.06a94.37,94.37,0,0,1-10.64,8.65,91.35,91.35,0,0,1-11.6,7,94.53,94.53,0,0,1-26.24,8.71,97.69,97.69,0,0,1-14.16,1.57c.5,1.61.9,3.25,1.25,4.9a53.27,53.27,0,0,1,1.14,12V217h.05a84.41,84.41,0,0,0,25.35-5.55,81,81,0,0,0,26.39-16.82c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,172.17a48.55,48.55,0,0,0,4.32-16.45c.09-1.23.2-2.47.19-3.7V150q-1.08,1.54-2.25,3.09A96.73,96.73,0,0,1,327,162.45Zm0,77.92c-.69.7-1.31,1.41-2,2.1a94.2,94.2,0,0,1-60.2,25.86h0l0,26.67h0a81.6,81.6,0,0,0,51.74-22.37A73.51,73.51,0,0,0,333,250.13a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.47.19-3.71v-2.19c-.74,1.07-1.46,2.15-2.27,3.21A95.68,95.68,0,0,1,327,240.37Zm0-39c-.69.7-1.31,1.41-2,2.1a93.18,93.18,0,0,1-10.63,8.65,91.63,91.63,0,0,1-11.63,7,95.47,95.47,0,0,1-37.94,10.18h0V256h0a81.65,81.65,0,0,0,51.74-22.37c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,211.15a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.48.19-3.71v-2.2c-.74,1.08-1.46,2.16-2.27,3.22A95.68,95.68,0,0,1,327,201.39Z"
/>
</svg>
);
};
export default LogoIcon;
+7 -24
View File
@@ -16,12 +16,12 @@
import React, { FC, useContext } from 'react';
import PropTypes from 'prop-types';
import { Link, makeStyles, Typography } from '@material-ui/core';
import { Link, makeStyles } from '@material-ui/core';
import HomeIcon from '@material-ui/icons/Home';
import ExploreIcon from '@material-ui/icons/Explore';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import AccountTreeIcon from '@material-ui/icons/AccountTree';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import {
Sidebar,
SidebarPage,
@@ -44,21 +44,9 @@ const useSidebarLogoStyles = makeStyles({
alignItems: 'center',
marginBottom: -14,
},
logoContainer: {
link: {
width: sidebarConfig.drawerWidthClosed,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: sidebarConfig.logoHeight,
fontWeight: 'bold',
marginLeft: 20,
whiteSpace: 'nowrap',
color: '#fff',
},
titleDot: {
color: '#68c5b5',
marginLeft: 24,
},
});
@@ -68,11 +56,8 @@ const SidebarLogo: FC<{}> = () => {
return (
<div className={classes.root}>
<Link href="/" underline="none">
<Typography variant="h6" color="inherit" className={classes.title}>
{isOpen ? 'Backstage' : 'B'}
<span className={classes.titleDot}>.</span>
</Typography>
<Link href="/" underline="none" className={classes.link}>
{isOpen ? <LogoFull /> : <LogoIcon />}
</Link>
</div>
);
@@ -96,8 +81,6 @@ const Root: FC<{}> = ({ children }) => (
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarItem icon={AccountTreeIcon} to="/catalog" text="Catalog" />
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
+1 -2
View File
@@ -40,8 +40,7 @@
"@types/helmet": "^0.0.47",
"jest": "^26.0.1",
"tsc-watch": "^4.2.3",
"typescript": "^3.9.2",
"winston": "^3.2.1"
"typescript": "^3.9.2"
},
"nodemonConfig": {
"watch": "./dist"
+5 -1
View File
@@ -58,9 +58,13 @@ function createEnv(plugin: string): PluginEnvironment {
async function main() {
const app = express();
const corsOptions: cors.CorsOptions = {
origin: 'http://localhost:3000',
credentials: true,
};
app.use(helmet());
app.use(cors());
app.use(cors(corsOptions));
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
+1 -1
View File
@@ -36,7 +36,7 @@ export default async function ({ logger, database }: PluginEnvironment) {
);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db, reader);
return await createRouter({ entitiesCatalog, locationsCatalog, logger });
}
+1 -1
View File
@@ -17,6 +17,6 @@
import { createRouter } from '@backstage/plugin-sentry-backend';
import { Logger } from 'winston';
export default async function(logger: Logger) {
export default async function (logger: Logger) {
return await createRouter(logger);
}
+1 -1
View File
@@ -62,7 +62,7 @@
"react-hot-loader": "^4.12.21",
"recursive-readdir": "^2.2.2",
"replace-in-file": "^6.0.0",
"rollup": "^2.3.2",
"rollup": "2.10.x",
"rollup-plugin-dts": "^1.4.6",
"rollup-plugin-esbuild": "^1.4.1",
"rollup-plugin-image-files": "^1.4.2",
@@ -62,7 +62,7 @@ async function buildApp(appDir: string) {
await Task.forItem('executing', cmd, async () => {
process.chdir(appDir);
await exec(cmd).catch((error) => {
await exec(cmd).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(`Could not execute command ${chalk.cyan(cmd)}`);
@@ -81,7 +81,7 @@ export async function moveApp(
id: string,
) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch((error) => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
`Failed to move app from ${tempDir} to ${destination}: ${error.message}`,
);
@@ -107,7 +107,7 @@ export async function addPluginDependencyToApp(
packageFileJson.dependencies = sortObjectByKeys(dependencies);
const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`;
await fs.writeFile(packageFile, newContents, 'utf-8').catch((error) => {
await fs.writeFile(packageFile, newContents, 'utf-8').catch(error => {
throw new Error(
`Failed to add plugin as dependency to app: ${packageFile}: ${error.message}`,
);
@@ -119,14 +119,14 @@ export async function addPluginToApp(rootDir: string, pluginName: string) {
const pluginPackage = `@backstage/plugin-${pluginName}`;
const pluginNameCapitalized = pluginName
.split('-')
.map((name) => capitalize(name))
.map(name => capitalize(name))
.join('');
const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`;
const pluginsFilePath = 'packages/app/src/plugins.ts';
const pluginsFile = resolvePath(rootDir, pluginsFilePath);
await Task.forItem('processing', pluginsFilePath, async () => {
await addExportStatement(pluginsFile, pluginExport).catch((error) => {
await addExportStatement(pluginsFile, pluginExport).catch(error => {
throw new Error(
`Failed to import plugin in app: ${pluginsFile}: ${error.message}`,
);
@@ -148,7 +148,7 @@ async function buildPlugin(pluginFolder: string) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
await exec(command).catch((error) => {
await exec(command).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(`Could not execute command ${chalk.cyan(command)}`);
@@ -163,7 +163,7 @@ export async function movePlugin(
id: string,
) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch((error) => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
`Failed to move plugin from ${tempDir} to ${destination}: ${error.message}`,
);
+1 -1
View File
@@ -48,7 +48,7 @@ export async function buildBundle(options: BuildOptions) {
const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
await fs.emptyDir(paths.targetDist);
const { stats } = await build(compiler, isCi).catch((error) => {
const { stats } = await build(compiler, isCi).catch(error => {
console.log(chalk.red('Failed to compile.\n'));
throw new Error(`Failed to compile.\n${error.message || error}`);
});
+7 -6
View File
@@ -73,7 +73,7 @@ export async function templatingTask(
destinationDir: string,
context: any,
) {
const files = await recursive(templateDir).catch((error) => {
const files = await recursive(templateDir).catch(error => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
@@ -89,7 +89,7 @@ export async function templatingTask(
const compiled = handlebars.compile(template.toString());
const contents = compiled({ name: basename(destination), ...context });
await fs.writeFile(destination, contents).catch((error) => {
await fs.writeFile(destination, contents).catch(error => {
throw new Error(
`Failed to create file: ${destination}: ${error.message}`,
);
@@ -97,7 +97,7 @@ export async function templatingTask(
});
} else {
await Task.forItem('copying', basename(file), async () => {
await fs.copyFile(file, destinationFile).catch((error) => {
await fs.copyFile(file, destinationFile).catch(error => {
const destination = destinationFile;
throw new Error(
`Failed to copy file to ${destination} : ${error.message}`,
@@ -112,6 +112,7 @@ export async function templatingTask(
const PATCH_PACKAGES = [
'cli',
'core',
'core-api',
'dev-utils',
'test-utils',
'test-utils-core',
@@ -145,7 +146,7 @@ export async function installWithLocalDeps(dir: string) {
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
.catch((error) => {
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
@@ -157,7 +158,7 @@ export async function installWithLocalDeps(dir: string) {
}
await Task.forItem('executing', 'yarn install', async () => {
await exec('yarn install', { cwd: dir }).catch((error) => {
await exec('yarn install', { cwd: dir }).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(
@@ -193,7 +194,7 @@ export async function installWithLocalDeps(dir: string) {
await fs
.writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
.catch((error) => {
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
@@ -4,7 +4,7 @@ import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import * as plugins from './plugins';
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
'@global': {
html: {
height: '100%',
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
// TODO: add prop types to JS and remove
'react/prop-types': 0,
'jest/expect-expect': 0,
},
};
+22
View File
@@ -0,0 +1,22 @@
# @backstage/core-api
This package provides the core API used by Backstage plugins and apps.
## Installation
Do not install this package directly, it is reexported by `@backstage/core`. Install that instead:
```sh
$ npm install --save @backstage/core
```
or
```sh
$ yarn add @backstage/core
```
## Documentation
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@backstage/core-api",
"description": "Internal Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.6",
"private": false,
"publishConfig": {
"access": "public"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/spotify/backstage",
"directory": "packages/core-api"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/theme": "^0.1.1-alpha.6",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "^5.2.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@backstage/test-utils-core": "^0.1.1-alpha.6",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
@@ -53,7 +53,7 @@ export function withApis<T>(apis: TypesToApiRefs<T>) {
return function withApisWrapper<P extends T>(
WrappedComponent: React.ComponentType<P>,
) {
const Hoc: FC<Omit<P, keyof T>> = (props) => {
const Hoc: FC<Omit<P, keyof T>> = props => {
const apiHolder = useContext(Context);
if (!apiHolder) {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
import { Observable } from '../../types';
export type AlertMessage = {
message: string;
@@ -30,6 +31,11 @@ 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 = createApiRef<AlertApi>({
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '../ApiRef';
import { Observable } from '../../types';
/**
* Mirrors the javascript Error class, for the purpose of
@@ -54,6 +55,11 @@ 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 = createApiRef<ErrorApi>({
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { IconComponent } from '../../../icons';
import { IconComponent } from '../../icons';
import { Observable } from '../../types';
import { createApiRef } from '../ApiRef';
@@ -85,7 +85,10 @@ export type OAuthApi = {
* 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): Promise<string>;
getAccessToken(
scope?: OAuthScope,
options?: AccessTokenOptions,
): Promise<string>;
/**
* Log out the user's session. This will reload the page.
@@ -13,23 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AlertApi, AlertMessage } from '../../../';
type SubscriberFunc = (message: AlertMessage) => void;
type Unsubscribe = () => void;
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 subscribers = new Set<SubscriberFunc>();
private readonly subject = new PublishSubject<AlertMessage>();
post(alert: AlertMessage) {
this.subscribers.forEach(subscriber => subscriber(alert));
this.subject.next(alert);
}
subscribe(func: SubscriberFunc): Unsubscribe {
this.subscribers.add(func);
return () => {
this.subscribers.delete(func);
};
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';
@@ -15,7 +15,7 @@
*/
import { AppThemeApi, AppTheme } from '../../definitions';
import { BehaviorSubject } from '../lib';
import { BehaviorSubject } from '../../../lib';
import { Observable } from '../../../types';
const STORAGE_KEY = 'theme';
@@ -33,7 +33,7 @@ export class AppThemeSelector implements AppThemeApi {
selector.setActiveThemeId(initialThemeId);
selector.activeThemeId$().subscribe((themeId) => {
selector.activeThemeId$().subscribe(themeId => {
if (themeId) {
window.localStorage.setItem(STORAGE_KEY, themeId);
} else {
@@ -41,7 +41,7 @@ export class AppThemeSelector implements AppThemeApi {
}
});
window.addEventListener('storage', (event) => {
window.addEventListener('storage', event => {
if (event.key === STORAGE_KEY) {
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(themeId);
@@ -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$();
}
}
@@ -13,33 +13,24 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ErrorApi, ErrorContext, AlertApi } from '../../../';
type SubscriberFunc = (error: Error) => void;
type Unsubscribe = () => void;
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 subscribers = new Set<SubscriberFunc>();
private alertApi: AlertApi;
constructor(alertApi: AlertApi) {
this.alertApi = alertApi;
}
private readonly subject = new PublishSubject<{
error: Error;
context?: ErrorContext;
}>();
post(error: Error, context?: ErrorContext) {
if (context?.hidden) {
return;
}
this.alertApi.post({ message: error.message, severity: 'error' });
this.subscribers.forEach(subscriber => subscriber(error));
this.subject.next({ error, context });
}
subscribe(func: SubscriberFunc): Unsubscribe {
this.subscribers.add(func);
return () => {
this.subscribers.delete(func);
};
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';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { BehaviorSubject } from '../lib';
import { BehaviorSubject } from '../../../lib';
import { Observable } from '../../../types';
type RequestQueueEntry<ResultType> = {
@@ -21,7 +21,7 @@ import {
AuthRequesterOptions,
} from '../../definitions';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
import { BehaviorSubject } from '../lib';
import { BehaviorSubject } from '../../../lib';
import { Observable } from '../../../types';
/**
@@ -15,16 +15,17 @@
*/
import GoogleIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../lib/AuthConnector';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GoogleSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
IdTokenOptions,
AccessTokenOptions,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth
@@ -40,7 +41,7 @@ type CreateOptions = {
export type GoogleAuthResponse = {
accessToken: string;
idToken: string;
scopes: string;
scope: string;
expiresInSeconds: number;
};
@@ -70,7 +71,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
return {
idToken: res.idToken,
accessToken: res.accessToken,
scopes: GoogleAuth.normalizeScopes(res.scopes),
scopes: GoogleAuth.normalizeScopes(res.scope),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
};
},
@@ -95,19 +96,23 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(scope?: string | string[]) {
async getAccessToken(
scope?: string | string[],
options?: AccessTokenOptions,
) {
const normalizedScopes = GoogleAuth.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
optional: false,
...options,
scopes: normalizedScopes,
});
return session.accessToken;
if (session) {
return session.accessToken;
}
return '';
}
async getIdToken({ optional }: IdTokenOptions = {}) {
const session = await this.sessionManager.getSession({
optional: optional || false,
});
async getIdToken(options: IdTokenOptions = {}) {
const session = await this.sessionManager.getSession(options);
if (session) {
return session.idToken;
}
@@ -19,7 +19,8 @@
// Plugins should rely on these APIs for functionality as much as possible.
export * from './auth';
export * from './AppThemeSelector';
export * from './AlertApiForwarder';
export * from './ErrorApiForwarder';
export * from './OAuthRequestManager';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ErrorApi';
export * from './OAuthRequestApi';
@@ -17,19 +17,13 @@
import React, { ComponentType, FC } from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { BackstageApp, AppOptions, AppComponents } from './types';
import { BackstageApp, AppComponents } from './types';
import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
import ErrorPage from '../../layout/ErrorPage';
import { AppThemeProvider } from './AppThemeProvider';
import {
IconComponent,
SystemIcons,
SystemIconKey,
defaultSystemIcons,
} from '../../icons';
import { IconComponent, SystemIcons, SystemIconKey } from '../icons';
import {
ApiHolder,
ApiProvider,
@@ -38,8 +32,6 @@ import {
AppThemeSelector,
appThemeApiRef,
} from '../apis';
import LoginPage from './LoginPage';
import { lightTheme, darkTheme } from '@backstage/theme';
import { ApiAggregator } from '../apis/ApiAggregator';
type FullAppOptions = {
@@ -50,7 +42,7 @@ type FullAppOptions = {
themes: AppTheme[];
};
class AppImpl implements BackstageApp {
export class PrivateAppImpl implements BackstageApp {
private readonly apis: ApiHolder;
private readonly icons: SystemIcons;
private readonly plugins: BackstagePlugin[];
@@ -138,10 +130,6 @@ class AppImpl implements BackstageApp {
FeatureFlags.registeredFeatureFlags = registeredFeatureFlags;
}
routes.push(
<Route key="login" path="/login" component={LoginPage} exact />,
);
const rendered = (
<Switch>
{routes}
@@ -180,40 +168,3 @@ class AppImpl implements BackstageApp {
}
}
}
/**
* Creates a new Backstage App.
*/
export function createApp(options?: AppOptions) {
const DefaultNotFoundPage = () => (
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
);
const apis = options?.apis ?? ApiRegistry.from([]);
const icons = { ...defaultSystemIcons, ...options?.icons };
const plugins = options?.plugins ?? [];
const components = {
NotFoundErrorPage: DefaultNotFoundPage,
...options?.components,
};
const themes = options?.themes ?? [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
},
{
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
theme: darkTheme,
},
];
const app = new AppImpl({ apis, icons, plugins, components, themes });
app.verify();
return app;
}
@@ -27,20 +27,20 @@ function resolveTheme(
themes: AppTheme[],
) {
if (themeId !== undefined) {
const selectedTheme = themes.find((theme) => theme.id === themeId);
const selectedTheme = themes.find(theme => theme.id === themeId);
if (selectedTheme) {
return selectedTheme;
}
}
if (shouldPreferDark) {
const darkTheme = themes.find((theme) => theme.variant === 'dark');
const darkTheme = themes.find(theme => theme.variant === 'dark');
if (darkTheme) {
return darkTheme;
}
}
const lightTheme = themes.find((theme) => theme.variant === 'light');
const lightTheme = themes.find(theme => theme.variant === 'light');
if (lightTheme) {
return lightTheme;
}
@@ -147,7 +147,7 @@ describe('FeatureFlags', () => {
it('should get the correct values', () => {
const getByName = (name: string) =>
featureFlags.getRegisteredFlags().find((flag) => flag.name === name);
featureFlags.getRegisteredFlags().find(flag => flag.name === name);
expect(getByName('registered-flag-0')).toBeUndefined();
expect(getByName('registered-flag-1')).toEqual({
@@ -127,12 +127,12 @@ export interface FeatureFlagsRegistryItem {
export class FeatureFlagsRegistry extends Array<FeatureFlagsRegistryItem> {
static from(entries: FeatureFlagsRegistryItem[]) {
Array.from(entries).forEach((entry) => validateFlagName(entry.name));
Array.from(entries).forEach(entry => validateFlagName(entry.name));
return new FeatureFlagsRegistry(...entries);
}
push(...entries: FeatureFlagsRegistryItem[]): number {
Array.from(entries).forEach((entry) => validateFlagName(entry.name));
Array.from(entries).forEach(entry => validateFlagName(entry.name));
return super.push(...entries);
}
@@ -143,7 +143,7 @@ export class FeatureFlagsRegistry extends Array<FeatureFlagsRegistryItem> {
)[]
): FeatureFlagsRegistryItem[] {
const _concat = super.concat(...entries);
Array.from(_concat).forEach((entry) => validateFlagName(entry.name));
Array.from(_concat).forEach(entry => validateFlagName(entry.name));
return _concat;
}
@@ -14,7 +14,6 @@
* limitations under the License.
*/
export { createApp } from './App';
export { FeatureFlags } from './FeatureFlags';
export { useApp } from './AppContext';
export * from './types';
@@ -15,7 +15,7 @@
*/
import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../../icons';
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme } from '../apis/definitions';
+39
View File
@@ -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 { SvgIconProps } from '@material-ui/core';
import PeopleIcon from '@material-ui/icons/People';
import PersonIcon from '@material-ui/icons/Person';
import React, { FC } from 'react';
import { useApp } from '../app/AppContext';
import { IconComponent, SystemIconKey, SystemIcons } from './types';
export const defaultSystemIcons: SystemIcons = {
user: PersonIcon,
group: PeopleIcon,
};
const overridableSystemIcon = (key: SystemIconKey): IconComponent => {
const Component: FC<SvgIconProps> = props => {
const app = useApp();
const Icon = app.getSystemIcon(key);
return <Icon {...props} />;
};
return Component;
};
export const UserIcon = overridableSystemIcon('user');
export const GroupIcon = overridableSystemIcon('group');
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './icons';
export * from './types';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentType } from 'react';
import { SvgIconProps } from '@material-ui/core';
export type IconComponent = ComponentType<SvgIconProps>;
export type SystemIconKey = 'user' | 'group';
export type SystemIcons = { [key in SystemIconKey]: IconComponent };
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './public';
import * as privateExports from './private';
export default privateExports;
@@ -16,7 +16,7 @@
import ProviderIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from './DefaultAuthConnector';
import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi';
import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi';
import * as loginPopup from '../loginPopup';
const anyFetch = fetch as any;
@@ -86,7 +86,7 @@ describe('DefaultAuthConnector', () => {
...defaultOptions,
oauthRequestApi: mockOauth,
});
const promise = helper.createSession(new Set(['a', 'b']));
const promise = helper.createSession({ scopes: new Set(['a', 'b']) });
await mockOauth.rejectAll();
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
});
@@ -106,7 +106,9 @@ describe('DefaultAuthConnector', () => {
oauthRequestApi: mockOauth,
});
const sessionPromise = helper.createSession(new Set(['a', 'b']));
const sessionPromise = helper.createSession({
scopes: new Set(['a', 'b']),
});
await mockOauth.triggerAll();
@@ -123,6 +125,26 @@ describe('DefaultAuthConnector', () => {
});
});
it('should instantly show popup if option is set', async () => {
const popupSpy = jest
.spyOn(loginPopup, 'showLoginPopup')
.mockResolvedValue('my-session');
const helper = new DefaultAuthConnector({
...defaultOptions,
oauthRequestApi: new MockOAuthApi(),
sessionTransform: str => str,
});
const sessionPromise = helper.createSession({
scopes: new Set(),
instantPopup: true,
});
expect(popupSpy).toBeCalledTimes(1);
await expect(sessionPromise).resolves.toBe('my-session');
});
it('should use join func to join scopes', async () => {
const mockOauth = new MockOAuthApi();
const popupSpy = jest
@@ -134,7 +156,7 @@ describe('DefaultAuthConnector', () => {
oauthRequestApi: mockOauth,
});
helper.createSession(new Set(['a', 'b']));
helper.createSession({ scopes: new Set(['a', 'b']) });
await mockOauth.triggerAll();
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { AuthRequester } from '../../..';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { AuthRequester } from '../../apis';
import { OAuthRequestApi, AuthProvider } from '../../apis/definitions';
import { showLoginPopup } from '../loginPopup';
import { AuthConnector } from './types';
import { AuthConnector, CreateSessionOptions } from './types';
const DEFAULT_BASE_PATH = '/api/auth/';
@@ -68,6 +68,7 @@ export class DefaultAuthConnector<AuthSession>
private readonly basePath: string;
private readonly environment: string;
private readonly provider: AuthProvider & { id: string };
private readonly joinScopesFunc: (scopes: Set<string>) => string;
private readonly authRequester: AuthRequester<AuthSession>;
private readonly sessionTransform: (response: any) => Promise<AuthSession>;
@@ -84,22 +85,26 @@ export class DefaultAuthConnector<AuthSession>
this.authRequester = oauthRequestApi.createAuthRequester({
provider,
onAuthRequest: scopes => this.showPopup(joinScopes(scopes)),
onAuthRequest: scopes => this.showPopup(scopes),
});
this.apiOrigin = apiOrigin;
this.basePath = basePath;
this.environment = environment;
this.provider = provider;
this.joinScopesFunc = joinScopes;
this.sessionTransform = sessionTransform;
}
async createSession(scopes: Set<string>): Promise<AuthSession> {
return this.authRequester(scopes);
async createSession(options: CreateSessionOptions): Promise<AuthSession> {
if (options.instantPopup) {
return this.showPopup(options.scopes);
}
return this.authRequester(options.scopes);
}
async refreshSession(): Promise<any> {
const res = await fetch(this.buildUrl('/token', { optional: true }), {
const res = await fetch(this.buildUrl('/refresh', { optional: true }), {
headers: {
'x-requested-with': 'XMLHttpRequest',
},
@@ -142,7 +147,8 @@ export class DefaultAuthConnector<AuthSession>
}
}
private async showPopup(scope: string): Promise<AuthSession> {
private async showPopup(scopes: Set<string>): Promise<AuthSession> {
const scope = this.joinScopesFunc(scopes);
const popupUrl = this.buildUrl('/start', { scope });
const payload = await showLoginPopup({
@@ -14,12 +14,17 @@
* limitations under the License.
*/
export type CreateSessionOptions = {
scopes: Set<string>;
instantPopup?: boolean;
};
/**
* An AuthConnector is responsible for realizing auth session actions
* by for example communicating with a backend or interacting with the user.
*/
export type AuthConnector<AuthSession> = {
createSession(scopes: Set<string>): Promise<AuthSession>;
createSession(options: CreateSessionOptions): Promise<AuthSession>;
refreshSession(): Promise<AuthSession>;
removeSession(): Promise<void>;
};
@@ -18,6 +18,7 @@ import {
SessionManager,
SessionScopesFunc,
SessionShouldRefreshFunc,
GetSessionOptions,
} from './types';
import { SessionScopeHelper } from './common';
@@ -59,18 +60,7 @@ export class AuthSessionStore<T> implements SessionManager<T> {
});
}
async getSession(options: {
optional: false;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined> {
async getSession(options: GetSessionOptions): Promise<T | undefined> {
const { scopes } = options;
const session = this.loadSession();
@@ -113,6 +113,23 @@ describe('RefreshingAuthSessionManager', () => {
expect(refreshSession).toBeCalledTimes(1);
});
it('should forward option to instantly show auth popup', async () => {
const createSession = jest.fn();
const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE'));
const manager = new RefreshingAuthSessionManager({
connector: { createSession, refreshSession },
...defaultOptions,
} as any);
expect(await manager.getSession({ instantPopup: true })).toBe(undefined);
expect(createSession).toBeCalledTimes(1);
expect(createSession).toHaveBeenCalledWith({
scopes: new Set(),
instantPopup: true,
});
expect(refreshSession).toBeCalledTimes(1);
});
it('should remove session and reload', async () => {
// This is a workaround that is used by Facebook and the Jest core team
// It is a limitation with the newest versions of JSDOM, and newer browser standards
@@ -18,10 +18,10 @@ import {
SessionManager,
SessionScopesFunc,
SessionShouldRefreshFunc,
GetSessionOptions,
} from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper } from './common';
import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests';
import { SessionScopeHelper, hasScopes } from './common';
type Options<T> = {
/** The connector used for acting on the auth session */
@@ -61,18 +61,7 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes });
}
async getSession(options: {
optional: false;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined> {
async getSession(options: GetSessionOptions): Promise<T | undefined> {
if (
this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes)
) {
@@ -116,9 +105,10 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
}
// We can call authRequester multiple times, the returned session will contain all requested scopes.
this.currentSession = await this.connector.createSession(
this.helper.getExtendedScope(this.currentSession, options.scopes),
);
this.currentSession = await this.connector.createSession({
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
return this.currentSession;
}
@@ -62,7 +62,7 @@ describe('StaticAuthSessionManager', () => {
it('should only request auth once for same scopes', async () => {
const createSession = jest
.fn()
.mockImplementation(scopes => [...scopes].join(' '));
.mockImplementation(({ scopes }) => [...scopes].join(' '));
const manager = new StaticAuthSessionManager({
connector: { createSession, ...baseConnector },
...defaultOptions,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { SessionManager } from './types';
import { SessionManager, GetSessionOptions } from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper } from './common';
@@ -43,18 +43,7 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes });
}
async getSession(options: {
optional: false;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined> {
async getSession(options: GetSessionOptions): Promise<T | undefined> {
if (
this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes)
) {
@@ -67,9 +56,10 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
}
// We can call authRequester multiple times, the returned session will contain all requested scopes.
this.currentSession = await this.connector.createSession(
this.helper.getExtendedScope(this.currentSession, options.scopes),
);
this.currentSession = await this.connector.createSession({
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
return this.currentSession;
}
@@ -14,17 +14,19 @@
* limitations under the License.
*/
export type GetSessionOptions = {
optional?: boolean;
instantPopup?: boolean;
scopes?: Set<string>;
};
/**
* A sessions manager keeps track of the current session and makes sure that
* multiple simultaneous requests for sessions with different scope are handled
* in a correct way.
*/
export type SessionManager<T> = {
getSession(options: { optional: false; scopes?: Set<string> }): Promise<T>;
getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
getSession(options: GetSessionOptions): Promise<T | undefined>;
removeSession(): Promise<void>;
};
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './subjects';
export * from './loginPopup';
export * from './AuthConnector';
export * from './AuthSessionManager';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Observable } from '../../../types';
import { Observable } from '../types';
import ObservableImpl from 'zen-observable';
// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects.
@@ -33,7 +33,7 @@ export class PublishSubject<T>
private isClosed = false;
private terminatingError?: Error;
private readonly observable = new ObservableImpl<T>((subscriber) => {
private readonly observable = new ObservableImpl<T>(subscriber => {
if (this.isClosed) {
if (this.terminatingError) {
subscriber.error(this.terminatingError);
@@ -61,7 +61,7 @@ export class PublishSubject<T>
if (this.isClosed) {
throw new Error('PublishSubject is closed');
}
this.subscribers.forEach((subscriber) => subscriber.next(value));
this.subscribers.forEach(subscriber => subscriber.next(value));
}
error(error: Error) {
@@ -70,7 +70,7 @@ export class PublishSubject<T>
}
this.isClosed = true;
this.terminatingError = error;
this.subscribers.forEach((subscriber) => subscriber.error(error));
this.subscribers.forEach(subscriber => subscriber.error(error));
}
complete() {
@@ -78,7 +78,7 @@ export class PublishSubject<T>
throw new Error('PublishSubject is closed');
}
this.isClosed = true;
this.subscribers.forEach((subscriber) => subscriber.complete());
this.subscribers.forEach(subscriber => subscriber.complete());
}
subscribe(observer: ZenObservable.Observer<T>): ZenObservable.Subscription;
@@ -126,7 +126,7 @@ export class BehaviorSubject<T>
this.currentValue = value;
}
private readonly observable = new ObservableImpl<T>((subscriber) => {
private readonly observable = new ObservableImpl<T>(subscriber => {
if (this.isClosed) {
if (this.terminatingError) {
subscriber.error(this.terminatingError);
@@ -157,7 +157,7 @@ export class BehaviorSubject<T>
throw new Error('BehaviorSubject is closed');
}
this.currentValue = value;
this.subscribers.forEach((subscriber) => subscriber.next(value));
this.subscribers.forEach(subscriber => subscriber.next(value));
}
error(error: Error) {
@@ -166,7 +166,7 @@ export class BehaviorSubject<T>
}
this.isClosed = true;
this.terminatingError = error;
this.subscribers.forEach((subscriber) => subscriber.error(error));
this.subscribers.forEach(subscriber => subscriber.error(error));
}
complete() {
@@ -174,7 +174,7 @@ export class BehaviorSubject<T>
throw new Error('BehaviorSubject is closed');
}
this.isClosed = true;
this.subscribers.forEach((subscriber) => subscriber.complete());
this.subscribers.forEach(subscriber => subscriber.complete());
}
subscribe(observer: ZenObservable.Observer<T>): ZenObservable.Subscription;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './AlertDisplay';
export { PrivateAppImpl } from './app/App';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './apis';
export * from './app';
export * from './icons';
export * from './plugin';
export * from './routing';
export * from './types';

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