Merge branch 'master' of https://github.com/spotify/backstage into samiram/pagerduty-plugin
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+5
-6
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
+59
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ApiEntityPage } from './ApiEntityPage';
|
||||
export { EntityLink } from './EntityLink';
|
||||
+61
@@ -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();
|
||||
});
|
||||
});
|
||||
+58
@@ -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();
|
||||
});
|
||||
});
|
||||
+28
@@ -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) || []
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,5 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Router } from './catalog';
|
||||
export * from './catalog';
|
||||
export * from './components';
|
||||
export { plugin } from './plugin';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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://.',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user