Merge branch 'master' of github.com:spotify/backstage into mob/prepare-from-catalog

* 'master' of github.com:spotify/backstage: (60 commits)
  packages,plugins: remove main:src and fix some main fields
  packages/config: added get and getOptional
  changed  down caret to be up caret on user profile in the sidebar
  packages/config: add getOptionalConfig and getOptionalConfigArray to mirror other accessors
  packages/config-loader,core: update config usage to include context
  build(deps): bump @types/react from 16.9.25 to 16.9.37 (#1342)
  build(deps): bump rollup-plugin-esbuild from 2.0.0 to 2.1.0 (#1361)
  build(deps-dev): bump lint-staged from 10.2.9 to 10.2.11 (#1359)
  Polishing the Create page (#1353)
  Add some air between sidebar sections (#1355)
  Starred icon is yellow (#1351)
  Updated FAQ with Gitlab link (#1352)
  build(deps): bump react-helmet from 6.0.0 to 6.1.0 (#1327)
  packages/config: added context to keep track of where config is from for error messages
  packages/config: added .keys()
  packages/config: keep track of key prefix to display better error messages
  packages/config: optimize some error message handling
  packages/config: allow config readers to be backed by undefined data
  packages/config: flip around must* and get* to get* and getOptional*
  packages/core-api: fix invocation order when continuing to app after sign-in
  ...
This commit is contained in:
blam
2020-06-18 14:00:29 +02:00
108 changed files with 2045 additions and 1379 deletions
-9
View File
@@ -14,8 +14,6 @@ jobs:
env:
CI: true
NODE_OPTIONS: --max-old-space-size=4096
BACKSTAGE_CACHE_DIR: <repoRoot>/.backstage-build-cache
BACKSTAGE_CACHE_MAX_ENTRIES: 2
steps:
- uses: actions/checkout@v2
@@ -36,13 +34,6 @@ jobs:
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
- name: cache build cache
uses: actions/cache@v2
with:
path: .backstage-build-cache
key: build-cache-${{ github.sha }}
restore-keys: |
build-cache-
- name: use node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
-9
View File
@@ -15,8 +15,6 @@ jobs:
env:
CI: true
NODE_OPTIONS: --max-old-space-size=4096
BACKSTAGE_CACHE_DIR: <repoRoot>/.backstage-build-cache
BACKSTAGE_CACHE_MAX_ENTRIES: 2
steps:
- uses: actions/checkout@v2
@@ -35,13 +33,6 @@ jobs:
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
- name: cache build cache
uses: actions/cache@v2
with:
path: .backstage-build-cache
key: build-cache-${{ github.sha }}
restore-keys: |
build-cache-
- name: use node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
+18 -16
View File
@@ -15,12 +15,12 @@ No, but it can be! Backstage is designed to be a developer portal for all your
infrastructure tooling, services, and documentation. So, it's not a monitoring
platform — but that doesn't mean you can't integrate a monitoring tool into
Backstage by writing
[a plugin](https://github.com/spotify/faq#what-is-a-plugin-in-backstage).
[a plugin](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage).
### How is Backstage licensed?
Backstage was released as free and open software by Spotify and is licensed
under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
Backstage was released as open sourced software by Spotify and is licensed under
[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
### Why did we open source Backstage?
@@ -99,15 +99,15 @@ plugins.
### What is a "plugin" in Backstage?
Plugins are what provide the feature functionality in Backstage. They are used
to integrate different systems into Backstage's frontend, so that the developer
gets a consistent UX, no matter what tool or service is being accessed on the
other side. Each plugin is treated as a self-contained web app and can include
almost any type of content. Plugins all use a common set of platform APIs and
reusable UI components. Plugins can fetch data either from the backend or an API
exposed through the proxy. Learn more about
[the different components](https://github.com/spotify/backstage#overview) that
make up Backstage.
By far, our most-used plugin is our TechDocs plugin, which we use for creating
technical documentation. Our philosophy at Spotify is to treat "docs like code",
where you write documentation using the same workflow as you write your code.
This makes it easier to create, find, and update documentation. We hope to
release
[the open source version](https://github.com/spotify/backstage/issues/687) in
the future. (See also:
"[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage)"
above)
### Do I have to write plugins in TypeScript?
@@ -156,10 +156,12 @@ Integrators also configure closed source plugins locally from the monorepo.
We chose GitHub because it is the tool that we are most familiar with, so that
will naturally lead to integrations for GitHub being developed at an early
stage. Hosting this project on GitHub does not exclude integrations with
alternatives, such as GitLab or Bitbucket. We believe that in time there will be
plugins that will provide functionality for these tools as well. Hopefully,
contributed by the community! Also note, implementations of Backstage can be
hosted wherever you feel suits your needs best.
alternatives, such as
[GitLab](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+GitLab)
or Bitbucket. We believe that in time there will be plugins that will provide
functionality for these tools as well. Hopefully, contributed by the community!
Also note, implementations of Backstage can be hosted wherever you feel suits
your needs best.
### Who maintains Backstage?
+122
View File
@@ -0,0 +1,122 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "backstage.io/v1alpha1",
"type": "object",
"title": "A JSON Schema for Backstage catalog entities.",
"description": "Each descriptor file has a number of entities. This schema matches each of those.",
"examples": [
{
"apiVersion": "backstage.io/v1alpha1",
"kind": "Component",
"metadata": {
"name": "LoremService",
"description": "Creates Lorems like a pro.",
"labels": {
"product_name": "Random value Generator"
},
"annnotations": {
"docs": "https://github.com/..../tree/develop/doc"
},
"teams": [
{
"name": "Team super great",
"email": "greatTeam@geemel.com"
}
]
},
"spec": {
"type": "service",
"lifecycle": "production",
"owner": "tools@example.com"
}
}
],
"required": ["apiVersion", "kind", "metadata"],
"additionalProperties": false,
"properties": {
"apiVersion": {
"type": "string",
"description": "Version of the specification format for a particular file is written against.",
"enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
},
"kind": {
"type": "string",
"description": "High level entity type being described, from the Backstage system model.",
"enum": ["Component"]
},
"metadata": {
"$ref": "#/definitions/metadata"
},
"spec": {
"$ref": "#/definitions/spec"
}
},
"definitions": {
"metadata": {
"type": "object",
"description": "Metadata about the entity, i.e. things that aren't directly part of the entity specification itself.",
"required": ["name"],
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9A-Z_.-]{1,63}$",
"description": "The name of the entity. This name is both meant for human eyes to recognize the entity, and for machines and other components to reference the entity"
},
"description": {
"type": "string",
"description": "A human readable description of the entity, to be shown in Backstage. Should be kept short and informative."
},
"namespace": {
"type": "string",
"description": "The name of a namespace that the entity belongs to."
},
"labels": {
"type": "object",
"description": "Labels are optional key/value pairs of that are attached to the entity, and their use is identical to kubernetes object labels.",
"additionalProperties": true,
"patternProperties": {
"^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": {
"type": "string",
"pattern": "^[a-z0-9A-Z_.-]{1,63}$"
}
}
},
"annnotations": {
"type": "object",
"description": "Arbitrary non-identifying metadata attached to the entity, identical in use to kubernetes object annotations.",
"additionalProperties": true,
"patternProperties": {
"^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": {
"type": "string",
"pattern": "^[a-z0-9A-Z_.-]{1,63}$"
}
}
}
}
},
"spec": {
"type": "object",
"description": "Actual specification data that describes the entity. TODO: shape depend on `kind`",
"required": ["type", "lifecycle", "owner"],
"additionalProperties": true,
"properties": {
"type": {
"type": "string",
"description": "The type of component.",
"examples": ["service"]
},
"lifecycle": {
"type": "string",
"description": "The lifecycle step that this component is in.",
"examples": ["production"]
},
"owner": {
"type": "string",
"description": "The owner of the component.",
"examples": ["tools@example.com"]
}
}
}
}
}
+1 -1
View File
@@ -2,5 +2,5 @@
"packages": ["packages/*", "plugins/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.1-alpha.8"
"version": "0.1.1-alpha.9"
}
+14 -14
View File
@@ -1,21 +1,21 @@
{
"name": "example-app",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": true,
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-catalog": "^0.1.1-alpha.8",
"@backstage/plugin-circleci": "^0.1.1-alpha.8",
"@backstage/plugin-explore": "^0.1.1-alpha.8",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.8",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.8",
"@backstage/plugin-register-component": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
"@backstage/plugin-sentry": "^0.1.1-alpha.8",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.8",
"@backstage/plugin-welcome": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/plugin-catalog": "^0.1.1-alpha.9",
"@backstage/plugin-circleci": "^0.1.1-alpha.9",
"@backstage/plugin-explore": "^0.1.1-alpha.9",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.9",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.9",
"@backstage/plugin-register-component": "^0.1.1-alpha.9",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.9",
"@backstage/plugin-sentry": "^0.1.1-alpha.9",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.9",
"@backstage/plugin-welcome": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"prop-types": "^15.7.2",
+18 -5
View File
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
import {
createApp,
AlertDisplay,
OAuthRequestDialog,
SignInPage,
} from '@backstage/core';
import React, { FC } from 'react';
import Root from './components/Root';
import * as plugins from './plugins';
@@ -24,18 +29,26 @@ import { hot } from 'react-hot-loader/root';
const app = createApp({
apis,
plugins: Object.values(plugins),
components: {
SignInPage: props => (
<SignInPage {...props} providers={['guest', 'google', 'custom']} />
),
},
});
const AppProvider = app.getProvider();
const AppComponent = app.getRootComponent();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const App: FC<{}> = () => (
<AppProvider>
<AlertDisplay />
<OAuthRequestDialog />
<Root>
<AppComponent />
</Root>
<AppRouter>
<Root>
<AppRoutes />
</Root>
</AppRouter>
</AppProvider>
);
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -40,7 +40,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@types/compression": "^1.7.0",
"@types/http-errors": "^1.6.3",
"@types/morgan": "^1.9.0",
+11 -9
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"private": true,
@@ -17,20 +17,22 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.8",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.8",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.8",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.8",
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/catalog-model": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.9",
"@backstage/config-loader": "^0.1.1-alpha.9",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.9",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.9",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.9",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.9",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.9",
"express": "^4.17.1",
"knex": "^0.21.1",
"sqlite3": "^4.2.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47"
+19 -11
View File
@@ -27,6 +27,8 @@ import {
getRootLogger,
useHotMemoize,
} from '@backstage/backend-common';
import { ConfigReader, AppConfig } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
import knex from 'knex';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
@@ -35,20 +37,26 @@ import scaffolder from './plugins/scaffolder';
import sentry from './plugins/sentry';
import { PluginEnvironment } from './types';
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 };
function makeCreateEnv(loadedConfigs: AppConfig[]) {
const config = ConfigReader.fromConfigs(loadedConfigs);
return (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, config };
};
}
async function main() {
const createEnv = makeCreateEnv(await loadConfig());
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const authEnv = useHotMemoize(module, () => createEnv('auth'));
+5 -2
View File
@@ -17,6 +17,9 @@
import { createRouter } from '@backstage/plugin-auth-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
}
+2
View File
@@ -16,8 +16,10 @@
import Knex from 'knex';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
export type PluginEnvironment = {
logger: Logger;
database: Knex;
config: Config;
};
+6 -7
View File
@@ -1,12 +1,10 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.8",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"main:src": "src/index.ts",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
@@ -22,14 +20,15 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@types/yup": "^0.28.2",
"lodash": "^4.17.15",
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@types/express": "^4.17.6",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"@types/yup": "^0.28.2",
"yaml": "^1.9.2"
},
"files": [
+3 -22
View File
@@ -25,32 +25,13 @@ async function getConfig() {
return require(path.resolve('jest.config.ts'));
}
const moduleNameMapper = {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
};
// Only point to src/ if we're not in CI, there we just build packages first anyway
if (!process.env.CI) {
const LernaProject = require('@lerna/project');
const project = new LernaProject(path.resolve('.'));
const packages = await project.getPackages();
// To avoid having to build all deps inside the monorepo before running tests,
// we point directory to src/ where applicable.
// For example, @backstage/core = <repo-root>/packages/core/src/index.ts is added to moduleNameMapper
for (const pkg of packages) {
const mainSrc = pkg.get('main:src');
if (mainSrc) {
moduleNameMapper[`^${pkg.name}$`] = path.resolve(pkg.location, mainSrc);
}
}
}
const options = {
rootDir: path.resolve('src'),
coverageDirectory: path.resolve('coverage'),
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'],
moduleNameMapper,
moduleNameMapper: {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
},
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
// TODO: jest is working on module support, it's possible that we can remove this in the future
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public"
@@ -29,8 +29,8 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/config-loader": "^0.1.1-alpha.7",
"@backstage/config": "0.1.1-alpha.9",
"@backstage/config-loader": "^0.1.1-alpha.9",
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
@@ -40,8 +40,8 @@
"@spotify/eslint-config": "^7.0.1",
"@sucrase/webpack-loader": "^2.0.0",
"@types/start-server-webpack-plugin": "^2.2.0",
"@types/webpack-node-externals": "^1.7.1",
"@types/webpack-env": "^1.15.2",
"@types/webpack-node-externals": "^1.7.1",
"bfj": "^7.0.2",
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
+1 -12
View File
@@ -15,20 +15,9 @@
*/
import fs from 'fs-extra';
import { resolve as resolvePath, relative as relativePath } from 'path';
import { paths } from '../../lib/paths';
import { getDefaultCacheOptions } from '../../lib/buildCache';
export default async function clean() {
const cacheOptions = getDefaultCacheOptions();
const packagePath = getPackagePath(cacheOptions.cacheDir);
await fs.remove(cacheOptions.output);
await fs.remove(packagePath);
await fs.remove(paths.resolveTarget('dist'));
await fs.remove(paths.resolveTarget('coverage'));
}
function getPackagePath(cacheDir: string) {
const relativePackagePath = relativePath(paths.targetRoot, paths.targetDir);
const packagePath = resolvePath(cacheDir, relativePackagePath);
return packagePath;
}
@@ -1,49 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command } from 'commander';
import { run } from '../../lib/run';
import { createLogPipe } from '../../lib/logging';
import { watchDeps, Options } from '../../lib/watchDeps';
/*
* The watch-deps command is meant to improve iteration speed while working in a large monorepo
* with packages that are built independently, meaning packages depends on each other's build output.
*
* The command traverses all dependencies of the current package within the monorepo, and starts
* watching for updates in all those packages. If a change is detected, we stop listening for changes,
* and instead start up watch mode for that package. Starting watch mode means running the first
* available yarn script out of "build:watch", "watch", or "build" --watch.
*/
export default async (cmd: Command, args: string[]) => {
const options: Options = {};
if (cmd.build) {
options.build = true;
}
await watchDeps(options);
if (args?.length) {
// Use log pipe to avoid clearing the terminal
const logPipe = createLogPipe();
await run(args[0], args.slice(1), {
stdoutLogFunc: logPipe(process.stdout),
stderrLogFunc: logPipe(process.stderr),
});
}
};
-23
View File
@@ -126,29 +126,6 @@ const main = (argv: string[]) => {
.description('Restores the changes made by the prepack command')
.action(lazyAction(() => import('./commands/pack'), 'post'));
program
.command('watch-deps')
.option('--build', 'Build all dependencies on startup')
.description('Watch all dependencies while running another command')
.action(lazyAction(() => import('./commands/watch-deps'), 'default'));
program
.command('build-cache')
.description('Wrap build command with a cache')
.option(
'--input <dirs>',
'List of input directories that invalidate the cache [.]',
(value, acc) => acc.concat(value),
[],
)
.option('--output <dir>', 'Output directory to cache', 'dist')
.option(
'--cache-dir <dir>',
'Cache dir',
'<repoRoot>/node_modules/.cache/backstage-builds',
)
.action(lazyAction(() => import('./commands/build-cache'), 'default'));
program
.command('clean')
.description('Delete cache directories')
@@ -1,39 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 tar from 'tar';
import { dirname } from 'path';
// packages all files in inputDir into an archive at archivePath, deleting any existing archive
export async function createArchive(
archivePath: string,
inputDir: string,
): Promise<void> {
await fs.remove(archivePath);
await fs.ensureDir(dirname(archivePath));
await tar.create({ gzip: true, file: archivePath, cwd: inputDir }, ['.']);
}
// extracts archive at archive path into outputDir, deleting any existing files at outputDir
export async function extractArchive(
archivePath: string,
outputDir: string,
): Promise<void> {
await fs.remove(outputDir);
await fs.ensureDir(outputDir);
await tar.extract({ file: archivePath, cwd: outputDir });
}
-214
View File
@@ -1,214 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { resolve as resolvePath, relative as relativePath } from 'path';
import { runPlain, runCheck } from '../run';
import { Options } from './options';
import { extractArchive, createArchive } from './archive';
import { paths } from '../paths';
import { version, isDev } from '../version';
const INFO_FILE = '.backstage-build-cache';
// Result from a cache query
export type CacheQueryResult = {
// True if there was a cache hit
hit: boolean;
// If there is a cache hit and this method is defined, it needs to be called to restore the
// output contents before continuing.
copy?: (outputDir: string) => Promise<void>;
// Call after a successful build to archive the output content.
// The content will be archived using the same key as was used to query the cache.
archive: (outputDir: string, maxEntries: number) => Promise<void>;
};
// Key that determines whether cached output can be reused
export type CacheKey = string[];
type CacheEntry = {
// Key for the input of this cache entry
key: CacheKey;
// Path to the archive of this cache entry
path: string;
};
// Struct containing information about cache entries on the filesystem.
// Stored inside the INFO_FILE as JSON.
type CacheInfo = {
// Optional key entry for the contents of the current directory. Used to key the
// output present in the outputs folder, where the info file resides inside the output folder.
key?: CacheKey;
// Optional list of cache archives present in the same directory. Resides in the external
// cache location inside one info file for each package.
entries?: CacheEntry[];
};
export class Cache {
// Read the current cache state form the filesystem.
static async read(options: Options) {
const relativePackagePath = relativePath(paths.targetRoot, paths.targetDir);
const location = resolvePath(options.cacheDir, relativePackagePath);
const outputInfo = await readCacheInfo(options.output);
const localKey = outputInfo?.key;
const { entries = [] } = (await readCacheInfo(location)) ?? {};
return new Cache(location, entries, localKey);
}
// Generates a key based on the contents of the input paths.
// Returns undefined if it's not possible to generate a stable key.
static async readInputKey(
inputPaths: string[],
): Promise<CacheKey | undefined> {
const allInputPaths = inputPaths.slice();
// If we're executing the cli inside the backstage repo, we add the cli src as cache key as well
if (isDev) {
allInputPaths.unshift(paths.ownDir);
}
// Make sure we don't have any uncommitted changes to the input, in that case we skip caching.
const noChanges = await runCheck(
'git',
'diff',
'--quiet',
'HEAD',
'--',
...allInputPaths,
);
if (!noChanges) {
return undefined;
}
const trees = [];
for (const inputPath of allInputPaths) {
const output = await runPlain('git', 'ls-tree', 'HEAD', inputPath);
const [, , sha] = output.split(/\s+/, 3);
// If we can't get a tree sha it means we're outside of tracked files, so treat as dirty
if (!sha) {
return undefined;
}
trees.push(sha);
}
if (isDev) {
return trees;
}
// If we're executing as a dependency, use the version as a key
return [version, ...trees];
}
constructor(
private readonly location: string,
private readonly entries: CacheEntry[] = [],
private readonly localKey?: CacheKey,
) {}
// Query for the presense of cached output for a given key
query(key: CacheKey): CacheQueryResult {
const { location } = this;
const archive = async (outputDir: string, maxEntries: number) => {
await writeCacheInfo(outputDir, { key });
const timestamp = new Date().toISOString().replace(/-|:|\..*/g, '');
const rand = Math.random().toString(36).slice(2, 6);
const archiveName = `cache-${timestamp}-${rand}.tgz`;
const archivePath = resolvePath(location, archiveName);
// Read existing entries and prepend the new one
const { entries = [] } = (await readCacheInfo(location)) ?? {};
// Check if there's already aan entry for this key, in that case we just wanna bump it
const entryIndex = entries.findIndex((e) => compareKeys(e.key, key));
if (entryIndex !== -1) {
const [existingEntry] = entries.splice(entryIndex, 1);
entries.unshift(existingEntry);
await writeCacheInfo(location, { entries });
return;
}
// Create and add new archive to entries
await createArchive(archivePath, outputDir);
entries.unshift({ key, path: archiveName });
// Remove old cache entries
const removedEntries = entries.splice(maxEntries);
for (const entry of removedEntries) {
try {
await fs.remove(resolvePath(location, entry.path));
} catch (error) {
process.stderr.write(`failed to remove old cache entry, ${error}\n`);
}
}
await writeCacheInfo(location, { entries });
};
if (compareKeys(this.localKey, key)) {
return { hit: true, archive };
}
const matchingEntry = this.entries.find((e) => compareKeys(e.key, key));
if (!matchingEntry) {
return { hit: false, archive };
}
return {
hit: true,
archive,
copy: async (outputDir: string) => {
const archivePath = resolvePath(location, matchingEntry.path);
await extractArchive(archivePath, outputDir);
},
};
}
}
// Compares to cache keys, returning true if they are both defined and equal
function compareKeys(a?: CacheKey, b?: CacheKey): boolean {
if (!a || !b) {
return false;
}
return a.join(',') === b.join(',');
}
// Read and parse a cache info file in the given directory
async function readCacheInfo(
parentDir: string,
): Promise<CacheInfo | undefined> {
const infoFile = resolvePath(parentDir, INFO_FILE);
const exists = await fs.pathExists(infoFile);
if (!exists) {
return undefined;
}
const infoData = await fs.readFile(infoFile);
const cacheInfo = JSON.parse(infoData.toString('utf8')) as CacheInfo;
return cacheInfo;
}
// Write a cache info file to the given directory
async function writeCacheInfo(
parentDir: string,
cacheInfo: CacheInfo,
): Promise<void> {
const infoData = Buffer.from(JSON.stringify(cacheInfo, null, 2), 'utf8');
await fs.writeFile(resolvePath(parentDir, INFO_FILE), infoData);
}
@@ -1,59 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolve as resolvePath } from 'path';
import { Command } from 'commander';
import { paths } from '../paths';
const DEFAULT_CACHE_DIR = '<repoRoot>/node_modules/.cache/backstage-builds';
const DEFAULT_MAX_ENTRIES = 10;
export type Options = {
inputs: string[];
output: string;
cacheDir: string;
maxCacheEntries: number;
};
function transformPath(path: string): string {
return resolvePath(path.replace(/<repoRoot>/g, paths.targetRoot));
}
export async function parseOptions(cmd: Command): Promise<Options> {
const inputs = cmd.input.map(transformPath) as string[];
if (inputs.length === 0) {
inputs.push(transformPath('.'));
}
const output = transformPath(cmd.output);
const cacheDir = transformPath(
process.env.BACKSTAGE_CACHE_DIR || cmd.cacheDir,
);
const maxCacheEntries =
Number(process.env.BACKSTAGE_CACHE_MAX_ENTRIES) || DEFAULT_MAX_ENTRIES;
return { inputs, output, cacheDir, maxCacheEntries };
}
export function getDefaultCacheOptions(): Options {
return {
inputs: [transformPath('.')],
output: transformPath('dist'),
cacheDir: transformPath(
process.env.BACKSTAGE_CACHE_DIR || DEFAULT_CACHE_DIR,
),
maxCacheEntries:
Number(process.env.BACKSTAGE_CACHE_MAX_ENTRIES) || DEFAULT_MAX_ENTRIES,
};
}
@@ -1,56 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { Cache } from './cache';
import { Options } from './options';
function print(msg: string) {
process.stdout.write(`[build-cache] ${msg}\n`);
}
// Wrap a build function with a cache, which won't call the build function on cache hit
export async function withCache(
options: Options,
buildFunc: () => Promise<void>,
): Promise<void> {
const key = await Cache.readInputKey(options.inputs);
if (!key) {
print('input directory is dirty, skipping cache');
await fs.remove(options.output);
await buildFunc();
return;
}
const cache = await Cache.read(options);
const cacheResult = cache.query(key);
if (cacheResult.hit) {
if (cacheResult.copy) {
print('external cache hit, copying archive to output folder');
await cacheResult.copy(options.output);
} else {
print('cache hit, nothing to be done');
}
return;
}
print('cache miss, need to build');
await fs.remove(options.output);
await buildFunc();
await cacheResult.archive(options.output, options.maxCacheEntries);
}
+7 -9
View File
@@ -33,9 +33,6 @@ import { BundlingOptions, BackendBundlingOptions } from './types';
export function resolveBaseUrl(config: Config): URL {
const baseUrl = config.getString('app.baseUrl');
if (!baseUrl) {
throw new Error('app.baseUrl must be set in config');
}
try {
return new URL(baseUrl, 'http://localhost:3000');
} catch (error) {
@@ -52,9 +49,6 @@ export function createConfig(
const { plugins, loaders } = transforms(options);
const baseUrl = options.config.getString('app.baseUrl');
if (!baseUrl) {
throw new Error('app.baseUrl must be set in config');
}
const validBaseUrl = new URL(baseUrl, 'https://backstage-app.dev');
if (checksEnabled) {
@@ -115,7 +109,7 @@ export function createConfig(
entry: [require.resolve('react-hot-loader/patch'), paths.targetEntry],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
mainFields: ['main:src', 'browser', 'module', 'main'],
mainFields: ['browser', 'module', 'main'],
plugins: [
new ModuleScopePlugin(
[paths.targetSrc, paths.targetDev],
@@ -182,10 +176,14 @@ export function createBackendConfig(
},
devtool: isDev ? 'cheap-module-eval-source-map' : 'source-map',
context: paths.targetPath,
entry: ['webpack/hot/poll?100', paths.targetEntry],
entry: [
'webpack/hot/poll?100',
paths.targetEntry,
...(paths.targetRunFile ? [paths.targetRunFile] : []),
],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
mainFields: ['main:src', 'browser', 'module', 'main'],
mainFields: ['browser', 'module', 'main'],
modules: [paths.targetNodeModules, paths.rootNodeModules],
plugins: [
new ModuleScopePlugin(
+5
View File
@@ -48,10 +48,15 @@ export function resolveBundlingPaths(options: BundlingPathsOptions) {
}
}
// Backend plugin dev run file
const targetRunFile = paths.resolveTarget('src/run.ts');
const runFileExists = fs.pathExistsSync(targetRunFile);
return {
targetHtml,
targetPublic,
targetPath: paths.resolveTarget('.'),
targetRunFile: runFileExists ? targetRunFile : undefined,
targetDist: paths.resolveTarget('dist'),
targetAssets: paths.resolveTarget('assets'),
targetSrc: paths.resolveTarget('src'),
+1 -4
View File
@@ -171,9 +171,7 @@ export async function installWithLocalDeps(dir: string) {
});
// This takes care of pointing all the installed packages from this repo to
// dist instead of the local src.
// For example node_modules/@backstage/core/packages.json is rewritten to point
// types to dist/index.d.ts and the main:src field is removed.
// dist instead of the local src, using the field overrides in publishConfig.
// Without this we get type checking errors in the e2e test
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Patching local dependencies for e2e tests');
@@ -192,7 +190,6 @@ export async function installWithLocalDeps(dir: string) {
const depJson = await fs.readJson(depJsonPath);
// We want dist to be used for e2e tests
delete depJson['main:src'];
for (const key of Object.keys(depJson.publishConfig)) {
if (key !== 'access') {
depJson[key] = depJson.publishConfig[key];
@@ -1,64 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { spawn } from 'child_process';
import { LogPipe } from '../logging';
import chalk from 'chalk';
import { Package } from './packages';
export function startCompiler(pkg: Package, logPipe: LogPipe) {
// First we figure out which yarn script is a available, falling back to "build --watch"
const scriptName = ['build:watch', 'watch'].find(
(script) => script in pkg.scripts,
);
const args = scriptName ? [scriptName] : ['build', '--watch'];
// Start the watch script inside the dependency
const watch = spawn('yarn', ['run', ...args], {
cwd: pkg.location,
env: { FORCE_COLOR: 'true', ...process.env },
stdio: 'pipe',
shell: true,
});
watch.stdin.end();
watch.stdout.on('data', logPipe(process.stdout));
const logErr = logPipe(process.stderr);
watch.stderr.on('data', logErr);
const promise = new Promise<void>((resolve, reject) => {
watch.on('error', (error) => {
reject(error);
});
watch.on('close', (code: number) => {
if (code !== 0) {
const msg = `Compiler exited with code ${code}`;
logErr(chalk.red(msg));
reject(new Error(msg));
} else {
resolve();
}
});
});
return {
promise,
close() {
watch.kill('SIGINT');
},
};
}
@@ -1,62 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { paths } from '../paths';
const LernaProject = require('@lerna/project');
const PackageGraph = require('@lerna/package-graph');
export type Package = {
name: string;
location: string;
scripts: { [name in string]: string };
};
// Uses lerna to find all local deps of the root package, excluding itself or any package in the blacklist
export async function findAllDeps(
rootPackageName: string,
blacklist: string[],
): Promise<Package[]> {
const project = new LernaProject(paths.targetDir);
const packages = await project.getPackages();
const graph = new PackageGraph(packages);
const deps = new Map<string, any>();
const searchNames = [rootPackageName];
while (searchNames.length) {
const name = searchNames.pop()!;
if (deps.has(name)) {
continue;
}
const node = graph.get(name);
if (!node) {
throw new Error(`Package '${name}' not found`);
}
searchNames.push(...node.localDependencies.keys());
deps.set(name, node.pkg);
}
deps.delete(rootPackageName);
for (const name of blacklist) {
deps.delete(name);
}
return [...deps.values()];
}
@@ -1,79 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import chalk from 'chalk';
import fs from 'fs-extra';
import { createLogPipeFactory } from './logger';
import { findAllDeps } from './packages';
import { startWatcher, startPackageWatcher } from './watcher';
import { startCompiler } from './compiler';
import { run } from '../run';
import { paths } from '../paths';
const PACKAGE_BLACKLIST = [
// We never want to watch for changes in the cli, but all packages will depend on it.
'@backstage/cli',
];
const WATCH_LOCATIONS = ['package.json', 'src', 'assets'];
export type Options = {
build?: boolean;
};
// Start watching for dependency changes.
// The returned promise resolves when watchers have started for all current dependencies.
export async function watchDeps(options: Options = {}) {
const localPackagePath = paths.resolveTarget('package.json');
// Rotate through different prefix colors to make it easier to differenciate between different deps
const createLogPipe = createLogPipeFactory([
chalk.yellow,
chalk.blue,
chalk.magenta,
chalk.green,
chalk.cyan,
]);
// Find all direct and transitive local dependencies of the current package.
const packageData = await fs.readFile(localPackagePath, 'utf8');
const packageJson = JSON.parse(packageData);
const packageName = packageJson.name;
const deps = await findAllDeps(packageName, PACKAGE_BLACKLIST);
if (options.build) {
await run('lerna', [
'run',
'--scope',
packageName,
'--include-dependencies',
'build',
]);
}
// We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected
const watcher = await startWatcher(deps, WATCH_LOCATIONS, (pkg) => {
startCompiler(pkg, createLogPipe(pkg.name)).promise.catch((error) => {
process.stderr.write(`${error}\n`);
});
});
await startPackageWatcher(localPackagePath, async () => {
const newDeps = await findAllDeps(packageName, PACKAGE_BLACKLIST);
await watcher.update(newDeps);
});
}
-125
View File
@@ -1,125 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolve as resolvePath } from 'path';
import chalk from 'chalk';
import chokidar from 'chokidar';
import { Package } from './packages';
import { createLogFunc } from '../logging';
export type Watcher = {
update(newPackages: Package[]): Promise<void>;
};
/*
* Watch for changes inside a collection of packages. When a change is detected, stop
* watching and call the callback with the package the change occured in.
*
* The returned promise is resolved once all watchers are ready.
*/
export async function startWatcher(
packages: Package[],
paths: string[],
callback: (pkg: Package) => void,
): Promise<Watcher> {
const watchedPackageLocations = new Set<string>();
const log = createLogFunc(process.stdout);
const watchPackage = async (pkg: Package) => {
let signalled = false;
watchedPackageLocations.add(pkg.location);
const watchLocations = paths.map((path) => resolvePath(pkg.location, path));
const watcher = chokidar
.watch(watchLocations, {
cwd: pkg.location,
ignoreInitial: true,
disableGlobbing: true,
})
.on('all', () => {
if (!signalled) {
signalled = true;
callback(pkg);
}
watcher.close();
});
return new Promise((resolve, reject) => {
watcher.on('ready', resolve);
watcher.on('error', reject);
});
};
const update = async (newPackages: Package[]) => {
const promises = new Array<Promise<unknown>>();
for (const pkg of newPackages) {
if (watchedPackageLocations.has(pkg.location)) {
continue;
}
log(chalk.green(`Starting watch of new dependency ${pkg.name}`));
promises.push(watchPackage(pkg));
}
await Promise.all(promises);
};
await Promise.all(packages.map(watchPackage));
return { update };
}
/**
* Watch a package.json for updates
*/
export function startPackageWatcher(packagePath: string, callback: () => void) {
let changed = false;
let working = false;
const notifyDeps = async () => {
changed = true;
if (working) {
return;
}
working = true;
changed = false;
try {
await callback();
} finally {
working = false;
// Keep going if a change was emitted while working
if (changed) {
notifyDeps();
}
}
};
const watcher = chokidar
.watch(packagePath, {
ignoreInitial: true,
disableGlobbing: true,
})
.on('all', () => {
notifyDeps();
});
return new Promise((resolve, reject) => {
watcher.on('ready', resolve);
watcher.on('error', reject);
});
}
@@ -26,13 +26,16 @@ const app = createApp({
});
const AppProvider = app.getProvider();
const AppComponent = app.getRootComponent();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const App: FC<{}> = () => {
useStyles();
return (
<AppProvider>
<AppComponent />
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
};
@@ -1,8 +1,7 @@
{
"name": "plugin-welcome",
"version": "0.0.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"private": true,
"publishConfig": {
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-{{id}}",
"version": "{{version}}",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/config-loader",
"description": "Config loading functionality used by Backstage backend, and CLI",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,7 +30,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/config": "0.1.1-alpha.9",
"fs-extra": "^9.0.0",
"yaml": "^1.9.2",
"yup": "^0.28.5"
+6 -3
View File
@@ -45,9 +45,12 @@ describe('readEnv', () => {
}),
).toEqual([
{
foo: 'bar',
numbers: { a: 1, b: 2, c: false },
very: { deep: { nested: { config: { object: {} } } } },
data: {
foo: 'bar',
numbers: { a: 1, b: 2, c: false },
very: { deep: { nested: { config: { object: {} } } } },
},
context: 'env',
},
]);
});
+3 -3
View File
@@ -42,7 +42,7 @@ const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
export function readEnv(env: {
[name: string]: string | undefined;
}): AppConfig[] {
let config: JsonObject | undefined = undefined;
let data: JsonObject | undefined = undefined;
for (const [name, value] of Object.entries(env)) {
if (!value) {
@@ -52,7 +52,7 @@ export function readEnv(env: {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
let obj = (data = data ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
@@ -87,5 +87,5 @@ export function readEnv(env: {
}
}
return config ? [config] : [];
return data ? [{ data, context: 'env' }] : [];
}
+11 -5
View File
@@ -38,11 +38,14 @@ describe('readConfigFile', () => {
} as ReaderContext);
await expect(config).resolves.toEqual({
app: {
title: 'Test',
x: 1,
y: [true],
data: {
app: {
title: 'Test',
x: 1,
y: [true],
},
},
context: 'app-config.yaml',
});
});
@@ -83,7 +86,10 @@ describe('readConfigFile', () => {
});
await expect(config).resolves.toEqual({
app: 'secret',
data: {
app: 'secret',
},
context: 'app-config.yaml',
});
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
});
+7 -3
View File
@@ -15,15 +15,19 @@
*/
import yaml from 'yaml';
import { basename } from 'path';
import { isObject } from './utils';
import { JsonValue, JsonObject } from '@backstage/config';
import { JsonValue, JsonObject, AppConfig } from '@backstage/config';
import { ReaderContext } from './types';
/**
* Reads and parses, and validates, and transforms a single config file.
* The transformation rewrites any special values, like the $secret key.
*/
export async function readConfigFile(filePath: string, ctx: ReaderContext) {
export async function readConfigFile(
filePath: string,
ctx: ReaderContext,
): Promise<AppConfig> {
const configYaml = await ctx.readFile(filePath);
const config = yaml.parse(configYaml);
@@ -76,5 +80,5 @@ export async function readConfigFile(filePath: string, ctx: ReaderContext) {
if (!isObject(finalConfig)) {
throw new TypeError('Expected object at config root');
}
return finalConfig;
return { data: finalConfig, context: basename(filePath) };
}
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/config",
"description": "Config API used by Backstage core, backend, and CLI",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -29,6 +29,9 @@
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"lodash": "^4.17.15"
},
"devDependencies": {
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0"
+341 -33
View File
@@ -37,72 +37,112 @@ const DATA = {
};
function expectValidValues(config: ConfigReader) {
expect(config.keys()).toEqual(Object.keys(DATA));
expect(config.get('zero')).toBe(0);
expect(config.getNumber('zero')).toBe(0);
expect(config.getNumber('one')).toBe(1);
expect(config.getOptional('true')).toBe(true);
expect(config.getBoolean('true')).toBe(true);
expect(config.getBoolean('false')).toBe(false);
expect(config.getString('string')).toBe('string');
expect(config.get('strings')).toEqual(['string1', 'string2']);
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
expect(config.getConfig('nested').getNumber('one')).toBe(1);
expect(config.get('nested')).toEqual({
one: 1,
string: 'string',
strings: ['string1', 'string2'],
});
expect(config.getConfig('nested').getString('string')).toBe('string');
expect(config.getConfig('nested').getStringArray('strings')).toEqual([
'string1',
'string2',
]);
expect(
config.getOptionalConfig('nested')!.getStringArray('strings'),
).toEqual(['string1', 'string2']);
expect(config.getOptional('missing')).toBe(undefined);
expect(config.getOptionalConfig('missing')).toBe(undefined);
expect(config.getOptionalConfigArray('missing')).toBe(undefined);
expect(config.getNumber('zero')).toBe(0);
expect(config.getBoolean('true')).toBe(true);
expect(config.getString('string')).toBe('string');
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
const [config1, config2, config3] = config.getConfigArray('nestlings');
expect(config1.getBoolean('boolean')).toBe(true);
expect(config2.getString('string')).toBe('string');
expect(config3.getNumber('number')).toBe(42);
expect(
config.getOptionalConfigArray('nestlings')![0].getBoolean('boolean'),
).toBe(true);
}
function expectInvalidValues(config: ConfigReader) {
expect(() => config.getBoolean('string')).toThrow(
"Invalid type in config for key 'string' in 'ctx', got string, wanted boolean",
);
expect(() => config.getNumber('string')).toThrow(
'Invalid type in config for key string, got string, wanted number',
"Invalid type in config for key 'string' in 'ctx', got string, wanted number",
);
expect(() => config.getString('one')).toThrow(
'Invalid type in config for key one, got number, wanted string',
"Invalid type in config for key 'one' in 'ctx', got number, wanted string",
);
expect(() => config.getNumber('true')).toThrow(
'Invalid type in config for key true, got boolean, wanted number',
"Invalid type in config for key 'true' in 'ctx', got boolean, wanted number",
);
expect(() => config.getStringArray('null')).toThrow(
'Invalid type in config for key null, got null, wanted string-array',
"Invalid type in config for key 'null' in 'ctx', got null, wanted string-array",
);
expect(() => config.getString('emptyString')).toThrow(
'Invalid type in config for key emptyString, got empty-string, wanted string',
"Invalid type in config for key 'emptyString' in 'ctx', got empty-string, wanted string",
);
expect(() => config.getStringArray('badStrings')).toThrow(
'Invalid type in config for key badStrings[1], got empty-string, wanted string',
"Invalid type in config for key 'badStrings[1]' in 'ctx', got empty-string, wanted string",
);
expect(() => config.getStringArray('worseStrings')).toThrow(
'Invalid type in config for key worseStrings[1], got number, wanted string',
"Invalid type in config for key 'worseStrings[1]' in 'ctx', got number, wanted string",
);
expect(() => config.getStringArray('worstStrings')).toThrow(
'Invalid type in config for key worstStrings[2], got object, wanted string',
"Invalid type in config for key 'worstStrings[2]' in 'ctx', got object, wanted string",
);
expect(() => config.getConfig('one')).toThrow(
'Invalid type in config for key one, got number, wanted object',
"Invalid type in config for key 'one' in 'ctx', got number, wanted object",
);
expect(() => config.getConfigArray('one')).toThrow(
'Invalid type in config for key one, got number, wanted object-array',
"Invalid type in config for key 'one' in 'ctx', got number, wanted object-array",
);
expect(() => config.getBoolean('missing')).toThrow(
"Missing required config value at 'missing'",
);
expect(() => config.getNumber('missing')).toThrow(
"Missing required config value at 'missing'",
);
expect(() => config.getString('missing')).toThrow(
"Missing required config value at 'missing'",
);
expect(() => config.getStringArray('missing')).toThrow(
"Missing required config value at 'missing'",
);
}
const CTX = 'ctx';
describe('ConfigReader', () => {
it('should read empty config with valid keys', () => {
const config = new ConfigReader({});
expect(config.getString('x')).toBeUndefined();
expect(config.getString('x_x')).toBeUndefined();
expect(config.getString('x-X')).toBeUndefined();
expect(config.getString('x0')).toBeUndefined();
expect(config.getString('X-x2')).toBeUndefined();
expect(config.getString('x0_x0')).toBeUndefined();
expect(config.getString('x_x-x_x')).toBeUndefined();
const config = new ConfigReader({}, CTX);
expect(config.keys()).toEqual([]);
expect(config.getOptionalString('x')).toBeUndefined();
expect(config.getOptionalString('x_x')).toBeUndefined();
expect(config.getOptionalString('x-X')).toBeUndefined();
expect(config.getOptionalString('x0')).toBeUndefined();
expect(config.getOptionalString('X-x2')).toBeUndefined();
expect(config.getOptionalString('x0_x0')).toBeUndefined();
expect(config.getOptionalString('x_x-x_x')).toBeUndefined();
expect(
new ConfigReader(undefined, CTX).getOptionalString('x'),
).toBeUndefined();
});
it('should throw on invalid keys', () => {
const config = new ConfigReader({});
const config = new ConfigReader({}, CTX);
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('0')).toThrow(/^Invalid config key/);
@@ -119,35 +159,39 @@ describe('ConfigReader', () => {
expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a._')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/);
expect(() => new ConfigReader(undefined, CTX).getString('.')).toThrow(
/^Invalid config key/,
);
});
it('should read valid values', () => {
const config = new ConfigReader(DATA);
const config = new ConfigReader(DATA, CTX);
expectValidValues(config);
});
it('should fail to read invalid values', () => {
const config = new ConfigReader(DATA);
const config = new ConfigReader(DATA, CTX);
expectInvalidValues(config);
});
});
describe('ConfigReader with fallback', () => {
it('should behave as if without fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
expect(config.getString('x')).toBeUndefined();
const config = new ConfigReader({}, CTX, new ConfigReader(DATA, CTX));
expect(config.getOptionalString('x')).toBeUndefined();
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
});
it('should read values from itself', () => {
const config = new ConfigReader(DATA, new ConfigReader({}));
const config = new ConfigReader(DATA, CTX, new ConfigReader({}, CTX));
expectValidValues(config);
expectInvalidValues(config);
});
it('should read values from a fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
const config = new ConfigReader({}, CTX, new ConfigReader(DATA, CTX));
expectValidValues(config);
expectInvalidValues(config);
});
@@ -155,12 +199,92 @@ describe('ConfigReader with fallback', () => {
it('should read values from multiple levels of fallbacks', () => {
const config = new ConfigReader(
{},
new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))),
CTX,
new ConfigReader(
{},
CTX,
new ConfigReader({}, CTX, new ConfigReader(DATA, CTX)),
),
);
expectValidValues(config);
expectInvalidValues(config);
});
it('should show error with correct context', () => {
const config = ConfigReader.fromConfigs([
{
data: {
c: true,
},
context: 'x',
},
{
data: {
b: true,
c: true,
nested1: {
a: true,
},
badBefore: true,
badAfter: {
a: true,
},
},
context: 'y',
},
{
data: {
a: true,
b: true,
c: true,
nested1: {
a: true,
b: true,
},
badBefore: {
a: true,
},
badAfter: true,
},
context: 'z',
},
]);
expect(() => config.getNumber('a')).toThrow(
"Invalid type in config for key 'a' in 'z', got boolean, wanted number",
);
expect(() => config.getNumber('b')).toThrow(
"Invalid type in config for key 'b' in 'y', got boolean, wanted number",
);
expect(() => config.getNumber('c')).toThrow(
"Invalid type in config for key 'c' in 'x', got boolean, wanted number",
);
expect(() => config.getNumber('nested1.a')).toThrow(
"Invalid type in config for key 'nested1.a' in 'y', got boolean, wanted number",
);
expect(() => config.getNumber('nested1.b')).toThrow(
"Invalid type in config for key 'nested1.b' in 'z', got boolean, wanted number",
);
expect(() => config.getConfig('nested1').getNumber('a')).toThrow(
"Invalid type in config for key 'nested1.a' in 'y', got boolean, wanted number",
);
expect(() => config.getConfig('nested1').getNumber('b')).toThrow(
"Invalid type in config for key 'nested1.b' in 'z', got boolean, wanted number",
);
expect(() => config.getNumber('badBefore.a')).toThrow(
"Invalid type in config for key 'badBefore' in 'y', got boolean, wanted object",
);
expect(() => config.getNumber('badBefore.b')).toThrow(
"Invalid type in config for key 'badBefore' in 'y', got boolean, wanted object",
);
expect(() => config.getNumber('badAfter.a')).toThrow(
"Invalid type in config for key 'badAfter.a' in 'y', got boolean, wanted number",
);
expect(() => config.getNumber('badAfter.b')).toThrow(
"Invalid type in config for key 'badAfter' in 'z', got boolean, wanted object",
);
});
it('should read merged objects', () => {
const a = {
merged: {
@@ -181,7 +305,18 @@ describe('ConfigReader with fallback', () => {
},
};
const config = new ConfigReader(a, new ConfigReader(b));
const config = new ConfigReader(a, CTX, new ConfigReader(b, CTX));
expect(config.keys()).toEqual(['merged']);
expect(config.getConfig('merged').keys()).toEqual([
'x',
'z',
'arr',
'config',
'configs',
'y',
]);
expect(config.getConfig('merged.config').keys()).toEqual(['d', 'e']);
expect(config.getString('merged.x')).toBe('x');
expect(config.getString('merged.y')).toBe('y');
@@ -206,12 +341,19 @@ describe('ConfigReader with fallback', () => {
'a',
'b',
]);
expect(() => config.getConfig('merged').getStringArray('x')).toThrow(
"Invalid type in config for key 'merged.x' in 'ctx', got string, wanted string-array",
);
// Config arrays aren't merged either
expect(config.getConfigArray('merged.configs').length).toBe(1);
expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a');
expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a');
expect(() =>
config.getConfigArray('merged.configs')[0].getString('missing'),
).toThrow("Missing required config value at 'merged.configs[0].missing'");
expect(
config.getConfigArray('merged.configs')[0].getString('b'),
config.getConfigArray('merged.configs')[0].getOptionalString('b'),
).toBeUndefined();
// Config arrays aren't merged either
@@ -220,7 +362,173 @@ describe('ConfigReader with fallback', () => {
config.getConfig('merged').getConfigArray('configs')[0].getString('a'),
).toBe('a');
expect(
config.getConfig('merged').getConfigArray('configs')[0].getString('b'),
config
.getConfig('merged')
.getConfigArray('configs')[0]
.getOptionalString('b'),
).toBeUndefined();
});
});
describe('ConfigReader.get()', () => {
const config1 = {
a: {
x: 'x1',
y: ['y11', 'y12', 'y13'],
z: false,
},
b: {
x: 'x1',
y: ['y11'],
},
};
const config2 = {
b: {
x: 'x2',
y: ['y21', 'y22'],
z: 'z2',
},
c: {
c1: {
c2: 'c2',
},
},
};
const config3 = {
c: {
c1: 'c1',
},
};
const configs = [
{
data: config1,
context: '1',
},
{
data: config2,
context: '2',
},
{
data: config3,
context: '3',
},
];
it('should be able to select sub-configs', () => {
expect(new ConfigReader(config1).get('a')).toEqual(config1.a);
expect(new ConfigReader(config1).get('b')).toEqual(config1.b);
expect(new ConfigReader(config2).get('b')).toEqual(config2.b);
expect(new ConfigReader(config2).get('c')).toEqual(config2.c);
expect(new ConfigReader(config3).get('c')).toEqual(config3.c);
expect(new ConfigReader(config2).get('c.c1')).toEqual(config2.c.c1);
expect(new ConfigReader(config2).getConfig('c').get('c1')).toEqual(
config2.c.c1,
);
});
it('should merge in fallback configs', () => {
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('a')).toEqual(
{
x: 'x1',
y: ['y11', 'y12', 'y13'],
z: false,
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('b')).toEqual(
{
x: 'x1',
y: ['y11'],
z: 'z2',
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('c')).toEqual(
{
c1: {
c2: 'c2',
},
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('a')).toEqual(
{
x: 'x1',
y: ['y11', 'y12', 'y13'],
z: false,
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('b')).toEqual(
{
x: 'x1',
y: ['y11'],
z: 'z2',
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('c')).toEqual(
{
c1: {
c2: 'c2',
},
},
);
expect(
ConfigReader.fromConfigs([configs[2], configs[1]]).getOptional('b'),
).toEqual({
x: 'x2',
y: ['y21', 'y22'],
z: 'z2',
});
expect(
ConfigReader.fromConfigs([configs[2], configs[1]]).getOptional('c'),
).toEqual({
c1: 'c1',
});
});
it('should not merge non-objects', () => {
const config = ConfigReader.fromConfigs([
{
data: {
a: ['1', '2'],
c: [],
d: {
x: 'x',
},
e: ['3'],
f: 'foo',
g: { z: 'z' },
h: {
a: 'a1',
c: 'c1',
},
},
context: '1',
},
{
data: {
a: ['x', 'y', 'z'],
b: ['1'],
c: ['1'],
d: ['2'],
e: {
y: 'y',
},
f: { x: 'x' },
g: 'bar',
h: {
a: 'a2',
b: 'b2',
},
},
context: '2',
},
]);
expect(config.get('a')).toEqual(['1', '2']);
expect(config.get('b')).toEqual(['1']);
expect(config.get('c')).toEqual([]);
expect(config.get('d')).toEqual({ x: 'x' });
expect(config.get('e')).toEqual(['3']);
expect(config.get('f')).toEqual('foo');
expect(config.get('g')).toEqual({ z: 'z' });
expect(config.get('h')).toEqual({ a: 'a1', b: 'b2', c: 'c1' });
});
});
+151 -25
View File
@@ -15,6 +15,8 @@
*/
import { AppConfig, Config, JsonValue, JsonObject } from './types';
import cloneDeep from 'lodash/cloneDeep';
import mergeWith from 'lodash/mergeWith';
// Update the same pattern in config-loader package if this is changed
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
@@ -39,43 +41,106 @@ function typeOf(value: JsonValue | undefined): string {
return type;
}
export class ConfigReader implements Config {
private static readonly nullReader = new ConfigReader({});
// Separate out a couple of common error messages to reduce bundle size.
const errors = {
type(key: string, context: string, typeName: string, expected: string) {
return `Invalid type in config for key '${key}' in '${context}', got ${typeName}, wanted ${expected}`;
},
missing(key: string) {
return `Missing required config value at '${key}'`;
},
};
export class ConfigReader implements Config {
static fromConfigs(configs: AppConfig[]): ConfigReader {
if (configs.length === 0) {
return new ConfigReader({});
return new ConfigReader(undefined);
}
// Merge together all configs info a single config with recursive fallback
// readers, giving the first config object in the array the highest priority.
return configs.reduceRight<ConfigReader>((previousReader, nextConfig) => {
return new ConfigReader(nextConfig, previousReader);
}, undefined!);
return configs.reduceRight<ConfigReader>(
(previousReader, { data, context }) => {
return new ConfigReader(data, context, previousReader);
},
undefined!,
);
}
constructor(
private readonly data: JsonObject,
private readonly data: JsonObject | undefined,
private readonly context: string = 'empty-config',
private readonly fallback?: ConfigReader,
private readonly prefix: string = '',
) {}
getConfig(key: string): ConfigReader {
keys(): string[] {
const localKeys = this.data ? Object.keys(this.data) : [];
const fallbackKeys = this.fallback?.keys() ?? [];
return [...new Set([...localKeys, ...fallbackKeys])];
}
get(key: string): JsonValue {
const value = this.getOptional(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptional(key: string): JsonValue | undefined {
const value = this.readValue(key);
const fallbackConfig = this.fallback?.getConfig(key);
const fallbackValue = this.fallback?.getOptional(key);
if (value === undefined) {
return fallbackValue;
} else if (fallbackValue === undefined) {
return value;
}
// Avoid merging arrays and primitive values, since that's how merging works for other
// methods for reading config.
return mergeWith(
{},
{ value: cloneDeep(fallbackValue) },
{ value },
(into, from) => (!isObject(from) || !isObject(into) ? from : undefined),
).value;
}
getConfig(key: string): ConfigReader {
const value = this.getOptionalConfig(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalConfig(key: string): ConfigReader | undefined {
const value = this.readValue(key);
const fallbackConfig = this.fallback?.getOptionalConfig(key);
const prefix = this.fullKey(key);
if (isObject(value)) {
return new ConfigReader(value, fallbackConfig);
return new ConfigReader(value, this.context, fallbackConfig, prefix);
}
if (value !== undefined) {
throw new TypeError(
`Invalid type in config for key ${key}, got ${typeOf(
value,
)}, wanted object`,
errors.type(this.fullKey(key), this.context, typeOf(value), 'object'),
);
}
return fallbackConfig ?? ConfigReader.nullReader;
return fallbackConfig;
}
getConfigArray(key: string): ConfigReader[] {
const value = this.getOptionalConfigArray(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalConfigArray(key: string): ConfigReader[] | undefined {
const configs = this.readConfigValue<JsonObject[]>(key, values => {
if (!Array.isArray(values)) {
return { expected: 'object-array' };
@@ -89,24 +154,60 @@ export class ConfigReader implements Config {
return true;
});
return (configs ?? []).map(obj => new ConfigReader(obj));
if (!configs) {
return undefined;
}
return configs.map(
(obj, index) =>
new ConfigReader(
obj,
this.context,
undefined,
this.fullKey(`${key}[${index}]`),
),
);
}
getNumber(key: string): number | undefined {
getNumber(key: string): number {
const value = this.getOptionalNumber(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalNumber(key: string): number | undefined {
return this.readConfigValue(
key,
value => typeof value === 'number' || { expected: 'number' },
);
}
getBoolean(key: string): boolean | undefined {
getBoolean(key: string): boolean {
const value = this.getOptionalBoolean(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalBoolean(key: string): boolean | undefined {
return this.readConfigValue(
key,
value => typeof value === 'boolean' || { expected: 'boolean' },
);
}
getString(key: string): string | undefined {
getString(key: string): string {
const value = this.getOptionalString(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalString(key: string): string | undefined {
return this.readConfigValue(
key,
value =>
@@ -114,7 +215,15 @@ export class ConfigReader implements Config {
);
}
getStringArray(key: string): string[] | undefined {
getStringArray(key: string): string[] {
const value = this.getOptionalStringArray(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalStringArray(key: string): string[] | undefined {
return this.readConfigValue(key, values => {
if (!Array.isArray(values)) {
return { expected: 'string-array' };
@@ -128,6 +237,10 @@ export class ConfigReader implements Config {
});
}
private fullKey(key: string): string {
return `${this.prefix}${this.prefix ? '.' : ''}${key}`;
}
private readConfigValue<T extends JsonValue>(
key: string,
validate: (
@@ -147,9 +260,13 @@ export class ConfigReader implements Config {
value: theValue = value,
expected,
} = result;
const typeName = typeOf(theValue);
throw new TypeError(
`Invalid type in config for key ${keyName}, got ${typeName}, wanted ${expected}`,
errors.type(
this.fullKey(keyName),
this.context,
typeOf(theValue),
expected,
),
);
}
}
@@ -159,16 +276,25 @@ export class ConfigReader implements Config {
private readValue(key: string): JsonValue | undefined {
const parts = key.split('.');
let value: JsonValue | undefined = this.data;
for (const part of parts) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid config key '${key}'`);
}
}
if (this.data === undefined) {
return undefined;
}
let value: JsonValue | undefined = this.data;
for (const [index, part] of parts.entries()) {
if (isObject(value)) {
value = value[part];
} else {
value = undefined;
} else if (value !== undefined) {
const badKey = this.fullKey(parts.slice(0, index).join('.'));
throw new TypeError(
errors.type(badKey, this.context, typeOf(value), 'object'),
);
}
}
+19 -5
View File
@@ -24,18 +24,32 @@ export type JsonValue =
| boolean
| null;
export type AppConfig = JsonObject;
export type AppConfig = {
context: string;
data: JsonObject;
};
export type Config = {
keys(): string[];
get(key: string): JsonValue;
getOptional(key: string): JsonValue | undefined;
getConfig(key: string): Config;
getOptionalConfig(key: string): Config | undefined;
getConfigArray(key: string): Config[];
getOptionalConfigArray(key: string): Config[] | undefined;
getNumber(key: string): number | undefined;
getNumber(key: string): number;
getOptionalNumber(key: string): number | undefined;
getBoolean(key: string): boolean | undefined;
getBoolean(key: string): boolean;
getOptionalBoolean(key: string): boolean | undefined;
getString(key: string): string | undefined;
getString(key: string): string;
getOptionalString(key: string): string | undefined;
getStringArray(key: string): string[] | undefined;
getStringArray(key: string): string[];
getOptionalStringArray(key: string): string[] | undefined;
};
+6 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-api",
"description": "Internal Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
@@ -30,8 +29,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/config": "0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
@@ -42,8 +41,8 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/test-utils-core": "0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+7 -1
View File
@@ -39,13 +39,19 @@ ApiProvider.propTypes = {
children: PropTypes.node,
};
export function useApi<T>(apiRef: ApiRef<T>): T {
export function useApiHolder(): ApiHolder {
const apiHolder = useContext(Context);
if (!apiHolder) {
throw new Error('No ApiProvider available in react context');
}
return apiHolder;
}
export function useApi<T>(apiRef: ApiRef<T>): T {
const apiHolder = useApiHolder();
const api = apiHolder.get(apiRef);
if (!api) {
throw new Error(`No implementation available for ${apiRef}`);
@@ -14,20 +14,7 @@
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
export type Config = {
getConfig(key: string): Config;
getConfigArray(key: string): Config[];
getNumber(key: string): number | undefined;
getBoolean(key: string): boolean | undefined;
getString(key: string): string | undefined;
getStringArray(key: string): string[] | undefined;
};
import { Config } from '@backstage/config';
// Using interface to make the ConfigApi name show up in docs
export interface ConfigApi extends Config {}
@@ -38,9 +38,14 @@ export type IdentityApi = {
getIdToken(): string | undefined;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
/**
* Log out the current user
*/
logout(): Promise<void>;
};
export const identifyApiRef = createApiRef<IdentityApi>({
export const identityApiRef = createApiRef<IdentityApi>({
id: 'core.identity',
description: 'Provides access to the identity of the signed in user',
});
+1 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
export { ApiProvider, useApi } from './ApiProvider';
export { ApiProvider, useApi, useApiHolder } from './ApiProvider';
export { ApiRegistry } from './ApiRegistry';
export { ApiTestRegistry } from './ApiTestRegistry';
export * from './ApiRef';
+146 -44
View File
@@ -13,13 +13,32 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, FC, useMemo } from 'react';
import React, {
ComponentType,
FC,
useMemo,
useCallback,
useState,
ReactElement,
} from 'react';
import { Route, Routes, Navigate } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types';
import {
BackstageApp,
AppComponents,
AppConfigLoader,
Apis,
SignInResult,
SignInPageProps,
} from './types';
import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
import {
featureFlagsApiRef,
AppThemeApi,
ConfigApi,
identityApiRef,
} from '../apis/definitions';
import { AppThemeProvider } from './AppThemeProvider';
import { IconComponent, SystemIcons, SystemIconKey } from '../icons';
@@ -32,9 +51,11 @@ import {
appThemeApiRef,
configApiRef,
ConfigReader,
useApi,
} from '../apis';
import { ApiAggregator } from '../apis/ApiAggregator';
import { useAsync } from 'react-use';
import { AppIdentity } from './AppIdentity';
type FullAppOptions = {
apis: Apis;
@@ -45,6 +66,41 @@ type FullAppOptions = {
configLoader?: AppConfigLoader;
};
function useConfigLoader(
configLoader: AppConfigLoader | undefined,
components: AppComponents,
appThemeApi: AppThemeApi,
): { api: ConfigApi } | { node: JSX.Element } {
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(configLoader);
const config = useAsync(configLoader || (() => Promise.resolve([])));
let noConfigNode = undefined;
if (hasConfig && config.loading) {
const { Progress } = components;
noConfigNode = <Progress />;
} else if (config.error) {
const { BootErrorPage } = components;
noConfigNode = <BootErrorPage step="load-config" error={config.error} />;
}
// Before the config is loaded we can't use a router, so exit early
if (noConfigNode) {
return {
node: (
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
</ApiProvider>
),
};
}
const configReader = ConfigReader.fromConfigs(config.value ?? []);
return { api: configReader };
}
export class PrivateAppImpl implements BackstageApp {
private apis?: ApiHolder = undefined;
private readonly icons: SystemIcons;
@@ -53,6 +109,8 @@ export class PrivateAppImpl implements BackstageApp {
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
private readonly identityApi = new AppIdentity();
private apisOrFactory: Apis;
constructor(options: FullAppOptions) {
@@ -79,7 +137,7 @@ export class PrivateAppImpl implements BackstageApp {
return this.icons[key];
}
getRootComponent(): ComponentType<{}> {
getRoutes(): ComponentType<{}> {
const routes = new Array<JSX.Element>();
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
@@ -151,71 +209,115 @@ export class PrivateAppImpl implements BackstageApp {
[],
);
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(this.configLoader);
const config = useAsync(this.configLoader || (() => Promise.resolve([])));
const loadedConfig = useConfigLoader(
this.configLoader,
this.components,
appThemeApi,
);
let noConfigNode = undefined;
if (hasConfig && config.loading) {
const { Progress } = this.components;
noConfigNode = <Progress />;
} else if (config.error) {
const { BootErrorPage } = this.components;
noConfigNode = (
<BootErrorPage step="load-config" error={config.error} />
);
if ('node' in loadedConfig) {
return loadedConfig.node;
}
const configApi = loadedConfig.api;
// Before the config is loaded we can't use a router, so exit early
if (noConfigNode) {
return (
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
</ApiProvider>
);
}
const configReader = ConfigReader.fromConfigs(config.value ?? []);
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
[configApiRef, configReader],
[appThemeApiRef, appThemeApi],
[configApiRef, configApi],
[identityApiRef, this.identityApi],
]);
if (!this.apis) {
if ('get' in this.apisOrFactory) {
this.apis = this.apisOrFactory;
} else {
this.apis = this.apisOrFactory(configReader);
this.apis = this.apisOrFactory(configApi);
}
}
const apis = new ApiAggregator(this.apis, appApis);
const { Router } = this.components;
return (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{children}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
};
return Provider;
}
getRouter(): ComponentType<{}> {
const {
Router: RouterComponent,
SignInPage: SignInPageComponent,
} = this.components;
// This wraps the sign-in page and waits for sign-in to be completed before rendering the app
const SignInPageWrapper: FC<{
component: ComponentType<SignInPageProps>;
children: ReactElement;
}> = ({ component: Component, children }) => {
const [done, setDone] = useState(false);
const onResult = useCallback(
(result: SignInResult) => {
if (done) {
throw new Error('Identity result callback was called twice');
}
this.identityApi.setSignInResult(result);
setDone(true);
},
[done],
);
if (done) {
return children;
}
return <Component onResult={onResult} />;
};
const AppRouter: FC<{}> = ({ children }) => {
const configApi = useApi(configApiRef);
let { pathname } = new URL(
configReader.getString('app.baseUrl') ?? '/',
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://dummy.dev', // baseUrl can be specified as just a path
);
if (pathname.endsWith('/')) {
pathname = pathname.replace(/\/$/, '');
}
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
idToken: undefined,
logout: async () => {},
});
return (
<RouterComponent>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</RouterComponent>
);
}
return (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>
<Router>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</Router>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
<RouterComponent>
<SignInPageWrapper component={SignInPageComponent}>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</SignInPageWrapper>
</RouterComponent>
);
};
return Provider;
return AppRouter;
}
verify() {
+70
View File
@@ -0,0 +1,70 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IdentityApi } from '../apis';
import { SignInResult } from './types';
/**
* Implementation of the connection between the App-wide IdentityApi
* and sign-in page.
*/
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private idToken?: string;
private logoutFunc?: () => Promise<void>;
getUserId(): string {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi userId before app was loaded',
);
}
return this.userId!;
}
getIdToken(): string | undefined {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idToken;
}
async logout(): Promise<void> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi logoutFunc before app was loaded',
);
}
await this.logoutFunc?.();
location.reload();
}
setSignInResult(result: SignInResult) {
if (this.hasIdentity) {
return;
}
if (!result.userId) {
throw new Error('Invalid sign-in result, userId not set');
}
this.hasIdentity = true;
this.userId = result.userId;
this.idToken = result.idToken;
this.logoutFunc = result.logout;
}
}
+47 -8
View File
@@ -25,11 +25,45 @@ export type BootErrorPageProps = {
step: 'load-config';
error: Error;
};
export type SignInResult = {
/**
* User ID that will be returned by the IdentityApi
*/
userId: string;
/**
* ID token that will be returned by the IdentityApi
*/
idToken?: string;
/**
* Logout handler that will be called if the user requests a logout.
*/
logout?: () => Promise<void>;
};
export type SignInPageProps = {
/**
* Set the sign-in result for the app. This should only be called once.
*/
onResult(result: SignInResult): void;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
/**
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
*
* If a sign-in page is set, it will always be shown before the app, and it is up
* to the sign-in page to handle e.g. saving of login methods for subsequent visits.
*
* The sign-in page will be displayed until it has passed up a result to the parent,
* and which point the AppRouter and all of its children will be rendered instead.
*/
SignInPage?: ComponentType<SignInPageProps>;
};
/**
@@ -117,14 +151,19 @@ export type BackstageApp = {
getSystemIcon(key: SystemIconKey): IconComponent;
/**
* Creates a root component for this app, including the App chrome
* and routes to all plugins.
*/
getRootComponent(): ComponentType<{}>;
/**
* Provider component that should wrap the App's RootComponent and
* any other components that need to be within the app context.
* Provider component that should wrap the Router created with getRouter()
* and any other components that need to be within the app context.
*/
getProvider(): ComponentType<{}>;
/**
* Router component that should wrap the App Routes create with getRoutes()
* and any other components that should only be available while signed in.
*/
getRouter(): ComponentType<{}>;
/**
* Routes component that contains all routes for plugin pages in the app.
*/
getRoutes(): ComponentType<{}>;
};
+9 -9
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core",
"description": "Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
@@ -30,9 +29,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/core-api": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/config": "0.1.1-alpha.9",
"@backstage/core-api": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -46,7 +45,8 @@
"rc-progress": "^3.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet": "6.0.0",
"react-helmet": "6.1.0",
"react-hook-form": "^5.7.2",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-sparklines": "^1.7.0",
@@ -54,8 +54,8 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -15,6 +15,7 @@
*/
import { defaultConfigLoader } from './createApp';
import { AppConfig } from '@backstage/config';
describe('defaultConfigLoader', () => {
afterEach(() => {
@@ -24,24 +25,33 @@ describe('defaultConfigLoader', () => {
it('loads static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }, { my: 'override-config' }] as any,
value: [
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
] as AppConfig[],
});
const configs = await defaultConfigLoader();
expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]);
expect(configs).toEqual([
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
]);
});
it('loads runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'override-config' }, { my: 'config' }] as any,
value: [
{ data: { my: 'override-config' }, context: 'a' },
{ data: { my: 'config' }, context: 'b' },
] as AppConfig[],
});
const configs = await (defaultConfigLoader as any)(
'{"my":"runtime-config"}',
);
expect(configs).toEqual([
{ my: 'runtime-config' },
{ my: 'override-config' },
{ my: 'config' },
{ data: { my: 'runtime-config' }, context: 'env' },
{ data: { my: 'override-config' }, context: 'a' },
{ data: { my: 'config' }, context: 'b' },
]);
});
@@ -64,7 +74,7 @@ describe('defaultConfigLoader', () => {
it('fails to load bad runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }] as any,
value: [{ data: { my: 'config' }, context: 'a' }] as AppConfig[],
});
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
+3 -2
View File
@@ -27,7 +27,7 @@ import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { ErrorPage } from '../layout/ErrorPage';
import Progress from '../components/Progress';
import { lightTheme, darkTheme } from '@backstage/theme';
import { AppConfig } from '@backstage/config';
import { AppConfig, JsonObject } from '@backstage/config';
const { PrivateAppImpl } = privateExports;
@@ -59,7 +59,8 @@ export const defaultConfigLoader: AppConfigLoader = async (
// Avoiding this string also being replaced at runtime
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
try {
configs.unshift(JSON.parse(runtimeConfigJson));
const data = JSON.parse(runtimeConfigJson) as JsonObject;
configs.unshift({ data, context: 'env' });
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
+13 -3
View File
@@ -17,7 +17,7 @@
// TODO(blam): Remove this implementation when the Tabs are ready
// This is just a temporary solution to implementing tabs for now
import React from 'react';
import React, { useState } from 'react';
import { makeStyles, Tabs, Tab } from '@material-ui/core';
const useStyles = makeStyles(theme => ({
@@ -42,9 +42,18 @@ export type Tab = {
id: string;
label: string;
};
export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
export const HeaderTabs: React.FC<{
tabs: Tab[];
onChange?: (index: Number) => void;
}> = ({ tabs, onChange }) => {
const [selectedTab, setSelectedTab] = useState<Number>(0);
const styles = useStyles();
const handleChange = (_: React.ChangeEvent<{}>, index: Number) => {
setSelectedTab(index);
if (onChange) onChange(index);
};
return (
<div className={styles.tabsWrapper}>
<Tabs
@@ -53,7 +62,8 @@ export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
variant="scrollable"
scrollButtons="auto"
aria-label="scrollable auto tabs example"
value={0}
onChange={handleChange}
value={selectedTab}
>
{tabs.map((tab, index) => (
<Tab
+1 -1
View File
@@ -233,5 +233,5 @@ export const SidebarDivider = styled('hr')({
width: '100%',
background: '#383838',
border: 'none',
margin: 0,
margin: '12px 0px',
});
@@ -104,7 +104,7 @@ export const UserProfile: FC<{ open: boolean; setOpen: Function }> = ({
onClick={handleClick}
icon={avatar || AccountCircleIcon}
>
{open ? <ExpandLess /> : <ExpandMore />}
{open ? <ExpandMore /> : <ExpandLess />}
</SidebarItem>
</>
);
@@ -17,17 +17,25 @@
import React, { useContext, useEffect } from 'react';
import Collapse from '@material-ui/core/Collapse';
import Star from '@material-ui/icons/Star';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import { SidebarContext } from './config';
import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api';
import {
googleAuthApiRef,
githubAuthApiRef,
identityApiRef,
useApi,
} from '@backstage/core-api';
import {
OAuthProviderSettings,
OIDCProviderSettings,
UserProfile as SidebarUserProfile,
} from './Settings';
import { SidebarItem } from './Items';
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
const identityApi = useApi(identityApiRef);
// Close the provider list when sidebar collapse
useEffect(() => {
@@ -48,6 +56,11 @@ export function SidebarUserSettings() {
apiRef={githubAuthApiRef}
icon={Star}
/>
<SidebarItem
icon={SignOutIcon}
text="Sign Out"
onClick={() => identityApi.logout()}
/>
</Collapse>
</>
);
@@ -0,0 +1,49 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Page } from '../Page';
import { Header } from '../Header';
import { Content } from '../Content/Content';
import { ContentHeader } from '../ContentHeader/ContentHeader';
import { Grid } from '@material-ui/core';
import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api';
import { useSignInProviders, SignInProviderId } from './providers';
import Progress from '../../components/Progress';
export type Props = SignInPageProps & {
providers: SignInProviderId[];
};
export const SignInPage: FC<Props> = ({ onResult, providers }) => {
const configApi = useApi(configApiRef);
const [loading, providerElements] = useSignInProviders(providers, onResult);
if (loading) {
return <Progress />;
}
return (
<Page>
<Header title={configApi.getString('app.title')} />
<Content>
<ContentHeader title="Select a sign-in method" />
<Grid container>{providerElements}</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,111 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useForm } from 'react-hook-form';
import {
Grid,
Typography,
Button,
FormControl,
TextField,
FormHelperText,
makeStyles,
} from '@material-ui/core';
import isEmpty from 'lodash/isEmpty';
import { InfoCard } from '../InfoCard/InfoCard';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
import { SignInResult } from '@backstage/core-api';
const ID_TOKEN_REGEX = /^[a-z0-9+/]+\.[a-z0-9+/]+\.[a-z0-9+/]+$/i;
const useFormStyles = makeStyles(theme => ({
form: {
display: 'flex',
flexFlow: 'column nowrap',
},
button: {
alignSelf: 'center',
marginTop: theme.spacing(2),
},
}));
const Component: ProviderComponent = ({ onResult }) => {
const classes = useFormStyles();
const { register, handleSubmit, errors, formState } = useForm<SignInResult>({
mode: 'onChange',
});
return (
<Grid item>
<InfoCard title="Custom User">
<Typography variant="body1">
Enter your own User ID and credentials.
<br />
This selection will not be stored.
</Typography>
<form className={classes.form} onSubmit={handleSubmit(onResult)}>
<FormControl>
<TextField
name="userId"
label="User ID"
margin="normal"
error={Boolean(errors.userId)}
inputRef={register({ required: true })}
/>
{errors.userId && (
<FormHelperText error>{errors.userId.message}</FormHelperText>
)}
</FormControl>
<FormControl>
<TextField
name="idToken"
label="ID Token (optional)"
margin="normal"
autoComplete="off"
error={Boolean(errors.idToken)}
inputRef={register({
required: false,
validate: token =>
!token ||
ID_TOKEN_REGEX.test(token) ||
'Token is not a valid OpenID Connect JWT Token',
})}
/>
{errors.idToken && (
<FormHelperText error>{errors.idToken.message}</FormHelperText>
)}
</FormControl>
<Button
type="submit"
color="primary"
variant="outlined"
className={classes.button}
disabled={!formState?.dirty || !isEmpty(errors)}
>
Continue
</Button>
</form>
</InfoCard>
</Grid>
);
};
// Custom provider doesn't store credentials
const loader: ProviderLoader = async () => undefined;
export const customProvider: SignInProvider = { Component, loader };
@@ -0,0 +1,86 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Grid, Typography, Button } from '@material-ui/core';
import { InfoCard } from '../InfoCard/InfoCard';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
import {
useApi,
googleAuthApiRef,
errorApiRef,
ProfileInfo,
} from '@backstage/core-api';
function parseUserId(profile: ProfileInfo) {
return profile!.email.replace(/@.*/, '');
}
const Component: ProviderComponent = ({ onResult }) => {
const googleAuthApi = useApi(googleAuthApiRef);
const errorApi = useApi(errorApiRef);
const handleLogin = async () => {
try {
const idToken = await googleAuthApi.getIdToken({ instantPopup: true });
const profile = await googleAuthApi.getProfile();
onResult({
userId: parseUserId(profile!),
idToken,
logout: async () => {
await googleAuthApi.logout();
},
});
} catch (error) {
errorApi.post(error);
}
};
return (
<Grid item>
<InfoCard
title="Google"
actions={
<Button color="primary" variant="outlined" onClick={handleLogin}>
Sign In
</Button>
}
>
<Typography variant="body1">Sign In using Google</Typography>
</InfoCard>
</Grid>
);
};
const loader: ProviderLoader = async apis => {
const googleAuthApi = apis.get(googleAuthApiRef)!;
const [idToken, profile] = await Promise.all([
googleAuthApi.getIdToken({ optional: true }),
googleAuthApi.getProfile({ optional: true }),
]);
return {
userId: parseUserId(profile!),
idToken,
logout: async () => {
await googleAuthApi.logout();
},
};
};
export const googleProvider: SignInProvider = { Component, loader };
@@ -0,0 +1,51 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Grid, Typography, Button } from '@material-ui/core';
import { InfoCard } from '../InfoCard/InfoCard';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
const Component: ProviderComponent = ({ onResult }) => (
<Grid item>
<InfoCard
title="Guest"
actions={
<Button
color="primary"
variant="outlined"
onClick={() => onResult({ userId: 'guest' })}
>
Enter
</Button>
}
>
<Typography variant="body1">
Enter as a Guest User.
<br />
You will not have a verified identity,
<br />
meaning some features might be unavailable.
</Typography>
</InfoCard>
</Grid>
);
const loader: ProviderLoader = async () => {
return { userId: 'guest' };
};
export const guestProvider: SignInProvider = { Component, loader };
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './withCache';
export * from './options';
export { SignInPage } from './SignInPage';
@@ -0,0 +1,134 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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, { useLayoutEffect, useState, useMemo, useCallback } from 'react';
import { guestProvider } from './guestProvider';
import { googleProvider } from './googleProvider';
import { customProvider } from './customProvider';
import {
SignInPageProps,
SignInResult,
useApi,
useApiHolder,
errorApiRef,
} from '@backstage/core-api';
import { SignInProvider } from './types';
const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider';
// Separate list here to avoid exporting internal types
export type SignInProviderId = 'guest' | 'google' | 'custom';
const signInProviders: { [id in SignInProviderId]: SignInProvider } = {
guest: guestProvider,
google: googleProvider,
custom: customProvider,
};
export const useSignInProviders = (
providers: SignInProviderId[],
onResult: SignInPageProps['onResult'],
) => {
const errorApi = useApi(errorApiRef);
const apiHolder = useApiHolder();
const [loading, setLoading] = useState(true);
// This decorates the result with logout logic from this hook
const handleWrappedResult = useCallback(
(result: SignInResult) => {
onResult({
...result,
logout: async () => {
localStorage.removeItem(PROVIDER_STORAGE_KEY);
await result.logout?.();
},
});
},
[onResult],
);
// In this effect we check if the user has already selected an existing login
// provider, and in that case try to load an existing session for the provider.
useLayoutEffect(() => {
if (!loading) {
return undefined;
}
// We can't use storageApi here, as it might have a dependency on the IdentityApi
const selectedProvider = localStorage.getItem(
PROVIDER_STORAGE_KEY,
) as SignInProviderId;
// No provider selected, let the user pick one
if (selectedProvider === null) {
setLoading(false);
return undefined;
}
const provider = signInProviders[selectedProvider];
if (!provider) {
setLoading(false);
return undefined;
}
let didCancel = false;
provider
.loader(apiHolder)
.then(result => {
if (didCancel) {
return;
}
if (result) {
handleWrappedResult(result);
}
setLoading(false);
})
.catch(error => {
if (didCancel) {
return;
}
errorApi.post(error);
setLoading(false);
});
return () => {
didCancel = true;
};
}, [loading, errorApi, onResult, apiHolder, providers, handleWrappedResult]);
// This renders all available sign-in providers
const elements = useMemo(
() =>
providers.map(providerId => {
const provider = signInProviders[providerId];
if (!provider) {
throw new Error(`Unknown sign-in provider: ${providerId}`);
}
const { Component } = provider;
const handleResult = (result: SignInResult) => {
localStorage.setItem(PROVIDER_STORAGE_KEY, providerId);
handleWrappedResult(result);
};
return <Component key={providerId} onResult={handleResult} />;
}),
[providers, handleWrappedResult],
);
return [loading, elements];
};
@@ -14,19 +14,16 @@
* limitations under the License.
*/
import { Command } from 'commander';
import { run } from '../../lib/run';
import { withCache, parseOptions } from '../../lib/buildCache';
import { ComponentType } from 'react';
import { SignInPageProps, SignInResult, ApiHolder } from '@backstage/core-api';
/*
* The build-cache command is used to make builds a no-op if there are no changes to the package.
* It supports both local development where the output directory remains intact, as well as CI
* where the output directory is stored in a separate cache dir.
*/
export default async (cmd: Command, args: string[]) => {
const options = await parseOptions(cmd);
export type ProviderComponent = ComponentType<SignInPageProps>;
await withCache(options, async () => {
await run(args[0], args.slice(1));
});
export type ProviderLoader = (
apis: ApiHolder,
) => Promise<SignInResult | undefined>;
export type SignInProvider = {
Component: ProviderComponent;
loader: ProviderLoader;
};
+1
View File
@@ -23,5 +23,6 @@ export * from './HomepageTimer';
export * from './InfoCard';
export * from './Page';
export * from './Sidebar';
export * from './SignInPage';
export * from './TabbedCard';
export * from './HeaderTabs';
+6 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/dev-utils",
"description": "Utilities for developing Backstage plugins.",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
@@ -30,10 +29,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
+10 -5
View File
@@ -82,8 +82,10 @@ class DevAppBuilder {
apis: this.setupApiRegistry(this.factories),
plugins: this.plugins,
});
const AppProvider = app.getProvider();
const AppComponent = app.getRootComponent();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const sidebar = this.setupSidebar(this.plugins);
@@ -93,10 +95,13 @@ class DevAppBuilder {
<AlertDisplay />
<OAuthRequestDialog />
{this.rootChildren}
<SidebarPage>
{sidebar}
<AppComponent />
</SidebarPage>
<AppRouter>
<SidebarPage>
{sidebar}
<AppRoutes />
</SidebarPage>
</AppRouter>
</AppProvider>
);
};
+7
View File
@@ -2,6 +2,7 @@ import {
ApiRegistry,
alertApiRef,
errorApiRef,
identityApiRef,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
@@ -19,6 +20,12 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
builder.add(identityApiRef, {
getUserId: () => 'guest',
getIdToken: () => undefined,
logout: async () => {},
});
const oauthRequestApi = builder.add(
oauthRequestApiRef,
new OAuthRequestManager(),
+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = {
const coreSrc = path.resolve(__dirname, '../../core/src');
// Mirror config in packages/cli/src/lib/bundler
config.resolve.mainFields = ['main:src', 'browser', 'module', 'main'];
config.resolve.mainFields = ['browser', 'module', 'main'];
// Remove the default babel-loader for js files, we're using sucrase instead
const [jsLoader] = config.module.rules.splice(0, 1);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "storybook",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"description": "Storybook build for core package",
"private": true,
"scripts": {
@@ -14,7 +14,7 @@
]
},
"dependencies": {
"@backstage/theme": "^0.1.1-alpha.8"
"@backstage/theme": "^0.1.1-alpha.9"
},
"devDependencies": {
"@storybook/addon-actions": "^5.3.17",
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/test-utils-core",
"description": "Utilities to test Backstage core",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
+6 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/test-utils",
"description": "Utilities to test Backstage plugins and apps.",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
@@ -30,10 +29,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core-api": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/core-api": "^0.1.1-alpha.9",
"@backstage/test-utils-core": "0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
@@ -89,12 +89,15 @@ export function wrapInTestApp(
}
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
return (
<AppProvider>
{/* The path of * here is needed to be set as a catch all, so it will render the wrapper element
* and work with nested routes if they exist too */}
<Route path="*" element={<Wrapper />} />
<AppRouter>
{/* The path of * here is needed to be set as a catch all, so it will render the wrapper element
* and work with nested routes if they exist too */}
<Route path="*" element={<Wrapper />} />
</AppRouter>
</AppProvider>
);
}
+3 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/theme",
"description": "material-ui theme for use with Backstage.",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
@@ -32,7 +31,7 @@
"@material-ui/core": "^4.9.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8"
"@backstage/cli": "^0.1.1-alpha.9"
},
"files": [
"dist/**/*.{js,d.ts}"
+13 -10
View File
@@ -1,10 +1,10 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
@@ -20,12 +20,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.8",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.9",
"@backstage/config-loader": "^0.1.1-alpha.9",
"@types/express": "^4.17.6",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.5",
@@ -44,12 +42,17 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@types/passport-saml": "^1.1.2",
"@types/passport": "^1.0.3",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
@@ -37,7 +37,7 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers {
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.start(req, res);
await provider.start(req, res);
}
async frameHandler(
@@ -45,18 +45,18 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers {
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.frameHandler(req, res);
await provider.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
if (provider.refresh) {
provider.refresh(req, res);
await provider.refresh(req, res);
}
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.logout(req, res);
await provider.logout(req, res);
}
}
@@ -96,6 +96,7 @@ export function createGithubProvider(
}
envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
disableRefresh: true,
providerId: 'github',
secure,
baseUrl,
@@ -143,6 +143,7 @@ export function createGoogleProvider(
}
envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), {
disableRefresh: false,
providerId: 'google',
secure,
baseUrl,
+4 -2
View File
@@ -20,9 +20,11 @@ import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
import { Config } from '@backstage/config';
export interface RouterOptions {
logger: Logger;
config: Config;
}
export async function createRouter(
@@ -38,7 +40,7 @@ export async function createRouter(
// TODO: read from app config
const config = {
backend: {
baseUrl: 'http://localhost:7000',
baseUrl: options.config.getString('backend.baseUrl'),
},
auth: {
providers: {
@@ -77,7 +79,7 @@ export async function createRouter(
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${config.backend.baseUrl}/auth`;
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
@@ -19,6 +19,7 @@ import {
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
@@ -29,12 +30,13 @@ import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
logger: Logger;
config: Config;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger } = options;
const { enableCors, logger, config } = options;
const app = express();
app.use(helmet());
@@ -44,7 +46,7 @@ export async function createStandaloneApplication(
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger }));
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
@@ -17,6 +17,8 @@
import { Server } from 'http';
import { Logger } from 'winston';
import { createStandaloneApplication } from './standaloneApplication';
import { ConfigReader } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
export interface ServerOptions {
port: number;
@@ -28,11 +30,13 @@ export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'auth-backend' });
const config = ConfigReader.fromConfigs(await loadConfig());
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger,
config,
});
logger.debug('Starting application server...');
+8 -7
View File
@@ -1,10 +1,10 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
@@ -21,8 +21,9 @@
"mock-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/catalog-model": "^0.1.1-alpha.9",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
@@ -38,7 +39,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
@@ -48,7 +49,7 @@
"supertest": "^4.0.2"
},
"files": [
"dist",
"migrations"
"dist/**/*.{js,d.ts}",
"migrations/**/*.{js,d.ts}"
]
}
+11 -12
View File
@@ -1,11 +1,10 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -22,11 +21,11 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
"@backstage/plugin-sentry": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.9",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.9",
"@backstage/plugin-sentry": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -39,9 +38,9 @@
"swr": "^0.2.2"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/react-hooks": "^3.3.0",
@@ -25,15 +25,26 @@ import {
} from '@backstage/core';
import CatalogLayout from './CatalogLayout';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
import {
Button,
Link,
makeStyles,
Typography,
withStyles,
} from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import React, { FC, useCallback, useState } from 'react';
import React, { FC, useCallback, useState, useMemo } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { catalogApiRef } from '../..';
import { defaultFilter, entityFilters, filterGroups } from '../../data/filters';
import {
defaultFilter,
entityFilters,
filterGroups,
EntityFilterType,
} from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
@@ -43,6 +54,30 @@ import {
import { CatalogTable } from '../CatalogTable/CatalogTable';
import useStaleWhileRevalidate from 'swr';
// TODO: replace me with the proper tabs implemntation
const tabs = [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'lib',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
@@ -58,8 +93,8 @@ const useStyles = makeStyles(theme => ({
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const [selectedTab, setSelectedTab] = useState<string>(tabs[0].id);
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
@@ -69,18 +104,27 @@ export const CatalogPage: FC<{}> = () => {
async () => catalogApi.getEntities(),
);
const data =
entities?.filter(e =>
entityFilters[selectedFilter.id](e, { isStarred: isStarredEntity(e) }),
) ?? [];
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
[],
);
const filteredEntities = useMemo(() => {
const typeFilter = entityFilters[EntityFilterType.TYPE];
const leftMenuFilter = entityFilters[selectedFilter.id];
return entities
?.filter(e => leftMenuFilter(e, { isStarred: isStarredEntity(e) }))
.filter(e => typeFilter(e, { type: selectedTab }));
}, [selectedFilter.id, selectedTab, isStarredEntity, entities?.filter]);
const styles = useStyles();
const YellowStar = withStyles({
root: {
color: '#f3ba37',
},
})(Star);
const actions = [
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
@@ -120,40 +164,21 @@ export const CatalogPage: FC<{}> = () => {
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
icon: isStarred ? Star : StarOutline,
icon: isStarred ? YellowStar : StarOutline,
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
onClick: () => toggleStarredEntity(rowData),
};
},
];
// TODO: replace me with the proper tabs implemntation
const tabs = [
{
id: 'services',
label: 'Services',
},
{
id: 'websites',
label: 'Websites',
},
{
id: 'libs',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
return (
<CatalogLayout>
<HeaderTabs tabs={tabs} />
<HeaderTabs
tabs={tabs}
onChange={index => {
setSelectedTab(tabs[index as number].id);
}}
/>
<Content>
<DismissableBanner
variant="info"
@@ -193,8 +218,8 @@ export const CatalogPage: FC<{}> = () => {
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
entities={data || []}
loading={!data && !error}
entities={filteredEntities || []}
loading={!entities && !error}
error={error}
actions={actions}
/>
@@ -88,7 +88,7 @@
"Swedish": "God afton",
"Tagalog": "Magandang gabi",
"Tatar": "Xäyerle kiç",
"Telugu" : "శుభ సాయంత్రం",
"Telugu": "శుభ సాయంత్రం",
"Thai": "Sawat-dii torn khum",
"Turkish": "İyi akşamlar",
"Ukrainian": "Dobry vechir",
+6 -3
View File
@@ -28,6 +28,7 @@ export enum EntityFilterType {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
TYPE = 'TYPE',
}
export const filterGroups: CatalogFilterGroup[] = [
@@ -54,7 +55,7 @@ export const filterGroups: CatalogFilterGroup[] = [
items: [
{
id: EntityFilterType.ALL,
label: 'All Services',
label: 'All Entities',
count: AllServicesCount,
},
],
@@ -64,13 +65,15 @@ export const filterGroups: CatalogFilterGroup[] = [
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
type EntityFilterOptions = {
isStarred: boolean;
isStarred?: boolean;
type?: string;
};
export const entityFilters: Record<string, EntityFilter> = {
[EntityFilterType.OWNED]: () => false,
[EntityFilterType.ALL]: () => true,
[EntityFilterType.STARRED]: (_, { isStarred }) => isStarred,
[EntityFilterType.STARRED]: (_, { isStarred }) => !!isStarred,
[EntityFilterType.TYPE]: (e, { type }) => (e.spec as any)?.type === type,
};
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
+8 -9
View File
@@ -1,11 +1,10 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -31,12 +30,11 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react-lazylog": "^4.5.0",
"circleci-api": "^4.0.0",
"moment": "^2.25.3",
"react": "^16.13.1",
@@ -47,13 +45,14 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react-lazylog": "^4.5.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
+8 -9
View File
@@ -1,11 +1,10 @@
{
"name": "@backstage/plugin-explore",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -22,8 +21,8 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -33,9 +32,9 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+7 -8
View File
@@ -1,11 +1,10 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -22,8 +21,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -33,8 +32,8 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+1 -1
View File
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core-api';
import { createApiRef } from '@backstage/core';
export interface CloneFromTemplateRequest {
templateRepository: string;
@@ -20,7 +20,7 @@ import mockFetch from 'jest-fetch-mock';
import ProfileCatalog from './ProfileCatalog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
describe('ProfileCatalog', () => {
+7 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-graphiql",
"description": "Backstage plugin for browsing GraphQL APIs",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"private": false,
"publishConfig": {
"access": "public",
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
@@ -32,8 +31,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -44,9 +43,9 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+6 -5
View File
@@ -1,10 +1,10 @@
{
"name": "@backstage/plugin-identity-backend",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
@@ -20,7 +20,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/backend-common": "^0.1.1-alpha.9",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -32,10 +33,10 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
"dist/**/*.{js,d.ts}"
]
}
@@ -0,0 +1,82 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Group,
GroupsResponse,
IdentityApi,
GroupsRequest,
GroupsJson,
} from './types';
export class StaticJsonAdapter implements IdentityApi {
private readonly groups: Group[];
constructor(userGroups: GroupsJson) {
this.groups = userGroups.groups;
}
getUserGroups(req: GroupsRequest): Promise<GroupsResponse> {
return new Promise(resolve => {
const { user, type } = req;
const userGroups = this._getUserGroups(this.groups, user);
const groups = this.filterGroupsByType(userGroups, type);
resolve({ groups });
});
}
_getUserGroups(groups: Group[], user: string) {
const userGroups: Set<Group> = new Set();
groups.forEach(group => {
if (this.isUserInGroup(group, user)) {
userGroups.add(group);
}
if (group.children) {
const userSubGroups = this._getUserGroups(group.children, user) ?? [];
const isUserInSubGroup = Boolean(userSubGroups.length);
if (isUserInSubGroup) {
userGroups.add(group);
}
userSubGroups.forEach(subGroup => userGroups.add(subGroup));
}
});
return Array.from(userGroups);
}
private filterGroupsByType = (userGroups: Group[], type: string) => {
const groups = type
? userGroups
.filter((group: Group) => group.type === type)
.map(group => ({ name: group.name, type: group.type }))
: userGroups.map(group => ({
name: group.name,
type: group.type,
}));
return groups;
};
private isUserInGroup = (group: Group, user: string): boolean => {
if (group.members) {
const groupMembers = group.members;
const groupsWithUser = groupMembers.filter(
member => member.name === user,
);
return Boolean(groupsWithUser.length);
}
return false;
};
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export * from './watchDeps';
export * from './StaticJsonAdapter';
@@ -14,20 +14,29 @@
* limitations under the License.
*/
import { createLogPipe } from '../logging';
export type User = {
name: string;
};
export type ColorFunc = (msg: string) => string;
export type Group = {
name: string;
type: string;
members?: User[];
children?: Group[];
};
// A factory for creating log pipes that rotate between different coloring functions
export function createLogPipeFactory(colorFuncs: ColorFunc[]) {
let colorIndex = 0;
export type GroupsJson = {
groups: Group[];
};
return (name: string) => {
const colorFunc = colorFuncs[colorIndex];
export type GroupsResponse = {
groups: Group[];
};
colorIndex = (colorIndex + 1) % colorFuncs.length;
const prefix = `${colorFunc(name)}: `;
return createLogPipe({ prefix });
};
export type GroupsRequest = {
user: string;
type: string;
};
export interface IdentityApi {
getUserGroups(req: GroupsRequest): Promise<GroupsResponse>;
}
@@ -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.
*/
export const userGroups = {
groups: [
{
name: 'engineering',
type: 'org',
children: [
{
name: 'authentication',
type: 'team',
members: [{ name: 'kent' }, { name: 'dobbs' }],
},
{
name: 'checkout',
type: 'team',
members: [{ name: 'don' }, { name: 'abramev' }],
},
],
},
],
};
+18 -7
View File
@@ -17,21 +17,32 @@
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { StaticJsonAdapter } from '../adapters';
import { IdentityApi } from '../adapters/types';
import { userGroups } from '../adapters/userGroups';
export interface RouterOptions {
logger: Logger;
}
const makeRouter = (adapter: IdentityApi): express.Router => {
const router = Router();
router.get('/users/:user/groups', async (req, res) => {
const user = req.params.user;
const type = req.query.type?.toString() ?? '';
const response = await adapter.getUserGroups({ user, type });
res.send(response);
});
return router;
};
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const logger = options.logger;
router.use('/ping', (_, res) => {
logger.info('heartbeat for identity service');
res.send('pong');
});
return router;
logger.info('Initializing identity API backend');
const adapter = new StaticJsonAdapter(userGroups);
return makeRouter(adapter);
}
+7 -8
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-lighthouse",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
@@ -22,8 +21,8 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -34,9 +33,9 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+9 -10
View File
@@ -1,11 +1,10 @@
{
"name": "@backstage/plugin-register-component",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -22,10 +21,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-catalog": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.9",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/plugin-catalog": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -37,8 +36,8 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+10 -6
View File
@@ -1,10 +1,10 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"version": "0.1.1-alpha.8",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
@@ -21,8 +21,8 @@
"mock-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/catalog-model": "^0.1.1-alpha.9",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dockerode": "^3.2.0",
@@ -35,10 +35,14 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/cli": "^0.1.1-alpha.9",
"@types/fs-extra": "^9.0.1",
"@types/express": "^4.17.6",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
"yaml": "^1.10.0"
}
},
"files": [
"dist/**/*.{js,d.ts}"
]
}

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