Merge branch 'master' of github.com:spotify/backstage into mob/union-types

* 'master' of github.com:spotify/backstage: (22 commits)
  [microsite] Add CNCF logo (#2511)
  feat: use title for API type in table
  new tutorials (#2435)
  e2e-test: ignore unhandled errors from jsdom
  Techdocs without docker-in-docker (#2438)
  Test for docker before attempting to run a container (#2497)
  scaffolder: Fix backend fails to start
  feat: add api-docs config ref for widgets
  Mark docs template description as required
  Send component description to GitHub
  Pass props to lists and control rendering from outside wrapper
  Remove commented out code
  Remove unnecessary boolean conversion
  docs: improve navigation around ADRs
  Update default-app template
  Handle empty sections
  Change logic of FeatureFlags.toggle()
  Update sidepar
  Add featureflags components and toggle method on api
  Cleanup and breaking out into more components
  ...
This commit is contained in:
blam
2020-09-18 12:50:21 +02:00
55 changed files with 1654 additions and 354 deletions
@@ -16,8 +16,8 @@
import { ComponentEntity, Entity } from '@backstage/catalog-model';
import { Progress } from '@backstage/core';
import React from 'react';
import { Grid } from '@material-ui/core';
import React from 'react';
import {
ApiDefinitionCard,
useComponentApiEntities,
@@ -15,15 +15,16 @@
*/
import { ApiEntity } from '@backstage/catalog-model';
import { CardTab, TabbedCard } from '@backstage/core';
import { CardTab, useApi, TabbedCard } 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';
type ApiDefinitionWidget = {
export type ApiDefinitionWidget = {
type: string;
title: string;
component: (definition: string) => React.ReactElement;
@@ -61,34 +62,25 @@ export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
type Props = {
apiEntity?: ApiEntity;
definitionWidgets?: ApiDefinitionWidget[];
};
const defaultProps = {
definitionWidgets: defaultDefinitionWidgets(),
};
export const ApiDefinitionCard = (props: Props) => {
const { apiEntity, definitionWidgets } = {
...defaultProps,
...props,
};
export const ApiDefinitionCard = ({ apiEntity }: Props) => {
const config = useApi(apiDocsConfigRef);
const { getApiDefinitionWidget } = config;
if (!apiEntity) {
return <Alert severity="error">Could not fetch the API</Alert>;
}
const definitionWidget = definitionWidgets.find(
d => d.type === apiEntity.spec.type,
);
const definitionWidget = getApiDefinitionWidget(apiEntity);
if (definitionWidget) {
return (
<TabbedCard title={apiEntity.metadata.name}>
<CardTab label={definitionWidget.title}>
<CardTab label={definitionWidget.title} key="widget">
{definitionWidget.component(apiEntity.spec.definition)}
</CardTab>
<CardTab label="Raw">
<CardTab label="Raw" key="raw">
<PlainApiDefinitionWidget
definition={apiEntity.spec.definition}
language={definitionWidget.rawLanguage || apiEntity.spec.type}
@@ -103,7 +95,7 @@ export const ApiDefinitionCard = (props: Props) => {
title={apiEntity.metadata.name}
children={[
// Has to be an array, otherwise typescript doesn't like that this has only a single child
<CardTab label={apiEntity.spec.type}>
<CardTab label={apiEntity.spec.type} key="raw">
<PlainApiDefinitionWidget
definition={apiEntity.spec.definition}
language={apiEntity.spec.type}
@@ -14,4 +14,8 @@
* limitations under the License.
*/
export { ApiDefinitionCard } from './ApiDefinitionCard';
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
export {
ApiDefinitionCard,
defaultDefinitionWidgets,
} from './ApiDefinitionCard';
@@ -20,6 +20,7 @@ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerPage } from './ApiExplorerPage';
describe('ApiCatalogPage', () => {
@@ -32,6 +33,7 @@ describe('ApiCatalogPage', () => {
metadata: {
name: 'Entity1',
},
spec: { type: 'openapi' },
},
{
apiVersion: 'backstage.io/v1alpha1',
@@ -39,12 +41,17 @@ describe('ApiCatalogPage', () => {
metadata: {
name: 'Entity2',
},
spec: { type: 'openapi' },
},
] as Entity[]),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const apiDocsConfig = {
getApiDefinitionWidget: () => undefined,
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
@@ -52,6 +59,7 @@ describe('ApiCatalogPage', () => {
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[storageApiRef, MockStorageApi.create()],
[apiDocsConfigRef, apiDocsConfig],
])}
>
{children}
@@ -15,9 +15,11 @@
*/
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import * as React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerTable } from './ApiExplorerTable';
const entites: Entity[] = [
@@ -25,29 +27,38 @@ const entites: Entity[] = [
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api1' },
spec: { type: 'openapi' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api2' },
spec: { type: 'openapi' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api3' },
spec: { type: 'grpc' },
},
];
const apiRegistry = ApiRegistry.with(apiDocsConfigRef, {
getApiDefinitionWidget: () => undefined,
});
describe('ApiCatalogTable component', () => {
it('should render error message when error is passed in props', async () => {
const rendered = render(
wrapInTestApp(
<ApiExplorerTable
titlePreamble="APIs"
entities={[]}
loading={false}
error={{ code: 'error' }}
/>,
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable
titlePreamble="APIs"
entities={[]}
loading={false}
error={{ code: 'error' }}
/>
</ApiProvider>,
),
);
const errorMessage = await rendered.findByText(
@@ -59,11 +70,13 @@ describe('ApiCatalogTable component', () => {
it('should display entity names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInTestApp(
<ApiExplorerTable
titlePreamble="APIs"
entities={entites}
loading={false}
/>,
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable
titlePreamble="APIs"
entities={entites}
loading={false}
/>
</ApiProvider>,
),
);
expect(rendered.getByText(/APIs \(3\)/)).toBeInTheDocument();
@@ -14,14 +14,23 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link, Chip } from '@material-ui/core';
import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
import { Table, TableColumn, useApi } from '@backstage/core';
import { Chip, Link } 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';
import { entityRoute } from '../../routes';
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>;
};
const columns: TableColumn<Entity>[] = [
{
title: 'Name',
@@ -54,8 +63,11 @@ const columns: TableColumn<Entity>[] = [
field: 'spec.lifecycle',
},
{
title: 'Type', // TODO: Resolve the type display name using the API from https://github.com/spotify/backstage/pull/2451
title: 'Type',
field: 'spec.type',
render: (entity: Entity) => (
<ApiTypeTitle apiEntity={entity as ApiEntityV1alpha1} />
),
},
{
title: 'Description',
+5 -1
View File
@@ -14,7 +14,11 @@
* limitations under the License.
*/
export { ApiDefinitionCard } from './ApiDefinitionCard';
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
export {
ApiDefinitionCard,
defaultDefinitionWidgets,
} from './ApiDefinitionCard';
export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntity } from '@backstage/catalog-model';
import { createApiRef } from '@backstage/core';
import { ApiDefinitionWidget } from './components';
export const apiDocsConfigRef = createApiRef<ApiDocsConfig>({
id: 'plugin.api-docs.config',
description: 'Used to configure api-docs widgets',
});
export interface ApiDocsConfig {
getApiDefinitionWidget: (
apiEntity: ApiEntity,
) => ApiDefinitionWidget | undefined;
}
+18 -1
View File
@@ -14,13 +14,30 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import { ApiEntity } from '@backstage/catalog-model';
import { createApiFactory, createPlugin } from '@backstage/core';
import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage';
import { defaultDefinitionWidgets } from './components/ApiDefinitionCard';
import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage';
import { entityRoute, rootRoute } from './routes';
import { apiDocsConfigRef } from './config';
export const plugin = createPlugin({
id: 'api-docs',
apis: [
createApiFactory({
api: apiDocsConfigRef,
deps: {},
factory: () => {
const definitionWidgets = defaultDefinitionWidgets();
return {
getApiDefinitionWidget: (apiEntity: ApiEntity) => {
return definitionWidgets.find(d => d.type === apiEntity.spec.type);
},
};
},
}),
],
register({ router }) {
router.addRoute(rootRoute, ApiExplorerPage);
router.addRoute(entityRoute, ApiEntityPage);
+2
View File
@@ -23,11 +23,13 @@ export const rootRoute = createRouteRef({
path: '/api-docs',
title: 'APIs',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs/:optionalNamespaceAndName/',
title: 'API',
});
export const catalogRoute = createRouteRef({
icon: NoIcon,
path: '',
@@ -17,6 +17,7 @@ spec:
schema:
required:
- component_id
- description
properties:
component_id:
title: Name
@@ -147,6 +147,7 @@ describe('GitHub Publisher', () => {
storePath: 'blam/test',
owner: 'bob',
access: 'bob',
description: 'description',
},
directory: '/tmp/test',
});
@@ -154,6 +155,7 @@ describe('GitHub Publisher', () => {
expect(
mockGithubClient.repos.createForAuthenticatedUser,
).toHaveBeenCalledWith({
description: 'description',
name: 'test',
private: false,
});
@@ -61,6 +61,7 @@ export class GithubPublisher implements PublisherBase {
values: RequiredTemplateValues & Record<string, JsonValue>,
) {
const [owner, name] = values.storePath.split('/');
const description = values.description as string;
const user = await this.client.users.getByUsername({ username: owner });
@@ -71,10 +72,12 @@ export class GithubPublisher implements PublisherBase {
org: owner,
private: this.repoVisibility !== 'public',
visibility: this.repoVisibility,
description,
})
: this.client.repos.createForAuthenticatedUser({
name,
private: this.repoVisibility === 'private',
description,
});
const { data } = await repoCreationPromise;
+1
View File
@@ -25,6 +25,7 @@
"@backstage/config": "^0.1.1-alpha.21",
"@types/dockerode": "^2.5.34",
"@types/express": "^4.17.6",
"command-exists-promise": "^2.0.2",
"default-branch": "^1.0.8",
"dockerode": "^3.2.1",
"express": "^4.17.1",
@@ -51,6 +51,10 @@ describe('helpers', () => {
jest
.spyOn(mockDocker, 'run')
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
jest
.spyOn(mockDocker, 'ping')
.mockResolvedValue(Buffer.from('OK', 'utf-8'));
});
const imageName = 'spotify/techdocs';
@@ -99,5 +103,39 @@ describe('helpers', () => {
},
);
});
it('should ping docker to test availability', async () => {
await runDockerContainer({
imageName,
args,
docsDir,
resultDir,
dockerClient: mockDocker,
});
expect(mockDocker.ping).toHaveBeenCalled();
});
describe('where docker is unavailable', () => {
const dockerError = 'a docker error';
beforeEach(() => {
jest.spyOn(mockDocker, 'ping').mockImplementationOnce(() => {
throw new Error(dockerError);
});
});
it('should throw with a descriptive error message including the docker error message', async () => {
await expect(
runDockerContainer({
imageName,
args,
docsDir,
resultDir,
dockerClient: mockDocker,
}),
).rejects.toThrow(new RegExp(`.+: ${dockerError}`));
});
});
});
});
@@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model';
import { Writable, PassThrough } from 'stream';
import Docker from 'dockerode';
import { SupportedGeneratorKey } from './types';
import { spawn } from 'child_process';
// TODO: Implement proper support for more generators.
export function getGeneratorKey(entity: Entity): SupportedGeneratorKey {
@@ -38,6 +39,13 @@ type RunDockerContainerOptions = {
createOptions?: Docker.ContainerCreateOptions;
};
export type RunCommandOptions = {
command: string;
args: string[];
options: object;
logStream?: Writable;
};
export async function runDockerContainer({
imageName,
args,
@@ -47,6 +55,14 @@ export async function runDockerContainer({
dockerClient,
createOptions,
}: RunDockerContainerOptions) {
try {
await dockerClient.ping();
} catch (e) {
throw new Error(
`This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`,
);
}
await new Promise((resolve, reject) => {
dockerClient.pull(imageName, {}, (err, stream) => {
if (err) return reject(err);
@@ -88,3 +104,41 @@ export async function runDockerContainer({
return { error, statusCode };
}
/**
*
* @param options the options object
* @param options.command the command to run
* @param options.args the arguments to pass the command
* @param options.options options used in spawn
* @param options.logStream the log streamer to capture log messages
*/
export const runCommand = async ({
command,
args,
options,
logStream = new PassThrough(),
}: RunCommandOptions) => {
await new Promise((resolve, reject) => {
const process = spawn(command, args, options);
process.stdout.on('data', stream => {
logStream.write(stream);
});
process.stderr.on('data', stream => {
logStream.write(stream);
});
process.on('error', error => {
return reject(error);
});
process.on('close', code => {
if (code !== 0) {
return reject(`Command ${command} failed, exit code: ${code}`);
}
return resolve();
});
});
};
@@ -24,7 +24,9 @@ import {
GeneratorRunOptions,
GeneratorRunResult,
} from './types';
import { runDockerContainer } from './helpers';
import { runDockerContainer, runCommand } from './helpers';
const commandExists = require('command-exists-promise');
export class TechdocsGenerator implements GeneratorBase {
private readonly logger: Logger;
@@ -46,17 +48,29 @@ export class TechdocsGenerator implements GeneratorBase {
);
try {
await runDockerContainer({
imageName: 'spotify/techdocs',
args: ['build', '-d', '/result'],
logStream,
docsDir: directory,
resultDir,
dockerClient,
});
this.logger.info(
`[TechDocs]: Successfully generated docs from ${directory} into ${resultDir}`,
);
const mkdocsInstalled = await commandExists('mkdocs');
if (mkdocsInstalled) {
await runCommand({
command: 'mkdocs',
args: ['build', '-d', resultDir, '-v'],
options: {
cwd: directory,
},
logStream,
});
} else {
await runDockerContainer({
imageName: 'spotify/techdocs',
args: ['build', '-d', '/result'],
logStream,
docsDir: directory,
resultDir,
dockerClient,
});
this.logger.info(
`[TechDocs]: Successfully generated docs from ${directory} into ${resultDir}`,
);
}
} catch (error) {
this.logger.debug(
`[TechDocs]: Failed to generate docs from ${directory} into ${resultDir}`,