Merge pull request #3479 from SDA-SE/feat/api-docs-relations

API Consumers & Providers
This commit is contained in:
Patrik Oldsberg
2020-12-08 21:44:46 +01:00
committed by GitHub
34 changed files with 1516 additions and 232 deletions
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-api-docs': minor
---
Stop exposing a custom router from the `api-docs` plugin. Instead, use the
widgets exported by the plugin to compose your custom entity pages.
Instead of displaying the API definitions directly in the API tab of the
component, it now contains tables linking to the API entities. This also adds
new widgets to display relationships (bot provides & consumes relationships)
between components and APIs.
See the changelog of `create-app` for a migration guide.
+136
View File
@@ -0,0 +1,136 @@
---
'@backstage/create-app': patch
---
Adjust template to the latest changes in the `api-docs` plugin.
## Template Changes
While updating to the latest `api-docs` plugin, the following changes are
necessary for the `create-app` template in your
`app/src/components/catalog/EntityPage.tsx`. This adds:
- A custom entity page for API entities
- Changes the API tab to include the new `ConsumedApisCard` and
`ProvidedApisCard` that link to the API entity.
```diff
import {
+ ApiDefinitionCard,
- Router as ApiDocsRouter,
+ ConsumedApisCard,
+ ProvidedApisCard,
+ ConsumedApisCard,
+ ConsumingComponentsCard,
+ ProvidedApisCard,
+ ProvidingComponentsCard
} from '@backstage/plugin-api-docs';
...
+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
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<CICDSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/api/*"
title="API"
- element={<ApiDocsRouter entity={entity} />}
+ element={<ComponentApisContent entity={entity} />}
/>
...
-export const EntityPage = () => {
- const { entity } = useEntity();
- switch (entity?.spec?.type) {
- case 'service':
- return <ServiceEntityPage entity={entity} />;
- case 'website':
- return <WebsiteEntityPage entity={entity} />;
- default:
- return <DefaultEntityPage entity={entity} />;
- }
-};
+export const ComponentEntityPage = ({ entity }: { entity: Entity }) => {
+ switch (entity?.spec?.type) {
+ case 'service':
+ return <ServiceEntityPage entity={entity} />;
+ case 'website':
+ return <WebsiteEntityPage entity={entity} />;
+ default:
+ return <DefaultEntityPage entity={entity} />;
+ }
+};
+
+const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
+ <Grid container spacing={3}>
+ <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>
+);
+
+const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => (
+ <Grid container spacing={3}>
+ <Grid item xs={12}>
+ <ApiDefinitionCard apiEntity={entity} />
+ </Grid>
+ </Grid>
+);
+
+const ApiEntityPage = ({ entity }: { entity: Entity }) => (
+ <EntityPageLayout>
+ <EntityPageLayout.Content
+ path="/*"
+ title="Overview"
+ element={<ApiOverviewContent entity={entity} />}
+ />
+ <EntityPageLayout.Content
+ path="/definition/*"
+ title="Definition"
+ element={<ApiDefinitionContent entity={entity as ApiEntity} />}
+ />
+ </EntityPageLayout>
+);
+
+export const EntityPage = () => {
+ const { entity } = useEntity();
+
+ switch (entity?.kind?.toLowerCase()) {
+ case 'component':
+ return <ComponentEntityPage entity={entity} />;
+ case 'api':
+ return <ApiEntityPage entity={entity} />;
+ default:
+ return <DefaultEntityPage entity={entity} />;
+ }
+};
```
@@ -22,7 +22,10 @@ import {
import { EmptyState } from '@backstage/core';
import {
ApiDefinitionCard,
Router as ApiDocsRouter,
ConsumedApisCard,
ConsumingComponentsCard,
ProvidedApisCard,
ProvidingComponentsCard,
} from '@backstage/plugin-api-docs';
import {
AboutCard,
@@ -187,6 +190,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
@@ -207,7 +221,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout.Content
path="/api/*"
title="API"
element={<ApiDocsRouter entity={entity} />}
element={<ComponentApisContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -308,6 +322,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>
);
@@ -10,3 +10,5 @@ spec:
- ./apis/spotify-api.yaml
- ./apis/streetlights-api.yaml
- ./apis/swapi-graphql.yaml
- ./apis/wayback-archive-api.yaml
- ./apis/wayback-search-api.yaml
@@ -14,3 +14,5 @@ spec:
- ./components/playback-lib-component.yaml
- ./components/www-artist-component.yaml
- ./components/shuffle-api-component.yaml
- ./components/wayback-archive-component.yaml
- ./components/wayback-search-component.yaml
@@ -0,0 +1,11 @@
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: wayback-archive
description: Archive API for the wayback machine
spec:
type: openapi
lifecycle: production
owner: archive@example.com
definition:
$text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/wayback/1.0.0/openapi.yaml
@@ -0,0 +1,11 @@
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: wayback-search
description: Search API for the wayback machine
spec:
type: openapi
lifecycle: production
owner: archive@example.com
definition:
$text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/search/1.0.0/openapi.yaml
@@ -0,0 +1,11 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: wayback-archive
description: Archive of the wayback machine
spec:
type: service
lifecycle: production
owner: archive@example.com
providesApis:
- wayback-archive
@@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: wayback-search
description: Search of the wayback machine
spec:
type: service
lifecycle: production
owner: archive@example.com
providesApis:
- wayback-search
consumesApis:
- wayback-archive
@@ -13,26 +13,29 @@
* 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 { ApiEntity, Entity } from '@backstage/catalog-model';
import { WarningPanel } from '@backstage/core';
import {
ApiDefinitionCard,
ConsumedApisCard,
ConsumingComponentsCard,
ProvidedApisCard,
ProvidingComponentsCard
} 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 +63,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 +89,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout.Content
path="/api/*"
title="API"
element={<ApiDocsRouter entity={entity} />}
element={<ComponentApisContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -120,8 +134,7 @@ const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
</EntityPageLayout>
);
export const EntityPage = () => {
const { entity } = useEntity();
export const ComponentEntityPage = ({ entity }: { entity: Entity }) => {
switch (entity?.spec?.type) {
case 'service':
return <ServiceEntityPage entity={entity} />;
@@ -131,3 +144,55 @@ export const EntityPage = () => {
return <DefaultEntityPage entity={entity} />;
}
};
const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3}>
<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>
);
const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => (
<Grid container spacing={3}>
<Grid item xs={12}>
<ApiDefinitionCard apiEntity={entity} />
</Grid>
</Grid>
);
const ApiEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<ApiOverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/definition/*"
title="Definition"
element={<ApiDefinitionContent entity={entity as ApiEntity} />}
/>
</EntityPageLayout>
);
export const EntityPage = () => {
const { entity } = useEntity();
switch (entity?.kind?.toLowerCase()) {
case 'component':
return <ComponentEntityPage entity={entity} />;
case 'api':
return <ApiEntityPage entity={entity} />;
default:
return <DefaultEntityPage entity={entity} />;
}
};
@@ -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',
});