feature: implement useApi logic for fetching components
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
|
||||
|
||||
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
|
||||
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
|
||||
@@ -72,4 +73,12 @@ builder.add(
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
catalogApiRef,
|
||||
new CatalogApi({
|
||||
apiOrigin: 'http://localhost:3000',
|
||||
basePath: '/catalog/api',
|
||||
}),
|
||||
);
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { DescriptorEnvelope } from './types';
|
||||
|
||||
export const catalogApiRef = createApiRef<CatalogApi>({
|
||||
id: 'plugin.catalog.service',
|
||||
description:
|
||||
'Used by the Catalog plugin to make requests to accompanying backend',
|
||||
});
|
||||
|
||||
export class 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 entities = await this.getEntities();
|
||||
const entity = entities.find(e => e.metadata.name === name);
|
||||
if (entity) return entity;
|
||||
throw new Error(`'Entity not found: ${name}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ComponentDescriptorV1beta1 } from './descriptors/ComponentDescriptorV1beta1Parser';
|
||||
|
||||
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 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>;
|
||||
};
|
||||
|
||||
export class ParserError extends Error {
|
||||
constructor(message?: string, private _entityName?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
get entityName() {
|
||||
return this._entityName;
|
||||
}
|
||||
}
|
||||
|
||||
export type ReaderOutput =
|
||||
| { type: 'error'; error: Error }
|
||||
| { type: 'data'; data: object };
|
||||
|
||||
export type LocationReader = {
|
||||
/**
|
||||
* Reads the contents of a single location.
|
||||
*
|
||||
* @param type The type of location to read
|
||||
* @param target The location target (type-specific)
|
||||
* @returns The parsed contents, as an array of unverified descriptors or
|
||||
* errors where the individual documents could not be parsed.
|
||||
* @throws An error if the location as a whole could not be read
|
||||
*/
|
||||
read(type: string, target: string): Promise<ReaderOutput[]>;
|
||||
};
|
||||
|
||||
export type LocationSource = {
|
||||
/**
|
||||
* Reads the contents of a single location.
|
||||
*
|
||||
* @param target The location target to read
|
||||
* @returns The parsed contents, as an array of unverified descriptors
|
||||
* @throws An error if the location target could not be read
|
||||
*/
|
||||
read(target: string): Promise<ReaderOutput[]>;
|
||||
};
|
||||
@@ -19,19 +19,12 @@ import { render } from '@testing-library/react';
|
||||
import CatalogPage from './CatalogPage';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
|
||||
const testComponentFactory: ComponentFactory = {
|
||||
getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
|
||||
getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
|
||||
removeComponentByName: jest.fn(() => Promise.resolve(true)),
|
||||
};
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
it('should render', async () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CatalogPage componentFactory={testComponentFactory} />
|
||||
<CatalogPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -23,17 +23,19 @@ import {
|
||||
SupportButton,
|
||||
Page,
|
||||
pageTheme,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { useAsync } from 'react-use';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
import { Button } from '@material-ui/core';
|
||||
|
||||
type CatalogPageProps = {
|
||||
componentFactory: ComponentFactory;
|
||||
};
|
||||
const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
|
||||
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent } from '../../data/utils';
|
||||
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title="Service Catalog" subtitle="Keep track of your software">
|
||||
@@ -47,7 +49,7 @@ const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
|
||||
<SupportButton>All your components</SupportButton>
|
||||
</ContentHeader>
|
||||
<CatalogTable
|
||||
components={value || []}
|
||||
components={(value && value.map(envelopeToComponent)) || []}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -30,11 +30,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 +47,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.home}>
|
||||
<Header title={catalogRequest?.value?.name || 'Catalog'}>
|
||||
<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>
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
*/
|
||||
export type Component = {
|
||||
name: string;
|
||||
status: 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"
|
||||
}
|
||||
]
|
||||
@@ -1,46 +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 { DescriptorEnvelope } from '../../../catalog-backend/src/ingestion/types';
|
||||
|
||||
function transformEnvelopeToComponent(data: DescriptorEnvelope): Component {
|
||||
return {
|
||||
name: data.metadata?.name ?? '',
|
||||
status: data.metadata?.labels?.status ?? 'Up and running',
|
||||
};
|
||||
}
|
||||
|
||||
let inMemoryStore: Promise<Component[]>;
|
||||
|
||||
export const MockComponentFactory: ComponentFactory = {
|
||||
getAllComponents(): Promise<Component[]> {
|
||||
inMemoryStore =
|
||||
inMemoryStore ??
|
||||
fetch('//localhost:3000/catalog/api/entities')
|
||||
.then(response => response.json())
|
||||
.then(data => data.map(transformEnvelopeToComponent));
|
||||
return inMemoryStore;
|
||||
},
|
||||
async getComponentByName(name: string): Promise<Component | undefined> {
|
||||
const components = await this.getAllComponents();
|
||||
const mockComponent = components.find(component => component.name === name);
|
||||
if (mockComponent) return mockComponent;
|
||||
throw new Error(`'Component not found: ${name}`);
|
||||
},
|
||||
async removeComponentByName(_: string): Promise<boolean> {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -13,14 +13,13 @@
|
||||
* 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 { DescriptorEnvelope } from '../api/types';
|
||||
import { Component } from './component';
|
||||
|
||||
const componentFactory: ComponentFactory = MockComponentFactory;
|
||||
|
||||
export const withMockStore = (Component: React.ElementType) => {
|
||||
return (props: any) => (
|
||||
<Component {...props} componentFactory={componentFactory} />
|
||||
);
|
||||
};
|
||||
export function envelopeToComponent(envelope: DescriptorEnvelope): Component {
|
||||
return {
|
||||
name: envelope.metadata?.name ?? '',
|
||||
kind: envelope.kind ?? 'unknown',
|
||||
description: 'placeholder',
|
||||
};
|
||||
}
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export * from './api';
|
||||
|
||||
@@ -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('/catalog', withMockStore(CatalogPage));
|
||||
router.registerRoute('/catalog/:name/', withMockStore(ComponentPage));
|
||||
router.registerRoute('/catalog', CatalogPage);
|
||||
router.registerRoute('/catalog/:name/', ComponentPage);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user