Show consumers and providers for APIs

This commit is contained in:
Oliver Sand
2020-11-26 16:33:41 +01:00
parent 9e2e0d433f
commit 246799c7f2
27 changed files with 1263 additions and 229 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/create-app': patch
'@backstage/plugin-api-docs': patch
---
Add tables with consumes and provides relationships to the API and component entity pages.
@@ -17,7 +17,10 @@ import { ApiEntity, Entity } from '@backstage/catalog-model';
import { EmptyState } from '@backstage/core';
import {
ApiDefinitionCard,
Router as ApiDocsRouter,
ConsumedApisCard,
ConsumingComponentsCard,
ProvidedApisCard,
ProvidingComponentsCard,
} from '@backstage/plugin-api-docs';
import {
AboutCard,
@@ -167,6 +170,17 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
</Grid>
);
const ComponentApisContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<ProvidedApisCard entity={entity} />
</Grid>
<Grid item md={6}>
<ConsumedApisCard entity={entity} />
</Grid>
</Grid>
);
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
@@ -187,7 +201,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout.Content
path="/api/*"
title="API"
element={<ApiDocsRouter entity={entity} />}
element={<ComponentApisContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -288,6 +302,14 @@ const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid item md={6}>
<AboutCard entity={entity} />
</Grid>
<Grid container item md={12}>
<Grid item md={6}>
<ProvidingComponentsCard entity={entity} />
</Grid>
<Grid item md={6}>
<ConsumingComponentsCard entity={entity} />
</Grid>
</Grid>
</Grid>
);
@@ -13,26 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Router as GitHubActionsRouter,
isPluginApplicableToEntity as isGitHubActionsAvailable,
} from '@backstage/plugin-github-actions';
import {
Router as CircleCIRouter,
isPluginApplicableToEntity as isCircleCIAvailable,
} from '@backstage/plugin-circleci';
import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import React from 'react';
import {
EntityPageLayout,
useEntity,
AboutCard,
} from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { Grid } from '@material-ui/core';
import { WarningPanel } from '@backstage/core';
import { ConsumedApisCard, ProvidedApisCard } from '@backstage/plugin-api-docs';
import {
AboutCard, EntityPageLayout,
useEntity
} from '@backstage/plugin-catalog';
import {
isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter
} from '@backstage/plugin-circleci';
import {
isPluginApplicableToEntity as isGitHubActionsAvailable, Router as GitHubActionsRouter
} from '@backstage/plugin-github-actions';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Grid } from '@material-ui/core';
import React from 'react';
const CICDSwitcher = ({ entity }: { entity: Entity }) => {
// This component is just an example of how you can implement your company's logic in entity page.
@@ -60,6 +57,17 @@ const OverviewContent = ({ entity }: { entity: Entity }) => (
</Grid>
);
const ComponentApisContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<ProvidedApisCard entity={entity} />
</Grid>
<Grid item md={6}>
<ConsumedApisCard entity={entity} />
</Grid>
</Grid>
);
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
@@ -75,7 +83,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout.Content
path="/api/*"
title="API"
element={<ApiDocsRouter entity={entity} />}
element={<ComponentApisContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -1,51 +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 { ComponentEntity, Entity } from '@backstage/catalog-model';
import { Progress } from '@backstage/core';
import { Grid } from '@material-ui/core';
import React from 'react';
import {
ApiDefinitionCard,
useComponentApiEntities,
useComponentApiNames,
} from '../../components';
type Props = {
entity: Entity;
};
export const EntityPageApi = ({ entity }: Props) => {
const apiNames = useComponentApiNames(entity as ComponentEntity);
const { apiEntities, loading } = useComponentApiEntities({
entity: entity as ComponentEntity,
});
if (loading) {
return <Progress />;
}
return (
<Grid container spacing={3}>
{apiNames.map(api => (
<Grid item xs={12} key={api}>
<ApiDefinitionCard apiEntity={apiEntities!.get(api)} />
</Grid>
))}
</Grid>
);
};
-40
View File
@@ -1,40 +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 React from 'react';
import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model';
import { Route, Routes } from 'react-router';
import { catalogRoute } from '../routes';
import { EntityPageApi } from './EntityPageApi';
import { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState';
const isPluginApplicableToEntity = (entity: Entity) => {
// TODO: Also support RELATION_CONSUMES_API
return entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
};
export const Router = ({ entity }: { entity: Entity }) =>
!isPluginApplicableToEntity(entity) ? (
<MissingImplementsApisEmptyState />
) : (
<Routes>
<Route
path={`/${catalogRoute.path}`}
element={<EntityPageApi entity={entity} />}
/>
)
</Routes>
);
@@ -0,0 +1,84 @@
/*
* 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 { Table, TableColumn } from '@backstage/core';
import React from 'react';
import { ApiTypeTitle } from '../ApiDefinitionCard';
import { EntityLink } from '../EntityLink';
const columns: TableColumn<ApiEntity>[] = [
{
title: 'Name',
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<EntityLink entity={entity}>{entity.metadata.name}</EntityLink>
),
},
{
title: 'Owner',
field: 'spec.owner',
},
{
title: 'Lifecycle',
field: 'spec.lifecycle',
},
{
title: 'Type',
field: 'spec.type',
render: (entity: ApiEntity) => <ApiTypeTitle apiEntity={entity} />,
},
{
title: 'Description',
field: 'metadata.description',
width: 'auto',
},
];
type Props = {
title: string;
variant?: string;
entities: (ApiEntity | undefined)[];
};
export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => {
const tableStyle: React.CSSProperties = {
minWidth: '0',
width: '100%',
};
if (variant === 'gridItem') {
tableStyle.height = 'calc(100% - 10px)';
}
return (
<Table<ApiEntity>
columns={columns}
title={title}
style={tableStyle}
options={{
// TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px;
search: false,
paging: false,
actionsColumnIndex: -1,
padding: 'dense',
}}
// TODO: For now we skip all APIs that we can't find without a warning!
data={entities.filter(e => e !== undefined) as ApiEntity[]}
/>
);
};
@@ -0,0 +1,127 @@
/*
* 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, RELATION_CONSUMES_API } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { ConsumedApisCard } from './ConsumedApisCard';
describe('<ConsumedApisCard />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
apiDocsConfigRef,
apiDocsConfig,
);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumedApisCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument();
});
it('shows consumed APIs', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [
{
target: {
kind: 'API',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_CONSUMES_API,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
});
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
component: () => <div />,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumedApisCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,85 @@
/*
* 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,
Entity,
RELATION_CONSUMES_API,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { ApisTable } from './ApisTable';
import { MissingConsumesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
const ApisCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Consumed APIs">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ConsumedApisCard = ({ entity, variant = 'gridItem' }: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_CONSUMES_API,
);
if (loading) {
return (
<ApisCard variant={variant}>
<Progress />
</ApisCard>
);
}
if (error) {
return (
<ApisCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the consumed APIs."
/>
</ApisCard>
);
}
if (!entities || entities.length === 0) {
return (
<ApisCard variant={variant}>
<MissingConsumesApisEmptyState />
</ApisCard>
);
}
return (
<ApisTable
title="Consumed APIs"
variant={variant}
entities={entities as (ApiEntity | undefined)[]}
/>
);
};
@@ -0,0 +1,127 @@
/*
* 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, RELATION_PROVIDES_API } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { ProvidedApisCard } from './ProvidedApisCard';
describe('<ProvidedApisCard />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
apiDocsConfigRef,
apiDocsConfig,
);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidedApisCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Provided APIs/i)).toBeInTheDocument();
expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument();
});
it('shows consumed APIs', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [
{
target: {
kind: 'API',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_PROVIDES_API,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
});
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
component: () => <div />,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidedApisCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Provided APIs/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,85 @@
/*
* 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,
Entity,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { ApisTable } from './ApisTable';
import { MissingProvidesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
const ApisCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Provided APIs">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ProvidedApisCard = ({ entity, variant = 'gridItem' }: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_PROVIDES_API,
);
if (loading) {
return (
<ApisCard variant={variant}>
<Progress />
</ApisCard>
);
}
if (error) {
return (
<ApisCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the provided APIs."
/>
</ApisCard>
);
}
if (!entities || entities.length === 0) {
return (
<ApisCard variant={variant}>
<MissingProvidesApisEmptyState />
</ApisCard>
);
}
return (
<ApisTable
title="Provided APIs"
variant={variant}
entities={entities as (ApiEntity | undefined)[]}
/>
);
};
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { EntityPageApi } from './EntityPageApi';
export { ConsumedApisCard } from './ConsumedApisCard';
export { ProvidedApisCard } from './ProvidedApisCard';
@@ -0,0 +1,87 @@
/*
* 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 { ComponentEntity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import React from 'react';
import { EntityLink } from '../EntityLink';
const columns: TableColumn<ComponentEntity>[] = [
{
title: 'Name',
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<EntityLink entity={entity}>{entity.metadata.name}</EntityLink>
),
},
{
title: 'Owner',
field: 'spec.owner',
},
{
title: 'Lifecycle',
field: 'spec.lifecycle',
},
{
title: 'Type',
field: 'spec.type',
},
{
title: 'Description',
field: 'metadata.description',
width: 'auto',
},
];
type Props = {
title: string;
variant?: string;
entities: (ComponentEntity | undefined)[];
};
// TODO: In theory this could also be systems!
export const ComponentsTable = ({
entities,
title,
variant = 'gridItem',
}: Props) => {
const tableStyle: React.CSSProperties = {
minWidth: '0',
width: '100%',
};
if (variant === 'gridItem') {
tableStyle.height = 'calc(100% - 10px)';
}
return (
<Table<ComponentEntity>
columns={columns}
title={title}
style={tableStyle}
options={{
// TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px;
search: false,
paging: false,
actionsColumnIndex: -1,
padding: 'dense',
}}
// TODO: For now we skip all APIs that we can't find without a warning!
data={entities.filter(e => e !== undefined) as ComponentEntity[]}
/>
);
};
@@ -0,0 +1,125 @@
/*
* 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, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ConsumingComponentsCard } from './ConsumingComponentsCard';
describe('<ConsumingComponentsCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumingComponentsCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Consumers/i)).toBeInTheDocument();
expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument();
});
it('shows consuming components', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [
{
target: {
kind: 'Component',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_API_CONSUMED_BY,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'service',
owner: 'Test',
lifecycle: 'production',
},
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumingComponentsCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Consumers/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,88 @@
/*
* 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 {
ComponentEntity,
Entity,
RELATION_API_CONSUMED_BY,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { MissingConsumesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
import { ComponentsTable } from './ComponentsTable';
const ComponentsCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Consumers">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ConsumingComponentsCard = ({
entity,
variant = 'gridItem',
}: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_API_CONSUMED_BY,
);
if (loading) {
return (
<ComponentsCard variant={variant}>
<Progress />
</ComponentsCard>
);
}
if (error) {
return (
<ComponentsCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the consumers."
/>
</ComponentsCard>
);
}
if (!entities || entities.length === 0) {
return (
<ComponentsCard variant={variant}>
<MissingConsumesApisEmptyState />
</ComponentsCard>
);
}
return (
<ComponentsTable
title="Consumers"
variant={variant}
entities={entities as (ComponentEntity | undefined)[]}
/>
);
};
@@ -0,0 +1,125 @@
/*
* 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, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ProvidingComponentsCard } from './ProvidingComponentsCard';
describe('<ProvidingComponentsCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidingComponentsCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Providers/i)).toBeInTheDocument();
expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument();
});
it('shows providing components', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [
{
target: {
kind: 'Component',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_API_PROVIDED_BY,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'service',
owner: 'Test',
lifecycle: 'production',
},
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidingComponentsCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Providers/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,88 @@
/*
* 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 {
ComponentEntity,
Entity,
RELATION_API_PROVIDED_BY,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { MissingProvidesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
import { ComponentsTable } from './ComponentsTable';
const ComponentsCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Providers">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ProvidingComponentsCard = ({
entity,
variant = 'gridItem',
}: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_API_PROVIDED_BY,
);
if (loading) {
return (
<ComponentsCard variant={variant}>
<Progress />
</ComponentsCard>
);
}
if (error) {
return (
<ComponentsCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the providers."
/>
</ComponentsCard>
);
}
if (!entities || entities.length === 0) {
return (
<ComponentsCard variant={variant}>
<MissingProvidesApisEmptyState />
</ComponentsCard>
);
}
return (
<ComponentsTable
title="Providers"
variant={variant}
entities={entities as (ComponentEntity | undefined)[]}
/>
);
};
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { Router } from './Router';
export { ConsumingComponentsCard } from './ConsumingComponentsCard';
export { ProvidingComponentsCard } from './ProvidingComponentsCard';
@@ -14,16 +14,15 @@
* limitations under the License.
*/
import {
ComponentEntity,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState';
export const useComponentApiNames = (entity: ComponentEntity) => {
// TODO: This code doesn't handle namespaces and kinds correctly, but will be removed soon
return (
entity.relations
?.filter(r => r.type === RELATION_PROVIDES_API)
?.map(r => r.target.name) || []
);
};
describe('<MissingConsumesApisEmptyState />', () => {
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(
<MissingConsumesApisEmptyState />,
);
expect(getByText(/consumesApis:/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
/*
* 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 { Button, makeStyles, Typography } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import { CodeSnippet, EmptyState } from '@backstage/core';
const COMPONENT_YAML = `# Example
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: example
spec:
type: service
lifecycle: production
owner: guest
consumesApis:
- example-api
`;
const useStyles = makeStyles<BackstageTheme>(theme => ({
code: {
borderRadius: 6,
margin: `${theme.spacing(2)}px 0px`,
background: theme.palette.type === 'dark' ? '#444' : '#fff',
},
}));
export const MissingConsumesApisEmptyState = () => {
const classes = useStyles();
return (
<EmptyState
missing="field"
title="No APIs consumed by this entity"
description={
<>
Components can consume APIs that are displayed on this page. You need
to fill the <code>consumesApis</code> field to enable this tool.
</>
}
action={
<>
<Typography variant="body1">
Link an API to your component as shown in the highlighted example
below:
</Typography>
<div className={classes.code}>
<CodeSnippet
text={COMPONENT_YAML}
language="yaml"
showLineNumbers
highlightedNumbers={[10, 11]}
customStyle={{ background: 'inherit', fontSize: '115%' }}
/>
</div>
<Button
variant="contained"
color="primary"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional"
>
Read more
</Button>
</>
}
/>
);
};
@@ -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 { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState';
describe('<MissingProvidesApisEmptyState />', () => {
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(
<MissingProvidesApisEmptyState />,
);
expect(getByText(/providesApis:/i)).toBeInTheDocument();
});
});
@@ -40,12 +40,12 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
},
}));
export const MissingImplementsApisEmptyState = () => {
export const MissingProvidesApisEmptyState = () => {
const classes = useStyles();
return (
<EmptyState
missing="field"
title="No APIs implemented by this entity"
title="No APIs provided by this entity"
description={
<>
Components can implement APIs that are displayed on this page. You
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState';
export { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState';
export { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState';
+6 -10
View File
@@ -14,13 +14,9 @@
* limitations under the License.
*/
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
export {
ApiDefinitionCard,
defaultDefinitionWidgets,
} from './ApiDefinitionCard';
export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
export { useComponentApiNames } from './useComponentApiNames';
export { useComponentApiEntities } from './useComponentApiEntities';
export * from './ApiDefinitionCard';
export * from './ApisCards';
export * from './AsyncApiDefinitionWidget';
export * from './ComponentsCards';
export * from './OpenApiDefinitionWidget';
export * from './PlainApiDefinitionWidget';
@@ -1,83 +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 { useAsyncRetry } from 'react-use';
import { errorApiRef, useApi } from '@backstage/core';
import {
ApiEntity,
ComponentEntity,
parseEntityName,
} from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { useComponentApiNames } from './useComponentApiNames';
export function useComponentApiEntities({
entity,
}: {
entity: ComponentEntity;
}): {
loading: boolean;
apiEntities?: Map<String, ApiEntity>;
error?: Error;
retry: () => void;
} {
const catalogApi = useApi(catalogApiRef);
const errorApi = useApi(errorApiRef);
const apiNames = useComponentApiNames(entity);
const { loading, value: apiEntities, retry, error } = useAsyncRetry<
Map<string, ApiEntity>
>(async () => {
const resultMap = new Map<string, ApiEntity>();
await Promise.all(
apiNames.map(async name => {
try {
const apiEntityName = parseEntityName(name, {
defaultNamespace: entity.metadata.namespace,
defaultKind: 'API',
});
if (apiEntityName.kind !== 'API') {
throw new Error(
`Referenced entity of kind "${apiEntityName.kind}" as an API`,
);
}
const api = (await catalogApi.getEntityByName(apiEntityName)) as
| ApiEntity
| undefined;
if (api) {
resultMap.set(api.metadata.name, api);
}
} catch (e) {
errorApi.post(e);
}
}),
);
return resultMap;
}, [catalogApi, entity]);
return {
apiEntities,
loading,
error,
retry,
};
}
@@ -0,0 +1,51 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { useAsyncRetry } from 'react-use';
// TODO: Maybe this hook is interesting for others too?
export function useRelatedEntities(
entity: Entity,
type: string,
): {
entities: (Entity | undefined)[] | undefined;
loading: boolean;
error: Error | undefined;
} {
const catalogApi = useApi(catalogApiRef);
const { loading, value, error } = useAsyncRetry<
(Entity | undefined)[]
>(async () => {
const relations =
entity.relations && entity.relations.filter(r => r.type === type);
if (!relations) {
return [];
}
return await Promise.all(
relations?.map(r => catalogApi.getEntityByName(r.target)),
);
}, [entity, type]);
return {
entities: value,
loading,
error,
};
}
-1
View File
@@ -14,6 +14,5 @@
* limitations under the License.
*/
export * from './catalog';
export * from './components';
export { plugin } from './plugin';
-6
View File
@@ -23,9 +23,3 @@ export const rootRoute = createRouteRef({
path: '/api-docs',
title: 'APIs',
});
export const catalogRoute = createRouteRef({
icon: NoIcon,
path: '',
title: 'API',
});