Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Nikita Nek Dudnik
2020-05-15 11:37:02 +02:00
176 changed files with 4778 additions and 838 deletions
+1
View File
@@ -7,6 +7,7 @@ on:
- 'packages/cli/**'
- 'packages/core/**'
- 'scripts/**'
- 'yarn.lock'
jobs:
build:
+14 -1
View File
@@ -48,18 +48,31 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: check for yarn.lock changes
id: yarn-lock
run: git diff --quiet origin/master HEAD -- yarn.lock
continue-on-error: true
- name: yarn install
run: yarn install --frozen-lockfile
- name: verify plugin template
run: yarn lerna -- run diff -- --check
- name: lint
run: yarn lerna -- run lint --since origin/master
- name: build
run: yarn build
- name: test
- name: test changed packages
if: ${{ steps.yarn-lock.outcome == 'success' }}
run: yarn lerna -- run test --since origin/master -- --coverage
- name: test all packages
if: ${{ steps.yarn-lock.outcome == 'failure' }}
run: yarn lerna -- run test -- --coverage
- name: yarn bundle, if app was changed
run: git diff --quiet origin/master HEAD -- yarn.lock packages/app packages/core || yarn bundle
+3
View File
@@ -1,2 +1,5 @@
Backstage
Copyright 2020 Spotify AB
Portions of this software were developed by third-party software vendors:
- Tech Radar Plugin (https://opensource.zalando.com/tech-radar/), Copyright (c) 2017 Zalando SE
+1 -1
View File
@@ -95,7 +95,7 @@ We would love your help in building Backstage! See [CONTRIBUTING](CONTRIBUTING.m
## Community
- [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the project
- [Good First Issues](https://github.com/spotify/backstage/labels/good%20first%20issue) - Start here if you want to contribute
- [Good First Issues](https://github.com/spotify/backstage/contribute) - Start here if you want to contribute
- [RFCs](https://github.com/spotify/backstage/labels/rfc) - Help shape the technical direction
- [FAQ](docs/FAQ.md) - Frequently Asked Questions
- [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+1 -1
View File
@@ -5,5 +5,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.1-alpha.4"
"version": "0.1.1-alpha.5"
}
+1
View File
@@ -9,6 +9,7 @@
"bundle": "yarn build && yarn workspace example-app bundle",
"build": "lerna run build",
"clean": "lerna run clean",
"diff": "lerna run diff --",
"test": "yarn build && lerna run test --since origin/master -- --coverage",
"test:all": "yarn build && lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
+15 -14
View File
@@ -1,19 +1,20 @@
{
"name": "example-app",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"private": true,
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@backstage/core": "^0.1.1-alpha.4",
"@backstage/cli": "^0.1.1-alpha.5",
"@backstage/core": "^0.1.1-alpha.5",
"@backstage/plugin-circleci": "^0.1.1-alpha.4",
"@backstage/plugin-explore": "^0.1.1-alpha.4",
"@backstage/plugin-home-page": "^0.1.1-alpha.4",
"@backstage/plugin-inventory": "^0.1.1-alpha.4",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.4",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.4",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.4",
"@backstage/plugin-welcome": "^0.1.1-alpha.4",
"@backstage/theme": "^0.1.1-alpha.4",
"@backstage/plugin-catalog": "^0.1.1-alpha.5",
"@backstage/plugin-explore": "^0.1.1-alpha.5",
"@backstage/plugin-home-page": "^0.1.1-alpha.5",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.5",
"@backstage/plugin-register-component": "^0.1.1-alpha.5",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.5",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.5",
"@backstage/plugin-welcome": "^0.1.1-alpha.5",
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -21,8 +22,8 @@
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2",
"react-use": "^13.24.0",
"react-router-dom": "^5.2.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
@@ -30,7 +31,7 @@
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/jest": "^25.2.1",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
+1 -1
View File
@@ -85,7 +85,7 @@ const Root: FC<{}> = ({ children }) => (
<SidebarItem icon={ExploreIcon} to="/explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
<SidebarDivider />
<SidebarItem icon={AccountTreeIcon} to="/inventory" text="Inventory" />
<SidebarItem icon={AccountTreeIcon} to="/catalog" text="Catalog" />
<SidebarItem icon={AccountCircle} to="/login" text="Login" />
<SidebarDivider />
<SidebarSpace />
+2 -1
View File
@@ -16,8 +16,9 @@
export { plugin as HomePagePlugin } from '@backstage/plugin-home-page';
export { plugin as WelcomePlugin } from '@backstage/plugin-welcome';
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
export { plugin as InventoryPlugin } from '@backstage/plugin-inventory';
export { plugin as CatalogPlugin } from '@backstage/plugin-catalog';
export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder';
export { plugin as TechRadar } from '@backstage/plugin-tech-radar';
export { plugin as Explore } from '@backstage/plugin-explore';
export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
+7 -3
View File
@@ -1,8 +1,9 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"main": "dist",
"types": "src/index.ts",
"private": false,
"publishConfig": {
"access": "public"
@@ -21,6 +22,8 @@
"build": "backstage-cli build-cache -- tsc",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
@@ -29,17 +32,18 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@backstage/cli": "^0.1.1-alpha.5",
"@types/express": "^4.17.6",
"@types/http-errors": "^1.6.3",
"@types/morgan": "^1.9.0",
"@types/supertest": "^2.0.8",
"@types/yaml": "^1.9.7",
"get-port": "^5.1.1",
"http-errors": "^1.7.3",
"jest": "^25.1.0",
"jest-fetch-mock": "^3.0.3",
"supertest": "^4.0.2",
"typescript": "^3.8.3"
"typescript": "^3.9.2"
},
"files": [
"dist"
+58
View File
@@ -0,0 +1,58 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
module.exports = {
development: {
client: 'sqlite3',
connection: {
filename: './dev.sqlite3',
},
},
/*
staging: {
client: 'postgresql',
connection: {
database: 'my_db',
user: 'username',
password: 'password',
},
pool: {
min: 2,
max: 10,
},
migrations: {
tableName: 'knex_migrations',
},
},
production: {
client: 'postgresql',
connection: {
database: 'my_db',
user: 'username',
password: 'password',
},
pool: {
min: 2,
max: 10,
},
migrations: {
tableName: 'knex_migrations',
},
},
*/
};
+13 -9
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"main": "dist",
"private": true,
"license": "Apache-2.0",
@@ -12,27 +12,31 @@
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess nodemon",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean"
"clean": "backstage-cli clean",
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
"@backstage/backend-common": "0.1.1-alpha.4",
"@backstage/plugin-inventory-backend": "0.1.1-alpha.4",
"@backstage/plugin-scaffolder-backend": "0.1.1-alpha.4",
"@backstage/backend-common": "^0.1.1-alpha.5",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.5",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.5",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
"helmet": "^3.22.0"
"helmet": "^3.22.0",
"knex": "^0.21.1",
"sqlite3": "^4.2.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@backstage/cli": "^0.1.1-alpha.5",
"@types/compression": "^1.7.0",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.45",
"@types/helmet": "^0.0.47",
"jest": "^25.1.0",
"tsc-watch": "^4.2.3",
"typescript": "^3.8.3",
"typescript": "^3.9.2",
"winston": "^3.2.1"
},
"nodemonConfig": {
+18 -7
View File
@@ -28,19 +28,30 @@ import {
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import inventory from './plugins/inventory';
import scaffolder from './plugins/scaffolder';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import knex from 'knex';
import catalog from './plugins/catalog';
import scaffolder from './plugins/scaffolder';
import { PluginEnvironment } from './types';
const DEFAULT_PORT = 7000;
const PORT = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT;
const pluginEnvironment: PluginEnvironment = {
logger: getRootLogger().child({ type: 'plugin' }),
};
function createEnv(plugin: string): PluginEnvironment {
const logger = getRootLogger().child({ type: 'plugin', plugin });
const database = knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return { logger, database };
}
async function main() {
const app = express();
@@ -50,8 +61,8 @@ async function main() {
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/inventory', await inventory(pluginEnvironment));
app.use('/scaffolder', await scaffolder(pluginEnvironment));
app.use('/catalog', await catalog(createEnv('catalog')));
app.use('/scaffolder', await scaffolder(createEnv('scaffolder')));
app.use(notFoundHandler());
app.use(errorHandler());
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
DatabaseCatalog,
createRouter,
} from '@backstage/plugin-catalog-backend';
import { PluginEnvironment } from '../types';
export default async function ({ logger, database }: PluginEnvironment) {
const catalog = await DatabaseCatalog.create(database, logger);
return await createRouter({ catalog, logger });
}
+3 -1
View File
@@ -14,8 +14,10 @@
* limitations under the License.
*/
import type { Logger } from 'winston';
import Knex from 'knex';
import { Logger } from 'winston';
export type PluginEnvironment = {
logger: Logger;
database: Knex;
};
-1
View File
@@ -4,7 +4,6 @@
"compilerOptions": {
"allowJs": true,
"noEmit": false,
"declarationMap": true,
"incremental": true,
"target": "ES2019",
"module": "ESNext",
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"private": false,
"publishConfig": {
"access": "public"
@@ -16,7 +16,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist",
"main": "dist/index.js",
"scripts": {
"build": "backstage-cli build-cache -- tsc --project tsconfig.build.json",
"lint": "backstage-cli lint",
@@ -43,7 +43,7 @@
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-monorepo": "^0.2.1",
"fork-ts-checker-webpack-plugin": "^4.0.5",
"fs-extra": "^8.1.0",
"fs-extra": "^9.0.0",
"handlebars": "^4.7.3",
"html-webpack-plugin": "^3.2.0",
"inquirer": "^7.0.4",
@@ -55,14 +55,14 @@
"react-dev-utils": "^10.2.0",
"react-scripts": "^3.4.1",
"recursive-readdir": "^2.2.2",
"replace-in-file": "^5.0.2",
"replace-in-file": "^6.0.0",
"rollup": "^2.3.2",
"rollup-plugin-image-files": "^1.4.2",
"rollup-plugin-peer-deps-external": "^2.2.2",
"rollup-plugin-postcss": "^2.5.0",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.26.0",
"tar": "^6.0.1",
"ts-loader": "^6.2.1",
"ts-loader": "^7.0.4",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3"
},
@@ -89,7 +89,7 @@
"templates",
"config",
"bin",
"dist"
"dist/**/*.js"
],
"nodemonConfig": {
"watch": "./src",
@@ -88,25 +88,39 @@ export async function moveApp(
async function addPackageResolutions(appDir: string) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const packageFileContent = await fs.readFile(pkgJsonPath, 'utf-8');
const packageFileJson = JSON.parse(packageFileContent);
const pkgJson = await fs.readJson(pkgJsonPath);
packageFileJson.resolutions = packageFileJson.resolutions || {};
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
const packages = ['cli', 'core', 'test-utils', 'test-utils-core', 'theme'];
const depNames = [
'cli',
'core',
'dev-utils',
'test-utils',
'test-utils-core',
'theme',
];
for (const pkg of packages) {
await Task.forItem('adding', `${pkg} link to package.json`, async () => {
const pkgPath = paths.resolveOwnRoot('packages', pkg);
packageFileJson.resolutions[`@backstage/${pkg}`] = `file:${pkgPath}`;
const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`;
for (const name of depNames) {
await Task.forItem(
'adding',
`@backstage/${name} link to package.json`,
async () => {
const pkgPath = paths.resolveOwnRoot('packages', name);
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
await fs.writeFile(pkgJsonPath, newContents, 'utf-8').catch((error) => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
});
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
.catch((error) => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
@@ -14,23 +14,21 @@
* limitations under the License.
*/
import {
AggregatorInventory,
createRouter,
StaticInventory,
} from '@backstage/plugin-inventory-backend';
import type { PluginEnvironment } from '../types';
import fs from 'fs-extra';
import { paths } from '../lib/paths';
export default async function ({ logger }: PluginEnvironment) {
const inventory = new AggregatorInventory();
inventory.enlist(
new StaticInventory([
{ id: 'component1' },
{ id: 'component2' },
{ id: 'component3' },
{ id: 'component4' },
]),
);
export const pre = async () => {
const pkgPath = paths.resolveTarget('package.json');
return await createRouter({ inventory, logger });
}
const pkg = await fs.readJson(pkgPath);
pkg.types = 'dist/index.d.ts';
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
};
export const post = async () => {
const pkgPath = paths.resolveTarget('package.json');
const pkg = await fs.readJson(pkgPath);
pkg.types = 'src/index.ts';
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
};
@@ -54,6 +54,7 @@ class PackageJsonHandler {
await this.syncField('types');
await this.syncField('files');
await this.syncScripts();
await this.syncPublishConfig();
await this.syncDependencies('dependencies');
await this.syncDependencies('devDependencies');
}
@@ -105,6 +106,33 @@ class PackageJsonHandler {
}
}
private async syncPublishConfig() {
const pkgPublishConf = this.pkg.publishConfig;
const targetPublishConf = this.targetPkg.publishConfig;
// If template doesn't have a publish config we're done
if (!pkgPublishConf) {
return;
}
// Publish config can be removed the the target, skip in that case
if (!targetPublishConf) {
return;
}
for (const key of Object.keys(pkgPublishConf)) {
// Don't want to mess with peoples internal setup
if (!['access', 'registry'].includes(key)) {
await this.syncField(
key,
pkgPublishConf,
targetPublishConf,
'publishConfig',
);
}
}
}
private async syncDependencies(fieldName: string) {
const pkgDeps = this.pkg[fieldName];
const targetDeps = (this.targetPkg[fieldName] =
+15 -2
View File
@@ -82,6 +82,16 @@ const main = (argv: string[]) => {
.description('Run tests, forwarding args to Jest, defaulting to watch mode')
.action(actionHandler(() => require('./commands/testCommand')));
program
.command('prepack')
.description('Prepares a package for packaging before publishing')
.action(actionHandler(() => require('./commands/pack').pre));
program
.command('postpack')
.description('Restores the changes made by the prepack command')
.action(actionHandler(() => require('./commands/pack').post));
program
.command('watch-deps')
.option('--build', 'Build all dependencies on startup')
@@ -129,11 +139,14 @@ const main = (argv: string[]) => {
// Wraps an action function so that it always exits and handles errors
function actionHandler<T extends readonly any[]>(
actionRequireFunc: () => { default(...args: T): Promise<any> },
actionRequireFunc:
| (() => { default(...args: T): Promise<any> })
| (() => (...args: T) => Promise<any>),
): (...args: T) => Promise<never> {
return async (...args: T) => {
try {
const actionFunc = actionRequireFunc().default;
const ret = actionRequireFunc();
const actionFunc = typeof ret === 'function' ? ret : ret.default;
await actionFunc(...args);
process.exit(0);
} catch (error) {
@@ -26,8 +26,5 @@
"@backstage/cli": "^{{version}}",
"lerna": "^3.20.2",
"prettier": "^1.19.1"
},
"resolutions": {
"**/cypress": "4.2.0"
}
}
@@ -12,14 +12,14 @@
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.1.2",
"react-use": "^13.24.0"
"react-router-dom": "^5.2.0",
"react-use": "^14.2.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
"@types/testing-library__jest-dom": "^5.0.4",
@@ -2,7 +2,7 @@
"name": "plugin-welcome",
"version": "0.0.0",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"types": "src/index.ts",
"private": true,
"scripts": {
"build": "backstage-cli plugin:build",
@@ -10,6 +10,8 @@
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
@@ -19,8 +21,8 @@
"@material-ui/icons": "^4.9.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^13.24.0",
"react-router-dom": "5.1.2"
"react-use": "^14.2.0",
"react-router-dom": "^5.2.0"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
@@ -31,6 +33,6 @@
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
@@ -2,15 +2,20 @@
"name": "@backstage/plugin-{{id}}",
"version": "{{version}}",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
@@ -21,7 +26,7 @@
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^13.24.0"
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
@@ -29,12 +34,12 @@
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
+19 -13
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core",
"description": "Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"private": false,
"publishConfig": {
"access": "public"
@@ -17,15 +17,17 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"types": "dist/index.d.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.4",
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"classnames": "^2.2.6",
@@ -33,30 +35,34 @@
"lodash": "^4.17.15",
"material-table": "^1.57.2",
"prop-types": "^15.7.2",
"rc-progress": "^2.5.2",
"rc-progress": "^3.0.0",
"react": "^16.12.0",
"react-addons-text-content": "0.0.4",
"react-dom": "^16.12.0",
"react-helmet": "5.2.1",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-sparklines": "^1.7.0"
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^12.2.1",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@backstage/test-utils": "0.1.1-alpha.4",
"@backstage/test-utils-core": "^0.1.1-alpha.4",
"@backstage/cli": "^0.1.1-alpha.5",
"@backstage/test-utils": "^0.1.1-alpha.5",
"@backstage/test-utils-core": "^0.1.1-alpha.5",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/classnames": "^2.2.9",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^24.0.0",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/react-helmet": "^5.0.15",
"@types/react-sparklines": "^1.7.0"
"@types/react-sparklines": "^1.7.0",
"@types/zen-observable": "^0.8.0"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
@@ -15,9 +15,9 @@
*/
import React from 'react';
import ApiProvider, { useApi, withApis } from './ApiProvider';
import ApiRef from './ApiRef';
import ApiRegistry from './ApiRegistry';
import { ApiProvider, useApi, withApis } from './ApiProvider';
import { ApiRef } from './ApiRef';
import { ApiRegistry } from './ApiRegistry';
import { render } from '@testing-library/react';
import { withLogCollector } from '@backstage/test-utils-core';
+3 -5
View File
@@ -16,7 +16,7 @@
import React, { FC, createContext, useContext, ReactNode } from 'react';
import PropTypes from 'prop-types';
import ApiRef from './ApiRef';
import { ApiRef } from './ApiRef';
import { ApiHolder, TypesToApiRefs } from './types';
type Props = {
@@ -26,7 +26,7 @@ type Props = {
const Context = createContext<ApiHolder | undefined>(undefined);
const ApiProvider: FC<Props> = ({ apis, children }) => {
export const ApiProvider: FC<Props> = ({ apis, children }) => {
return <Context.Provider value={apis} children={children} />;
};
@@ -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) {
@@ -84,5 +84,3 @@ export function withApis<T>(apis: TypesToApiRefs<T>) {
return Hoc;
};
}
export default ApiProvider;
+1 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import ApiRef from './ApiRef';
import { ApiRef } from './ApiRef';
describe('ApiRef', () => {
it('should be created', () => {
+5 -1
View File
@@ -19,7 +19,7 @@ export type ApiRefConfig = {
description: string;
};
export default class ApiRef<T> {
export class ApiRef<T> {
constructor(private readonly config: ApiRefConfig) {
if (!config.id.match(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/)) {
throw new Error(
@@ -45,3 +45,7 @@ export default class ApiRef<T> {
return `apiRef{${this.config.id}}`;
}
}
export function createApiRef<T>(config: ApiRefConfig) {
return new ApiRef<T>(config);
}
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import ApiRegistry from './ApiRegistry';
import ApiRef from './ApiRef';
import { ApiRegistry } from './ApiRegistry';
import { ApiRef } from './ApiRef';
describe('ApiRegistry', () => {
const x1Ref = new ApiRef<number>({ id: 'x', description: '' });
+2 -2
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import ApiRef from './ApiRef';
import { ApiRef } from './ApiRef';
import { ApiHolder } from './types';
type ApiImpl<T = unknown> = readonly [ApiRef<T>, T];
@@ -33,7 +33,7 @@ class ApiRegistryBuilder {
}
}
export default class ApiRegistry implements ApiHolder {
export class ApiRegistry implements ApiHolder {
static builder() {
return new ApiRegistryBuilder();
}
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import ApiTestRegistry from './ApiTestRegistry';
import ApiRef from './ApiRef';
import { ApiTestRegistry } from './ApiTestRegistry';
import { ApiRef } from './ApiRef';
describe('ApiTestRegistry', () => {
const aRef = new ApiRef<number>({ id: 'a', description: '' });
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import ApiRef from './ApiRef';
import { ApiRef } from './ApiRef';
import { TypesToApiRefs, AnyApiRef, ApiHolder, ApiFactory } from './types';
export default class ApiTestRegistry implements ApiHolder {
export class ApiTestRegistry implements ApiHolder {
private readonly apis = new Map<AnyApiRef, unknown>();
private factories = new Map<
AnyApiRef,
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ApiRef from '../ApiRef';
import { ApiRef } from '../ApiRef';
export type AlertMessage = {
message: string;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import ApiRef from '../ApiRef';
import { ApiRef } from '../ApiRef';
/**
* Mirrors the javascript Error class, for the purpose of
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import ApiRef from '../ApiRef';
import { ApiRef } from '../ApiRef';
import {
UserFlags,
FeatureFlagsRegistry,
@@ -0,0 +1,185 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 } from '../ApiRef';
export type OAuthScopes = {
extend(scopes: OAuthScopeLike): OAuthScopes;
hasScopes(scopes: OAuthScopeLike): boolean;
toSet(): Set<string>;
toString(): string;
};
export type OAuthScopeLike =
| string /** Space separated scope strings */
| string[] /** Array of individual scope strings */
| OAuthScopes;
/**
* Options used to open a login popup.
*/
export type LoginPopupOptions = {
/**
* The URL that the auth popup should point to
*/
url: string;
/**
* The name of the popup, as in second argument to window.open
*/
name: string;
/**
* The origin of the final popup page that will post a message to this window.
*/
origin: string;
/**
* The width of the popup in pixels, defaults to 500
*/
width?: number;
/**
* The height of the popup in pixels, defaults to 700
*/
height?: number;
};
/**
* 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(scope: OAuthScopes): 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> = (
scope: OAuthScopes,
) => 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 = {
/**
* Show a popup pointing to a URL that starts an OAuth flow.
*
* The redirect handler of the flow should use postMessage to communicate back
* to the app window.
*
* The returned promise resolves to the contents of the message that was posted from the auth popup.
*/
showLoginPopup(options: LoginPopupOptions): Promise<any>;
/**
* A utility for showing login popups or similar things, and merging together multiple requests for
* different scopes into one request that inclues 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 panding 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 = new ApiRef<OAuthRequestApi>({
id: 'core.oauthrequest',
description: 'An API for implementing unified OAuth flows in Backstage',
});
@@ -20,6 +20,7 @@
//
// If you think some API definition is missing, please open an Issue or send a PR!
export * from './alert';
export * from './error';
export * from './featureFlags';
export * from './AlertApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
@@ -0,0 +1,77 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { OAuthScopes, OAuthScopeLike } from '../../definitions';
/**
* The BasicOAuthScopes class is an implementation of OAuthScopes that
* works for any simple comma- or space-separated format of scope.
*/
export class BasicOAuthScopes implements OAuthScopes {
static from(scopes: OAuthScopeLike, normalizer?: (scope: string) => string) {
const normalized = BasicOAuthScopes.asStrings(scopes, normalizer);
return new BasicOAuthScopes(new Set(normalized), normalizer);
}
constructor(
private readonly scopes: Set<string>,
private readonly normalizer?: (scope: string) => string,
) {}
extend(requestedScopes: OAuthScopeLike): BasicOAuthScopes {
const newScopes = new Set(this.scopes);
BasicOAuthScopes.asStrings(requestedScopes, this.normalizer).forEach((s) =>
newScopes.add(s),
);
return new BasicOAuthScopes(newScopes, this.normalizer);
}
hasScopes(scopes: OAuthScopeLike): boolean {
return BasicOAuthScopes.asStrings(scopes, this.normalizer).every((s) =>
this.scopes.has(s),
);
}
toSet(): Set<string> {
return this.scopes;
}
toString(): string {
return Array.from(this.scopes).join(' ');
}
toJSON() {
return Array.from(this.scopes);
}
static asStrings(
input: OAuthScopeLike,
normalizer?: (scope: string) => string,
): string[] {
let scopeArray: string[];
if (typeof input === 'string') {
scopeArray = input.split(/[,\s]/).filter(Boolean);
} else if (Array.isArray(input)) {
scopeArray = input;
} else {
scopeArray = Array.from(input.toSet());
}
if (normalizer) {
scopeArray = scopeArray.map((x) => normalizer(x));
}
return scopeArray;
}
}
@@ -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 { wait } from '@testing-library/react';
import { OAuthPendingRequests } from './OAuthPendingRequests';
import { BasicOAuthScopes } from './BasicOAuthScopes';
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 = BasicOAuthScopes.from('a b');
target.pending().subscribe({ next, error });
target.request(input);
await wait(() => 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(BasicOAuthScopes.from('a'));
const request2 = target.request(BasicOAuthScopes.from('a'));
target.pending().subscribe({ next, error });
target.resolve(BasicOAuthScopes.from('a'), 'session1');
target.resolve(BasicOAuthScopes.from('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(BasicOAuthScopes.from('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(BasicOAuthScopes.from('a'));
const request2 = target.request(BasicOAuthScopes.from('a'));
target.pending().subscribe({ next, error });
target.reject(rejection);
target.resolve(BasicOAuthScopes.from('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(BasicOAuthScopes.from('a'));
target.pending().subscribe({ next, error });
await expect(request1).rejects.toBe(rejection);
expect(next).toBeCalledTimes(2);
});
});
@@ -0,0 +1,116 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Observable from 'zen-observable';
import { OAuthScopes } from '../../definitions';
type RequestQueueEntry<ResultType> = {
scopes: OAuthScopes;
resolve: (value?: ResultType | PromiseLike<ResultType> | undefined) => void;
reject: (reason: Error) => void;
};
export type PendingRequest<ResultType> = {
scopes: OAuthScopes | undefined;
resolve: (value: ResultType) => void;
reject: (reason: Error) => void;
};
export type OAuthPendingRequestsApi<ResultType> = {
request(scopes: OAuthScopes): Promise<ResultType>;
resolve(scopes: OAuthScopes, result: ResultType): void;
reject(error: Error): void;
pending(): Observable<PendingRequest<ResultType>>;
};
/**
* 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>
implements OAuthPendingRequestsApi<ResultType> {
private requests: RequestQueueEntry<ResultType>[] = [];
private listeners: ZenObservable.SubscriptionObserver<
PendingRequest<ResultType>
>[] = [];
request(scopes: OAuthScopes): Promise<ResultType> {
return new Promise((resolve, reject) => {
this.requests.push({ scopes, resolve, reject });
const pending = this.getCurrentPending();
this.listeners.forEach((listener) => listener.next(pending));
});
}
resolve(scopes: OAuthScopes, result: ResultType): void {
this.requests = this.requests.filter((request) => {
if (scopes.hasScopes(request.scopes)) {
request.resolve(result);
return false;
}
return true;
});
const pending = this.getCurrentPending();
this.listeners.forEach((listener) => listener.next(pending));
}
reject(error: Error) {
this.requests.forEach((request) => request.reject(error));
this.requests = [];
const pending = this.getCurrentPending();
this.listeners.forEach((listener) => listener.next(pending));
}
pending(): Observable<PendingRequest<ResultType>> {
return new Observable((subscriber) => {
this.listeners.push(subscriber);
subscriber.next(this.getCurrentPending());
return () => {
this.listeners = this.listeners.filter((l) => l !== subscriber);
};
});
}
private getCurrentPending(): PendingRequest<ResultType> {
const currentScopes =
this.requests.length === 0
? undefined
: this.requests
.slice(1)
.reduce(
(acc, current) => acc.extend(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,178 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { OAuthRequestManager } from './OAuthRequestManager';
describe('OAuthApi login popup', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should show an auth popup', async () => {
const oauth = new OAuthRequestManager();
const popupMock = { closed: false };
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue(popupMock as Window);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const payloadPromise = oauth.showLoginPopup({
url:
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
name: 'test-popup',
origin: 'my-origin',
});
expect(openSpy).toBeCalledTimes(1);
expect(openSpy.mock.calls[0][0]).toBe(
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
);
expect(openSpy.mock.calls[0][1]).toBe('test-popup');
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(0);
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
listener({} as MessageEvent);
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
// None of these should be accepted
listener({ source: popupMock } as MessageEvent);
listener({ origin: 'my-origin' } as MessageEvent);
listener({ data: { type: 'oauth-result' } } as MessageEvent);
listener({
source: popupMock,
origin: 'my-origin',
data: {},
} as MessageEvent);
listener({
source: popupMock,
origin: 'my-origin',
data: { type: 'not-oauth-result', payload: {} },
} as MessageEvent);
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
const myPayload = {};
// This should be accepted as a valid sessions response
listener({
source: popupMock,
origin: 'my-origin',
data: {
type: 'oauth-result',
payload: myPayload,
},
} as MessageEvent);
await expect(payloadPromise).resolves.toBe(myPayload);
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(1);
});
it('should fail if popup returns error', async () => {
const oauth = new OAuthRequestManager();
const popupMock = { closed: false };
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue(popupMock as Window);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const payloadPromise = oauth.showLoginPopup({
url: 'url',
name: 'name',
origin: 'my-origin',
});
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(0);
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
listener({
source: popupMock,
origin: 'my-origin',
data: {
type: 'oauth-result',
payload: {
error: {
message: 'NOPE',
name: 'NopeError',
},
},
},
} as MessageEvent);
await expect(payloadPromise).rejects.toThrow({
name: 'NopeError',
message: 'NOPE',
});
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(1);
});
it('should fail if popup is closed', async () => {
const oauth = new OAuthRequestManager();
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue({ closed: false } as Window);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const popupMock = { closed: false };
openSpy.mockReturnValue(popupMock as Window);
const payloadPromise = oauth.showLoginPopup({
url: 'url',
name: 'name',
origin: 'origin',
});
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(0);
setTimeout(() => {
popupMock.closed = true;
}, 150);
await expect(payloadPromise).rejects.toThrow(
'Login failed, popup was closed',
);
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(1);
});
});
@@ -0,0 +1,165 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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,
LoginPopupOptions,
PendingAuthRequest,
AuthRequester,
AuthRequesterOptions,
} from '../../definitions';
import Observable from 'zen-observable';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
/**
* 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 request$: Observable<PendingAuthRequest[]>;
private readonly observers = new Set<
ZenObservable.SubscriptionObserver<PendingAuthRequest[]>
>();
private currentRequests: PendingAuthRequest[] = [];
private handlerCount = 0;
constructor() {
this.request$ = new Observable<PendingAuthRequest[]>((observer) => {
observer.next(this.currentRequests);
this.observers.add(observer);
return () => {
this.observers.delete(observer);
};
}).map((requests) => requests.filter(Boolean)); // Convert from sparse array to array of present items only
}
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;
this.observers.forEach((observer) => observer.next(newRequests));
},
});
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.request$;
}
async showLoginPopup(options: LoginPopupOptions): Promise<any> {
return new Promise((resolve, reject) => {
const width = options.width || 500;
const height = options.height || 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const popup = window.open(
options.url,
options.name,
`menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`,
);
if (!popup || typeof popup.closed === 'undefined' || popup.closed) {
reject(new Error('Failed to open auth popup.'));
return;
}
const messageListener = (event: MessageEvent) => {
if (event.source !== popup) {
return;
}
if (event.origin !== options.origin) {
return;
}
const { data } = event;
if (data.type !== 'oauth-result') {
return;
}
if (data.payload.error) {
const error = new Error(data.payload.error.message);
error.name = data.payload.error.name;
// TODO: proper error type
// error.extra = data.payload.error.extra;
reject(error);
} else {
resolve(data.payload);
}
done();
};
const intervalId = setInterval(() => {
if (popup.closed) {
const error = new Error('Login failed, popup was closed');
error.name = 'PopupClosedError';
reject(error);
done();
}
}, 100);
function done() {
window.removeEventListener('message', messageListener);
clearInterval(intervalId);
}
window.addEventListener('message', messageListener);
});
}
}
@@ -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';
@@ -20,3 +20,4 @@
export * from './AlertApiForwarder';
export * from './ErrorApiForwarder';
export * from './OAuthRequestManager';
+4 -4
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
export { default as ApiProvider, useApi } from './ApiProvider';
export { default as ApiRegistry } from './ApiRegistry';
export { default as ApiTestRegistry } from './ApiTestRegistry';
export { default as ApiRef } from './ApiRef';
export { ApiProvider, useApi } from './ApiProvider';
export { ApiRegistry } from './ApiRegistry';
export { ApiTestRegistry } from './ApiTestRegistry';
export * from './ApiRef';
export * from './types';
export * from './helpers';
export * from './definitions';
+1 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import ApiRef from './ApiRef';
import { ApiRef } from './ApiRef';
export type AnyApiRef = ApiRef<any>;
+20 -3
View File
@@ -18,9 +18,9 @@ import React, { ComponentType } from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { App } from './types';
import BackstagePlugin from '../plugin/Plugin';
import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions/featureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
import ErrorPage from '../../layout/ErrorPage';
import {
@@ -40,7 +40,7 @@ class AppImpl implements App {
}
}
export default class AppBuilder {
export class AppBuilder {
private apis?: ApiHolder;
private systemIcons = { ...defaultSystemIcons };
private readonly plugins = new Set<BackstagePlugin>();
@@ -84,6 +84,19 @@ export default class AppBuilder {
);
break;
}
case 'nav-target-component': {
const { target, component, options = {} } = output;
const { exact = true } = options;
routes.push(
<Route
key={`${plugin.getId()}-${target.path}`}
path={target.path}
component={component}
exact={exact}
/>,
);
break;
}
case 'redirect-route': {
const { path, target, options = {} } = output;
const { exact = true } = options;
@@ -132,3 +145,7 @@ export default class AppBuilder {
return () => <AppContextProvider app={app} children={rendered} />;
}
}
export function createApp() {
return new AppBuilder();
}
@@ -15,10 +15,7 @@
*/
import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags';
import {
FeatureFlagState,
FeatureFlagsApi,
} from '../apis/definitions/featureFlags';
import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions';
describe('FeatureFlags', () => {
beforeEach(() => {
+1 -4
View File
@@ -15,10 +15,7 @@
*/
import { FeatureFlagName } from '../plugin/types';
import {
FeatureFlagState,
FeatureFlagsApi,
} from '../apis/definitions/featureFlags';
import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions';
/**
* Helper method for validating compatibility and flag name.
+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 { createApp } from './AppBuilder';
export { FeatureFlags } from './FeatureFlags';
export { useApp } from './AppContext';
export * from './types';
+4 -3
View File
@@ -14,7 +14,8 @@
* limitations under the License.
*/
export * from './api';
export * from './apis';
export { FeatureFlags } from './app/FeatureFlags';
export { useApp } from './app/AppContext';
export * from './app';
export * from './navTargets';
export * from './plugin';
export * from './types';
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { NavTargetConfig, NavTargetOverrideConfig } from './types';
export class MutableNavTarget {
private effectiveConfig: NavTargetConfig = this.config;
constructor(private readonly config: NavTargetConfig) {}
override(overrideConfig: NavTargetOverrideConfig) {
this.effectiveConfig = { ...this.config, ...overrideConfig };
}
get icon() {
return this.effectiveConfig.icon;
}
get path() {
return this.effectiveConfig.path;
}
get title() {
return this.effectiveConfig.title;
}
}
export function createNavTarget(config: NavTargetConfig): MutableNavTarget {
return new MutableNavTarget(config);
}
+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 type {
NavTarget,
NavTargetConfig,
NavTargetOverrideConfig,
} from './types';
export { createNavTarget } from './NavTarget';
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IconComponent } from '../../icons';
export type NavTarget = {
path: string;
icon: IconComponent;
title: string;
};
export type NavTargetConfig = {
path: string;
icon: IconComponent;
title: string;
};
export type NavTargetOverrideConfig = {
path?: string;
icon?: IconComponent;
title?: string;
};
+21 -4
View File
@@ -20,8 +20,10 @@ import {
RoutePath,
RouteOptions,
FeatureFlagName,
BackstagePlugin,
} from './types';
import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags';
import { NavTarget } from '../navTargets';
export type PluginConfig = {
id: string;
@@ -34,6 +36,12 @@ export type PluginHooks = {
};
export type RouterHooks = {
addRoute(
target: NavTarget,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
registerRoute(
path: RoutePath,
Component: ComponentType<any>,
@@ -51,10 +59,7 @@ export type FeatureFlagsHooks = {
register(name: FeatureFlagName): void;
};
export const registerSymbol = Symbol('plugin-register');
export const outputSymbol = Symbol('plugin-output');
export default class Plugin {
export class PluginImpl {
private storedOutput?: PluginOutput[];
constructor(private readonly config: PluginConfig) {}
@@ -75,6 +80,14 @@ export default class Plugin {
this.config.register({
router: {
addRoute(target, component, options) {
outputs.push({
type: 'nav-target-component',
target,
component,
options,
});
},
registerRoute(path, component, options) {
outputs.push({ type: 'route', path, component, options });
},
@@ -99,3 +112,7 @@ export default class Plugin {
return `plugin{${this.config.id}}`;
}
}
export function createPlugin(config: PluginConfig): BackstagePlugin {
return new PluginImpl(config);
}
@@ -14,6 +14,5 @@
* limitations under the License.
*/
export * from './AggregatorInventory';
export * from './StaticInventory';
export { createPlugin } from './Plugin';
export * from './types';
+14
View File
@@ -15,6 +15,7 @@
*/
import { ComponentType } from 'react';
import { NavTarget } from '../navTargets';
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
@@ -30,6 +31,13 @@ export type RouteOutput = {
options?: RouteOptions;
};
export type RouteTargetOutput = {
type: 'nav-target-component';
target: NavTarget;
component: ComponentType<{}>;
options?: RouteOptions;
};
export type RedirectRouteOutput = {
type: 'redirect-route';
path: RoutePath;
@@ -46,5 +54,11 @@ export type FeatureFlagOutput = {
export type PluginOutput =
| RouteOutput
| RouteTargetOutput
| RedirectRouteOutput
| FeatureFlagOutput;
export type BackstagePlugin = {
getId(): string;
output(): PluginOutput[];
};
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 file contains non-react related core types used throught Backstage.
*/
/**
* Observer interface for consuming an Observer, see TC39.
*/
export type Observer<T> = {
next?(value: T): void;
error?(error: Error): void;
complete?(): void;
};
/**
* Subscription returned when subscribing to an Observable, see TC39.
*/
export type Subscription = {
/**
* Cancels the subscription
*/
unsubscribe(): void;
/**
* Value indicating whether the subscription is closed.
*/
readonly closed: Boolean;
};
/**
* Observable sequence of values and errors, see TC39.
*
* https://github.com/tc39/proposal-observable
*
* This is used as a common return type for observable values and can be created
* using many different observable implementations, such as zen-observable or RxJS 5.
*/
export type Observable<T> = {
/**
* Subscribes to this observable to start receiving new values.
*/
subscribe(observer: Observer<T>): Subscription;
subscribe(
onNext: (value: T) => void,
onError?: (error: Error) => void,
onComplete?: () => void,
): Subscription;
};
@@ -0,0 +1,81 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 CodeSnippet from './CodeSnippet';
export default {
title: 'CodeSnippet',
component: CodeSnippet,
};
const containerStyle = { width: 300 };
const JAVASCRIPT = `const greeting = "Hello";
const world = "World";
const greet = person => greeting + " " + person + "!";
greet(world);
`;
const TYPESCRIPT = `const greeting: string = "Hello";
const world: string = "World";
const greet = (person: string): string => greeting + " " + person + "!";
greet(world);
`;
const PYTHON = `greeting = "Hello"
world = "World"
def greet(person):
return f"{greeting} {person}!"
greet(world)
`;
export const Default = () => (
<CodeSnippet text={"const hello = 'World';"} language="javascript" />
);
export const MultipleLines = () => (
<CodeSnippet text={JAVASCRIPT} language="javascript" />
);
export const LineNumbers = () => (
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
);
export const Overflow = () => (
<>
<div style={containerStyle}>
<CodeSnippet text={JAVASCRIPT} language="javascript" />
</div>
<div style={containerStyle}>
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
</div>
</>
);
export const Laguages = () => (
<>
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
<CodeSnippet text={TYPESCRIPT} language="typescript" showLineNumbers />
<CodeSnippet text={PYTHON} language="python" showLineNumbers />
</>
);
@@ -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 from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import CodeSnippet from './CodeSnippet';
const JAVASCRIPT = `const greeting = "Hello";
const world = "World";
const greet = person => gretting + " " + person + "!";
`;
const minProps = {
text: JAVASCRIPT,
language: 'javascript',
};
describe('<CodeSnippet />', () => {
it('renders text without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} />),
);
expect(getByText(/"Hello"/)).toBeInTheDocument();
expect(getByText(/"World"/)).toBeInTheDocument();
});
it('renders without line numbers', () => {
const { queryByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} />),
);
expect(queryByText('1')).not.toBeInTheDocument();
expect(queryByText('2')).not.toBeInTheDocument();
expect(queryByText('3')).not.toBeInTheDocument();
});
it('renders with line numbers', () => {
const { queryByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} showLineNumbers />),
);
expect(queryByText(/1/)).toBeInTheDocument();
expect(queryByText(/2/)).toBeInTheDocument();
expect(queryByText(/3/)).toBeInTheDocument();
});
});
@@ -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 React, { FC } from 'react';
import PropTypes from 'prop-types';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { docco, dark } from 'react-syntax-highlighter/dist/cjs/styles/hljs';
import { useTheme } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
type Props = {
text: string;
language: string;
showLineNumbers?: boolean;
};
const defaultProps = {
showLineNumbers: false,
};
const CodeSnippet: FC<Props> = (props) => {
const { text, language, showLineNumbers } = {
...defaultProps,
...props,
};
const theme = useTheme<BackstageTheme>();
const mode = theme.palette.type === 'dark' ? dark : docco;
return (
<SyntaxHighlighter
language={language}
style={mode}
showLineNumbers={showLineNumbers}
>
{text}
</SyntaxHighlighter>
);
};
// Type check for the JS files using this core component
CodeSnippet.propTypes = {
text: PropTypes.string.isRequired,
language: PropTypes.string.isRequired,
showLineNumbers: PropTypes.bool,
};
export default CodeSnippet;
@@ -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 } from './CodeSnippet';
@@ -0,0 +1,81 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ListItem,
ListItemAvatar,
ListItemText,
makeStyles,
Typography,
Theme,
} from '@material-ui/core';
import React, { FC, useState } from 'react';
import { PendingAuthRequest } from '../../api';
const useItemStyles = makeStyles<Theme>((theme) => ({
root: {
paddingLeft: theme.spacing(3),
},
}));
type RowProps = {
request: PendingAuthRequest;
busy: boolean;
setBusy: (busy: boolean) => void;
};
const LoginRequestListItem: FC<RowProps> = ({ request, busy, setBusy }) => {
const classes = useItemStyles();
const [error, setError] = useState<Error>();
const handleContinue = async () => {
setBusy(true);
try {
await request.trigger();
} catch (e) {
setError(e);
} finally {
setBusy(false);
}
};
const IconComponent = request.provider.icon;
return (
<ListItem
button
disabled={busy}
onClick={handleContinue}
classes={{ root: classes.root }}
>
<ListItemAvatar>
<IconComponent fontSize="large" />
</ListItemAvatar>
<ListItemText
primary={request.provider.title}
secondary={
error && (
<Typography color="error">
{error.message || 'An unspecified error occurred'}
</Typography>
)
}
/>
</ListItem>
);
};
export default LoginRequestListItem;
@@ -0,0 +1,88 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
makeStyles,
Theme,
Button,
} from '@material-ui/core';
import React, { FC, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
import LoginRequestListItem from './LoginRequestListItem';
import { useApi, oauthRequestApiRef } from '../../api';
const useStyles = makeStyles<Theme>((theme) => ({
dialog: {
paddingTop: theme.spacing(1),
},
title: {
minWidth: 0,
},
contentList: {
padding: 0,
},
}));
type OAuthRequestDialogProps = {};
export const OAuthRequestDialog: FC<OAuthRequestDialogProps> = () => {
const classes = useStyles();
const [busy, setBusy] = useState(false);
const oauthRequestApi = useApi(oauthRequestApiRef);
const requests = useObservable(
useMemo(() => oauthRequestApi.authRequest$(), [oauthRequestApi]),
[],
);
const handleRejectAll = () => {
requests.forEach((request) => request.reject());
};
return (
<Dialog
open={Boolean(requests.length)}
fullWidth
maxWidth="xs"
classes={{ paper: classes.dialog }}
>
<DialogTitle classes={{ root: classes.title }}>
Login Required
</DialogTitle>
<DialogContent classes={{ root: classes.contentList }}>
<List>
{requests.map((request) => (
<LoginRequestListItem
key={request.provider.title}
request={request}
busy={busy}
setBusy={setBusy}
/>
))}
</List>
</DialogContent>
<DialogActions>
<Button onClick={handleRejectAll}>Reject All</Button>
</DialogActions>
</Dialog>
);
};
@@ -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 { OAuthRequestDialog } from './OAuthRequestDialog';
@@ -17,8 +17,7 @@
import React from 'react';
import {
StatusError,
StatusFailed,
StatusNA,
StatusAborted,
StatusOK,
StatusPending,
StatusRunning,
@@ -34,45 +33,34 @@ export default {
const data = [
{
status: <StatusOK />,
status: <StatusOK>OK</StatusOK>,
label: 'OK',
usage: 'Deployment successful',
},
{
status: <StatusWarning />,
label: 'Warning',
status: <StatusWarning>Warning</StatusWarning>,
usage: 'CPU utilization at 90%',
},
{
status: <StatusError />,
label: 'Error',
status: <StatusError>Error</StatusError>,
usage: 'Service could not be created',
},
{
status: <StatusFailed />,
label: 'Failed',
usage: 'Build for PR #34 failed',
status: <StatusAborted>Aborted</StatusAborted>,
usage: 'Build for PR #34 aborted',
},
{
status: <StatusPending />,
label: 'Pending',
status: <StatusPending>Pending</StatusPending>,
usage: 'Job is waiting',
},
{
status: <StatusRunning />,
label: 'Running',
status: <StatusRunning>Running</StatusRunning>,
usage: 'Job is running',
},
{
status: <StatusNA />,
label: 'N/A',
usage: 'Not sure what to do',
},
];
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'label', title: 'Label' },
{ field: 'usage', title: 'Example usage' },
];
@@ -97,7 +85,6 @@ export const Default = () => (
export const statusOK = () => <StatusOK />;
export const statusWarning = () => <StatusWarning />;
export const statusError = () => <StatusError />;
export const statusFailed = () => <StatusFailed />;
export const statusAborted = () => <StatusAborted />;
export const statusPending = () => <StatusPending />;
export const statusRunning = () => <StatusRunning />;
export const statusNA = () => <StatusNA />;
+33 -45
View File
@@ -21,79 +21,67 @@ import React, { FC } from 'react';
const useStyles = makeStyles<BackstageTheme>((theme) => ({
status: {
width: 12,
height: 12,
display: 'inline-block',
marginRight: 1,
fontWeight: 500,
'&::before': {
width: '0.7em',
height: '0.7em',
display: 'inline-block',
marginRight: 8,
borderRadius: '50%',
content: '""',
},
},
ok: {
backgroundColor: theme.palette.status.ok,
borderRadius: '50%',
'&::before': {
backgroundColor: theme.palette.status.ok,
},
},
warning: {
backgroundColor: theme.palette.status.warning,
'&::before': {
backgroundColor: theme.palette.status.warning,
},
},
error: {
// Use same for Failed status.
width: '0',
height: '0',
borderLeft: '7px solid transparent',
borderRight: '7px solid transparent',
borderBottom: `14px solid ${theme.palette.status.error}`,
'&::before': {
backgroundColor: theme.palette.status.error,
},
},
pending: {
backgroundColor: theme.palette.status.pending,
'&::before': {
backgroundColor: theme.palette.status.pending,
},
},
running: {
animation: '$blink 0.8s step-start 0s infinite',
backgroundColor: theme.palette.status.running,
'&::before': {
backgroundColor: theme.palette.status.running,
},
},
'@keyframes blink': {
'50%': {
backgroundColor: theme.palette.status.background,
aborted: {
'&::before': {
backgroundColor: theme.palette.status.aborted,
},
},
}));
export const StatusOK: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
className={classNames(classes.status, classes.ok)}
aria-label="Status OK"
{...props}
/>
);
return <span className={classNames(classes.status, classes.ok)} {...props} />;
};
export const StatusWarning: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
className={classNames(classes.status, classes.warning)}
aria-label="Status warning"
{...props}
/>
<span className={classNames(classes.status, classes.warning)} {...props} />
);
};
export const StatusError: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
className={classNames(classes.status, classes.error)}
aria-label="Status error"
{...props}
/>
<span className={classNames(classes.status, classes.error)} {...props} />
);
};
export const StatusNA: FC<{}> = (props) => (
<span aria-label="Status N/A" {...props}>
N/A
</span>
);
export const StatusPending: FC<{}> = (props) => {
const classes = useStyles(props);
return (
@@ -116,12 +104,12 @@ export const StatusRunning: FC<{}> = (props) => {
);
};
export const StatusFailed: FC<{}> = (props) => {
export const StatusAborted: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
className={classNames(classes.status, classes.error)}
aria-label="Status failed"
className={classNames(classes.status, classes.aborted)}
aria-label="Status aborted"
{...props}
/>
);
+1 -2
View File
@@ -16,8 +16,7 @@
export {
StatusError,
StatusFailed,
StatusNA,
StatusAborted,
StatusOK,
StatusPending,
StatusRunning,
+1
View File
@@ -33,6 +33,7 @@ export { default as HorizontalProgress } from './components/ProgressBars/Horizon
export { default as CopyTextButton } from './components/CopyTextButton';
export { default as Progress } from './components/Progress';
export * from './components/SimpleStepper';
export { OAuthRequestDialog } from './components/OAuthRequestDialog';
export { AlphaLabel, BetaLabel } from './components/Lifecycle';
export { default as SupportButton } from './components/SupportButton';
export { default as Table, SubvalueCell } from './components/Table';
+4 -10
View File
@@ -14,19 +14,13 @@
* limitations under the License.
*/
import {
Link,
makeStyles,
styled,
SvgIcon,
Theme,
Typography,
} from '@material-ui/core';
import { Link, makeStyles, styled, Theme, Typography } from '@material-ui/core';
import clsx from 'clsx';
import React, { FC, useContext } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { IconComponent } from '../../icons';
const useStyles = makeStyles<Theme>(theme => ({
const useStyles = makeStyles<Theme>((theme) => ({
root: {
color: '#b5b5b5',
display: 'flex',
@@ -59,7 +53,7 @@ const useStyles = makeStyles<Theme>(theme => ({
}));
type SidebarItemProps = {
icon: typeof SvgIcon;
icon: IconComponent;
text: string;
to?: string;
onClick?: () => void;
+12 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/dev-utils",
"description": "Utilities for developing Backstage plugins.",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"private": false,
"publishConfig": {
"access": "public"
@@ -17,31 +17,33 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"types": "dist/index.d.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/cli": "^0.1.1-alpha.4",
"@backstage/core": "^0.1.1-alpha.4",
"@backstage/test-utils": "^0.1.1-alpha.4",
"@backstage/theme": "^0.1.1-alpha.4",
"@backstage/cli": "^0.1.1-alpha.5",
"@backstage/core": "^0.1.1-alpha.5",
"@backstage/test-utils": "^0.1.1-alpha.5",
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2"
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
+12
View File
@@ -127,6 +127,18 @@ class DevAppBuilder {
);
break;
}
case 'nav-target-component': {
const { target } = output;
sidebarItems.push(
<SidebarItem
key={target.path}
to={target.path}
text={target.title}
icon={target.icon}
/>,
);
break;
}
default:
break;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "storybook",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"description": "Storybook build for core package",
"private": true,
"scripts": {
@@ -14,7 +14,7 @@
]
},
"dependencies": {
"@backstage/theme": "0.1.1-alpha.4"
"@backstage/theme": "^0.1.1-alpha.5"
},
"devDependencies": {
"@storybook/addon-actions": "^5.3.17",
+6 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/test-utils-core",
"description": "Utilities to test Backstage core",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"private": false,
"publishConfig": {
"access": "public"
@@ -17,22 +17,24 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"types": "dist/index.d.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": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@types/jest": "^24.0.0",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
+13 -11
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/test-utils",
"description": "Utilities to test Backstage plugins and apps.",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"private": false,
"publishConfig": {
"access": "public"
@@ -17,29 +17,31 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"types": "dist/index.d.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/cli": "^0.1.1-alpha.4",
"@backstage/test-utils-core": "^0.1.1-alpha.4",
"@backstage/theme": "^0.1.1-alpha.4",
"@backstage/cli": "^0.1.1-alpha.5",
"@backstage/test-utils-core": "^0.1.1-alpha.5",
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"react": "^16.12.0",
"@types/jest": "^24.0.0",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@testing-library/jest-dom": "^4.2.4",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2"
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
+6 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/theme",
"description": "material-ui theme for use with Backstage.",
"version": "0.1.1-alpha.4",
"version": "0.1.1-alpha.5",
"private": false,
"publishConfig": {
"access": "public"
@@ -17,19 +17,21 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@material-ui/core": "^4.9.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.4"
"@backstage/cli": "^0.1.1-alpha.5"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
+12 -12
View File
@@ -23,12 +23,12 @@ export const lightTheme = createTheme({
default: '#F8F8F8',
},
status: {
ok: '#1db855',
warning: '#f49b20',
error: '#CA001B',
running: '#BEBEBE',
pending: '#5BC0DE',
background: '#F8F8F8',
ok: '#1DB954',
warning: '#FF9800',
error: '#E22134',
running: '#2E77D0',
pending: '#FFED51',
aborted: '#757575',
},
bursts: {
fontColor: '#FEFEFE',
@@ -63,12 +63,12 @@ export const darkTheme = createTheme({
default: '#333333',
},
status: {
ok: '#1db855',
warning: '#f49b20',
error: '#CA001B',
running: '#BEBEBE',
pending: '#5BC0DE',
background: '#282828',
ok: '#1DB954',
warning: '#FF9800',
error: '#E22134',
running: '#2E77D0',
pending: '#FFED51',
aborted: '#757575',
},
bursts: {
fontColor: '#FEFEFE',
+1 -1
View File
@@ -27,7 +27,7 @@ type PaletteAdditions = {
error: string;
pending: string;
running: string;
background: string;
aborted: string;
};
border: string;
textContrast: string;
@@ -1,13 +1,13 @@
# Inventory Backend
# Catalog Backend
WORK IN PROGRESS
This is the backend part of the default inventory plugin.
This is the backend part of the default catalog plugin.
It responds to requests from the frontend part, and fulfills them by delegating
to your existing inventory related services.
to your existing catalog related services.
## Links
- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/inventory]
- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog]
- (The Backstage homepage)[https://backstage.io]
@@ -0,0 +1,6 @@
apiVersion: catalog.backstage.io/v1
kind: Component
metadata:
name: component1
spec:
type: service
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-inventory-backend",
"version": "0.1.1-alpha.4",
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.5",
"main": "dist",
"license": "Apache-2.0",
"private": true,
@@ -12,17 +12,25 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "0.1.1-alpha.4",
"@backstage/backend-common": "^0.1.1-alpha.5",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"knex": "^0.21.1",
"morgan": "^1.10.0",
"winston": "^3.2.1"
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yaml": "^1.9.2",
"yn": "^4.0.0",
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@backstage/cli": "^0.1.1-alpha.5",
"@types/uuid": "^7.0.3",
"@types/yup": "^0.28.2",
"jest-fetch-mock": "^3.0.3",
"tsc-watch": "^4.2.3"
},
@@ -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 { PassThrough } from 'stream';
import winston from 'winston';
import { CatalogLogic } from './CatalogLogic';
import { Catalog } from './types';
describe('CatalogLogic', () => {
const logger = winston.createLogger({
transports: [new winston.transports.Stream({ stream: new PassThrough() })],
});
describe('refreshLocations', () => {
it('works with no locations added', async () => {
const catalog = ({
addOrUpdateComponent: jest.fn(),
locations: jest.fn().mockResolvedValue([]),
} as unknown) as Catalog;
const locationReader = jest.fn();
await expect(
CatalogLogic.refreshLocations(catalog, locationReader, logger),
).resolves.toBeUndefined();
expect(locationReader).not.toHaveBeenCalled();
});
it('can update a single location', async () => {
const catalog = ({
addOrUpdateComponent: jest.fn(),
locations: jest.fn().mockResolvedValue([
{
id: '123',
type: 'some',
target: 'thing',
},
]),
} as unknown) as Catalog;
const locationReader = jest.fn().mockResolvedValue([
{
name: 'c1',
},
]);
await expect(
CatalogLogic.refreshLocations(catalog, locationReader, logger),
).resolves.toBeUndefined();
expect(locationReader).toHaveBeenCalledTimes(1);
expect(locationReader).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ type: 'some' }),
);
expect(catalog.addOrUpdateComponent).toHaveBeenCalledTimes(1);
expect(catalog.addOrUpdateComponent).toHaveBeenNthCalledWith(
1,
'123',
expect.objectContaining({ name: 'c1' }),
);
});
});
});
@@ -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 { Logger } from 'winston';
import { LocationReader } from '../ingestion/types';
import { Catalog } from './types';
export class CatalogLogic {
public static startRefreshLoop(
catalog: Catalog,
reader: LocationReader,
logger: Logger,
): () => void {
let cancel: () => void;
let cancelled = false;
const cancellationPromise = new Promise((resolve) => {
cancel = () => {
resolve();
cancelled = true;
};
});
const startRefresh = async () => {
while (!cancelled) {
await CatalogLogic.refreshLocations(catalog, reader, logger);
await Promise.race([
new Promise((resolve) => setTimeout(resolve, 10000)),
cancellationPromise,
]);
}
};
startRefresh();
return cancel!;
}
public static async refreshLocations(
catalog: Catalog,
reader: LocationReader,
logger: Logger,
): Promise<void> {
const locations = await catalog.locations();
for (const location of locations) {
try {
logger.debug(`Attempting refresh of location: ${location.id}`);
const componentDescriptors = await reader(location);
for (const componentDescriptor of componentDescriptors) {
await catalog.addOrUpdateComponent(location.id, componentDescriptor);
}
} catch (e) {
logger.debug(`Failed to update location "${location.id}", ${e}`);
}
}
}
}
@@ -0,0 +1,63 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import knex from 'knex';
import path from 'path';
import { PassThrough } from 'stream';
import winston from 'winston';
import { DatabaseCatalog } from './DatabaseCatalog';
describe('DatabaseCatalog', () => {
const database = knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
const logger = winston.createLogger({
transports: [new winston.transports.Stream({ stream: new PassThrough() })],
});
beforeEach(async () => {
await database.migrate.latest({
directory: path.resolve(__dirname, '..', 'migrations'),
loadExtensions: ['.ts'],
});
});
it('manages locations', async () => {
const catalog = new DatabaseCatalog(database, logger);
const input = { type: 'a', target: 'b' };
const output = { id: expect.anything(), type: 'a', target: 'b' };
await catalog.addLocation(input);
const locations = await catalog.locations();
expect(locations).toEqual([output]);
const location = await catalog.location(locations[0].id);
expect(location).toEqual(output);
await catalog.removeLocation(locations[0].id);
await expect(catalog.locations()).resolves.toEqual([]);
await expect(catalog.location(locations[0].id)).rejects.toThrow(
/Found no location/,
);
});
});
@@ -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 { NotFoundError } from '@backstage/backend-common';
import Knex from 'knex';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { ComponentDescriptor } from '../descriptors';
import { readLocation } from '../ingestion';
import { CatalogLogic } from './CatalogLogic';
import { AddLocationRequest, Catalog, Component, Location } from './types';
export class DatabaseCatalog implements Catalog {
static async create(
database: Knex,
logger: Logger,
): Promise<DatabaseCatalog> {
await database.migrate.latest({
directory: path.resolve(__dirname, '..', 'migrations'),
loadExtensions: ['.js'],
});
const databaseCatalog = new DatabaseCatalog(database, logger);
CatalogLogic.startRefreshLoop(databaseCatalog, readLocation, logger);
return databaseCatalog;
}
constructor(
private readonly database: Knex,
private readonly logger: Logger,
) {}
async addOrUpdateComponent(
locationId: string,
descriptor: ComponentDescriptor,
): Promise<void> {
const component: Component = {
name: descriptor.metadata.name,
};
await this.database.transaction(async (tx) => {
// TODO(freben): Currently, several locations can compete for the same component
const count = await tx('components')
.where({ name: component.name })
.update({ ...component, locationId });
if (!count) {
await tx('components').insert({
...component,
id: uuidv4(),
locationId,
});
}
});
}
async components(): Promise<Component[]> {
return await this.database('components').select();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async component(name: string): Promise<Component> {
const items = await this.database('components').where({ name }).select();
if (!items.length) {
throw new NotFoundError(`Found no component with name ${name}`);
}
return items[0];
}
async addLocation(location: AddLocationRequest): Promise<Location> {
const id = uuidv4();
const { type, target } = location;
await this.database('locations').insert({ id, type, target });
return await this.location(id);
}
async removeLocation(id: string): Promise<void> {
const result = await this.database('locations').where({ id }).del();
if (!result) {
throw new NotFoundError(`Found no location with ID ${id}`);
}
}
async location(id: string): Promise<Location> {
const items = await this.database('locations').where({ id }).select();
if (!items.length) {
throw new NotFoundError(`Found no location with ID ${id}`);
}
return items[0];
}
async locations(): Promise<Location[]> {
return await this.database('locations').select();
}
}
@@ -0,0 +1,73 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NotFoundError } from '@backstage/backend-common';
import { v4 as uuidv4 } from 'uuid';
import { ComponentDescriptor } from '../descriptors';
import { AddLocationRequest, Catalog, Component, Location } from './types';
export class StaticCatalog implements Catalog {
private _components: Component[];
private _locations: Location[];
constructor(components: Component[], locations: Location[]) {
this._components = components;
this._locations = locations;
}
async addOrUpdateComponent(
locationId: string,
descriptor: ComponentDescriptor,
): Promise<void> {
this._components = this._components
.filter((c) => c.name !== descriptor.metadata.name)
.concat([{ name: descriptor.metadata.name }]);
}
async components(): Promise<Component[]> {
return this._components.slice();
}
async component(name: string): Promise<Component> {
const item = this._components.find((i) => i.name === name);
if (!item) {
throw new NotFoundError(`Found no component with name ${name}`);
}
return item;
}
async addLocation(location: AddLocationRequest): Promise<Location> {
const l = { id: uuidv4(), type: location.type, target: location.target };
this._locations.push(l);
return l;
}
async removeLocation(id: string): Promise<void> {
this._locations = this._locations.filter((l) => l.id !== id);
}
async location(id: string): Promise<Location> {
const location = this._locations.find((l) => l.id === id);
if (!location) {
throw new NotFoundError(`Found no location with ID ${id}`);
}
return location;
}
async locations(): Promise<Location[]> {
return this._locations;
}
}
@@ -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 './DatabaseCatalog';
export * from './StaticCatalog';
export * from './types';
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as yup from 'yup';
import { ComponentDescriptor } from '../descriptors';
export type Component = {
name: string;
};
export type Location = {
id: string;
type: string;
target: string;
};
export type AddLocationRequest = {
type: string;
target: string;
};
export const addLocationRequestShape: yup.Schema<AddLocationRequest> = yup
.object({
type: yup.string().required(),
target: yup.string().required(),
})
.noUnknown();
export type Catalog = {
addOrUpdateComponent(
locationId: string,
descriptor: ComponentDescriptor,
): Promise<void>;
components(): Promise<Component[]>;
component(id: string): Promise<Component>;
addLocation(location: AddLocationRequest): Promise<Location>;
removeLocation(id: string): Promise<void>;
locations(): Promise<Location[]>;
location(id: string): Promise<Location>;
};
@@ -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 * as yup from 'yup';
import { DescriptorEnvelope } from './envelope';
export type ComponentDescriptor = {
metadata: {
name: string;
};
spec: {
type: string;
};
};
const componentDescriptorSchema: yup.Schema<ComponentDescriptor> = yup.object({
kind: yup
.string()
.required()
.matches(/^Component$/),
metadata: yup.object({
name: yup.string().required(),
}),
spec: yup.object({
type: yup.string().required(),
}),
});
export async function parseComponentDescriptor(
envelope: DescriptorEnvelope,
): Promise<ComponentDescriptor[]> {
let componentDescriptor;
try {
componentDescriptor = await componentDescriptorSchema.validate(envelope, {
strict: true,
});
} catch (e) {
throw new Error(`Malformed component, ${e}`);
}
return [componentDescriptor];
}
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import yaml from 'yaml';
import * as yup from 'yup';
export type DescriptorEnvelope = {
apiVersion: string;
kind: string;
metadata?: object;
spec?: object;
};
const descriptorEnvelopeSchema: yup.Schema<DescriptorEnvelope> = yup
.object({
apiVersion: yup.string().required(),
kind: yup.string().required(),
metadata: yup.object(),
spec: yup.object(),
})
.noUnknown();
export async function parseDescriptorEnvelope(
rawYaml: string,
): Promise<DescriptorEnvelope> {
let descriptor;
try {
descriptor = yaml.parse(rawYaml);
} catch (e) {
throw new Error(`Malformed YAML, ${e}`);
}
try {
return await descriptorEnvelopeSchema.validate(descriptor, {
strict: true,
});
} catch (e) {
throw new Error(`Malformed envelope, ${e}`);
}
}
@@ -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 './component';
export * from './envelope';
export * from './util';
@@ -0,0 +1,26 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentDescriptor, parseComponentDescriptor } from './component';
import { parseDescriptorEnvelope } from './envelope';
// TODO(freben): Temporary helper that ignores the kind
export async function parseDescriptor(
rawYaml: string,
): Promise<ComponentDescriptor[]> {
const env = await parseDescriptorEnvelope(rawYaml);
return await parseComponentDescriptor(env);
}
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export * from './inventory';
export * from './catalog';
export * from './service/router';
@@ -0,0 +1,35 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { ComponentDescriptor, parseDescriptor } from '../descriptors';
export async function readFileLocation(
target: string,
): Promise<ComponentDescriptor[]> {
let rawYaml;
try {
rawYaml = await fs.readFile(target, 'utf8');
} catch (e) {
throw new Error(`Unable to read "${target}", ${e}`);
}
try {
return parseDescriptor(rawYaml);
} catch (e) {
throw new Error(`Malformed descriptor at "${target}", ${e}`);
}
}
@@ -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 './fileLocation';
export * from './types';
export * from './util';

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