Merge branch 'master' of github.com:backstage/backstage into blam/tubgoat

* 'master' of github.com:backstage/backstage: (94 commits)
  return same data for all product insights requests in mock client
  Feat: Bump GitHub Insights plugin version (#3509)
  chore: set the port as number if it comes through as a string
  Add tests for components in the api-docs plugin
  backend-common: allow port in config to be both a number or a string
  Update swagger-ui-react to 3.37.2
  Add Kubernetes plugin (#3505)
  Use case-insensitive filters
  techdocs-backend: update Gitlab clone auth
  Fix review comments
  Update dark theme colors
  Drop fill opacity for lighter colors
  Fix another Windows test issue
  backend,yarnrc: bring yarn network-timeout down to 300s
  Refactor the hooks and also support users owning components
  backend: remove yarn cache from built docker image
  Make backend-commons tests work on Windows
  changeset
  stack rank product menu items; add mock data
  Filter the response headers in the proxy backend
  ...
This commit is contained in:
blam
2020-12-01 21:08:08 +01:00
159 changed files with 4582 additions and 887 deletions
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/create-app': patch
---
Optimized the `yarn install` step in the backend `Dockerfile`.
To apply these changes to an existing app, make the following changes to `packages/backend/Dockerfile`:
Replace the `RUN yarn install ...` line with the following:
```bash
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-api-docs': patch
---
Update swagger-ui-react to 3.37.2
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
Add breakdown view to the Cost Overview panel
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Update URL auth format for Gitlab clone
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-sentry': patch
'@backstage/plugin-welcome': patch
---
Refactor route registration to remove deprecating code
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/config-loader': minor
---
Fix typo of "visibility" in config schema reference
If you have defined a config element named `visiblity`, you
will need to fix the spelling to `visibility`. For more info,
see https://backstage.io/docs/conf/defining#visibility.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Allow the `backend.listen.port` config to be both a number or a string.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Bump versions of `esbuild` and `rollup-plugin-esbuild`
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-proxy-backend': patch
---
Filter the headers that are sent from the proxied-targed back to the frontend to not forwarded unwanted authentication or
monitoring contexts from other origins (like `Set-Cookie` with e.g. a google analytics context). The implementation reuses
the `allowedHeaders` configuration that now controls both directions `frontend->target` and `target->frontend`.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/create-app': patch
---
Removed `"resolutions"` entry for `esbuild` in the root `package.json` in order to use the version specified by `@backstage/cli`.
To apply this change to an existing app, remove the following from your root `package.json`:
```json
"resolutions": {
"esbuild": "0.6.3"
},
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Add [API docs plugin](https://github.com/backstage/backstage/tree/master/plugins/api-docs) to new apps being created through the CLI.
+28
View File
@@ -0,0 +1,28 @@
---
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-catalog-import': minor
'@backstage/catalog-model': patch
'@backstage/plugin-scaffolder': patch
---
Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file:
```ts
export default async function createPlugin(env: PluginEnvironment) {
const builder = new CatalogBuilder(env);
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
} = await builder.build();
return await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
logger: env.logger,
});
}
```
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-backend': patch
---
Gracefully handle missing codeowners.
The CodeOwnersProcessor now also takes a logger as a parameter.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/cli': minor
'@backstage/plugin-cost-insights': patch
---
sort product panels and navigation menu by greatest cost
update tsconfig.json to use ES2020 api
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Use type EntityName from catalog-model for entities
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Use the OWNED_BY relation and compare it to the users MEMBER_OF relation. The user entity is searched by name, based on the userId of the identity.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-proxy-backend': patch
---
Add configuration schema for the commonly used properties
+1
View File
@@ -230,3 +230,4 @@ yaml
Zalando
Zhou
Zolotusky
zoomable
+1 -1
View File
@@ -6,4 +6,4 @@ registry "https://registry.npmjs.org/"
disable-self-update-check true
lastUpdateCheck 1580389148099
yarn-path ".yarn/releases/yarn-1.22.1.js"
network-timeout 600000
network-timeout 300000
+1 -1
View File
@@ -38,7 +38,7 @@ proxy:
headers:
Authorization:
$env: TRAVISCI_AUTH_TOKEN
travis-api-version: 3
travis-api-version: '3'
'/newrelic/apm/api':
target: https://api.newrelic.com/v2
+2 -2
View File
@@ -9,7 +9,7 @@ description: Documentation on TechDocs Architecture
When you deploy Backstage (with TechDocs enabled by default), you get a basic
out-of-the box experience.
![TechDocs Architecture diagram](../../assets/techdocs/architecture-basic.drawio.svg)
<img data-zoomable src="../../assets/techdocs/architecture-basic.drawio.svg" alt="TechDocs Architecture diagram" />
> Note: See below for our recommended deployment architecture which takes care
> of stability, scalability and speed.
@@ -43,7 +43,7 @@ channel to talk about it.
This is how we recommend deploying TechDocs in production environment.
![TechDocs Architecture diagram](../../assets/techdocs/architecture-recommended.drawio.svg)
<img data-zoomable src="../../assets/techdocs/architecture-recommended.drawio.svg" alt="TechDocs Architecture diagram" />
The key difference in the recommended deployment approach is where the docs are
built.
@@ -6,7 +6,53 @@ description: Documentation on How Configuring App with plugins
## Adding existing plugins to your app
Coming soon!
The following steps assume that you have created a new Backstage app and want to
add an existing plugin to it. We are using the
[CircleCI](https://github.com/backstage/backstage/blob/master/plugins/circleci/README.md)
plugin in this example.
1. Add the plugin's NPM package to the repo:
```bash
yarn add @backstage/plugin-circleci
```
2. Add the plugin itself:
```js
// packages/app/src/plugins.ts
export { plugin as Circleci } from '@backstage/plugin-circleci';
```
3. Register the plugin router:
```jsx
// packages/app/src/components/catalog/EntityPage.tsx
import { Router as CircleCIRouter } from '@backstage/plugin-circleci';
// Then somewhere inside <EntityPageLayout>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<CircleCIRouter />}
/>;
```
Note that stand-alone plugins that are not "attached" to the Software Catalog
would be added outside the `EntityPage`.
4. [Optional] Add proxy config:
```yaml
// app-config.yaml
proxy:
'/circleci/api':
target: https://circleci.com/api/v1.1
headers:
Circle-Token:
$env: CIRCLECI_AUTH_TOKEN
```
### Adding a plugin page to the Sidebar
+20 -13
View File
@@ -52,19 +52,7 @@ configuration will lead to the proxy acting on backend requests to
The value inside each route is either a simple URL string, or an object on the
format accepted by
[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware). It
is also possible to limit the forwarded HTTP methods with the configuration
`allowedMethods`, for example `allowedMethods: ['GET']` to enforce read-only
access.
By default, the proxy will only forward safe HTTP request headers to the target.
Those are based on the headers that are considered safe for CORS and includes
headers like `content-type` or `last-modified`, as well as all headers that are
set by the proxy. If the proxy should forward other headers like
`authorization`, this must be enabled by the `allowedHeaders` config, for
example `allowedHeaders: ['Authorization']`. This should help to not
accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to
third-parties.
[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware).
If the value is a string, it is assumed to correspond to:
@@ -85,3 +73,22 @@ except with the following caveats for convenience:
`'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to
`/api/proxy/larger-example/v1/some/path` will be translated to a request to
`http://larger.example.com:8080/svc.v1/some/path`.
There are also additional settings:
- `allowedMethods`: Limit the forwarded HTTP methods. For example
`allowedMethods: ['GET']` enforces read-only access.
- `allowedHeaders`: A list of headers that should be forwarded to and received
from the target.
By default, the proxy will only forward safe HTTP request headers to the target.
Those are based on the headers that are considered safe for CORS and includes
headers like `content-type` or `last-modified`, as well as all headers that are
set by the proxy. If the proxy should forward other headers like
`authorization`, this must be enabled by the `allowedHeaders` config, for
example `allowedHeaders: ['Authorization']`. This should help to not
accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to
third-parties.
The same logic applies to headers that are sent from the target back to the
frontend.
+9 -5
View File
@@ -4,7 +4,7 @@ title: createPlugin
description: Documentation on createPlugin
---
Taking a plugin config as argument and returns a new plugin.
Takes a plugin config as an argument and returns a new plugin.
## Plugin Config
@@ -28,18 +28,22 @@ type PluginHooks = {
### Creating a basic plugin
Showcasing adding multiple routes, a feature flag and a redirect.
Showcasing adding a route and a feature flag.
```jsx
import { createPlugin } from '@backstage/core';
import { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
export const rootRouteRef = createRouteRef({
path: '/new-plugin',
title: 'New Plugin',
});
export default createPlugin({
id: 'new-plugin',
register({ router, featureFlags }) {
router.addRoute(rootRouteRef, ExampleComponent);
featureFlags.register('enable-example-component');
router.registerRoute('/new-plugin', ExampleComponent);
},
});
```
+5
View File
@@ -204,3 +204,8 @@ For more information about custom pages, click [here](https://docusaurus.io/docs
# Full Documentation
Full documentation can be found on the [website](https://docusaurus.io/).
## Additional notes
- If you want to make images zoomable on click, add the `data-zoomable` attribute to your `img` element.
- In a docs or blog `.md` file, convert `![This is image](/microsite/static/img/code.png)` syntax to `<img data-zoomable src="/microsite/static/img/code.png" alt="This is image" />`
+12
View File
@@ -0,0 +1,12 @@
---
title: Kubernetes
author: Spotify
authorUrl: https://github.com/spotify
category: Kubernetes
description: Surfaces components in a Kubernetes container orchestration environment into the Backstage catalog.
documentation: https://github.com/backstage/backstage/tree/master/plugins/kubernetes
iconUrl: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.png
npmPackageName: '@backstage/plugin-kubernetes'
tags:
- kubernetes
- k8s
+1 -1
View File
@@ -17,7 +17,7 @@
"@spotify/prettier-config": "^9.0.0",
"docusaurus": "^2.0.0-alpha.66",
"js-yaml": "^3.14.0",
"prettier": "^2.2.0"
"prettier": "^2.2.1"
},
"prettier": "@spotify/prettier-config"
}
+5 -1
View File
@@ -86,7 +86,11 @@ const siteConfig = {
},
// Add custom scripts here that would be placed in <script> tags.
scripts: ['https://buttons.github.io/buttons.js'],
scripts: [
'https://buttons.github.io/buttons.js',
'https://unpkg.com/medium-zoom@1.0.6/dist/medium-zoom.min.js',
'/js/medium-zoom.js',
],
// On page navigation for the current documentation page.
onPageNav: 'separate',
+5
View File
@@ -1094,3 +1094,8 @@ code {
margin: 0 1.5em;
}
}
/* Zoomed images using the medium-zoom library should be on top of screen. */
.medium-zoom-image {
z-index: 10000;
}
+11
View File
@@ -0,0 +1,11 @@
// Ref: https://github.com/francoischalifour/medium-zoom#options
window.addEventListener(
'load',
() => {
mediumZoom('[data-zoomable]', {
margin: 20,
background: '#000',
});
},
false,
);
+4 -4
View File
@@ -5204,10 +5204,10 @@ prepend-http@^2.0.0:
resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
prettier@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.0.tgz#8a03c7777883b29b37fb2c4348c66a78e980418b"
integrity sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw==
prettier@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5"
integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==
prismjs@^1.17.1:
version "1.21.0"
+2 -1
View File
@@ -9,6 +9,7 @@
"@backstage/core": "^0.3.2",
"@backstage/plugin-api-docs": "^0.3.0",
"@backstage/plugin-catalog": "^0.2.4",
"@backstage/plugin-catalog-import": "^0.2.0",
"@backstage/plugin-circleci": "^0.2.2",
"@backstage/plugin-cloudbuild": "^0.2.2",
"@backstage/plugin-cost-insights": "^0.4.1",
@@ -35,7 +36,7 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@octokit/rest": "^18.0.0",
"@roadiehq/backstage-plugin-github-insights": "^0.2.15",
"@roadiehq/backstage-plugin-github-insights": "^0.2.16",
"@roadiehq/backstage-plugin-github-pull-requests": "^0.6.3",
"@roadiehq/backstage-plugin-travis-ci": "^0.2.8",
"@roadiehq/backstage-plugin-buildkite": "^0.1.3",
+5
View File
@@ -34,6 +34,7 @@ import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { Route, Routes, Navigate } from 'react-router';
import { EntityPage } from './components/catalog/EntityPage';
@@ -67,6 +68,10 @@ const catalogRouteRef = createRouteRef({
const AppRoutes = () => (
<Routes>
<Navigate key="/" to="/catalog" />
<Route
path="/catalog-import/*"
element={<ImportComponentRouter catalogRouteRef={catalogRouteRef} />}
/>
<Route
path={`${catalogRouteRef.path}/*`}
element={<CatalogRouter EntityPage={EntityPage} />}
+1
View File
@@ -37,6 +37,7 @@ export { plugin as Kubernetes } from '@backstage/plugin-kubernetes';
export { plugin as Cloudbuild } from '@backstage/plugin-cloudbuild';
export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights';
export { plugin as CatalogImport } from '@backstage/plugin-catalog-import';
export { plugin as UserSettings } from '@backstage/plugin-user-settings';
export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
export { plugin as Search } from '@backstage/plugin-search';
+1 -1
View File
@@ -29,7 +29,7 @@ export interface Config {
/** Address of the interface that the backend should bind to. */
address?: string;
/** Port that the backend should listen to. */
port?: number;
port?: string | number;
};
/** HTTPS configuration for the backend. If omitted the backend will serve HTTP */
@@ -126,7 +126,7 @@ describe('TarArchiveResponse', () => {
const res = new TarArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
const dir = await res.dir();
expect(dir).toMatch(/^\/tmp\/.*$/);
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
await expect(
fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
@@ -126,7 +126,7 @@ describe('ZipArchiveResponse', () => {
const res = new ZipArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
const dir = await res.dir();
expect(dir).toMatch(/^\/tmp\/.*$/);
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
await expect(
fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
@@ -85,7 +85,10 @@ export class ServiceBuilderImpl implements ServiceBuilder {
const baseOptions = readBaseOptions(backendConfig);
if (baseOptions.listenPort) {
this.port = baseOptions.listenPort;
this.port =
typeof baseOptions.listenPort === 'string'
? parseInt(baseOptions.listenPort, 10)
: baseOptions.listenPort;
}
if (baseOptions.listenHost) {
this.host = baseOptions.listenHost;
@@ -18,7 +18,7 @@ import { Config } from '@backstage/config';
import { CorsOptions } from 'cors';
export type BaseOptions = {
listenPort?: number;
listenPort?: string | number;
listenHost?: string;
};
@@ -89,8 +89,19 @@ export function readBaseOptions(config: Config): BaseOptions {
});
}
const port = config.getOptional('listen.port');
if (
typeof port !== 'undefined' &&
typeof port !== 'number' &&
typeof port !== 'string'
) {
throw new Error(
`Invalid type in config for key 'backend.listen.post', got ${typeof port}, wanted string or number`,
);
}
return removeUnknown({
listenPort: config.getOptionalNumber('listen.port'),
listenPort: port,
listenHost: config.getOptionalString('listen.host'),
baseUrl: config.getOptionalString('baseUrl'),
});
+1 -1
View File
@@ -7,7 +7,7 @@ WORKDIR /usr/src/app
# and along with yarn.lock and the root package.json, that's enough to run yarn install.
ADD yarn.lock package.json skeleton.tar ./
RUN yarn install --frozen-lockfile --production
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
# This will copy the contents of the dist-workspace when running the build-image command.
# Do not use this Dockerfile outside of that command, as it will copy in the source code instead.
+2
View File
@@ -28,6 +28,7 @@ export default async function createPlugin(env: PluginEnvironment) {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
} = await builder.build();
useHotCleanup(
@@ -39,6 +40,7 @@ export default async function createPlugin(env: PluginEnvironment) {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
logger: env.logger,
});
}
+5 -1
View File
@@ -15,5 +15,9 @@
*/
export type { Location, LocationSpec } from './types';
export { locationSchema, locationSpecSchema } from './validation';
export {
locationSchema,
locationSpecSchema,
analyzeLocationSchema,
} from './validation';
export { LOCATION_ANNOTATION } from './annotation';
@@ -34,3 +34,10 @@ export const locationSchema = yup
})
.noUnknown()
.required();
export const analyzeLocationSchema = yup
.object<{ location: LocationSpec }>({
location: locationSpecSchema,
})
.noUnknown()
.required();
+1 -1
View File
@@ -11,7 +11,7 @@
"incremental": true,
"isolatedModules": true,
"jsx": "react",
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019", "ESNext.Promise"],
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2020", "ESNext.Promise"],
"module": "ESNext",
"moduleResolution": "node",
"noEmit": false,
+2 -2
View File
@@ -59,7 +59,7 @@
"css-loader": "^3.5.3",
"dashify": "^2.0.0",
"diff": "^4.0.2",
"esbuild": "^0.7.7",
"esbuild": "^0.8.16",
"eslint": "^7.1.0",
"eslint-config-prettier": "^6.0.0",
"eslint-formatter-friendly": "^7.0.0",
@@ -88,7 +88,7 @@
"replace-in-file": "^6.0.0",
"rollup": "2.33.x",
"rollup-plugin-dts": "1.4.13",
"rollup-plugin-esbuild": "2.3.x",
"rollup-plugin-esbuild": "2.6.x",
"rollup-plugin-peer-deps-external": "^2.2.2",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.27.3",
+6 -6
View File
@@ -25,7 +25,7 @@ export default async (cmd: Command) => {
args: cmd.config,
fromPackage: cmd.package,
});
const visibility = getVisiblityOption(cmd);
const visibility = getVisibilityOption(cmd);
const data = serializeConfigData(appConfigs, schema, visibility);
if (cmd.format === 'json') {
@@ -35,7 +35,7 @@ export default async (cmd: Command) => {
}
};
function getVisiblityOption(cmd: Command): ConfigVisibility {
function getVisibilityOption(cmd: Command): ConfigVisibility {
if (cmd.frontend && cmd.withSecrets) {
throw new Error('Not allowed to combine frontend and secret config');
}
@@ -50,14 +50,14 @@ function getVisiblityOption(cmd: Command): ConfigVisibility {
function serializeConfigData(
appConfigs: AppConfig[],
schema: ConfigSchema,
visiblity: ConfigVisibility,
visibility: ConfigVisibility,
) {
if (visiblity === 'frontend') {
if (visibility === 'frontend') {
const frontendConfigs = schema.process(appConfigs, {
visiblity: ['frontend'],
visibility: ['frontend'],
});
return ConfigReader.fromConfigs(frontendConfigs).get();
} else if (visiblity === 'secret') {
} else if (visibility === 'secret') {
return ConfigReader.fromConfigs(appConfigs).get();
}
+17
View File
@@ -83,6 +83,23 @@ async function build(config: RollupOptions) {
}
export const buildPackage = async (options: BuildOptions) => {
try {
const { resolutions } = await fs.readJson(
paths.resolveTargetRoot('package.json'),
);
if (resolutions?.esbuild) {
console.warn(
chalk.red(
'Your root package.json contains a "resolutions" entry for "esbuild". This was ' +
'included in older @backstage/create-app templates in order to work around build ' +
'issues that have since been fixed. Please remove the entry and run `yarn install`',
),
);
}
} catch {
/* Errors ignored, this is just a warning */
}
const configs = await makeConfigs(options);
await fs.remove(paths.resolveTarget('dist'));
await Promise.all(configs.map(build));
+1 -1
View File
@@ -51,7 +51,7 @@ export async function loadCliConfig(options: Options) {
try {
const frontendAppConfigs = schema.process(appConfigs, {
visiblity: ['frontend'],
visibility: ['frontend'],
});
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
@@ -15,6 +15,7 @@
*/
import mockFs from 'mock-fs';
import path from 'path';
import * as runObj from '../run';
import { paths } from '../paths';
import { fetchPackageInfo, mapDependencies } from './packages';
@@ -54,16 +55,16 @@ describe('mapDependencies', () => {
await project.getPackages();
mockFs({
'/lerna.json': JSON.stringify({
'lerna.json': JSON.stringify({
packages: ['pkgs/*'],
}),
'/pkgs/a/package.json': JSON.stringify({
'pkgs/a/package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': '1 || 2',
},
}),
'/pkgs/b/package.json': JSON.stringify({
'pkgs/b/package.json': JSON.stringify({
name: 'b',
dependencies: {
'@backstage/core': '3',
@@ -72,9 +73,6 @@ describe('mapDependencies', () => {
}),
});
const oldDir = paths.targetDir;
paths.targetDir = '/';
const dependencyMap = await mapDependencies(paths.targetDir);
expect(Array.from(dependencyMap)).toEqual([
[
@@ -83,12 +81,12 @@ describe('mapDependencies', () => {
{
name: 'a',
range: '1 || 2',
location: '/pkgs/a',
location: path.resolve('pkgs', 'a'),
},
{
name: 'b',
range: '3',
location: '/pkgs/b',
location: path.resolve('pkgs', 'b'),
},
],
],
@@ -98,12 +96,10 @@ describe('mapDependencies', () => {
{
name: 'b',
range: '^0',
location: '/pkgs/b',
location: path.resolve('pkgs', 'b'),
},
],
],
]);
paths.targetDir = oldDir;
});
});
@@ -89,7 +89,7 @@ describe('compileConfigSchemas', () => {
});
});
it('should reject visiblity conflicts', () => {
it('should reject visibility conflicts', () => {
expect(() =>
compileConfigSchemas([
{
@@ -38,7 +38,7 @@ const data = {
objS: { never: 'here' },
};
const visiblity = new Map<string, ConfigVisibility>(
const visibility = new Map<string, ConfigVisibility>(
Object.entries({
'.arr.0': 'frontend',
'.arr.1': 'backend',
@@ -100,6 +100,6 @@ describe('filterByVisibility', () => {
],
[['frontend', 'backend', 'secret'], data],
])('should filter correctly with %p', (filter, expected) => {
expect(filterByVisibility(data, filter, visiblity)).toEqual(expected);
expect(filterByVisibility(data, filter, visibility)).toEqual(expected);
});
});
@@ -61,12 +61,12 @@ describe('loadConfigSchema', () => {
const configs = [{ data: { key1: 'a', key2: 2 }, context: 'test' }];
expect(schema.process(configs)).toEqual(configs);
expect(schema.process(configs, { visiblity: ['frontend'] })).toEqual([
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
]);
expect(
schema.process(configs, {
visiblity: ['frontend'],
visibility: ['frontend'],
valueTransform: () => 'X',
}),
).toEqual([{ data: { key1: 'X' }, context: 'test' }]);
@@ -79,7 +79,7 @@ describe('loadConfigSchema', () => {
const serialized = schema.serialize();
const schema2 = await loadConfigSchema({ serialized });
expect(schema2.process(configs, { visiblity: ['frontend'] })).toEqual([
expect(schema2.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
]);
expect(() =>
@@ -57,7 +57,7 @@ export async function loadConfigSchema(
return {
process(
configs: AppConfig[],
{ visiblity, valueTransform } = {},
{ visibility, valueTransform } = {},
): AppConfig[] {
const result = validate(configs);
if (result.errors) {
@@ -70,12 +70,12 @@ export async function loadConfigSchema(
let processedConfigs = configs;
if (visiblity) {
if (visibility) {
processedConfigs = processedConfigs.map(({ data, context }) => ({
context,
data: filterByVisibility(
data,
visiblity,
visibility,
result.visibilityByPath,
valueTransform,
),
@@ -87,7 +87,7 @@ type ConfigProcessingOptions = {
* The visibilities that should be included in the output data.
* If omitted, the data will not be filtered by visibility.
*/
visiblity?: ConfigVisibility[];
visibility?: ConfigVisibility[];
/**
* A transform function that can be used to transform primitive configuration values
+7 -1
View File
@@ -184,7 +184,13 @@ export class PrivateAppImpl implements BackstageApp {
}
}
routes.push(<Route path="/*" element={<NotFoundErrorPage />} />);
routes.push(
<Route
key="not-found-error-page"
path="/*"
element={<NotFoundErrorPage />}
/>,
);
return routes;
}
+1
View File
@@ -92,6 +92,7 @@ export type RouterHooks = {
/**
* @deprecated See the `addRoute` method
* @see https://github.com/backstage/backstage/issues/418
*/
registerRoute(
path: RoutePath,
@@ -32,9 +32,6 @@
"lerna": "^3.20.2",
"prettier": "^1.19.1"
},
"resolutions": {
"esbuild": "0.6.3"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -15,7 +15,6 @@
"@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}",
"@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}",
"@backstage/plugin-circleci": "^{{version '@backstage/plugin-circleci'}}",
"@backstage/plugin-explore": "^{{version '@backstage/plugin-explore'}}",
"@backstage/plugin-lighthouse": "^{{version '@backstage/plugin-lighthouse'}}",
"@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}",
"@backstage/plugin-github-actions": "^{{version '@backstage/plugin-github-actions'}}",
@@ -3,7 +3,6 @@ export { plugin as CatalogPlugin } from '@backstage/plugin-catalog';
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder';
export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs';
export { plugin as Explore } from '@backstage/plugin-explore';
export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
export { plugin as TechRadar } from '@backstage/plugin-tech-radar';
@@ -1,6 +1,7 @@
import React, { FC, useContext } from 'react';
import HomeIcon from '@material-ui/icons/Home';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import ExtensionIcon from '@material-ui/icons/Extension';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import BuildIcon from '@material-ui/icons/BuildRounded';
import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
@@ -26,6 +27,7 @@ export const AppSidebar = () => (
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
<SidebarDivider />
@@ -7,7 +7,7 @@ WORKDIR /usr/src/app
# and along with yarn.lock and the root package.json, that's enough to run yarn install.
ADD yarn.lock package.json skeleton.tar ./
RUN yarn install --frozen-lockfile --production
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
# This will copy the contents of the dist-workspace when running the build-image command.
# Do not use this Dockerfile outside of that command, as it will copy in the source code instead.
+1 -1
View File
@@ -37,7 +37,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swagger-ui-react": "^3.31.1"
"swagger-ui-react": "^3.37.2"
},
"devDependencies": {
"@backstage/cli": "^0.3.2",
@@ -0,0 +1,121 @@
/*
* 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 { ApiEntity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
import { ApiDefinitionCard } from './ApiDefinitionCard';
describe('<ApiDefinitionCard />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('renders API', async () => {
const definition = `
openapi: "3.0.0"
info:
version: 1.0.0
title: Artist API
license:
name: MIT
servers:
- url: http://artist.spotify.net/v1
paths:
/artists:
get:
summary: List all artists
responses:
"200":
description: Success
`;
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'openapi',
lifecycle: '...',
owner: '...',
definition,
},
};
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
rawLanguage: 'yaml',
component: definition => (
<OpenApiDefinitionWidget definition={definition} />
),
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiDefinitionCard apiEntity={apiEntity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Raw/i)).toBeInTheDocument();
expect(getByText(/List all artists/i)).toBeInTheDocument();
});
});
it('fallback to plain view', async () => {
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'custom-type',
lifecycle: '...',
owner: '...',
definition: 'Custom Definition',
},
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiDefinitionCard apiEntity={apiEntity} />
</Wrapper>,
);
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/custom-type/i)).toBeInTheDocument();
expect(getByText(/Custom Definition/i)).toBeInTheDocument();
});
});
@@ -15,50 +15,11 @@
*/
import { ApiEntity } from '@backstage/catalog-model';
import { CardTab, useApi, TabbedCard } from '@backstage/core';
import { CardTab, TabbedCard, useApi } from '@backstage/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
export type ApiDefinitionWidget = {
type: string;
title: string;
component: (definition: string) => React.ReactElement;
rawLanguage?: string;
};
export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
return [
{
type: 'openapi',
title: 'OpenAPI',
rawLanguage: 'yaml',
component: definition => (
<OpenApiDefinitionWidget definition={definition} />
),
},
{
type: 'asyncapi',
title: 'AsyncAPI',
rawLanguage: 'yaml',
component: definition => (
<AsyncApiDefinitionWidget definition={definition} />
),
},
{
type: 'graphql',
title: 'GraphQL',
rawLanguage: 'graphql',
component: definition => (
<GraphQlDefinitionWidget definition={definition} />
),
},
];
}
type Props = {
apiEntity?: ApiEntity;
@@ -0,0 +1,55 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
export type ApiDefinitionWidget = {
type: string;
title: string;
component: (definition: string) => React.ReactElement;
rawLanguage?: string;
};
export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
return [
{
type: 'openapi',
title: 'OpenAPI',
rawLanguage: 'yaml',
component: definition => (
<OpenApiDefinitionWidget definition={definition} />
),
},
{
type: 'asyncapi',
title: 'AsyncAPI',
rawLanguage: 'yaml',
component: definition => (
<AsyncApiDefinitionWidget definition={definition} />
),
},
{
type: 'graphql',
title: 'GraphQL',
rawLanguage: 'graphql',
component: definition => (
<GraphQlDefinitionWidget definition={definition} />
),
},
];
}
@@ -0,0 +1,92 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { ApiTypeTitle } from './ApiTypeTitle';
describe('<ApiTypeTitle />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('renders API type title', async () => {
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'openapi',
lifecycle: '...',
owner: '...',
definition: '...',
},
};
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
component: () => <div />,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiTypeTitle apiEntity={apiEntity} />
</Wrapper>,
);
expect(getByText(/OpenAPI/)).toBeInTheDocument();
});
it('fallback if title is unknown', async () => {
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'custom-type',
lifecycle: '...',
owner: '...',
definition: '...',
},
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiTypeTitle apiEntity={apiEntity} />
</Wrapper>,
);
expect(getByText(/custom-type/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
export const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntity }) => {
const config = useApi(apiDocsConfigRef);
const definition = config.getApiDefinitionWidget(apiEntity);
const type = definition ? definition.title : apiEntity.spec.type;
return <span>{type}</span>;
};
@@ -14,8 +14,7 @@
* limitations under the License.
*/
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
export {
ApiDefinitionCard,
defaultDefinitionWidgets,
} from './ApiDefinitionCard';
export { ApiDefinitionCard } from './ApiDefinitionCard';
export { defaultDefinitionWidgets } from './ApiDefinitionWidget';
export type { ApiDefinitionWidget } from './ApiDefinitionWidget';
export { ApiTypeTitle } from './ApiTypeTitle';
@@ -22,7 +22,7 @@ import * as React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerTable } from './ApiExplorerTable';
const entites: Entity[] = [
const entities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
@@ -70,7 +70,7 @@ describe('ApiCatalogTable component', () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable entities={entites} loading={false} />
<ApiExplorerTable entities={entities} loading={false} />
</ApiProvider>,
),
);
@@ -20,23 +20,13 @@ import {
TableColumn,
TableFilter,
TableState,
useApi,
useQueryParamState,
} from '@backstage/core';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Chip, Link } from '@material-ui/core';
import { Chip } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { apiDocsConfigRef } from '../../config';
const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntityV1alpha1 }) => {
const config = useApi(apiDocsConfigRef);
const definition = config.getApiDefinitionWidget(apiEntity);
const type = definition ? definition.title : apiEntity.spec.type;
return <span>{type}</span>;
};
import { ApiTypeTitle } from '../ApiDefinitionCard';
import { EntityLink } from '../EntityLink';
const columns: TableColumn<Entity>[] = [
{
@@ -44,15 +34,7 @@ const columns: TableColumn<Entity>[] = [
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
>
{entity.metadata.name}
</Link>
<EntityLink entity={entity}>{entity.metadata.name}</EntityLink>
),
},
{
@@ -0,0 +1,59 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
describe('<AsyncApiDefinitionWidget />', () => {
it('renders asyncapi spec', async () => {
const definition = `
asyncapi: 2.0.0
info:
title: Account Service
version: 1.0.0
channels:
user/signedup:
subscribe:
message:
$ref: '#/components/messages/UserSignedUp'
components:
messages:
UserSignedUp:
payload:
type: object
properties:
displayName:
type: string
`;
const { getByText, getAllByText } = await renderInTestApp(
<AsyncApiDefinitionWidget definition={definition} />,
);
expect(getByText(/Account Service/i)).toBeInTheDocument();
expect(getByText(/user\/signedup/i)).toBeInTheDocument();
expect(getByText(/UserSignedUp/i)).toBeInTheDocument();
expect(getAllByText(/displayName/i)).toHaveLength(4);
});
it('renders error if definition is missing', async () => {
const { getByText } = await renderInTestApp(
<AsyncApiDefinitionWidget definition="" />,
);
expect(getByText(/Error/i)).toBeInTheDocument();
expect(getByText(/Document can't be null or falsey/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,41 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { EntityLink } from './EntityLink';
describe('<EntityLink />', () => {
it('provides link to entity page', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
};
const { getByText } = await renderInTestApp(
<EntityLink entity={entity}>inner</EntityLink>,
);
expect(getByText(/inner/i)).toBeInTheDocument();
expect(getByText(/inner/i)).toHaveAttribute(
'href',
'/catalog/my-namespace/component/my-name',
);
});
});
@@ -0,0 +1,40 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Link } from '@material-ui/core';
import React, { PropsWithChildren } from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
type Props = {
entity: Entity;
};
// TODO: Could be useful for others too, as part of the catalog plugin
export const EntityLink = ({ entity, children }: PropsWithChildren<Props>) => {
return (
<Link
component={RouterLink}
to={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
>
{children}
</Link>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityLink } from './EntityLink';
@@ -0,0 +1,61 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget';
describe('<GraphQlDefinitionWidget />', () => {
it('renders graphql schema', async () => {
const definition = `
"""Hello World!"""
schema {
query: Film
}
"""A single film."""
type Film {
"""The ID of an object"""
id: ID!
"""The title of this film."""
title: String
}
`;
// CodeMirror uses features that jsdom doesn't support so we have to patch
// it here, see: https://github.com/jsdom/jsdom/issues/3002
document.createRange = () => {
const range = new Range();
range.getBoundingClientRect = jest.fn();
range.getClientRects = () => {
return {
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
};
};
return range;
};
const { getByText } = await renderInTestApp(
<GraphQlDefinitionWidget definition={definition} />,
);
expect(getByText(/Film/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
describe('<OpenApiDefinitionWidget />', () => {
it('renders openapi spec', async () => {
const definition = `
openapi: "3.0.0"
info:
version: 1.0.0
title: Artist API
license:
name: MIT
servers:
- url: http://artist.spotify.net/v1
paths:
/artists:
get:
summary: List all artists
responses:
"200":
description: Success
`;
const { getByText } = await renderInTestApp(
<OpenApiDefinitionWidget definition={definition} />,
);
// swagger-ui loads the documentation asynchronously
await waitFor(() => {
expect(getByText(/\/artists/i)).toBeInTheDocument();
expect(getByText(/List all artists/i)).toBeInTheDocument();
});
});
it('renders error if definition is missing', async () => {
const { getByText } = await renderInTestApp(
<OpenApiDefinitionWidget definition="" />,
);
expect(getByText(/No API definition provided/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
describe('<PlainApiDefinitionWidget />', () => {
it('renders plain text', async () => {
const { getByText } = await renderInTestApp(
<PlainApiDefinitionWidget definition="Hello World" language="yaml" />,
);
expect(getByText(/Hello World/i)).toBeInTheDocument();
});
});
+1 -1
View File
@@ -88,7 +88,7 @@ export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
{ visiblity: ['frontend'] },
{ visibility: ['frontend'] },
);
appConfigs.push(...frontendConfigs);
}
@@ -0,0 +1,52 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Logger } from 'winston';
import parseGitUri from 'git-url-parse';
import {
AnalyzeLocationRequest,
AnalyzeLocationResponse,
LocationAnalyzer,
} from './types';
export class RepoLocationAnalyzer implements LocationAnalyzer {
private readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
async analyzeLocation(
request: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse> {
const { owner, name, source } = parseGitUri(request.location.target);
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: name,
// Probably won't handle properly self-hosted git providers with custom url
annotations: { [`${source}/project-slug`]: `${owner}/${name}` },
},
spec: { type: 'other', lifecycle: 'unknown' },
};
this.logger.debug(`entity created for ${request.location.target}`);
return {
existingEntityFiles: [],
generateEntities: [{ entity, fields: [] }],
};
}
}
@@ -16,12 +16,5 @@
export { HigherOrderOperations } from './HigherOrderOperations';
export { LocationReaders } from './LocationReaders';
export type {
AddLocationResult,
HigherOrderOperation,
LocationReader,
ReadLocationEntity,
ReadLocationError,
ReadLocationResult,
} from './types';
export * from './types';
export * from './processors';
@@ -16,6 +16,7 @@
import { LocationSpec } from '@backstage/catalog-model';
import { CodeOwnersEntry } from 'codeowners-utils';
import { createLogger } from 'winston';
import {
buildCodeOwnerUrl,
buildUrl,
@@ -27,6 +28,8 @@ import {
resolveCodeOwner,
} from './CodeOwnersProcessor';
const logger = createLogger();
describe('CodeOwnersProcessor', () => {
const mockUrl = ({ basePath = '' } = {}): string =>
`https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`;
@@ -158,17 +161,20 @@ describe('CodeOwnersProcessor', () => {
.fn()
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn() };
const result = await findRawCodeOwners(mockLocation(), reader);
const result = await findRawCodeOwners(mockLocation(), {
reader,
logger,
});
expect(result).toEqual(ownersText);
});
it('should raise error when no codeowner', async () => {
it('should return undefined when no codeowner', async () => {
const read = jest.fn().mockRejectedValue(mockReadResult());
const reader = { read, readTree: jest.fn() };
await expect(
findRawCodeOwners(mockLocation(), reader),
).rejects.toBeInstanceOf(Error);
findRawCodeOwners(mockLocation(), { reader, logger }),
).resolves.toBeUndefined();
});
it('should look at known codeowner locations', async () => {
@@ -180,7 +186,10 @@ describe('CodeOwnersProcessor', () => {
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn() };
const result = await findRawCodeOwners(mockLocation(), reader);
const result = await findRawCodeOwners(mockLocation(), {
reader,
logger,
});
expect(read.mock.calls.length).toBe(5);
expect(read.mock.calls[0]).toEqual([mockReadUrl('')]);
@@ -199,19 +208,19 @@ describe('CodeOwnersProcessor', () => {
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
const reader = { read, readTree: jest.fn() };
const owner = await resolveCodeOwner(mockLocation(), reader);
const owner = await resolveCodeOwner(mockLocation(), { reader, logger });
expect(owner).toBe('backstage-core');
});
it('should raise an error when no codeowner', async () => {
it('should return undefined when no codeowner', async () => {
const read = jest
.fn()
.mockImplementation(() => mockReadResult({ error: 'error: foo' }));
const reader = { read, readTree: jest.fn() };
await expect(
resolveCodeOwner(mockLocation(), reader),
).rejects.toBeInstanceOf(Error);
resolveCodeOwner(mockLocation(), { reader, logger }),
).resolves.toBeUndefined();
});
});
@@ -222,7 +231,7 @@ describe('CodeOwnersProcessor', () => {
.fn()
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
const reader = { read, readTree: jest.fn() };
const processor = new CodeOwnersProcessor({ reader });
const processor = new CodeOwnersProcessor({ reader, logger });
return { entity, processor, read };
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { UrlReader } from '@backstage/backend-common';
import { NotFoundError, UrlReader } from '@backstage/backend-common';
import { Entity, LocationSpec } from '@backstage/catalog-model';
import * as codeowners from 'codeowners-utils';
import { CodeOwnersEntry } from 'codeowners-utils';
@@ -22,6 +22,7 @@ import { CodeOwnersEntry } from 'codeowners-utils';
import 'core-js/features/promise';
import parseGitUri from 'git-url-parse';
import { filter, get, head, pipe, reverse } from 'lodash/fp';
import { Logger } from 'winston';
import { CatalogProcessor } from './types';
const ALLOWED_LOCATION_TYPES = [
@@ -41,6 +42,7 @@ const KNOWN_LOCATIONS = ['', '/docs', '/.bitbucket', '/.github', '/.gitlab'];
type Options = {
reader: UrlReader;
logger: Logger;
};
export class CodeOwnersProcessor implements CatalogProcessor {
@@ -60,7 +62,10 @@ export class CodeOwnersProcessor implements CatalogProcessor {
return entity;
}
const owner = await resolveCodeOwner(location, this.options.reader);
const owner = await resolveCodeOwner(location, this.options);
if (!owner) {
return entity;
}
return {
...entity,
@@ -71,12 +76,11 @@ export class CodeOwnersProcessor implements CatalogProcessor {
export async function resolveCodeOwner(
location: LocationSpec,
reader: UrlReader,
options: Options,
): Promise<string | undefined> {
const ownersText = await findRawCodeOwners(location, reader);
const ownersText = await findRawCodeOwners(location, options);
if (!ownersText) {
throw Error(`Unable to find codeowners file for: ${location.target}`);
return undefined;
}
const owners = parseCodeOwners(ownersText);
@@ -86,18 +90,33 @@ export async function resolveCodeOwner(
export async function findRawCodeOwners(
location: LocationSpec,
reader: UrlReader,
options: Options,
): Promise<string | undefined> {
const readOwnerLocation = async (basePath: string): Promise<string> => {
const ownerUrl = buildCodeOwnerUrl(
location.target,
`${basePath}/CODEOWNERS`,
);
const data = await reader.read(ownerUrl);
const data = await options.reader.read(ownerUrl);
return data.toString();
};
return Promise.any(KNOWN_LOCATIONS.map(readOwnerLocation));
const candidates = KNOWN_LOCATIONS.map(readOwnerLocation);
return Promise.any(candidates).catch((aggregateError: AggregateError) => {
const hardError = aggregateError.errors.find(
error => !(error instanceof NotFoundError),
);
if (hardError) {
options.logger.warn(
`Failed to read codeowners for location ${location.type}:${location.target}, ${hardError}`,
);
} else {
options.logger.debug(
`Failed to find codeowners for location ${location.type}:${location.target}`,
);
}
return undefined;
});
}
export function buildCodeOwnerUrl(
+74 -1
View File
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import type {
import {
Entity,
EntityRelationSpec,
Location,
LocationSpec,
} from '@backstage/catalog-model';
import { RecursivePartial } from '../util/RecursivePartial';
//
// HigherOrderOperation
@@ -68,3 +69,75 @@ export type ReadLocationError = {
location: LocationSpec;
error: Error;
};
//
// LocationAnalyzer
//
export type LocationAnalyzer = {
/**
* Generates an entity configuration for given git repository. It's used for
* importing new component to the backstage app.
*
* @param location Git repository to analyze and generate config for.
*/
analyzeLocation(
location: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse>;
};
export type AnalyzeLocationRequest = {
location: LocationSpec;
};
export type AnalyzeLocationResponse = {
existingEntityFiles: AnalyzeLocationExistingEntity[];
generateEntities: AnalyzeLocationGenerateEntity[];
};
// If the folder pointed to already contained catalog info yaml files, they are
// read and emitted like this so that the frontend can inform the user that it
// located them and can make sure to register them as well if they weren't
// already
type AnalyzeLocationExistingEntity = {
location: LocationSpec;
isRegistered: boolean;
entity: Entity;
};
// This is some form of representation of what the analyzer could deduce.
// We should probably have a chat about how this can best be conveyed to
// the frontend. It'll probably contain a (possibly incomplete) entity, plus
// enough info for the frontend to know what form data to show to the user
// for overriding/completing the info.
type AnalyzeLocationGenerateEntity = {
// Some form of partial representation of the entity
entity: RecursivePartial<Entity>;
// Lists the suggestions that the user may want to override
fields: AnalyzeLocationEntityField[];
};
// This is where I get really vague. Something like this perhaps? Or it could be
// something like a json-schema that contains enough info for the frontend to
// be able to present a form and explanations
type AnalyzeLocationEntityField = {
// e.g. "spec.owner"? The frontend needs to know how to "inject" the field into the
// entity again if the user wants to change it
field: string;
// The outcome of the analysis for this particular field
state:
| 'analysisSuggestedValue'
| 'analysisSuggestedNoValue'
| 'needsUserInput';
// If the analysis did suggest a value, this is where it would be. Not sure if we want
// to limit this to strings or if we want it to be any JsonValue
value: string | null;
// A text to show to the user to inform about the choices made. Like, it could say
// "Found a CODEOWNERS file that covers this target, so we suggest leaving this
// field empty; which would currently make it owned by X" where X is taken from the
// codeowners file.
description: string;
};
@@ -54,11 +54,13 @@ import {
UrlReaderProcessor,
} from '../ingestion';
import { CatalogRulesEnforcer } from '../ingestion/CatalogRules';
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
import {
jsonPlaceholderResolver,
textPlaceholderResolver,
yamlPlaceholderResolver,
} from '../ingestion/processors/PlaceholderProcessor';
import { LocationAnalyzer } from '../ingestion/types';
export type CatalogEnvironment = {
logger: Logger;
@@ -202,6 +204,7 @@ export class CatalogBuilder {
entitiesCatalog: EntitiesCatalog;
locationsCatalog: LocationsCatalog;
higherOrderOperation: HigherOrderOperation;
locationAnalyzer: LocationAnalyzer;
}> {
const { config, database, logger } = this.env;
@@ -229,11 +232,13 @@ export class CatalogBuilder {
locationReader,
logger,
);
const locationAnalyzer = new RepoLocationAnalyzer(logger);
return {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
};
}
@@ -280,7 +285,7 @@ export class CatalogBuilder {
LdapOrgReaderProcessor.fromConfig(config, { logger }),
MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
new CodeOwnersProcessor({ reader }),
new CodeOwnersProcessor({ reader, logger }),
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
);
+21 -4
View File
@@ -15,29 +15,38 @@
*/
import { errorHandler } from '@backstage/backend-common';
import {
locationSpecSchema,
analyzeLocationSchema,
} from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
import { locationSpecSchema } from '@backstage/catalog-model';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import yn from 'yn';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion/types';
import { EntityFilters } from './EntityFilters';
import { LocationAnalyzer, HigherOrderOperation } from '../ingestion/types';
import { translateQueryToFieldMapper } from './filterQuery';
import { EntityFilters } from './EntityFilters';
import { requireRequestBody, validateRequestBody } from './util';
export interface RouterOptions {
entitiesCatalog?: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
locationAnalyzer?: LocationAnalyzer;
logger: Logger;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { entitiesCatalog, locationsCatalog, higherOrderOperation } = options;
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
} = options;
const router = Router();
router.use(express.json());
@@ -127,6 +136,14 @@ export async function createRouter(
});
}
if (locationAnalyzer) {
router.post('/analyze-location', async (req, res) => {
const input = await validateRequestBody(req, analyzeLocationSchema);
const output = await locationAnalyzer.analyzeLocation(input);
res.status(200).send(output);
});
}
router.use(errorHandler());
return router;
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+21
View File
@@ -0,0 +1,21 @@
# Catalog import plugin
Welcome to the catalog-import plugin!
This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it.
When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import).
<img src="./src/assets/catalog-import-screenshot.png" />
## Running
Just run the backstage.
```
yarn start && yarn --cwd packages/backend start
```
## Usage
Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL.
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-catalog-import",
"version": "0.2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.3.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.0",
"@backstage/plugin-catalog-backend": "^0.2.2",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.0.6",
"git-url-parse": "^11.4.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^6.6.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"yaml": "^1.10.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,35 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { PartialEntity } from '../util/types';
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
id: 'plugin.catalogimport.service',
description: 'Used by the catalog import plugin to make requests',
});
export interface CatalogImportApi {
submitPrToRepo(options: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }>;
createRepositoryLocation(options: { location: string }): Promise<void>;
generateEntityDefinitions(options: {
repo: string;
}): Promise<PartialEntity[]>;
}
@@ -0,0 +1,192 @@
/*
* 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 { Octokit } from '@octokit/rest';
import { DiscoveryApi, OAuthApi } from '@backstage/core';
import { CatalogImportApi } from './CatalogImportApi';
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend';
import { PartialEntity } from '../util/types';
export class CatalogImportClient implements CatalogImportApi {
private readonly discoveryApi: DiscoveryApi;
private readonly githubAuthApi: OAuthApi;
constructor(options: {
discoveryApi: DiscoveryApi;
githubAuthApi: OAuthApi;
}) {
this.discoveryApi = options.discoveryApi;
this.githubAuthApi = options.githubAuthApi;
}
async generateEntityDefinitions({
repo,
}: {
repo: string;
}): Promise<PartialEntity[]> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
location: { type: 'github', target: repo },
}),
},
).catch(e => {
throw new Error(`Failed to generate entity definitions, ${e.message}`);
});
if (!response.ok) {
throw new Error(
`Failed to generate entity definitions. Received http response ${response.status}: ${response.statusText}`,
);
}
const payload = (await response.json()) as AnalyzeLocationResponse;
return payload.generateEntities.map(x => x.entity);
}
async createRepositoryLocation({
location,
}: {
location: string;
}): Promise<void> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/locations`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
type: 'github',
target: location,
presence: 'optional',
}),
},
);
if (!response.ok) {
throw new Error(
`Received http response ${response.status}: ${response.statusText}`,
);
}
}
async submitPrToRepo({
owner,
repo,
fileContent,
}: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }> {
const token = await this.githubAuthApi.getAccessToken(['repo']);
const octo = new Octokit({
auth: token,
});
const branchName = 'backstage-integration';
const fileName = 'catalog-info.yaml';
const repoData = await octo.repos
.get({
owner,
repo,
})
.catch(e => {
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
});
const parentRef = await octo.git
.getRef({
owner,
repo,
ref: `heads/${repoData.data.default_branch}`,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage("Couldn't fetch default branch data", e),
);
});
await octo.git
.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: parentRef.data.object.sha,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a new branch with name '${branchName}'`,
e,
),
);
});
await octo.repos
.createOrUpdateFileContents({
owner,
repo,
path: fileName,
message: `Add ${fileName} config file`,
content: btoa(fileContent),
branch: branchName,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a commit with ${fileName} file added`,
e,
),
);
});
const pullRequestRespone = await octo.pulls
.create({
owner,
repo,
title: `Add ${fileName} config file`,
head: branchName,
base: repoData.data.default_branch,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a pull request for ${branchName} branch`,
e,
),
);
});
return {
link: pullRequestRespone.data.html_url,
location: `https://github.com/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
};
}
}
function formatHttpErrorMessage(
message: string,
error: { status: number; message: string },
) {
return `${message}, received http response status code ${error.status}: ${error.message}`;
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './CatalogImportApi';
export * from './CatalogImportClient';
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,197 @@
/*
* 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, { useCallback, useState } from 'react';
import {
Button,
CircularProgress,
Grid,
Link,
List,
ListItem,
Typography,
Divider,
} from '@material-ui/core';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import {
errorApiRef,
RouteRef,
StructuredMetadataTable,
useApi,
} from '@backstage/core';
import parseGitUri from 'git-url-parse';
import { PartialEntity } from '../util/types';
import { generatePath, resolvePath } from 'react-router';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { Link as RouterLink } from 'react-router-dom';
import * as YAML from 'yaml';
const getEntityCatalogPath = ({
entity,
catalogRouteRef,
}: {
entity: PartialEntity;
catalogRouteRef: RouteRef;
}) => {
const relativeEntityPathInsideCatalog = generatePath(
entityRoute.path,
entityRouteParams(entity as Entity),
);
const resolvedAbsolutePath = resolvePath(
relativeEntityPathInsideCatalog,
catalogRouteRef.path,
)?.pathname;
return resolvedAbsolutePath;
};
type Props = {
nextStep: (options?: { reset: boolean }) => void;
configFile: ConfigSpec;
savePRLink: (PRLink: string) => void;
catalogRouteRef: RouteRef;
};
const ComponentConfigDisplay = ({
nextStep,
configFile,
savePRLink,
catalogRouteRef,
}: Props) => {
const [errorOccured, setErrorOccured] = useState(false);
const [submitting, setSubmitting] = useState(false);
const errorApi = useApi(errorApiRef);
const { submitPrToRepo, addLocation } = useGithubRepos();
const onNext = useCallback(async () => {
try {
setSubmitting(true);
if (!parseGitUri(configFile.location).filepathtype) {
const result = await submitPrToRepo(configFile);
savePRLink(result.link);
setSubmitting(false);
nextStep();
} else {
addLocation(configFile.location);
setSubmitting(false);
nextStep();
}
} catch (e) {
setErrorOccured(true);
setSubmitting(false);
errorApi.post(e);
}
}, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]);
return (
<Grid container direction="column" spacing={1}>
{!parseGitUri(configFile.location).filepathtype ? (
<Typography>
Following config object will be submitted in a pull request to the
repository{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>{' '}
and added as a new location to the backend
</Typography>
) : (
<Typography>
Following config object will be added as a new location to the backend{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>
</Typography>
)}
<Grid item>
{!parseGitUri(configFile.location).filepathtype ? (
<pre>{YAML.stringify(configFile.config)}</pre>
) : (
<List>
{configFile.config.map((entity: any, index: number) => {
const entityPath = getEntityCatalogPath({
entity,
catalogRouteRef,
});
return (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link component={RouterLink} to={entityPath}>
{entityPath}
</Link>
),
}}
/>
</ListItem>
{index < configFile.config.length - 1 && (
<Divider component="li" />
)}
</React.Fragment>
);
})}
</List>
)}
</Grid>
<Grid item container spacing={1}>
{submitting ? (
<Grid item>
<CircularProgress size="2rem" />
</Grid>
) : null}
<Grid item>
<Button
disabled={submitting}
variant="contained"
color="primary"
onClick={onNext}
>
Next
</Button>
{errorOccured ? (
<Button
style={{ marginLeft: '8px' }}
variant="outlined"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Start again
</Button>
) : null}
</Grid>
</Grid>
</Grid>
);
};
export default ComponentConfigDisplay;
@@ -0,0 +1,131 @@
/*
* 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 { errorApiRef, useApi } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
FormControl,
FormHelperText,
TextField,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import { useForm } from 'react-hook-form';
import { useMountedState } from 'react-use';
import parseGitUri from 'git-url-parse';
import { ComponentIdValidators } from '../util/validate';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import { catalogApiRef } from '@backstage/plugin-catalog';
const useStyles = makeStyles<BackstageTheme>(theme => ({
form: {
alignItems: 'flex-start',
display: 'flex',
flexFlow: 'column nowrap',
},
submit: {
marginTop: theme.spacing(1),
},
}));
type Props = {
nextStep: () => void;
saveConfig: (configFile: ConfigSpec) => void;
};
export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => {
const { register, handleSubmit, errors, formState } = useForm({
mode: 'onChange',
});
const classes = useStyles();
const hasErrors = !!errors.componentLocation;
const dirty = formState?.isDirty;
const catalogApi = useApi(catalogApiRef);
const isMounted = useMountedState();
const errorApi = useApi(errorApiRef);
const { generateEntityDefinitions } = useGithubRepos();
const onSubmit = async (formData: Record<string, string>) => {
const { componentLocation: target } = formData;
try {
if (!isMounted()) return;
const type = !parseGitUri(target).filepathtype ? 'repo' : 'file';
if (type === 'repo') {
saveConfig({
type,
location: target,
config: await generateEntityDefinitions(target),
});
} else {
const data = await catalogApi.addLocation({ target });
saveConfig({
type,
location: data.location.target,
config: data.entities,
});
}
nextStep();
} catch (e) {
errorApi.post(e);
}
};
return (
<form
autoComplete="off"
onSubmit={handleSubmit(onSubmit)}
className={classes.form}
>
<FormControl>
<TextField
id="registerComponentInput"
variant="outlined"
label="Repository URL"
error={hasErrors}
placeholder="https://github.com/spotify/backstage"
name="componentLocation"
required
margin="normal"
helperText="Enter the full path to the repository in GitHub to start tracking your component."
inputRef={register({
required: true,
validate: ComponentIdValidators,
})}
/>
{errors.componentLocation && (
<FormHelperText error={hasErrors} id="register-component-helper-text">
{errors.componentLocation.message}
</FormHelperText>
)}
</FormControl>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!dirty || hasErrors}
className={classes.submit}
>
Next
</Button>
</form>
);
};
@@ -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, { useState } from 'react';
import { Grid } from '@material-ui/core';
import {
InfoCard,
Page,
Content,
Header,
SupportButton,
ContentHeader,
RouteRef,
} from '@backstage/core';
import { RegisterComponentForm } from './ImportComponentForm';
import ImportStepper from './ImportStepper';
import ComponentConfigDisplay from './ComponentConfigDisplay';
import { ImportFinished } from './ImportFinished';
import { PartialEntity } from '../util/types';
export type ConfigSpec = {
type: 'repo' | 'file';
location: string;
config: PartialEntity[];
};
export const ImportComponentPage = ({
catalogRouteRef,
}: {
catalogRouteRef: RouteRef;
}) => {
const [activeStep, setActiveStep] = useState(0);
const [configFile, setConfigFile] = useState<ConfigSpec>({
type: 'repo',
location: '',
config: [],
});
const [endLink, setEndLink] = useState<string>('');
const nextStep = (options?: { reset: boolean }) => {
setActiveStep(step => (options?.reset ? 0 : step + 1));
};
return (
<Page themeId="home">
<Header title="Register an existing component" />
<Content>
<ContentHeader title="Start tracking your component on backstage">
<SupportButton>
Start tracking your component in Backstage. TODO: Add more
information about what this is.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard>
<ImportStepper
steps={[
{
step: 'Insert GitHub repo URL or Entity File URL',
content: (
<RegisterComponentForm
nextStep={nextStep}
saveConfig={setConfigFile}
/>
),
},
{
step: 'Review',
content: (
<ComponentConfigDisplay
nextStep={nextStep}
configFile={configFile}
savePRLink={setEndLink}
catalogRouteRef={catalogRouteRef}
/>
),
},
{
step: 'Finish',
content: (
<ImportFinished
nextStep={nextStep}
PRLink={endLink}
type={configFile.type}
/>
),
},
]}
activeStep={activeStep}
nextStep={nextStep}
/>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,58 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Alert } from '@material-ui/lab';
import { Button, Grid, Link } from '@material-ui/core';
type Props = {
type: 'repo' | 'file';
nextStep: (options?: { reset: boolean }) => void;
PRLink: string;
};
export const ImportFinished = ({ nextStep, PRLink, type }: Props) => {
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Alert severity="success">
{type === 'repo'
? 'Pull requests have been successfully opened. You can start again to import more repositories'
: 'Entity added to catalog successfully'}
</Alert>
</Grid>
<Grid item>
{type === 'repo' ? (
<Link
href={PRLink}
style={{ marginRight: '8px' }}
target="_blank"
rel="noopener noreferrer"
>
View pull request on GitHub
</Link>
) : null}
<Button
variant="contained"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Register another
</Button>
</Grid>
</Grid>
);
};
@@ -0,0 +1,42 @@
/*
* 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 Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step';
import StepLabel from '@material-ui/core/StepLabel';
import { StepContent } from '@material-ui/core';
type Props = {
steps: { step: string; content: React.ReactNode }[];
activeStep: number;
nextStep: (options?: { reset: boolean }) => void;
};
export default function ImportStepper({ steps, activeStep }: Props) {
return (
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map(({ step }) => {
const stepProps: { completed?: boolean } = {};
return (
<Step key={step} {...stepProps}>
<StepLabel>{step}</StepLabel>
<StepContent>{steps[activeStep].content}</StepContent>
</Step>
);
})}
</Stepper>
);
}
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RouteRef } from '@backstage/core';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { ImportComponentPage } from './ImportComponentPage';
export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => (
<Routes>
<Route
element={<ImportComponentPage catalogRouteRef={catalogRouteRef} />}
/>
</Routes>
);
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { plugin } from './plugin';
export { Router } from './components/Router';

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