Merge pull request #1043 from spotify/ndudnik/use-catalog-backend

Ndudnik/use catalog backend
This commit is contained in:
Nikita Dudnik
2020-05-29 14:16:17 +02:00
committed by GitHub
23 changed files with 299 additions and 215 deletions
+7
View File
@@ -71,6 +71,13 @@
"pathRewrite": {
"^/circleci/api/": "/"
}
},
"/catalog/api": {
"target": "http://localhost:3003",
"changeOrigin": true,
"pathRewrite": {
"^/catalog/api/": "/"
}
}
}
}
+9
View File
@@ -38,6 +38,7 @@ import {
import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
const builder = ApiRegistry.builder();
@@ -71,4 +72,12 @@ builder.add(
}),
);
builder.add(
catalogApiRef,
new CatalogClient({
apiOrigin: 'http://localhost:3000',
basePath: '/catalog/api',
}),
);
export default builder.build() as ApiHolder;
@@ -19,18 +19,18 @@ import {
getVoidLogger,
NotFoundError,
} from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Database } from './Database';
import {
AddDatabaseLocation,
DbEntityRequest,
DbEntityResponse,
Database,
AddDatabaseLocation,
DbLocationsRow,
DbLocationsRowWithStatus,
DatabaseLocationUpdateLogStatus,
} from './types';
} from '.';
import { Entity } from '@backstage/catalog-model';
describe('Database', () => {
let database: Knex;
@@ -15,9 +15,7 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { Entity, EntityPolicy } from '@backstage/catalog-model';
import Knex from 'knex';
import { IngestionModel } from '../ingestion/types';
import { Database } from './Database';
import { DatabaseManager } from './DatabaseManager';
import {
@@ -25,6 +23,8 @@ import {
DbLocationsRow,
DbLocationsRowWithStatus,
} from './types';
import { EntityPolicy, Entity } from '@backstage/catalog-model';
import { IngestionModel } from '..';
describe('DatabaseManager', () => {
describe('refreshLocations', () => {
@@ -22,7 +22,7 @@ import {
addLocationSchema,
EntitiesCatalog,
EntityFilters,
LocationsCatalog
LocationsCatalog,
} from '../catalog';
import { validateRequestBody } from './util';
+45
View File
@@ -0,0 +1,45 @@
/*
* 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 { CatalogApi } from './types';
import { DescriptorEnvelope } from '../types';
export class CatalogClient implements CatalogApi {
private apiOrigin: string;
private basePath: string;
constructor({
apiOrigin,
basePath,
}: {
apiOrigin: string;
basePath: string;
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
}
async getEntities(): Promise<DescriptorEnvelope[]> {
const response = await fetch(`${this.apiOrigin}${this.basePath}/entities`);
return await response.json();
}
async getEntityByName(name: string): Promise<DescriptorEnvelope> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-name/Component/default/${name}`,
);
const entity = await response.json();
if (entity) return entity;
throw new Error(`'Entity not found: ${name}`);
}
}
@@ -13,14 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { ComponentFactory } from './component';
import { MockComponentFactory } from './mock-factory';
import { createApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
const componentFactory: ComponentFactory = MockComponentFactory;
export const catalogApiRef = createApiRef<CatalogApi>({
id: 'plugin.catalog.service',
description:
'Used by the Catalog plugin to make requests to accompanying backend',
});
export const withMockStore = (Component: React.ElementType) => {
return (props: any) => (
<Component {...props} componentFactory={componentFactory} />
);
};
export interface CatalogApi {
getEntities(): Promise<Entity[]>;
getEntityByName(name: string): Promise<Entity>;
}
@@ -17,14 +17,12 @@
import React from 'react';
import { render } from '@testing-library/react';
import CatalogPage from './CatalogPage';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { ComponentFactory } from '../../data/component';
import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core';
import { wrapInTheme } from '@backstage/test-utils';
import { catalogApiRef } from '../..';
const testComponentFactory: ComponentFactory = {
getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
removeComponentByName: jest.fn(() => Promise.resolve(true)),
};
const errorApi = { post: () => {} };
const catalogApi = { getEntities: () => Promise.resolve([{ kind: '' }]) };
describe('CatalogPage', () => {
// this test right now causes some red lines in the log output when running tests
@@ -32,8 +30,15 @@ describe('CatalogPage', () => {
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const rendered = render(
wrapInThemedTestApp(
<CatalogPage componentFactory={testComponentFactory} />,
wrapInTheme(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, errorApi],
[catalogApiRef, catalogApi],
])}
>
<CatalogPage />
</ApiProvider>,
),
);
expect(
@@ -24,9 +24,9 @@ import {
SupportButton,
Page,
pageTheme,
useApi,
} from '@backstage/core';
import { useAsync } from 'react-use';
import { ComponentFactory } from '../../data/component';
import CatalogTable from '../CatalogTable/CatalogTable';
import {
CatalogFilter,
@@ -44,12 +44,12 @@ const useStyles = makeStyles(theme => ({
},
}));
type CatalogPageProps = {
componentFactory: ComponentFactory;
};
import { catalogApiRef } from '../..';
import { envelopeToComponent } from '../../data/utils';
const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
defaultFilter,
);
@@ -58,8 +58,8 @@ const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
selected => setSelectedFilter(selected),
[],
);
const styles = useStyles();
return (
<Page theme={pageTheme.home}>
<Header title="Service Catalog" subtitle="Keep track of your software">
@@ -98,7 +98,7 @@ const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={value || []}
components={(value && value.map(envelopeToComponent)) || []}
loading={loading}
error={error}
/>
@@ -20,9 +20,9 @@ import CatalogTable from './CatalogTable';
import { Component } from '../../data/component';
const components: Component[] = [
{ name: 'component1' },
{ name: 'component2' },
{ name: 'component3' },
{ name: 'component1', kind: 'Component', description: 'Placeholder' },
{ name: 'component2', kind: 'Component', description: 'Placeholder' },
{ name: 'component3', kind: 'Component', description: 'Placeholder' },
];
describe('CatalogTable component', () => {
@@ -15,13 +15,7 @@
*/
import React, { FC } from 'react';
import { Component } from '../../data/component';
import {
InfoCard,
Progress,
Table,
TableColumn,
StatusOK,
} from '@backstage/core';
import { InfoCard, Progress, Table, TableColumn } from '@backstage/core';
import { Typography, Link } from '@material-ui/core';
const columns: TableColumn[] = [
@@ -34,26 +28,8 @@ const columns: TableColumn[] = [
),
},
{
title: 'System',
field: 'system',
},
{
title: 'Owner',
field: 'owner',
},
{
title: 'Lifecycle',
field: 'lifecycle',
},
{
title: 'Status',
field: 'status',
render: (componentData: any) => (
<>
<StatusOK />
{componentData.status || 'Up and running'}
</>
),
title: 'Kind',
field: 'kind',
},
{
title: 'Description',
@@ -22,6 +22,8 @@ describe('ComponentMetadataCard component', () => {
it('should display component name if provided', async () => {
const testComponent: Component = {
name: 'test',
kind: 'Component',
description: 'Placeholder',
};
const rendered = await render(
<ComponentMetadataCard loading={false} component={testComponent} />,
@@ -17,7 +17,6 @@ import ComponentPage from './ComponentPage';
import { render } from '@testing-library/react';
import * as React from 'react';
import { wrapInTheme } from '@backstage/test-utils';
import { act } from 'react-dom/test-utils';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
const getTestProps = (componentName: string) => {
@@ -30,11 +29,6 @@ const getTestProps = (componentName: string) => {
history: {
push: jest.fn(),
},
componentFactory: {
getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
removeComponentByName: jest.fn(() => Promise.resolve(true)),
},
};
};
@@ -52,19 +46,4 @@ describe('ComponentPage', () => {
);
expect(props.history.push).toHaveBeenCalledWith('/catalog');
});
it('should use factory to fetch component by name and display it', async () => {
await act(async () => {
const props = getTestProps('test');
await render(
wrapInTheme(
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
<ComponentPage {...props} />
</ApiProvider>,
),
);
expect(props.componentFactory.getComponentByName).toHaveBeenCalledWith(
'test',
);
});
});
});
@@ -15,7 +15,6 @@
*/
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { ComponentFactory } from '../../data/component';
import ComponentMetadataCard from '../ComponentMetadataCard/ComponentMetadataCard';
import {
Content,
@@ -30,11 +29,12 @@ import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu';
import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Grid } from '@material-ui/core';
import { catalogApiRef } from '../..';
import { envelopeToComponent } from '../../data/utils';
const REDIRECT_DELAY = 1000;
type ComponentPageProps = {
componentFactory: ComponentFactory;
match: {
params: {
name: string;
@@ -45,11 +45,7 @@ type ComponentPageProps = {
};
};
const ComponentPage: FC<ComponentPageProps> = ({
match,
history,
componentFactory,
}) => {
const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [removingPending, setRemovingPending] = useState(false);
const showRemovalDialog = () => setConfirmationDialogOpen(true);
@@ -62,8 +58,9 @@ const ComponentPage: FC<ComponentPageProps> = ({
return null;
}
const catalogApi = useApi(catalogApiRef);
const catalogRequest = useAsync(() =>
componentFactory.getComponentByName(match.params.name),
catalogApi.getEntityByName(match.params.name),
);
useEffect(() => {
@@ -78,18 +75,20 @@ const ComponentPage: FC<ComponentPageProps> = ({
const removeComponent = async () => {
setConfirmationDialogOpen(false);
setRemovingPending(true);
await componentFactory.removeComponentByName(componentName);
// await componentFactory.removeComponentByName(componentName);
history.push('/catalog');
};
const component = envelopeToComponent(catalogRequest.value! ?? {});
return (
<Page theme={pageTheme.service}>
<Header title={catalogRequest?.value?.name || 'Catalog'} type="service">
<Page theme={pageTheme.home}>
<Header title={component.name || 'Catalog'}>
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
</Header>
{confirmationDialogOpen && catalogRequest.value && (
<ComponentRemovalDialog
component={catalogRequest.value}
component={component}
onClose={hideRemovalDialog}
onConfirm={removeComponent}
onCancel={hideRemovalDialog}
@@ -100,7 +99,7 @@ const ComponentPage: FC<ComponentPageProps> = ({
<Grid item>
<ComponentMetadataCard
loading={catalogRequest.loading || removingPending}
component={catalogRequest.value}
component={component}
/>
</Grid>
<Grid item>
+2 -6
View File
@@ -15,10 +15,6 @@
*/
export type Component = {
name: string;
kind: string;
description: string;
};
export interface ComponentFactory {
getAllComponents(): Promise<Component[]>;
getComponentByName(name: string): Promise<Component | undefined>;
removeComponentByName(name: string): Promise<boolean>;
}
@@ -1,35 +0,0 @@
[
{
"name": "example.com"
},
{
"name": "subdomain.example.com"
},
{
"name": "subdomain2.example.com"
},
{
"name": "User data pipeline 1"
},
{
"name": "User data pipeline 2"
},
{
"name": "User data pipeline 3"
},
{
"name": "Aggregation CRON job"
},
{
"name": "Authentication service"
},
{
"name": "Payments service"
},
{
"name": "Backstage supervisor"
},
{
"name": "Identity service"
}
]
-48
View File
@@ -1,48 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ComponentFactory } from './component';
import mock from './mock-factory-data.json';
const ARTIFICIAL_TIMEOUT = 800;
let inMemoryStore = [...mock];
export const MockComponentFactory: ComponentFactory = {
getAllComponents(): Promise<Component[]> {
return new Promise(resolve =>
setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT),
);
},
getComponentByName(name: string): Promise<Component | undefined> {
return new Promise((resolve, reject) =>
setTimeout(() => {
const mockComponent = inMemoryStore.find(
component => component.name === name,
);
if (mockComponent) return resolve(mockComponent);
return reject({ code: 'Component not found!' });
}, ARTIFICIAL_TIMEOUT),
);
},
removeComponentByName(name: string): Promise<boolean> {
return new Promise(resolve =>
setTimeout(() => {
inMemoryStore = inMemoryStore.filter(
component => component.name !== name,
);
resolve(true);
}, ARTIFICIAL_TIMEOUT),
);
},
};
@@ -13,13 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const proxySettings = {
'/circleci/api': {
target: 'https://circleci.com/api/v1.1',
changeOrigin: true,
logLevel: 'debug',
pathRewrite: {
'^/circleci/api/': '/',
},
},
};
import { Component } from './component';
import { Entity } from '@backstage/catalog-model';
export function envelopeToComponent(envelope: Entity): Component {
return {
name: envelope.metadata?.name ?? '',
kind: envelope.kind ?? 'unknown',
description: envelope.metadata?.annotations?.description ?? 'placeholder',
};
}
+3
View File
@@ -15,3 +15,6 @@
*/
export { plugin } from './plugin';
export * from './api/CatalogClient';
export * from './api/types';
export * from './types';
+2 -3
View File
@@ -17,12 +17,11 @@
import { createPlugin } from '@backstage/core';
import CatalogPage from './components/CatalogPage';
import ComponentPage from './components/ComponentPage/ComponentPage';
import { withMockStore } from './data/with-mock-store';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.registerRoute('/', withMockStore(CatalogPage));
router.registerRoute('/catalog/:name/', withMockStore(ComponentPage));
router.registerRoute('/', CatalogPage);
router.registerRoute('/catalog/:name/', ComponentPage);
},
});
+159
View File
@@ -0,0 +1,159 @@
/*
* 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 interface ComponentDescriptorV1beta1 extends DescriptorEnvelope {
spec: {
type: string;
};
}
export type ComponentDescriptor = ComponentDescriptorV1beta1;
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace, for any given kind.
*/
name: string;
/**
* The short description of the entity.
*
* A a human readable string.
*/
description: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
/**
* The format envelope that's common to all versions/kinds.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type DescriptorEnvelope = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Optional metadata related to the entity.
*/
metadata: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Parses and validates descriptors.
*
* The output must be validated and well formed.
*/
export type DescriptorParser = {
/**
* Parses and validates a single raw descriptor.
*
* @param descriptor A raw descriptor object
* @returns A structure describing the parsed and validated descriptor
* @throws An Error if the descriptor was malformed
*/
parse(descriptor: object): Promise<DescriptorEnvelope>;
};
/**
* Parses and validates a single envelope into its materialized kind.
*
* These parsers may assume that the envelope is already validated and well
* formed.
*/
export type KindParser = {
/**
* Try to parse an envelope into a materialized kind.
*
* @param envelope A valid descriptor envelope
* @returns A materialized type, or undefined if the given version/kind is
* not meant to be handled by this parser
* @throws An Error if the type was handled and found to not be properly
* formatted
*/
tryParse(
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined>;
};
-1
View File
@@ -16,6 +16,5 @@
export { plugin } from './plugin';
export * from './api';
export * from './proxy';
export * from './route-refs';
export { CircleCIWidget } from './components/App';
+2 -15
View File
@@ -4290,15 +4290,7 @@
"@types/passport" "*"
"@types/passport-oauth2" "*"
"@types/passport-oauth2-refresh@^1.1.1":
version "1.1.1"
resolved "https://registry.npmjs.org/@types/passport-oauth2-refresh/-/passport-oauth2-refresh-1.1.1.tgz#cbe466d4fcac36182fd75bf55279c0b1e953c382"
integrity sha512-Tw0JvfDPv9asgFPACd9oOGCaD/0/Uyi+QF7fmrJC74cJKC6I8N8wwhJJHyfd1N2E/qaLgTh431lhOa9jicpNdg==
dependencies:
"@types/oauth" "*"
"@types/passport-oauth2" "*"
"@types/passport-oauth2@*", "@types/passport-oauth2@^1.4.9":
"@types/passport-oauth2@*":
version "1.4.9"
resolved "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.9.tgz#134007c4b505a82548c9cb19094c5baeb2205c92"
integrity sha512-QP0q+NVQOaIu2r0e10QWkiUA0Ya5mOBHRJN0UrI+LolMLOP1/VN4EVIpJ3xVwFo+xqNFRoFvFwJhBvKnk7kpUA==
@@ -16110,12 +16102,7 @@ passport-google-oauth20@^2.0.0:
dependencies:
passport-oauth2 "1.x.x"
passport-oauth2-refresh@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/passport-oauth2-refresh/-/passport-oauth2-refresh-2.0.0.tgz#7b19c77ff3cc000819c69f6ad9e318450f57b85e"
integrity sha512-yXvCB6nem/O+WThhiyI3TlPXpzSGY+9+hy9OTx9QF8e9GInplyRHxHaaOhFylKvnof9UmWHAufQFZk8cO1Fb2g==
passport-oauth2@1.x.x, passport-oauth2@^1.5.0:
passport-oauth2@1.x.x:
version "1.5.0"
resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108"
integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==