Merge branch 'master' of https://github.com/spotify/backstage into samiram/pagerduty-plugin

This commit is contained in:
Samira Mokaram
2020-12-04 11:04:21 +01:00
396 changed files with 11718 additions and 2582 deletions
+30
View File
@@ -1,5 +1,35 @@
# @backstage/plugin-api-docs
## 0.3.1
### Patch Changes
- 7eb8bfe4a: Update swagger-ui-react to 3.37.2
- Updated dependencies [08835a61d]
- Updated dependencies [a9fd599f7]
- Updated dependencies [bcc211a08]
- Updated dependencies [ebf37bbae]
- @backstage/catalog-model@0.4.0
- @backstage/plugin-catalog@0.2.5
## 0.3.0
### Minor Changes
- f3bb55ee3: APIs now have real entity pages that are customizable in the app.
Therefore the old entity page from this plugin is removed.
See the `packages/app` on how to create and customize the API entity page.
### Patch Changes
- 6f70ed7a9: Replace usage of implementsApis with relations
- Updated dependencies [6f70ed7a9]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- @backstage/plugin-catalog@0.2.4
- @backstage/catalog-model@0.3.1
## 0.2.2
### Patch Changes
+1 -1
View File
@@ -21,7 +21,7 @@ Right now, the following API formats are supported:
Other formats are displayed as plain text, but this can easily be extended.
To fill the catalog with APIs, [provide entities of kind API](https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api).
To link that an component implements an API, see [`implementsApis` property on components](https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional).
To link that a component provides or consumes an API, see the [`providesApis`](https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional) and [`consumesApis`](https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional) properties on the Component kind.
## Links
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-api-docs",
"version": "0.2.2",
"version": "0.3.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,15 +20,16 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.3.0",
"@backstage/catalog-model": "^0.4.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.3",
"@backstage/plugin-catalog": "^0.2.5",
"@backstage/theme": "^0.2.1",
"@kyma-project/asyncapi-react": "^0.14.2",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"graphiql": "^1.0.0-alpha.10",
"graphql": "^15.3.0",
"react": "^16.13.1",
@@ -36,18 +37,17 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swagger-ui-react": "^3.31.1"
"swagger-ui-react": "^3.37.2"
},
"devDependencies": {
"@backstage/cli": "^0.3.1",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@backstage/cli": "^0.4.0",
"@backstage/dev-utils": "^0.1.5",
"@backstage/test-utils": "^0.1.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react": "^16.9",
"@types/swagger-ui-react": "^3.23.3",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
@@ -28,7 +28,7 @@ spec:
type: service
lifecycle: production
owner: guest
implementsApis:
providesApis:
- example-api
`;
@@ -47,11 +47,10 @@ export const MissingImplementsApisEmptyState = () => {
missing="field"
title="No APIs implemented by this entity"
description={
<Typography>
<>
Components can implement APIs that are displayed on this page. You
need to fill the <code>implementsApis</code> field to enable this
tool.
</Typography>
need to fill the <code>providesApis</code> field to enable this tool.
</>
}
action={
<>
@@ -71,7 +70,7 @@ export const MissingImplementsApisEmptyState = () => {
<Button
variant="contained"
color="primary"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional"
>
Read more
</Button>
+3 -2
View File
@@ -15,14 +15,15 @@
*/
import React from 'react';
import { Entity } from '@backstage/catalog-model';
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) => {
return ((entity.spec?.implementsApis as string[]) || []).length > 0;
// TODO: Also support RELATION_CONSUMES_API
return entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
};
export const Router = ({ entity }: { entity: Entity }) =>
@@ -0,0 +1,121 @@
/*
* 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 { ApiProvider, ApiRegistry } from '@backstage/core';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
import { ApiDefinitionCard } from './ApiDefinitionCard';
describe('<ApiDefinitionCard />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('renders API', async () => {
const definition = `
openapi: "3.0.0"
info:
version: 1.0.0
title: Artist API
license:
name: MIT
servers:
- url: http://artist.spotify.net/v1
paths:
/artists:
get:
summary: List all artists
responses:
"200":
description: Success
`;
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'openapi',
lifecycle: '...',
owner: '...',
definition,
},
};
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
rawLanguage: 'yaml',
component: definition => (
<OpenApiDefinitionWidget definition={definition} />
),
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiDefinitionCard apiEntity={apiEntity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Raw/i)).toBeInTheDocument();
expect(getByText(/List all artists/i)).toBeInTheDocument();
});
});
it('fallback to plain view', async () => {
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'custom-type',
lifecycle: '...',
owner: '...',
definition: 'Custom Definition',
},
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiDefinitionCard apiEntity={apiEntity} />
</Wrapper>,
);
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/custom-type/i)).toBeInTheDocument();
expect(getByText(/Custom Definition/i)).toBeInTheDocument();
});
});
@@ -15,50 +15,11 @@
*/
import { ApiEntity } from '@backstage/catalog-model';
import { CardTab, useApi, TabbedCard } from '@backstage/core';
import { CardTab, TabbedCard, useApi } from '@backstage/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
export type ApiDefinitionWidget = {
type: string;
title: string;
component: (definition: string) => React.ReactElement;
rawLanguage?: string;
};
export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
return [
{
type: 'openapi',
title: 'OpenAPI',
rawLanguage: 'yaml',
component: definition => (
<OpenApiDefinitionWidget definition={definition} />
),
},
{
type: 'asyncapi',
title: 'AsyncAPI',
rawLanguage: 'yaml',
component: definition => (
<AsyncApiDefinitionWidget definition={definition} />
),
},
{
type: 'graphql',
title: 'GraphQL',
rawLanguage: 'graphql',
component: definition => (
<GraphQlDefinitionWidget definition={definition} />
),
},
];
}
type Props = {
apiEntity?: ApiEntity;
@@ -0,0 +1,55 @@
/*
* 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 { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
export type ApiDefinitionWidget = {
type: string;
title: string;
component: (definition: string) => React.ReactElement;
rawLanguage?: string;
};
export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
return [
{
type: 'openapi',
title: 'OpenAPI',
rawLanguage: 'yaml',
component: definition => (
<OpenApiDefinitionWidget definition={definition} />
),
},
{
type: 'asyncapi',
title: 'AsyncAPI',
rawLanguage: 'yaml',
component: definition => (
<AsyncApiDefinitionWidget definition={definition} />
),
},
{
type: 'graphql',
title: 'GraphQL',
rawLanguage: 'graphql',
component: definition => (
<GraphQlDefinitionWidget definition={definition} />
),
},
];
}
@@ -0,0 +1,92 @@
/*
* 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 { ApiProvider, ApiRegistry } from '@backstage/core';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { ApiTypeTitle } from './ApiTypeTitle';
describe('<ApiTypeTitle />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('renders API type title', async () => {
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'openapi',
lifecycle: '...',
owner: '...',
definition: '...',
},
};
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
component: () => <div />,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiTypeTitle apiEntity={apiEntity} />
</Wrapper>,
);
expect(getByText(/OpenAPI/)).toBeInTheDocument();
});
it('fallback if title is unknown', async () => {
const apiEntity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-name',
},
spec: {
type: 'custom-type',
lifecycle: '...',
owner: '...',
definition: '...',
},
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ApiTypeTitle apiEntity={apiEntity} />
</Wrapper>,
);
expect(getByText(/custom-type/i)).toBeInTheDocument();
});
});
@@ -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 { ApiEntity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
export const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntity }) => {
const config = useApi(apiDocsConfigRef);
const definition = config.getApiDefinitionWidget(apiEntity);
const type = definition ? definition.title : apiEntity.spec.type;
return <span>{type}</span>;
};
@@ -14,8 +14,7 @@
* limitations under the License.
*/
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
export {
ApiDefinitionCard,
defaultDefinitionWidgets,
} from './ApiDefinitionCard';
export { ApiDefinitionCard } from './ApiDefinitionCard';
export { defaultDefinitionWidgets } from './ApiDefinitionWidget';
export type { ApiDefinitionWidget } from './ApiDefinitionWidget';
export { ApiTypeTitle } from './ApiTypeTitle';
@@ -1,71 +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 { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { ApiEntityPage } from './ApiEntityPage';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
return {
...actual,
useNavigate: jest.fn(() => mockNavigate),
useParams: jest.fn(),
};
});
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('ApiEntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, errorApi],
[
catalogApiRef,
({
async getEntityByName() {},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
<ApiEntityPage />
</ApiProvider>,
),
);
await waitFor(() =>
expect(useNavigate()).toHaveBeenCalledWith('/api-docs'),
);
});
});
@@ -1,127 +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 { ApiEntity, Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
Page,
Progress,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard';
const REDIRECT_DELAY = 1000;
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
type EntityPageTitleProps = {
title: string;
entity: Entity | undefined;
};
const EntityPageTitle = ({ title }: EntityPageTitleProps) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage = () => {
const { optionalNamespaceAndName } = useParams() as {
optionalNamespaceAndName: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind: 'API', namespace, name }),
[catalogApi, namespace, name],
);
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
navigate('/api-docs');
return null;
}
const { headerTitle, headerType } = headerProps(
'API',
namespace,
name,
entity,
);
return (
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
/>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<Content>
<ApiDefinitionCard apiEntity={entity as ApiEntity} />
</Content>
</>
)}
</Page>
);
};
@@ -22,7 +22,7 @@ import * as React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerTable } from './ApiExplorerTable';
const entites: Entity[] = [
const entities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
@@ -70,7 +70,7 @@ describe('ApiCatalogTable component', () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable entities={entites} loading={false} />
<ApiExplorerTable entities={entities} loading={false} />
</ApiProvider>,
),
);
@@ -20,23 +20,13 @@ import {
TableColumn,
TableFilter,
TableState,
useApi,
useQueryParamState,
} from '@backstage/core';
import { Chip, Link } from '@material-ui/core';
import { Chip } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { apiDocsConfigRef } from '../../config';
import { entityRoute } from '../../routes';
const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntityV1alpha1 }) => {
const config = useApi(apiDocsConfigRef);
const definition = config.getApiDefinitionWidget(apiEntity);
const type = definition ? definition.title : apiEntity.spec.type;
return <span>{type}</span>;
};
import { ApiTypeTitle } from '../ApiDefinitionCard';
import { EntityLink } from '../EntityLink';
const columns: TableColumn<Entity>[] = [
{
@@ -44,21 +34,7 @@ const columns: TableColumn<Entity>[] = [
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
selectedTabId: 'overview',
})}
>
{entity.metadata.name}
</Link>
<EntityLink entity={entity}>{entity.metadata.name}</EntityLink>
),
},
{
@@ -0,0 +1,59 @@
/*
* 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 { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
describe('<AsyncApiDefinitionWidget />', () => {
it('renders asyncapi spec', async () => {
const definition = `
asyncapi: 2.0.0
info:
title: Account Service
version: 1.0.0
channels:
user/signedup:
subscribe:
message:
$ref: '#/components/messages/UserSignedUp'
components:
messages:
UserSignedUp:
payload:
type: object
properties:
displayName:
type: string
`;
const { getByText, getAllByText } = await renderInTestApp(
<AsyncApiDefinitionWidget definition={definition} />,
);
expect(getByText(/Account Service/i)).toBeInTheDocument();
expect(getByText(/user\/signedup/i)).toBeInTheDocument();
expect(getByText(/UserSignedUp/i)).toBeInTheDocument();
expect(getAllByText(/displayName/i)).toHaveLength(4);
});
it('renders error if definition is missing', async () => {
const { getByText } = await renderInTestApp(
<AsyncApiDefinitionWidget definition="" />,
);
expect(getByText(/Error/i)).toBeInTheDocument();
expect(getByText(/Document can't be null or falsey/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,41 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { EntityLink } from './EntityLink';
describe('<EntityLink />', () => {
it('provides link to entity page', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
};
const { getByText } = await renderInTestApp(
<EntityLink entity={entity}>inner</EntityLink>,
);
expect(getByText(/inner/i)).toBeInTheDocument();
expect(getByText(/inner/i)).toHaveAttribute(
'href',
'/catalog/my-namespace/component/my-name',
);
});
});
@@ -0,0 +1,40 @@
/*
* 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 { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Link } from '@material-ui/core';
import React, { PropsWithChildren } from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
type Props = {
entity: Entity;
};
// TODO: Could be useful for others too, as part of the catalog plugin
export const EntityLink = ({ entity, children }: PropsWithChildren<Props>) => {
return (
<Link
component={RouterLink}
to={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
>
{children}
</Link>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { ApiEntityPage } from './ApiEntityPage';
export { EntityLink } from './EntityLink';
@@ -0,0 +1,61 @@
/*
* 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 { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget';
describe('<GraphQlDefinitionWidget />', () => {
it('renders graphql schema', async () => {
const definition = `
"""Hello World!"""
schema {
query: Film
}
"""A single film."""
type Film {
"""The ID of an object"""
id: ID!
"""The title of this film."""
title: String
}
`;
// CodeMirror uses features that jsdom doesn't support so we have to patch
// it here, see: https://github.com/jsdom/jsdom/issues/3002
document.createRange = () => {
const range = new Range();
range.getBoundingClientRect = jest.fn();
range.getClientRects = () => {
return {
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
};
};
return range;
};
const { getByText } = await renderInTestApp(
<GraphQlDefinitionWidget definition={definition} />,
);
expect(getByText(/Film/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
/*
* 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 { waitFor } from '@testing-library/react';
import React from 'react';
import { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
describe('<OpenApiDefinitionWidget />', () => {
it('renders openapi spec', async () => {
const definition = `
openapi: "3.0.0"
info:
version: 1.0.0
title: Artist API
license:
name: MIT
servers:
- url: http://artist.spotify.net/v1
paths:
/artists:
get:
summary: List all artists
responses:
"200":
description: Success
`;
const { getByText } = await renderInTestApp(
<OpenApiDefinitionWidget definition={definition} />,
);
// swagger-ui loads the documentation asynchronously
await waitFor(() => {
expect(getByText(/\/artists/i)).toBeInTheDocument();
expect(getByText(/List all artists/i)).toBeInTheDocument();
});
});
it('renders error if definition is missing', async () => {
const { getByText } = await renderInTestApp(
<OpenApiDefinitionWidget definition="" />,
);
expect(getByText(/No API definition provided/i)).toBeInTheDocument();
});
});
@@ -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 { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
describe('<PlainApiDefinitionWidget />', () => {
it('renders plain text', async () => {
const { getByText } = await renderInTestApp(
<PlainApiDefinitionWidget definition="Hello World" language="yaml" />,
);
expect(getByText(/Hello World/i)).toBeInTheDocument();
});
});
@@ -14,8 +14,16 @@
* limitations under the License.
*/
import { ComponentEntity } from '@backstage/catalog-model';
import {
ComponentEntity,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
export const useComponentApiNames = (entity: ComponentEntity) => {
return (entity.spec?.implementsApis as string[]) || [];
// 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) || []
);
};
+2 -1
View File
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { Router } from './catalog';
export * from './catalog';
export * from './components';
export { plugin } from './plugin';
+1 -3
View File
@@ -18,8 +18,7 @@ import { ApiEntity } from '@backstage/catalog-model';
import { createApiFactory, createPlugin } from '@backstage/core';
import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage';
import { defaultDefinitionWidgets } from './components/ApiDefinitionCard';
import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage';
import { entityRoute, rootRoute } from './routes';
import { rootRoute } from './routes';
import { apiDocsConfigRef } from './config';
export const plugin = createPlugin({
@@ -40,6 +39,5 @@ export const plugin = createPlugin({
],
register({ router }) {
router.addRoute(rootRoute, ApiExplorerPage);
router.addRoute(entityRoute, ApiEntityPage);
},
});
-6
View File
@@ -24,12 +24,6 @@ export const rootRoute = createRouteRef({
title: 'APIs',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs/:optionalNamespaceAndName/',
title: 'API',
});
export const catalogRoute = createRouteRef({
icon: NoIcon,
path: '',
+19
View File
@@ -1,5 +1,24 @@
# @backstage/plugin-app-backend
## 0.3.2
### Patch Changes
- Updated dependencies [4e7091759]
- Updated dependencies [b4488ddb0]
- Updated dependencies [612368274]
- @backstage/config-loader@0.4.0
- @backstage/backend-common@0.3.3
## 0.3.1
### Patch Changes
- ff1301d28: Warn if the app-backend can't start-up because the static directory that should be served is unavailable.
- Updated dependencies [3aa7efb3f]
- Updated dependencies [b3d4e4e57]
- @backstage/backend-common@0.3.2
## 0.3.0
### Minor Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app-backend",
"version": "0.3.0",
"version": "0.3.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,8 +20,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.3.0",
"@backstage/config-loader": "^0.3.0",
"@backstage/backend-common": "^0.3.3",
"@backstage/config-loader": "^0.4.0",
"@backstage/config": "^0.1.1",
"@types/express": "^4.17.6",
"express": "^4.17.1",
@@ -31,7 +31,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.4.0",
"@types/supertest": "^2.0.8",
"msw": "^0.20.5",
"supertest": "^4.0.2"
+1 -1
View File
@@ -88,7 +88,7 @@ export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
{ visiblity: ['frontend'] },
{ visibility: ['frontend'] },
);
appConfigs.push(...frontendConfigs);
}
+11 -1
View File
@@ -21,6 +21,7 @@ import { Logger } from 'winston';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { injectConfig, readConfigs } from '../lib/config';
import fs from 'fs-extra';
export interface RouterOptions {
config: Config;
@@ -35,9 +36,18 @@ export async function createRouter(
const { config, logger, appPackageName, staticFallbackHandler } = options;
const appDistDir = resolvePackagePath(appPackageName, 'dist');
logger.info(`Serving static app content from ${appDistDir}`);
const staticDir = resolvePath(appDistDir, 'static');
if (!(await fs.pathExists(staticDir))) {
logger.warn(
`Can't serve static app content from ${staticDir}, directory doesn't exist`,
);
return Router();
}
logger.info(`Serving static app content from ${appDistDir}`);
const appConfigs = await readConfigs({
config,
appDistDir,
+26
View File
@@ -1,5 +1,31 @@
# @backstage/plugin-auth-backend
## 0.2.5
### Patch Changes
- Updated dependencies [612368274]
- Updated dependencies [08835a61d]
- Updated dependencies [a9fd599f7]
- Updated dependencies [bcc211a08]
- @backstage/backend-common@0.3.3
- @backstage/catalog-model@0.4.0
- @backstage/catalog-client@0.3.2
## 0.2.4
### Patch Changes
- 50eff1d00: Allow the backend to register custom AuthProviderFactories
- 700a212b4: bug fix: issue 3223 - detect mismatching origin and indicate it in the message at auth failure
- Updated dependencies [3aa7efb3f]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- Updated dependencies [b3d4e4e57]
- @backstage/backend-common@0.3.2
- @backstage/catalog-model@0.3.1
## 0.2.3
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.2.3",
"version": "0.2.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,9 +20,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.3.1",
"@backstage/catalog-client": "^0.3.1",
"@backstage/catalog-model": "^0.3.0",
"@backstage/backend-common": "^0.3.3",
"@backstage/catalog-client": "^0.3.2",
"@backstage/catalog-model": "^0.4.0",
"@backstage/config": "^0.1.1",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
@@ -36,7 +36,7 @@
"got": "^11.5.2",
"helmet": "^4.0.0",
"jose": "^1.27.1",
"jwt-decode": "2.2.0",
"jwt-decode": "^3.1.0",
"knex": "^0.21.6",
"moment": "^2.26.0",
"morgan": "^1.10.0",
@@ -55,11 +55,11 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.1",
"@backstage/cli": "^0.4.0",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
"@types/jwt-decode": "2.2.1",
"@types/jwt-decode": "^3.1.0",
"@types/nock": "^11.1.0",
"@types/openid-client": "^3.7.0",
"@types/passport": "^1.0.3",
+8
View File
@@ -15,3 +15,11 @@
*/
export * from './service/router';
export * from './providers';
// flow package provides 2 functions
// ensuresXRequestedWith and postMessageResponse to safely handle CORS requests for login. The WebMessageResponse type in flow is used to type the response from the login-popup
export * from './lib/flow';
// OAuth wrapper over a passport or a custom `startegy`.
export * from './lib/oauth';
@@ -81,6 +81,52 @@ describe('oauth helpers', () => {
expect(mockResponse.end).toBeCalledWith(expect.stringContaining(encoded));
});
it('should call postMessage twice but only one of them with target *', () => {
let responseBody = '';
const mockResponse = ({
end: jest.fn(body => {
responseBody = body;
return this;
}),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
postMessageResponse(mockResponse, appOrigin, data);
expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2);
expect(
responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g),
).toHaveLength(1);
const errData: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occurred'),
};
postMessageResponse(mockResponse, appOrigin, errData);
expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2);
expect(
responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g),
).toHaveLength(1);
});
it('handles single quotes and unicode chars safely', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
@@ -38,10 +38,24 @@ export const postMessageResponse = (
// data.
// TODO: Make target app origin configurable globally
//
// postMessage fails silently if the targetOrigin is disallowed.
// So 2 postMessages are sent from the popup to the parent window.
// First, the origin being used to post the actual authorization response is
// shared with the parent window with a postMessage with targetOrigin '*'.
// Second, the actual authorization response is sent with the app origin
// as the targetOrigin.
// If the first message was received but the actual auth response was
// never received, the event listener can conclude that targetOrigin
// was disallowed, indicating potential misconfiguration.
//
const script = `
var json = decodeURIComponent('${base64Data}');
var authResponse = decodeURIComponent('${base64Data}');
var origin = decodeURIComponent('${base64Origin}');
(window.opener || window.parent).postMessage(JSON.parse(json), origin);
var originInfo = {'type': 'config_info', 'targetOrigin': origin};
(window.opener || window.parent).postMessage(originInfo, '*');
(window.opener || window.parent).postMessage(JSON.parse(authResponse), origin);
window.close();
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
@@ -15,3 +15,5 @@
*/
export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
export type { WebMessageResponse } from './types';
@@ -149,12 +149,12 @@ export class Auth0AuthProvider implements OAuthHandlers {
}
export const createAuth0Provider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'auth0';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
@@ -24,9 +24,9 @@ import { createSamlProvider } from './saml';
import { createAuth0Provider } from './auth0';
import { createMicrosoftProvider } from './microsoft';
import { createOneLoginProvider } from './onelogin';
import { AuthProviderFactory, AuthProviderFactoryOptions } from './types';
import { AuthProviderFactory } from './types';
const factories: { [providerId: string]: AuthProviderFactory } = {
export const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
github: createGithubProvider,
gitlab: createGitlabProvider,
@@ -38,15 +38,3 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
oidc: createOidcProvider,
onelogin: createOneLoginProvider,
};
export function createAuthProvider(
providerId: string,
options: AuthProviderFactoryOptions,
) {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
return factory(options);
}
@@ -137,12 +137,12 @@ export class GithubAuthProvider implements OAuthHandlers {
}
export const createGithubProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'github';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
@@ -140,12 +140,12 @@ export class GitlabAuthProvider implements OAuthHandlers {
}
export const createGitlabProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'gitlab';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
@@ -175,6 +175,7 @@ export class GoogleAuthProvider implements OAuthHandlers {
}
export const createGoogleProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
logger,
@@ -182,7 +183,6 @@ export const createGoogleProvider: AuthProviderFactory = ({
catalogApi,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'google';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
+13 -1
View File
@@ -14,4 +14,16 @@
* limitations under the License.
*/
export { createAuthProvider } from './factories';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a
// custom Authorization Handler
export type {
AuthProviderRouteHandlers,
AuthProviderFactoryOptions,
AuthProviderFactory,
} from './types';
// These types are needed for a postMessage from the login pop-up
// to the frontend
export type { AuthResponse, BackstageIdentity, ProfileInfo } from './types';
@@ -206,13 +206,12 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
}
export const createMicrosoftProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'microsoft';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantID = envConfig.getString('tenantId');
@@ -157,12 +157,12 @@ export class OAuth2AuthProvider implements OAuthHandlers {
}
export const createOAuth2Provider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'oauth2';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
@@ -54,9 +54,11 @@ describe('OidcAuthProvider', () => {
.get('/.well-known/openid-configuration')
.reply(200, issuerMetadata);
const provider = new OidcAuthProvider(clientMetadata);
const strategy = ((await provider._strategy) as any) as {
_client: ClientMetadata;
_issuer: IssuerMetadata;
const { strategy } = ((await (provider as any).implementation) as any) as {
strategy: {
_client: ClientMetadata;
_issuer: IssuerMetadata;
};
};
// Assert that the expected request to the metadaurl was made.
expect(scope.isDone()).toBeTruthy();
@@ -89,27 +91,38 @@ describe('OidcAuthProvider', () => {
const provider = new OidcAuthProvider(clientMetadata);
const req = {
method: 'GET',
url: '/?code=test2',
url: 'https://oidc.test/?code=test2',
session: ({ 'oidc:oidc.test': 'test' } as any) as Session,
} as express.Request;
await provider.handler(req);
expect(scope.isDone()).toBeTruthy();
});
const options = {
globalConfig: {
appUrl: 'https://oidc.test',
baseUrl: 'https://oidc.test',
},
config: ({
keys: jest.fn(() => ['test']),
getConfig: jest.fn(() => ({ getString: () => '' })),
} as any) as Config,
} as AuthProviderFactoryOptions;
it('createOidcProvider', () => {
it('createOidcProvider', async () => {
const scope = nock('https://oidc.test')
.get('/.well-known/openid-configuration')
.reply(200, issuerMetadata);
const options = {
globalConfig: {
appUrl: 'https://oidc.test',
baseUrl: 'https://oidc.test',
},
config: ({
keys: jest.fn(() => ['test']),
getConfig: jest.fn(() => ({
getString: (key: string) => {
const conf = {
...clientMetadata,
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
} as any;
return conf[key] as string;
},
})),
} as any) as Config,
} as AuthProviderFactoryOptions;
const provider = createOidcProvider(options) as OAuthAdapter;
console.log(provider);
expect(provider.start).toBeDefined();
await new Promise(resolve => process.nextTick(resolve)); // advance a tick to give nock a chance to intercept the request
expect(scope.isDone()).toBeTruthy();
});
});
@@ -33,10 +33,8 @@ import {
OAuthRefreshRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory, ProfileInfo } from '../types';
@@ -45,20 +43,25 @@ type PrivateInfo = {
refreshToken: string;
};
type OidcImpl = {
strategy: OidcStrategy<UserinfoResponse, Client>;
client: Client;
};
export type OidcAuthProviderOptions = OAuthProviderOptions & {
metadataUrl: string;
tokenSignedResponseAlg?: string;
};
export class OidcAuthProvider implements OAuthHandlers {
readonly _strategy: Promise<OidcStrategy<UserinfoResponse, Client>>;
private readonly implementation: Promise<OidcImpl>;
constructor(options: OidcAuthProviderOptions) {
this._strategy = this.setupStrategy(options);
this.implementation = this.setupStrategy(options);
}
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
const strategy = await this._strategy;
const { strategy } = await this.implementation;
return await executeRedirectStrategy(req, strategy, {
accessType: 'offline',
prompt: 'none',
@@ -70,7 +73,7 @@ export class OidcAuthProvider implements OAuthHandlers {
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const strategy = await this._strategy;
const { strategy } = await this.implementation;
const { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
@@ -83,31 +86,20 @@ export class OidcAuthProvider implements OAuthHandlers {
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const strategy = await this._strategy;
const refreshTokenResponse = await executeRefreshTokenStrategy(
strategy,
req.refreshToken,
req.scope,
);
const {
accessToken,
params,
refreshToken: updatedRefreshToken,
} = refreshTokenResponse;
const profile = await executeFetchUserProfileStrategy(
strategy,
accessToken,
params.id_token,
);
const { client } = await this.implementation;
const tokenset = await client.refresh(req.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
const profile = await client.userinfo(tokenset.access_token);
return this.populateIdentity({
providerInfo: {
accessToken,
refreshToken: updatedRefreshToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
accessToken: tokenset.access_token,
refreshToken: tokenset.refresh_token,
expiresInSeconds: tokenset.expires_in,
idToken: tokenset.id_token,
scope: tokenset.scope || '',
},
profile,
});
@@ -115,7 +107,7 @@ export class OidcAuthProvider implements OAuthHandlers {
private async setupStrategy(
options: OidcAuthProviderOptions,
): Promise<OidcStrategy<UserinfoResponse, Client>> {
): Promise<OidcImpl> {
const issuer = await Issuer.discover(options.metadataUrl);
const client = new issuer.Client({
client_id: options.clientId,
@@ -159,7 +151,7 @@ export class OidcAuthProvider implements OAuthHandlers {
},
);
strategy.error = console.error;
return strategy;
return { strategy, client };
}
// Use this function to grab the user profile info from the token
@@ -179,12 +171,12 @@ export class OidcAuthProvider implements OAuthHandlers {
}
export const createOidcProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'oidc';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
@@ -168,12 +168,12 @@ export class OktaAuthProvider implements OAuthHandlers {
}
export const createOktaProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'okta';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
@@ -147,12 +147,12 @@ export class OneLoginProvider implements OAuthHandlers {
}
export const createOneLoginProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'onelogin';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const issuer = envConfig.getString('issuer');
@@ -121,12 +121,12 @@ type SAMLProviderOptions = {
};
export const createSamlProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) => {
const url = new URL(globalConfig.baseUrl);
const providerId = 'saml';
const entryPoint = config.getString('entryPoint');
const issuer = config.getString('issuer');
const opts = {
@@ -113,6 +113,7 @@ export interface AuthProviderRouteHandlers {
}
export type AuthProviderFactoryOptions = {
providerId: string;
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
+23 -6
View File
@@ -14,6 +14,14 @@
* limitations under the License.
*/
import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import { Logger } from 'winston';
import {
defaultAuthProviderFactories,
AuthProviderFactory,
} from '../providers';
import {
NotFoundError,
PluginDatabaseManager,
@@ -21,20 +29,18 @@ import {
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import cookieParser from 'cookie-parser';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
import { createAuthProvider } from '../providers';
import session from 'express-session';
import passport from 'passport';
type ProviderFactories = { [s: string]: AuthProviderFactory };
export interface RouterOptions {
logger: Logger;
database: PluginDatabaseManager;
config: Config;
discovery: PluginEndpointDiscovery;
providerFactories?: ProviderFactories;
}
export async function createRouter({
@@ -42,6 +48,7 @@ export async function createRouter({
config,
discovery,
database,
providerFactories,
}: RouterOptions): Promise<express.Router> {
const router = Router();
@@ -74,13 +81,23 @@ export async function createRouter({
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
const allProviderFactories = {
...defaultAuthProviderFactories,
...providerFactories,
};
const providersConfig = config.getConfig('auth.providers');
const providers = providersConfig.keys();
for (const providerId of providers) {
logger.info(`Configuring provider, ${providerId}`);
try {
const provider = createAuthProvider(providerId, {
const providerFactory = allProviderFactories[providerId];
if (!providerFactory) {
throw Error(`No auth provider available for '${providerId}'`);
}
const provider = providerFactory({
providerId,
globalConfig: { baseUrl: authUrl, appUrl },
config: providersConfig.getConfig(providerId),
logger,
+70
View File
@@ -1,5 +1,75 @@
# @backstage/plugin-catalog-backend
## 0.3.0
### Minor Changes
- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file:
```ts
export default async function createPlugin(env: PluginEnvironment) {
const builder = new CatalogBuilder(env);
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
} = await builder.build();
return await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
logger: env.logger,
});
}
```
### Patch Changes
- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError
- 08835a61d: Add support for relative targets and implicit types in Location entities.
- e42402b47: Gracefully handle missing codeowners.
The CodeOwnersProcessor now also takes a logger as a parameter.
- Updated dependencies [612368274]
- Updated dependencies [08835a61d]
- Updated dependencies [a9fd599f7]
- Updated dependencies [bcc211a08]
- @backstage/backend-common@0.3.3
- @backstage/catalog-model@0.4.0
## 0.2.3
### Patch Changes
- 1ec19a3f4: Ignore empty YAML documents. Having a YAML file like this is now ingested without an error:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: web
spec:
type: website
---
```
This behaves now the same way as Kubernetes handles multiple documents in a single YAML file.
- ab94c9542: Add `providesApis` and `consumesApis` to the component entity spec.
- 2daf18e80: Start emitting all known relation types from the core entity kinds, based on their spec data.
- Updated dependencies [3aa7efb3f]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- Updated dependencies [b3d4e4e57]
- @backstage/backend-common@0.3.2
- @backstage/catalog-model@0.3.1
## 0.2.2
### Patch Changes
@@ -0,0 +1,93 @@
/*
* 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.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
if (knex.client.config.client === 'sqlite3') {
// sqlite doesn't support dropPrimary so we recreate it properly instead
await knex.schema.dropTable('entities_relations');
await knex.schema.createTable('entities_relations', table => {
table.comment('All relations between entities in the catalog');
table
.uuid('originating_entity_id')
.references('id')
.inTable('entities')
.onDelete('CASCADE')
.notNullable()
.comment('The entity that provided the relation');
table
.string('source_full_name')
.notNullable()
.comment('The full name of the source entity of the relation');
table
.string('type')
.notNullable()
.comment('The type of the relation between the entities');
table
.string('target_full_name')
.notNullable()
.comment('The full name of the target entity of the relation');
table.index('source_full_name', 'source_full_name_idx');
});
} else {
await knex.schema.alterTable('entities_relations', table => {
table.dropPrimary();
table.index('source_full_name', 'source_full_name_idx');
});
}
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
if (knex.client.config.client === 'sqlite3') {
await knex.schema.dropTable('entities_relations');
await knex.schema.createTable('entities_relations', table => {
table.comment('All relations between entities in the catalog');
table
.uuid('originating_entity_id')
.references('id')
.inTable('entities')
.onDelete('CASCADE')
.notNullable()
.comment('The entity that provided the relation');
table
.string('source_full_name')
.notNullable()
.comment('The full name of the source entity of the relation');
table
.string('type')
.notNullable()
.comment('The type of the relation between the entities');
table
.string('target_full_name')
.notNullable()
.comment('The full name of the target entity of the relation');
table.primary(['source_full_name', 'type', 'target_full_name']);
});
} else {
await knex.schema.alterTable('entities_relations', table => {
table.dropIndex([], 'source_full_name_idx');
table.primary(['source_full_name', 'type', 'target_full_name']);
});
}
};
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.2.2",
"version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,11 +21,12 @@
},
"dependencies": {
"@azure/msal-node": "^1.0.0-alpha.8",
"@backstage/backend-common": "^0.3.1",
"@backstage/catalog-model": "^0.3.0",
"@backstage/backend-common": "^0.3.3",
"@backstage/catalog-model": "^0.4.0",
"@backstage/config": "^0.1.1",
"@octokit/graphql": "^4.5.6",
"@types/express": "^4.17.6",
"@types/ldapjs": "^1.0.9",
"codeowners-utils": "^1.0.2",
"core-js": "^3.6.5",
"cross-fetch": "^3.0.6",
@@ -47,11 +48,10 @@
"yup": "^0.29.3"
},
"devDependencies": {
"@backstage/cli": "^0.3.1",
"@backstage/test-utils": "^0.1.3",
"@backstage/cli": "^0.4.0",
"@backstage/test-utils": "^0.1.4",
"@types/core-js": "^2.5.4",
"@types/git-url-parse": "^9.0.0",
"@types/ldapjs": "^1.0.9",
"@types/lodash": "^4.14.151",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
@@ -345,7 +345,11 @@ export class CommonDatabase implements Database {
);
// TODO(blam): translate constraint failures to sane NotFoundError instead
await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE);
await tx.batchInsert(
'entities_relations',
deduplicateRelations(relationsRows),
BATCH_SIZE,
);
}
async addLocation(
@@ -506,7 +510,7 @@ export class CommonDatabase implements Database {
.orderBy(['type', 'target_full_name'])
.select();
entity.relations = relations.map(r => ({
entity.relations = deduplicateRelations(relations).map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
@@ -517,3 +521,12 @@ export class CommonDatabase implements Database {
};
}
}
function deduplicateRelations(
rows: DbEntitiesRelationsRow[],
): DbEntitiesRelationsRow[] {
return lodash.uniqBy(
rows,
r => `${r.source_full_name}:${r.target_full_name}:${r.type}`,
);
}
@@ -0,0 +1,52 @@
/*
* 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 { Logger } from 'winston';
import parseGitUri from 'git-url-parse';
import {
AnalyzeLocationRequest,
AnalyzeLocationResponse,
LocationAnalyzer,
} from './types';
export class RepoLocationAnalyzer implements LocationAnalyzer {
private readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
async analyzeLocation(
request: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse> {
const { owner, name, source } = parseGitUri(request.location.target);
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: name,
// Probably won't handle properly self-hosted git providers with custom url
annotations: { [`${source}/project-slug`]: `${owner}/${name}` },
},
spec: { type: 'other', lifecycle: 'unknown' },
};
this.logger.debug(`entity created for ${request.location.target}`);
return {
existingEntityFiles: [],
generateEntities: [{ entity, fields: [] }],
};
}
}
@@ -16,12 +16,5 @@
export { HigherOrderOperations } from './HigherOrderOperations';
export { LocationReaders } from './LocationReaders';
export type {
AddLocationResult,
HigherOrderOperation,
LocationReader,
ReadLocationEntity,
ReadLocationError,
ReadLocationResult,
} from './types';
export * from './types';
export * from './processors';
@@ -14,6 +14,12 @@
* limitations under the License.
*/
import {
ApiEntity,
ComponentEntity,
GroupEntity,
UserEntity,
} from '@backstage/catalog-model';
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
describe('BuiltinKindsEntityProcessor', () => {
@@ -44,4 +50,212 @@ describe('BuiltinKindsEntityProcessor', () => {
},
});
});
describe('postProcessEntity', () => {
const processor = new BuiltinKindsEntityProcessor();
const location = { type: 'a', target: 'b' };
const emit = jest.fn();
afterEach(() => jest.resetAllMocks());
it('generates relations for component entities', async () => {
const entity: ComponentEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
spec: {
type: 'service',
owner: 'o',
lifecycle: 'l',
implementsApis: ['a'],
providesApis: ['b'],
consumesApis: ['c'],
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(8);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'o' },
type: 'ownerOf',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'ownedBy',
target: { kind: 'Group', namespace: 'default', name: 'o' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'a' },
type: 'apiProvidedBy',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'providesApi',
target: { kind: 'API', namespace: 'default', name: 'a' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'b' },
type: 'apiProvidedBy',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'providesApi',
target: { kind: 'API', namespace: 'default', name: 'b' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'c' },
type: 'apiConsumedBy',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'consumesApi',
target: { kind: 'API', namespace: 'default', name: 'c' },
},
});
});
it('generates relations for api entities', async () => {
const entity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'n' },
spec: {
type: 'service',
owner: 'o',
lifecycle: 'l',
definition: 'd',
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(2);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'o' },
type: 'ownerOf',
target: { kind: 'API', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'n' },
type: 'ownedBy',
target: { kind: 'Group', namespace: 'default', name: 'o' },
},
});
});
it('generates relations for user entities', async () => {
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: 'n' },
spec: {
memberOf: ['g'],
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(2);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'User', namespace: 'default', name: 'n' },
type: 'memberOf',
target: { kind: 'Group', namespace: 'default', name: 'g' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'g' },
type: 'hasMember',
target: { kind: 'User', namespace: 'default', name: 'n' },
},
});
});
it('generates relations for group entities', async () => {
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: { name: 'n' },
spec: {
type: 't',
parent: 'p',
ancestors: [],
children: ['c'],
descendants: [],
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(4);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'n' },
type: 'childOf',
target: { kind: 'Group', namespace: 'default', name: 'p' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'p' },
type: 'parentOf',
target: { kind: 'Group', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'c' },
type: 'childOf',
target: { kind: 'Group', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'n' },
type: 'parentOf',
target: { kind: 'Group', namespace: 'default', name: 'c' },
},
});
});
});
});
@@ -15,15 +15,33 @@
*/
import {
ApiEntity,
apiEntityV1alpha1Validator,
ComponentEntity,
componentEntityV1alpha1Validator,
Entity,
getEntityName,
GroupEntity,
groupEntityV1alpha1Validator,
locationEntityV1alpha1Validator,
LocationSpec,
parseEntityRef,
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CHILD_OF,
RELATION_CONSUMES_API,
RELATION_HAS_MEMBER,
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PARENT_OF,
RELATION_PROVIDES_API,
templateEntityV1alpha1Validator,
UserEntity,
userEntityV1alpha1Validator,
} from '@backstage/catalog-model';
import { CatalogProcessor } from './types';
import * as result from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
export class BuiltinKindsEntityProcessor implements CatalogProcessor {
private readonly validators = [
@@ -64,4 +82,126 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
return false;
}
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
const selfRef = getEntityName(entity);
/*
* Utilities
*/
function doEmit(
targets: string | string[] | undefined,
context: { defaultKind: string; defaultNamespace: string },
outgoingRelation: string,
incomingRelation: string,
): void {
if (!targets) {
return;
}
for (const target of [targets].flat()) {
const targetRef = parseEntityRef(target, context);
emit(
result.relation({
source: selfRef,
type: outgoingRelation,
target: targetRef,
}),
);
emit(
result.relation({
source: targetRef,
type: incomingRelation,
target: selfRef,
}),
);
}
}
/*
* Emit relations for the Component kind
*/
if (entity.kind === 'Component') {
const component = entity as ComponentEntity;
doEmit(
component.spec.owner,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_OWNED_BY,
RELATION_OWNER_OF,
);
doEmit(
component.spec.implementsApis,
{ defaultKind: 'API', defaultNamespace: selfRef.namespace },
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
);
doEmit(
component.spec.providesApis,
{ defaultKind: 'API', defaultNamespace: selfRef.namespace },
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
);
doEmit(
component.spec.consumesApis,
{ defaultKind: 'API', defaultNamespace: selfRef.namespace },
RELATION_CONSUMES_API,
RELATION_API_CONSUMED_BY,
);
}
/*
* Emit relations for the API kind
*/
if (entity.kind === 'API') {
const api = entity as ApiEntity;
doEmit(
api.spec.owner,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_OWNED_BY,
RELATION_OWNER_OF,
);
}
/*
* Emit relations for the User kind
*/
if (entity.kind === 'User') {
const user = entity as UserEntity;
doEmit(
user.spec.memberOf,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_MEMBER_OF,
RELATION_HAS_MEMBER,
);
}
/*
* Emit relations for the Group kind
*/
if (entity.kind === 'Group') {
const group = entity as GroupEntity;
doEmit(
group.spec.parent,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_CHILD_OF,
RELATION_PARENT_OF,
);
doEmit(
group.spec.children,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_PARENT_OF,
RELATION_CHILD_OF,
);
}
return entity;
}
}
@@ -16,6 +16,7 @@
import { LocationSpec } from '@backstage/catalog-model';
import { CodeOwnersEntry } from 'codeowners-utils';
import { createLogger } from 'winston';
import {
buildCodeOwnerUrl,
buildUrl,
@@ -27,6 +28,8 @@ import {
resolveCodeOwner,
} from './CodeOwnersProcessor';
const logger = createLogger();
describe('CodeOwnersProcessor', () => {
const mockUrl = ({ basePath = '' } = {}): string =>
`https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`;
@@ -158,17 +161,20 @@ describe('CodeOwnersProcessor', () => {
.fn()
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn() };
const result = await findRawCodeOwners(mockLocation(), reader);
const result = await findRawCodeOwners(mockLocation(), {
reader,
logger,
});
expect(result).toEqual(ownersText);
});
it('should raise error when no codeowner', async () => {
it('should return undefined when no codeowner', async () => {
const read = jest.fn().mockRejectedValue(mockReadResult());
const reader = { read, readTree: jest.fn() };
await expect(
findRawCodeOwners(mockLocation(), reader),
).rejects.toBeInstanceOf(Error);
findRawCodeOwners(mockLocation(), { reader, logger }),
).resolves.toBeUndefined();
});
it('should look at known codeowner locations', async () => {
@@ -180,7 +186,10 @@ describe('CodeOwnersProcessor', () => {
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn() };
const result = await findRawCodeOwners(mockLocation(), reader);
const result = await findRawCodeOwners(mockLocation(), {
reader,
logger,
});
expect(read.mock.calls.length).toBe(5);
expect(read.mock.calls[0]).toEqual([mockReadUrl('')]);
@@ -199,19 +208,19 @@ describe('CodeOwnersProcessor', () => {
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
const reader = { read, readTree: jest.fn() };
const owner = await resolveCodeOwner(mockLocation(), reader);
const owner = await resolveCodeOwner(mockLocation(), { reader, logger });
expect(owner).toBe('backstage-core');
});
it('should raise an error when no codeowner', async () => {
it('should return undefined when no codeowner', async () => {
const read = jest
.fn()
.mockImplementation(() => mockReadResult({ error: 'error: foo' }));
const reader = { read, readTree: jest.fn() };
await expect(
resolveCodeOwner(mockLocation(), reader),
).rejects.toBeInstanceOf(Error);
resolveCodeOwner(mockLocation(), { reader, logger }),
).resolves.toBeUndefined();
});
});
@@ -222,7 +231,7 @@ describe('CodeOwnersProcessor', () => {
.fn()
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
const reader = { read, readTree: jest.fn() };
const processor = new CodeOwnersProcessor({ reader });
const processor = new CodeOwnersProcessor({ reader, logger });
return { entity, processor, read };
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { UrlReader } from '@backstage/backend-common';
import { NotFoundError, UrlReader } from '@backstage/backend-common';
import { Entity, LocationSpec } from '@backstage/catalog-model';
import * as codeowners from 'codeowners-utils';
import { CodeOwnersEntry } from 'codeowners-utils';
@@ -22,6 +22,7 @@ import { CodeOwnersEntry } from 'codeowners-utils';
import 'core-js/features/promise';
import parseGitUri from 'git-url-parse';
import { filter, get, head, pipe, reverse } from 'lodash/fp';
import { Logger } from 'winston';
import { CatalogProcessor } from './types';
const ALLOWED_LOCATION_TYPES = [
@@ -41,6 +42,7 @@ const KNOWN_LOCATIONS = ['', '/docs', '/.bitbucket', '/.github', '/.gitlab'];
type Options = {
reader: UrlReader;
logger: Logger;
};
export class CodeOwnersProcessor implements CatalogProcessor {
@@ -60,7 +62,10 @@ export class CodeOwnersProcessor implements CatalogProcessor {
return entity;
}
const owner = await resolveCodeOwner(location, this.options.reader);
const owner = await resolveCodeOwner(location, this.options);
if (!owner) {
return entity;
}
return {
...entity,
@@ -71,12 +76,11 @@ export class CodeOwnersProcessor implements CatalogProcessor {
export async function resolveCodeOwner(
location: LocationSpec,
reader: UrlReader,
options: Options,
): Promise<string | undefined> {
const ownersText = await findRawCodeOwners(location, reader);
const ownersText = await findRawCodeOwners(location, options);
if (!ownersText) {
throw Error(`Unable to find codeowners file for: ${location.target}`);
return undefined;
}
const owners = parseCodeOwners(ownersText);
@@ -86,18 +90,33 @@ export async function resolveCodeOwner(
export async function findRawCodeOwners(
location: LocationSpec,
reader: UrlReader,
options: Options,
): Promise<string | undefined> {
const readOwnerLocation = async (basePath: string): Promise<string> => {
const ownerUrl = buildCodeOwnerUrl(
location.target,
`${basePath}/CODEOWNERS`,
);
const data = await reader.read(ownerUrl);
const data = await options.reader.read(ownerUrl);
return data.toString();
};
return Promise.any(KNOWN_LOCATIONS.map(readOwnerLocation));
const candidates = KNOWN_LOCATIONS.map(readOwnerLocation);
return Promise.any(candidates).catch((aggregateError: AggregateError) => {
const hardError = aggregateError.errors.find(
error => !(error instanceof NotFoundError),
);
if (hardError) {
options.logger.warn(
`Failed to read codeowners for location ${location.type}:${location.target}, ${hardError}`,
);
} else {
options.logger.debug(
`Failed to find codeowners for location ${location.type}:${location.target}`,
);
}
return undefined;
});
}
export function buildCodeOwnerUrl(
@@ -0,0 +1,44 @@
/*
* 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 { LocationSpec } from '@backstage/catalog-model';
import { toAbsoluteUrl } from './LocationEntityProcessor';
import path from 'path';
describe('LocationEntityProcessor', () => {
describe('toAbsoluteUrl', () => {
it('handles files', () => {
const base: LocationSpec = {
type: 'file',
target: `some${path.sep}path${path.sep}catalog-info.yaml`,
};
expect(toAbsoluteUrl(base, `.${path.sep}c`)).toBe(
`some${path.sep}path${path.sep}c`,
);
expect(toAbsoluteUrl(base, `${path.sep}c`)).toBe(`${path.sep}c`);
});
it('handles urls', () => {
const base: LocationSpec = {
type: 'url',
target: 'http://a.com/b/catalog-info.yaml',
};
expect(toAbsoluteUrl(base, './c/d')).toBe('http://a.com/b/c/d');
expect(toAbsoluteUrl(base, 'c/d')).toBe('http://a.com/b/c/d');
expect(toAbsoluteUrl(base, 'http://b.com/z')).toBe('http://b.com/z');
});
});
});
@@ -17,27 +17,52 @@
import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model';
import * as result from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
import path from 'path';
export function toAbsoluteUrl(base: LocationSpec, target: string): string {
try {
if (base.type === 'file') {
if (target.startsWith('.')) {
return path.join(path.dirname(base.target), target);
}
return target;
}
return new URL(target, base.target).toString();
} catch (e) {
return target;
}
}
export class LocationRefProcessor implements CatalogProcessor {
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
if (entity.kind === 'Location') {
const location = entity as LocationEntity;
if (location.spec.target) {
const locationEntity = entity as LocationEntity;
const type = locationEntity.spec.type || location.type;
if (type === 'file' && location.target.endsWith(path.sep)) {
emit(
result.location(
{ type: location.spec.type, target: location.spec.target },
false,
result.inputError(
location,
`LocationRefProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`,
),
);
}
if (location.spec.targets) {
for (const target of location.spec.targets) {
emit(result.location({ type: location.spec.type, target }, false));
}
const targets = new Array<string>();
if (locationEntity.spec.target) {
targets.push(locationEntity.spec.target);
}
if (locationEntity.spec.targets) {
targets.push(...locationEntity.spec.targets);
}
for (const maybeRelativeTarget of targets) {
const target = toAbsoluteUrl(location, maybeRelativeTarget);
emit(result.location({ type, target }, false));
}
}
@@ -1,74 +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 {
Entity,
ENTITY_DEFAULT_NAMESPACE,
LocationSpec,
parseEntityRef,
ApiEntityV1alpha1,
ComponentEntityV1alpha1,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
getEntityName,
} from '@backstage/catalog-model';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
import * as result from './results';
const includedKinds = new Set(['api', 'component']);
export class OwnerRelationProcessor implements CatalogProcessor {
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
if (!includedKinds.has(entity.kind.toLowerCase())) {
return entity;
}
const apiOrComponentEntity = entity as
| ApiEntityV1alpha1
| ComponentEntityV1alpha1;
const owner = apiOrComponentEntity.spec?.owner;
if (owner) {
const namespace = entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE;
const selfRef = getEntityName(entity);
const ownerRef = parseEntityRef(owner, {
defaultKind: 'group',
defaultNamespace: namespace,
});
emit(
result.relation({
source: selfRef,
type: RELATION_OWNED_BY,
target: ownerRef,
}),
);
emit(
result.relation({
source: ownerRef,
type: RELATION_OWNER_OF,
target: selfRef,
}),
);
}
return entity;
}
}
@@ -22,10 +22,11 @@ export * from './types';
export { parseEntityYaml } from './util/parse';
export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
export { CodeOwnersProcessor } from './CodeOwnersProcessor';
export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { OwnerRelationProcessor } from './OwnerRelationProcessor';
export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor';
export { LocationRefProcessor } from './LocationEntityProcessor';
export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProcessor';
export { PlaceholderProcessor } from './PlaceholderProcessor';
@@ -76,7 +76,7 @@ describe('buildMemberOf', () => {
const u: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name },
metadata: { name: 'n' },
spec: { profile: {}, memberOf: ['c'] },
};
@@ -115,6 +115,41 @@ describe('parseEntityYaml', () => {
]);
});
it('should handle empty yaml documents', () => {
// This happens if the user accidentially adds a "---"
// at the end of a file
const results = Array.from(
parseEntityYaml(
Buffer.from(
`
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: web
spec:
type: website
---
`,
'utf8',
),
testLoc,
),
);
expect(results).toEqual([
result.entity(testLoc, {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'web',
},
spec: {
type: 'website',
},
}),
]);
});
it('should emit parsing errors', () => {
const results = Array.from(
parseEntityYaml(Buffer.from('`', 'utf8'), testLoc),
@@ -40,6 +40,9 @@ export function* parseEntityYaml(
const json = document.toJSON();
if (lodash.isPlainObject(json)) {
yield result.entity(location, json as Entity);
} else if (json === null) {
// Ignore null values, these happen if there is an empty document in the
// YAML file, for example if --- is added to the end of the file.
} else {
const message = `Expected object at root, got ${typeof json}`;
yield result.generalError(location, message);
+74 -1
View File
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import type {
import {
Entity,
EntityRelationSpec,
Location,
LocationSpec,
} from '@backstage/catalog-model';
import { RecursivePartial } from '../util/RecursivePartial';
//
// HigherOrderOperation
@@ -68,3 +69,75 @@ export type ReadLocationError = {
location: LocationSpec;
error: Error;
};
//
// LocationAnalyzer
//
export type LocationAnalyzer = {
/**
* Generates an entity configuration for given git repository. It's used for
* importing new component to the backstage app.
*
* @param location Git repository to analyze and generate config for.
*/
analyzeLocation(
location: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse>;
};
export type AnalyzeLocationRequest = {
location: LocationSpec;
};
export type AnalyzeLocationResponse = {
existingEntityFiles: AnalyzeLocationExistingEntity[];
generateEntities: AnalyzeLocationGenerateEntity[];
};
// If the folder pointed to already contained catalog info yaml files, they are
// read and emitted like this so that the frontend can inform the user that it
// located them and can make sure to register them as well if they weren't
// already
type AnalyzeLocationExistingEntity = {
location: LocationSpec;
isRegistered: boolean;
entity: Entity;
};
// This is some form of representation of what the analyzer could deduce.
// We should probably have a chat about how this can best be conveyed to
// the frontend. It'll probably contain a (possibly incomplete) entity, plus
// enough info for the frontend to know what form data to show to the user
// for overriding/completing the info.
type AnalyzeLocationGenerateEntity = {
// Some form of partial representation of the entity
entity: RecursivePartial<Entity>;
// Lists the suggestions that the user may want to override
fields: AnalyzeLocationEntityField[];
};
// This is where I get really vague. Something like this perhaps? Or it could be
// something like a json-schema that contains enough info for the frontend to
// be able to present a form and explanations
type AnalyzeLocationEntityField = {
// e.g. "spec.owner"? The frontend needs to know how to "inject" the field into the
// entity again if the user wants to change it
field: string;
// The outcome of the analysis for this particular field
state:
| 'analysisSuggestedValue'
| 'analysisSuggestedNoValue'
| 'needsUserInput';
// If the analysis did suggest a value, this is where it would be. Not sure if we want
// to limit this to strings or if we want it to be any JsonValue
value: string | null;
// A text to show to the user to inform about the choices made. Like, it could say
// "Found a CODEOWNERS file that covers this target, so we suggest leaving this
// field empty; which would currently make it owned by X" where X is taken from the
// codeowners file.
description: string;
};
@@ -144,7 +144,7 @@ describe('CatalogBuilder', () => {
owner: 'o',
lifecycle: 'l',
},
relations: [],
relations: expect.anything(),
},
]);
});
@@ -37,15 +37,16 @@ import {
import { DatabaseManager } from '../database';
import {
AnnotateLocationEntityProcessor,
BuiltinKindsEntityProcessor,
CatalogProcessor,
CodeOwnersProcessor,
FileReaderProcessor,
GithubOrgReaderProcessor,
HigherOrderOperation,
HigherOrderOperations,
LdapOrgReaderProcessor,
LocationReaders,
LocationRefProcessor,
OwnerRelationProcessor,
MicrosoftGraphOrgReaderProcessor,
PlaceholderProcessor,
PlaceholderResolver,
@@ -53,13 +54,13 @@ import {
UrlReaderProcessor,
} from '../ingestion';
import { CatalogRulesEnforcer } from '../ingestion/CatalogRules';
import { BuiltinKindsEntityProcessor } from '../ingestion/processors/BuiltinKindsEntityProcessor';
import { LdapOrgReaderProcessor } from '../ingestion/processors/LdapOrgReaderProcessor';
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
import {
jsonPlaceholderResolver,
textPlaceholderResolver,
yamlPlaceholderResolver,
} from '../ingestion/processors/PlaceholderProcessor';
import { LocationAnalyzer } from '../ingestion/types';
export type CatalogEnvironment = {
logger: Logger;
@@ -203,6 +204,7 @@ export class CatalogBuilder {
entitiesCatalog: EntitiesCatalog;
locationsCatalog: LocationsCatalog;
higherOrderOperation: HigherOrderOperation;
locationAnalyzer: LocationAnalyzer;
}> {
const { config, database, logger } = this.env;
@@ -230,11 +232,13 @@ export class CatalogBuilder {
locationReader,
logger,
);
const locationAnalyzer = new RepoLocationAnalyzer(logger);
return {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
};
}
@@ -281,9 +285,8 @@ export class CatalogBuilder {
LdapOrgReaderProcessor.fromConfig(config, { logger }),
MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
new CodeOwnersProcessor({ reader }),
new CodeOwnersProcessor({ reader, logger }),
new LocationRefProcessor(),
new OwnerRelationProcessor(),
new AnnotateLocationEntityProcessor(),
);
}
+21 -4
View File
@@ -15,29 +15,38 @@
*/
import { errorHandler } from '@backstage/backend-common';
import {
locationSpecSchema,
analyzeLocationSchema,
} from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
import { locationSpecSchema } from '@backstage/catalog-model';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import yn from 'yn';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion/types';
import { EntityFilters } from './EntityFilters';
import { LocationAnalyzer, HigherOrderOperation } from '../ingestion/types';
import { translateQueryToFieldMapper } from './filterQuery';
import { EntityFilters } from './EntityFilters';
import { requireRequestBody, validateRequestBody } from './util';
export interface RouterOptions {
entitiesCatalog?: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
locationAnalyzer?: LocationAnalyzer;
logger: Logger;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { entitiesCatalog, locationsCatalog, higherOrderOperation } = options;
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
} = options;
const router = Router();
router.use(express.json());
@@ -127,6 +136,14 @@ export async function createRouter(
});
}
if (locationAnalyzer) {
router.post('/analyze-location', async (req, res) => {
const input = await validateRequestBody(req, analyzeLocationSchema);
const output = await locationAnalyzer.analyzeLocation(input);
res.status(200).send(output);
});
}
router.use(errorHandler());
return router;
}
+11
View File
@@ -1,5 +1,16 @@
# @backstage/plugin-catalog-graphql
## 0.2.3
### Patch Changes
- Updated dependencies [612368274]
- Updated dependencies [08835a61d]
- Updated dependencies [a9fd599f7]
- Updated dependencies [bcc211a08]
- @backstage/backend-common@0.3.3
- @backstage/catalog-model@0.4.0
## 0.2.2
### Patch Changes
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-graphql",
"version": "0.2.2",
"version": "0.2.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,8 +20,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.3.1",
"@backstage/catalog-model": "^0.3.0",
"@backstage/backend-common": "^0.3.3",
"@backstage/catalog-model": "^0.4.0",
"@backstage/config": "^0.1.1",
"@graphql-modules/core": "^0.7.17",
"apollo-server": "^2.16.1",
@@ -32,8 +32,8 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.3.1",
"@backstage/test-utils": "^0.1.3",
"@backstage/cli": "^0.4.0",
"@backstage/test-utils": "^0.1.4",
"@graphql-codegen/cli": "^1.17.7",
"@graphql-codegen/typescript": "^1.17.7",
"@graphql-codegen/typescript-resolvers": "^1.17.7",
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+39
View File
@@ -0,0 +1,39 @@
# @backstage/plugin-catalog-import
## 0.3.0
### Minor Changes
- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file:
```ts
export default async function createPlugin(env: PluginEnvironment) {
const builder = new CatalogBuilder(env);
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
} = await builder.build();
return await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
logger: env.logger,
});
}
```
### Patch Changes
- Updated dependencies [b4488ddb0]
- Updated dependencies [08835a61d]
- Updated dependencies [a9fd599f7]
- Updated dependencies [e42402b47]
- Updated dependencies [bcc211a08]
- Updated dependencies [ebf37bbae]
- @backstage/plugin-catalog-backend@0.3.0
- @backstage/catalog-model@0.4.0
- @backstage/plugin-catalog@0.2.5
+21
View File
@@ -0,0 +1,21 @@
# Catalog import plugin
Welcome to the catalog-import plugin!
This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it.
When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import).
<img src="./src/assets/catalog-import-screenshot.png" />
## Running
Just run the backstage.
```
yarn start && yarn --cwd packages/backend start
```
## Usage
Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL.
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-catalog-import",
"version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.4.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.5",
"@backstage/plugin-catalog-backend": "^0.3.0",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.0.6",
"git-url-parse": "^11.4.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^6.6.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"yaml": "^1.10.0"
},
"devDependencies": {
"@backstage/cli": "^0.4.0",
"@backstage/dev-utils": "^0.1.5",
"@backstage/test-utils": "^0.1.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,35 @@
/*
* 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 { PartialEntity } from '../util/types';
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
id: 'plugin.catalogimport.service',
description: 'Used by the catalog import plugin to make requests',
});
export interface CatalogImportApi {
submitPrToRepo(options: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }>;
createRepositoryLocation(options: { location: string }): Promise<void>;
generateEntityDefinitions(options: {
repo: string;
}): Promise<PartialEntity[]>;
}
@@ -0,0 +1,192 @@
/*
* 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 { Octokit } from '@octokit/rest';
import { DiscoveryApi, OAuthApi } from '@backstage/core';
import { CatalogImportApi } from './CatalogImportApi';
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend';
import { PartialEntity } from '../util/types';
export class CatalogImportClient implements CatalogImportApi {
private readonly discoveryApi: DiscoveryApi;
private readonly githubAuthApi: OAuthApi;
constructor(options: {
discoveryApi: DiscoveryApi;
githubAuthApi: OAuthApi;
}) {
this.discoveryApi = options.discoveryApi;
this.githubAuthApi = options.githubAuthApi;
}
async generateEntityDefinitions({
repo,
}: {
repo: string;
}): Promise<PartialEntity[]> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
location: { type: 'github', target: repo },
}),
},
).catch(e => {
throw new Error(`Failed to generate entity definitions, ${e.message}`);
});
if (!response.ok) {
throw new Error(
`Failed to generate entity definitions. Received http response ${response.status}: ${response.statusText}`,
);
}
const payload = (await response.json()) as AnalyzeLocationResponse;
return payload.generateEntities.map(x => x.entity);
}
async createRepositoryLocation({
location,
}: {
location: string;
}): Promise<void> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/locations`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
type: 'github',
target: location,
presence: 'optional',
}),
},
);
if (!response.ok) {
throw new Error(
`Received http response ${response.status}: ${response.statusText}`,
);
}
}
async submitPrToRepo({
owner,
repo,
fileContent,
}: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }> {
const token = await this.githubAuthApi.getAccessToken(['repo']);
const octo = new Octokit({
auth: token,
});
const branchName = 'backstage-integration';
const fileName = 'catalog-info.yaml';
const repoData = await octo.repos
.get({
owner,
repo,
})
.catch(e => {
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
});
const parentRef = await octo.git
.getRef({
owner,
repo,
ref: `heads/${repoData.data.default_branch}`,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage("Couldn't fetch default branch data", e),
);
});
await octo.git
.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: parentRef.data.object.sha,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a new branch with name '${branchName}'`,
e,
),
);
});
await octo.repos
.createOrUpdateFileContents({
owner,
repo,
path: fileName,
message: `Add ${fileName} config file`,
content: btoa(fileContent),
branch: branchName,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a commit with ${fileName} file added`,
e,
),
);
});
const pullRequestRespone = await octo.pulls
.create({
owner,
repo,
title: `Add ${fileName} config file`,
head: branchName,
base: repoData.data.default_branch,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a pull request for ${branchName} branch`,
e,
),
);
});
return {
link: pullRequestRespone.data.html_url,
location: `https://github.com/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
};
}
}
function formatHttpErrorMessage(
message: string,
error: { status: number; message: string },
) {
return `${message}, received http response status code ${error.status}: ${error.message}`;
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './CatalogImportApi';
export * from './CatalogImportClient';
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,197 @@
/*
* 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, { useCallback, useState } from 'react';
import {
Button,
CircularProgress,
Grid,
Link,
List,
ListItem,
Typography,
Divider,
} from '@material-ui/core';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import {
errorApiRef,
RouteRef,
StructuredMetadataTable,
useApi,
} from '@backstage/core';
import parseGitUri from 'git-url-parse';
import { PartialEntity } from '../util/types';
import { generatePath, resolvePath } from 'react-router';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { Link as RouterLink } from 'react-router-dom';
import * as YAML from 'yaml';
const getEntityCatalogPath = ({
entity,
catalogRouteRef,
}: {
entity: PartialEntity;
catalogRouteRef: RouteRef;
}) => {
const relativeEntityPathInsideCatalog = generatePath(
entityRoute.path,
entityRouteParams(entity as Entity),
);
const resolvedAbsolutePath = resolvePath(
relativeEntityPathInsideCatalog,
catalogRouteRef.path,
)?.pathname;
return resolvedAbsolutePath;
};
type Props = {
nextStep: (options?: { reset: boolean }) => void;
configFile: ConfigSpec;
savePRLink: (PRLink: string) => void;
catalogRouteRef: RouteRef;
};
const ComponentConfigDisplay = ({
nextStep,
configFile,
savePRLink,
catalogRouteRef,
}: Props) => {
const [errorOccured, setErrorOccured] = useState(false);
const [submitting, setSubmitting] = useState(false);
const errorApi = useApi(errorApiRef);
const { submitPrToRepo, addLocation } = useGithubRepos();
const onNext = useCallback(async () => {
try {
setSubmitting(true);
if (!parseGitUri(configFile.location).filepathtype) {
const result = await submitPrToRepo(configFile);
savePRLink(result.link);
setSubmitting(false);
nextStep();
} else {
addLocation(configFile.location);
setSubmitting(false);
nextStep();
}
} catch (e) {
setErrorOccured(true);
setSubmitting(false);
errorApi.post(e);
}
}, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]);
return (
<Grid container direction="column" spacing={1}>
{!parseGitUri(configFile.location).filepathtype ? (
<Typography>
Following config object will be submitted in a pull request to the
repository{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>{' '}
and added as a new location to the backend
</Typography>
) : (
<Typography>
Following config object will be added as a new location to the backend{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>
</Typography>
)}
<Grid item>
{!parseGitUri(configFile.location).filepathtype ? (
<pre>{YAML.stringify(configFile.config)}</pre>
) : (
<List>
{configFile.config.map((entity: any, index: number) => {
const entityPath = getEntityCatalogPath({
entity,
catalogRouteRef,
});
return (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link component={RouterLink} to={entityPath}>
{entityPath}
</Link>
),
}}
/>
</ListItem>
{index < configFile.config.length - 1 && (
<Divider component="li" />
)}
</React.Fragment>
);
})}
</List>
)}
</Grid>
<Grid item container spacing={1}>
{submitting ? (
<Grid item>
<CircularProgress size="2rem" />
</Grid>
) : null}
<Grid item>
<Button
disabled={submitting}
variant="contained"
color="primary"
onClick={onNext}
>
Next
</Button>
{errorOccured ? (
<Button
style={{ marginLeft: '8px' }}
variant="outlined"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Start again
</Button>
) : null}
</Grid>
</Grid>
</Grid>
);
};
export default ComponentConfigDisplay;
@@ -0,0 +1,131 @@
/*
* 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 { errorApiRef, useApi } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
FormControl,
FormHelperText,
TextField,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import { useForm } from 'react-hook-form';
import { useMountedState } from 'react-use';
import parseGitUri from 'git-url-parse';
import { ComponentIdValidators } from '../util/validate';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import { catalogApiRef } from '@backstage/plugin-catalog';
const useStyles = makeStyles<BackstageTheme>(theme => ({
form: {
alignItems: 'flex-start',
display: 'flex',
flexFlow: 'column nowrap',
},
submit: {
marginTop: theme.spacing(1),
},
}));
type Props = {
nextStep: () => void;
saveConfig: (configFile: ConfigSpec) => void;
};
export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => {
const { register, handleSubmit, errors, formState } = useForm({
mode: 'onChange',
});
const classes = useStyles();
const hasErrors = !!errors.componentLocation;
const dirty = formState?.isDirty;
const catalogApi = useApi(catalogApiRef);
const isMounted = useMountedState();
const errorApi = useApi(errorApiRef);
const { generateEntityDefinitions } = useGithubRepos();
const onSubmit = async (formData: Record<string, string>) => {
const { componentLocation: target } = formData;
try {
if (!isMounted()) return;
const type = !parseGitUri(target).filepathtype ? 'repo' : 'file';
if (type === 'repo') {
saveConfig({
type,
location: target,
config: await generateEntityDefinitions(target),
});
} else {
const data = await catalogApi.addLocation({ target });
saveConfig({
type,
location: data.location.target,
config: data.entities,
});
}
nextStep();
} catch (e) {
errorApi.post(e);
}
};
return (
<form
autoComplete="off"
onSubmit={handleSubmit(onSubmit)}
className={classes.form}
>
<FormControl>
<TextField
id="registerComponentInput"
variant="outlined"
label="Repository URL"
error={hasErrors}
placeholder="https://github.com/spotify/backstage"
name="componentLocation"
required
margin="normal"
helperText="Enter the full path to the repository in GitHub to start tracking your component."
inputRef={register({
required: true,
validate: ComponentIdValidators,
})}
/>
{errors.componentLocation && (
<FormHelperText error={hasErrors} id="register-component-helper-text">
{errors.componentLocation.message}
</FormHelperText>
)}
</FormControl>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!dirty || hasErrors}
className={classes.submit}
>
Next
</Button>
</form>
);
};
@@ -0,0 +1,111 @@
/*
* 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, { useState } from 'react';
import { Grid } from '@material-ui/core';
import {
InfoCard,
Page,
Content,
Header,
SupportButton,
ContentHeader,
RouteRef,
} from '@backstage/core';
import { RegisterComponentForm } from './ImportComponentForm';
import ImportStepper from './ImportStepper';
import ComponentConfigDisplay from './ComponentConfigDisplay';
import { ImportFinished } from './ImportFinished';
import { PartialEntity } from '../util/types';
export type ConfigSpec = {
type: 'repo' | 'file';
location: string;
config: PartialEntity[];
};
export const ImportComponentPage = ({
catalogRouteRef,
}: {
catalogRouteRef: RouteRef;
}) => {
const [activeStep, setActiveStep] = useState(0);
const [configFile, setConfigFile] = useState<ConfigSpec>({
type: 'repo',
location: '',
config: [],
});
const [endLink, setEndLink] = useState<string>('');
const nextStep = (options?: { reset: boolean }) => {
setActiveStep(step => (options?.reset ? 0 : step + 1));
};
return (
<Page themeId="home">
<Header title="Register an existing component" />
<Content>
<ContentHeader title="Start tracking your component on backstage">
<SupportButton>
Start tracking your component in Backstage. TODO: Add more
information about what this is.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard>
<ImportStepper
steps={[
{
step: 'Insert GitHub repo URL or Entity File URL',
content: (
<RegisterComponentForm
nextStep={nextStep}
saveConfig={setConfigFile}
/>
),
},
{
step: 'Review',
content: (
<ComponentConfigDisplay
nextStep={nextStep}
configFile={configFile}
savePRLink={setEndLink}
catalogRouteRef={catalogRouteRef}
/>
),
},
{
step: 'Finish',
content: (
<ImportFinished
nextStep={nextStep}
PRLink={endLink}
type={configFile.type}
/>
),
},
]}
activeStep={activeStep}
nextStep={nextStep}
/>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,58 @@
/*
* 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 { Alert } from '@material-ui/lab';
import { Button, Grid, Link } from '@material-ui/core';
type Props = {
type: 'repo' | 'file';
nextStep: (options?: { reset: boolean }) => void;
PRLink: string;
};
export const ImportFinished = ({ nextStep, PRLink, type }: Props) => {
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Alert severity="success">
{type === 'repo'
? 'Pull requests have been successfully opened. You can start again to import more repositories'
: 'Entity added to catalog successfully'}
</Alert>
</Grid>
<Grid item>
{type === 'repo' ? (
<Link
href={PRLink}
style={{ marginRight: '8px' }}
target="_blank"
rel="noopener noreferrer"
>
View pull request on GitHub
</Link>
) : null}
<Button
variant="contained"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Register another
</Button>
</Grid>
</Grid>
);
};
@@ -0,0 +1,42 @@
/*
* 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 Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step';
import StepLabel from '@material-ui/core/StepLabel';
import { StepContent } from '@material-ui/core';
type Props = {
steps: { step: string; content: React.ReactNode }[];
activeStep: number;
nextStep: (options?: { reset: boolean }) => void;
};
export default function ImportStepper({ steps, activeStep }: Props) {
return (
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map(({ step }) => {
const stepProps: { completed?: boolean } = {};
return (
<Step key={step} {...stepProps}>
<StepLabel>{step}</StepLabel>
<StepContent>{steps[activeStep].content}</StepContent>
</Step>
);
})}
</Stepper>
);
}
@@ -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 { RouteRef } from '@backstage/core';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { ImportComponentPage } from './ImportComponentPage';
export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => (
<Routes>
<Route
element={<ImportComponentPage catalogRouteRef={catalogRouteRef} />}
/>
</Routes>
);
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { plugin } from './plugin';
export { Router } from './components/Router';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('catalog-import', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+42
View File
@@ -0,0 +1,42 @@
/*
* 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 {
createApiFactory,
createPlugin,
createRouteRef,
discoveryApiRef,
githubAuthApiRef,
} from '@backstage/core';
import { catalogImportApiRef } from './api/CatalogImportApi';
import { CatalogImportClient } from './api/CatalogImportClient';
export const rootRouteRef = createRouteRef({
path: '',
title: 'catalog-import',
});
export const plugin = createPlugin({
id: 'catalog-import',
apis: [
createApiFactory({
api: catalogImportApiRef,
deps: { discoveryApi: discoveryApiRef, githubAuthApi: githubAuthApiRef },
factory: ({ discoveryApi, githubAuthApi }) =>
new CatalogImportClient({ discoveryApi, githubAuthApi }),
}),
],
});
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 '@testing-library/jest-dom';
+27
View File
@@ -0,0 +1,27 @@
/*
* 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';
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object
? RecursivePartial<T[P]>
: T[P];
};
export type PartialEntity = RecursivePartial<Entity>;
@@ -0,0 +1,57 @@
/*
* 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 * as YAML from 'yaml';
import { useApi } from '@backstage/core';
import { catalogImportApiRef } from '../api/CatalogImportApi';
import { ConfigSpec } from '../components/ImportComponentPage';
export function useGithubRepos() {
const api = useApi(catalogImportApiRef);
const submitPrToRepo = async (selectedRepo: ConfigSpec) => {
const [ownerName, repoName] = selectedRepo.location.split('/').slice(-2);
const submitPRResponse = await api
.submitPrToRepo({
owner: ownerName,
repo: repoName,
fileContent: selectedRepo.config
.map(entity => `---\n${YAML.stringify(entity)}`)
.join('\n'),
})
.catch(e => {
throw new Error(`Failed to submit PR to repo:\n${e.message}`);
});
await api
.createRepositoryLocation({
location: submitPRResponse.location,
})
.catch(e => {
throw new Error(`Failed to create repository location:\n${e.message}`);
});
return submitPRResponse;
};
return {
submitPrToRepo,
generateEntityDefinitions: (repo: string) =>
api.generateEntityDefinitions({ repo }),
addLocation: (location: string) =>
api.createRepositoryLocation({ location }),
};
}
@@ -0,0 +1,33 @@
/*
* 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 { ComponentIdValidators } from './validate';
describe('ComponentIdValidators', () => {
describe('httpsValidator validator', () => {
const errorMessage = 'Must start with https://.';
test.each([
[true, 'https://example.com'],
[errorMessage, 'http://example.com'],
[errorMessage, 'example.com'],
[errorMessage, 'www.example.com'],
[errorMessage, ''],
[errorMessage, undefined],
])('should return %p for %s', (expected: string | boolean, arg: any) => {
expect(ComponentIdValidators.httpsValidator(arg)).toBe(expected);
});
});
});
@@ -14,14 +14,8 @@
* limitations under the License.
*/
/**
* To uniquely identify an entity in Backstage.
* @property {string} kind
* @property {string} namespace
* @property {string} name
*/
export type ParsedEntityId = {
kind: string;
namespace?: string;
name: string;
export const ComponentIdValidators = {
httpsValidator: (value: any) =>
(typeof value === 'string' && value.match(/^https:\/\//) !== null) ||
'Must start with https://.',
};
+26
View File
@@ -1,5 +1,31 @@
# @backstage/plugin-catalog
## 0.2.5
### Patch Changes
- ebf37bbae: Use the OWNED_BY relation and compare it to the users MEMBER_OF relation. The user entity is searched by name, based on the userId of the identity.
- Updated dependencies [08835a61d]
- Updated dependencies [a9fd599f7]
- Updated dependencies [bcc211a08]
- Updated dependencies [da2ad65cb]
- @backstage/catalog-model@0.4.0
- @backstage/plugin-scaffolder@0.3.2
- @backstage/plugin-techdocs@0.3.1
- @backstage/catalog-client@0.3.2
## 0.2.4
### Patch Changes
- 6f70ed7a9: Replace usage of implementsApis with relations
- Updated dependencies [4b53294a6]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- @backstage/plugin-techdocs@0.3.0
- @backstage/catalog-model@0.3.1
## 0.2.3
### Patch Changes
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.2.3",
"version": "0.2.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,11 +21,11 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-client": "^0.3.1",
"@backstage/catalog-model": "^0.3.0",
"@backstage/catalog-client": "^0.3.2",
"@backstage/catalog-model": "^0.4.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-scaffolder": "^0.3.1",
"@backstage/plugin-techdocs": "^0.2.3",
"@backstage/plugin-scaffolder": "^0.3.2",
"@backstage/plugin-techdocs": "^0.3.1",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -43,9 +43,9 @@
"swr": "^0.3.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.1",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@backstage/cli": "^0.4.0",
"@backstage/dev-utils": "^0.1.5",
"@backstage/test-utils": "^0.1.4",
"@microsoft/microsoft-graph-types": "^1.25.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
@@ -18,6 +18,7 @@ import {
Entity,
ENTITY_DEFAULT_NAMESPACE,
RELATION_OWNED_BY,
RELATION_PROVIDES_API,
serializeEntityRef,
} from '@backstage/catalog-model';
import {
@@ -111,6 +112,8 @@ type AboutCardProps = {
export function AboutCard({ entity, variant }: AboutCardProps) {
const classes = useStyles();
const codeLink = getCodeLinkInfo(entity);
// TODO: Also support RELATION_CONSUMES_API here
const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
return (
<Card className={variant === 'gridItem' ? classes.gridItemCard : ''}>
@@ -146,9 +149,9 @@ export function AboutCard({ entity, variant }: AboutCardProps) {
}/${entity.kind}/${entity.metadata.name}`}
/>
<IconLinkVertical
disabled={!entity.spec?.implementsApis}
disabled={!hasApis}
label="View API"
title={!entity.spec?.implementsApis ? 'No APIs available' : ''}
title={hasApis ? '' : 'No APIs available'}
icon={<ExtensionIcon />}
href="api"
/>

Some files were not shown because too many files have changed in this diff Show More