Merge branch 'master' of https://github.com/backstage/backstage into pr-draft
This commit is contained in:
@@ -1,5 +1,25 @@
|
||||
# @backstage/plugin-allure
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata.
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.6.1
|
||||
- @backstage/core-plugin-api@0.1.10
|
||||
- @backstage/plugin-catalog-react@0.5.2
|
||||
- @backstage/catalog-model@0.9.4
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-plugin-api@0.1.9
|
||||
- @backstage/core-components@0.6.0
|
||||
- @backstage/plugin-catalog-react@0.5.1
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-allure",
|
||||
"description": "A Backstage plugin that integrates with Allure",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -22,10 +22,10 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/core-components": "^0.5.0",
|
||||
"@backstage/core-plugin-api": "^0.1.8",
|
||||
"@backstage/plugin-catalog-react": "^0.5.0",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/core-components": "^0.6.1",
|
||||
"@backstage/core-plugin-api": "^0.1.10",
|
||||
"@backstage/plugin-catalog-react": "^0.5.2",
|
||||
"@backstage/theme": "^0.2.10",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
@@ -36,10 +36,10 @@
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/core-app-api": "^0.1.14",
|
||||
"@backstage/dev-utils": "^0.2.10",
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@backstage/core-app-api": "^0.1.16",
|
||||
"@backstage/dev-utils": "^0.2.11",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
|
||||
@@ -44,6 +44,7 @@ export const allurePlugin = createPlugin({
|
||||
|
||||
export const EntityAllureReportContent = allurePlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'EntityAllureReportContent',
|
||||
component: () =>
|
||||
import('./components/AllureReportComponent').then(
|
||||
m => m.AllureReportComponent,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
# Analytics Module: Google Analytics
|
||||
|
||||
This plugin provides an opinionated implementation of the Backstage Analytics
|
||||
API for Google Analytics. Once installed and configured, analytics events will
|
||||
be sent to GA as your users navigate and use your Backstage instance.
|
||||
|
||||
This plugin contains no other functionality.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install the plugin package in your Backstage app:
|
||||
`cd packages/app && yarn add @backstage/plugin-analytics-module-ga`
|
||||
2. Wire up the API implementation to your App:
|
||||
|
||||
```tsx
|
||||
// packages/app/src/apis.ts
|
||||
import { analyticsApiRef, configApiRef } from '@backstage/core-plugin-api';
|
||||
import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga';
|
||||
|
||||
export const apis: AnyApiFactory[] = [
|
||||
// Instantiate and register the GA Analytics API Implementation.
|
||||
createApiFactory({
|
||||
api: analyticsApiRef,
|
||||
deps: { configApi: configApiRef },
|
||||
factory: ({ configApi }) => GoogleAnalytics.fromConfig(configApi),
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
3. Configure the plugin in your `app-config.yaml`:
|
||||
|
||||
The following is the minimum configuration required to start sending analytics
|
||||
events to GA. All that's needed is your Universal Analytics tracking ID:
|
||||
|
||||
```yaml
|
||||
# app-config.yaml
|
||||
app:
|
||||
analytics:
|
||||
ga:
|
||||
trackingId: UA-0000000-0
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
In order to be able to analyze usage of your Backstage instance _by plugin_, we
|
||||
strongly recommend configuring at least one [custom dimension][what-is-a-custom-dimension]
|
||||
to capture Plugin IDs associated with events, including page views.
|
||||
|
||||
1. First, [configure the custom dimension in GA][configure-custom-dimension].
|
||||
Be sure to set the Scope to `hit`, and name it something like `Plugin`. Note
|
||||
the index of the dimension you just created (e.g. `1`, if this is the first
|
||||
custom dimension you've created in your GA property).
|
||||
2. Then, add a mapping to your `app.analytics.ga` configuration that instructs
|
||||
the plugin to capture Plugin IDs on the custom dimension you just created.
|
||||
It should look like this:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
analytics:
|
||||
ga:
|
||||
trackingId: UA-0000000-0
|
||||
customDimensionsMetrics:
|
||||
- type: dimension
|
||||
index: 1
|
||||
source: context
|
||||
key: pluginId
|
||||
```
|
||||
|
||||
You can configure additional custom dimension and metric collection by adding
|
||||
more entries to the `customDimensionsMetrics` array:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
analytics:
|
||||
ga:
|
||||
customDimensionsMetrics:
|
||||
- type: dimension
|
||||
index: 1
|
||||
source: context
|
||||
key: pluginId
|
||||
- type: dimension
|
||||
index: 2
|
||||
source: context
|
||||
key: routeRef
|
||||
- type: dimension
|
||||
index: 3
|
||||
source: context
|
||||
key: extension
|
||||
- type: metric
|
||||
index: 1
|
||||
source: attributes
|
||||
key: someEventContextAttr
|
||||
```
|
||||
|
||||
### Debugging and Testing
|
||||
|
||||
In pre-production environments, you may wish to set additional configurations
|
||||
to turn off reporting to Analytics and/or print debug statements to the
|
||||
console. You can do so like this:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
analytics:
|
||||
ga:
|
||||
testMode: true # Prevents data being sent to GA
|
||||
debug: true # Logs analytics event to the web console
|
||||
```
|
||||
|
||||
You might commonly set the above in an `app-config.local.yaml` file, which is
|
||||
normally `gitignore`'d but loaded and merged in when Backstage is bootstrapped.
|
||||
|
||||
## Development
|
||||
|
||||
If you would like to contribute improvements to this plugin, the easiest way to
|
||||
make and test changes is to do the following:
|
||||
|
||||
1. Clone the main Backstage monorepo `git clone git@github.com:backstage/backstage.git`
|
||||
2. Install all dependencies `yarn install`
|
||||
3. If one does not exist, create an `app-config.local.yaml` file in the root of
|
||||
the monorepo and add config for this plugin (see below)
|
||||
4. Enter this plugin's working directory: `cd plugins/analytics-provider-ga`
|
||||
5. Start the plugin in isolation: `yarn start`
|
||||
6. Navigate to the playground page at `http://localhost:3000/ga`
|
||||
7. Open the web console to see events fire when you navigate or when you
|
||||
interact with instrumented components.
|
||||
|
||||
Code for the isolated version of the plugin can be found inside the [/dev](./dev)
|
||||
directory. Changes to the plugin are hot-reloaded.
|
||||
|
||||
#### Recommended Dev Config
|
||||
|
||||
Paste this into your `app-config.local.yaml` while developing this plugin:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
analytics:
|
||||
ga:
|
||||
trackingId: UA-0000000-0
|
||||
debug: true
|
||||
testMode: true
|
||||
customDimensionsMetrics:
|
||||
- type: dimension
|
||||
index: 1
|
||||
source: context
|
||||
key: pluginId
|
||||
```
|
||||
|
||||
[what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828
|
||||
[configure-custom-dimension]: https://support.google.com/analytics/answer/2709828#configuration
|
||||
@@ -0,0 +1,31 @@
|
||||
## API Report File for "@backstage/plugin-analytics-module-ga"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AnalyticsApi } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsEvent } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "analyticsModuleGA" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const analyticsModuleGA: BackstagePlugin<{}, {}>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "GoogleAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export class GoogleAnalytics implements AnalyticsApi {
|
||||
captureEvent({
|
||||
context,
|
||||
action,
|
||||
subject,
|
||||
value,
|
||||
attributes,
|
||||
}: AnalyticsEvent): void;
|
||||
static fromConfig(config: Config): GoogleAnalytics;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 interface Config {
|
||||
app: {
|
||||
// TODO: Only marked as optional because backstage-cli config:check in the
|
||||
// context of the monorepo is too strict. Ideally, this would be marked as
|
||||
// required.
|
||||
analytics?: {
|
||||
ga: {
|
||||
/**
|
||||
* Google Analytics tracking ID, e.g. UA-000000-0
|
||||
* @visibility frontend
|
||||
*/
|
||||
trackingId: string;
|
||||
|
||||
/**
|
||||
* Whether or not to log analytics debug statements to the console.
|
||||
* Defaults to false.
|
||||
*
|
||||
* @visibility frontend
|
||||
*/
|
||||
debug?: boolean;
|
||||
|
||||
/**
|
||||
* Prevents events from actually being sent when set to true. Defaults
|
||||
* to false.
|
||||
*
|
||||
* @visibility frontend
|
||||
*/
|
||||
testMode?: boolean;
|
||||
|
||||
/**
|
||||
* Configuration informing how Analytics Context and Event Attributes
|
||||
* metadata will be captured in Google Analytics.
|
||||
*/
|
||||
customDimensionsMetrics?: Array<{
|
||||
/**
|
||||
* Specifies whether the corresponding metadata should be collected
|
||||
* as a Google Analytics custom dimension or custom metric.
|
||||
*
|
||||
* @visibility frontend
|
||||
*/
|
||||
type: 'dimension' | 'metric';
|
||||
|
||||
/**
|
||||
* The index of the Google Analytics custom dimension or metric that
|
||||
* the metadata should be written to.
|
||||
*
|
||||
* @visibility frontend
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* Specifies whether the desired value lives as an attribute on the
|
||||
* Analytics Context or the Event's Attributes.
|
||||
*
|
||||
* @visibility frontend
|
||||
*/
|
||||
source: 'context' | 'attributes';
|
||||
|
||||
/**
|
||||
* The property of the context or attributes that should be captured.
|
||||
* e.g. to capture the Plugin ID associated with an event, the source
|
||||
* should be set to "context" and the key should be set to pluginId.
|
||||
*
|
||||
* @visibility frontend
|
||||
*/
|
||||
key: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { Link } from '@backstage/core-components';
|
||||
|
||||
export const Playground = () => {
|
||||
return (
|
||||
<>
|
||||
<Link to="#clicked">Click Here</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { analyticsModuleGA } from '../src/plugin';
|
||||
import { Playground } from './Playground';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(analyticsModuleGA)
|
||||
.addPage({
|
||||
path: '/ga',
|
||||
title: 'GA Playground',
|
||||
element: <Playground />,
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@backstage/plugin-analytics-module-ga",
|
||||
"version": "0.1.1",
|
||||
"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/config": "^0.1.5",
|
||||
"@backstage/core-components": "^0.6.1",
|
||||
"@backstage/core-plugin-api": "^0.1.10",
|
||||
"@backstage/theme": "^0.2.10",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-ga": "^3.3.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@backstage/core-app-api": "^0.1.16",
|
||||
"@backstage/dev-utils": "^0.2.11",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.29.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import ReactGA from 'react-ga';
|
||||
import { GoogleAnalytics } from './GoogleAnalytics';
|
||||
|
||||
describe('GoogleAnalytics', () => {
|
||||
const trackingId = 'UA-000000-0';
|
||||
const basicValidConfig = new ConfigReader({
|
||||
app: { analytics: { ga: { trackingId, testMode: true } } },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ReactGA.testModeAPI.resetCalls();
|
||||
});
|
||||
|
||||
describe('fromConfig', () => {
|
||||
it('throws when missing trackingId', () => {
|
||||
const config = new ConfigReader({ app: { analytics: { ga: {} } } });
|
||||
expect(() => GoogleAnalytics.fromConfig(config)).toThrowError(
|
||||
/Missing required config value/,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns implementation', () => {
|
||||
const api = GoogleAnalytics.fromConfig(basicValidConfig);
|
||||
expect(api.captureEvent).toBeDefined();
|
||||
|
||||
// Initializes GA with tracking ID.
|
||||
expect(ReactGA.testModeAPI.calls[0]).toEqual([
|
||||
'create',
|
||||
trackingId,
|
||||
'auto',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration', () => {
|
||||
const context = {
|
||||
extension: 'App',
|
||||
pluginId: 'some-plugin',
|
||||
routeRef: 'unknown',
|
||||
releaseNum: 1337,
|
||||
};
|
||||
const advancedConfig = new ConfigReader({
|
||||
app: {
|
||||
analytics: {
|
||||
ga: {
|
||||
trackingId,
|
||||
testMode: true,
|
||||
customDimensionsMetrics: [
|
||||
{
|
||||
type: 'dimension',
|
||||
index: 1,
|
||||
source: 'context',
|
||||
key: 'pluginId',
|
||||
},
|
||||
{
|
||||
type: 'dimension',
|
||||
index: 2,
|
||||
source: 'attributes',
|
||||
key: 'extraDimension',
|
||||
},
|
||||
{
|
||||
type: 'metric',
|
||||
index: 1,
|
||||
source: 'context',
|
||||
key: 'releaseNum',
|
||||
},
|
||||
{
|
||||
type: 'metric',
|
||||
index: 2,
|
||||
source: 'attributes',
|
||||
key: 'extraMetric',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('tracks basic pageview', () => {
|
||||
const api = GoogleAnalytics.fromConfig(basicValidConfig);
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/',
|
||||
context,
|
||||
});
|
||||
|
||||
const [command, data] = ReactGA.testModeAPI.calls[1];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'pageview',
|
||||
page: '/',
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks basic event', () => {
|
||||
const api = GoogleAnalytics.fromConfig(basicValidConfig);
|
||||
|
||||
const expectedAction = 'click';
|
||||
const expectedLabel = 'on something';
|
||||
const expectedValue = 42;
|
||||
api.captureEvent({
|
||||
action: expectedAction,
|
||||
subject: expectedLabel,
|
||||
value: expectedValue,
|
||||
context,
|
||||
});
|
||||
|
||||
const [command, data] = ReactGA.testModeAPI.calls[1];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'event',
|
||||
eventCategory: context.extension,
|
||||
eventAction: expectedAction,
|
||||
eventLabel: expectedLabel,
|
||||
eventValue: expectedValue,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures configured custom dimensions/metrics on pageviews', () => {
|
||||
const api = GoogleAnalytics.fromConfig(advancedConfig);
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/a-page',
|
||||
context,
|
||||
});
|
||||
|
||||
// Expect a set command first.
|
||||
const [setCommand, setData] = ReactGA.testModeAPI.calls[1];
|
||||
expect(setCommand).toBe('set');
|
||||
expect(setData).toMatchObject({
|
||||
dimension1: context.pluginId,
|
||||
metric1: context.releaseNum,
|
||||
});
|
||||
|
||||
// Followed by a send command.
|
||||
const [sendCommand, sendData] = ReactGA.testModeAPI.calls[2];
|
||||
expect(sendCommand).toBe('send');
|
||||
expect(sendData).toMatchObject({
|
||||
hitType: 'pageview',
|
||||
page: '/a-page',
|
||||
});
|
||||
});
|
||||
|
||||
it('captures configured custom dimensions/metrics on events', () => {
|
||||
const api = GoogleAnalytics.fromConfig(advancedConfig);
|
||||
|
||||
const expectedAction = 'search';
|
||||
const expectedLabel = 'some query';
|
||||
const expectedValue = 5;
|
||||
api.captureEvent({
|
||||
action: expectedAction,
|
||||
subject: expectedLabel,
|
||||
value: expectedValue,
|
||||
attributes: {
|
||||
extraDimension: false,
|
||||
extraMetric: 0,
|
||||
},
|
||||
context,
|
||||
});
|
||||
|
||||
const [command, data] = ReactGA.testModeAPI.calls[1];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'event',
|
||||
eventCategory: context.extension,
|
||||
eventAction: expectedAction,
|
||||
eventLabel: expectedLabel,
|
||||
eventValue: expectedValue,
|
||||
dimension1: context.pluginId,
|
||||
metric1: context.releaseNum,
|
||||
dimension2: false,
|
||||
metric2: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not pass non-numeric data on metrics', () => {
|
||||
const api = GoogleAnalytics.fromConfig(advancedConfig);
|
||||
|
||||
api.captureEvent({
|
||||
action: 'verb',
|
||||
subject: 'noun',
|
||||
attributes: {
|
||||
extraMetric: 'not a number',
|
||||
},
|
||||
context,
|
||||
});
|
||||
|
||||
const [, data] = ReactGA.testModeAPI.calls[1];
|
||||
expect(data).not.toMatchObject({
|
||||
metric2: 'not a number',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 ReactGA from 'react-ga';
|
||||
import {
|
||||
AnalyticsApi,
|
||||
AnalyticsContextValue,
|
||||
AnalyticsEventAttributes,
|
||||
AnalyticsEvent,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
type CustomDimensionOrMetricConfig = {
|
||||
type: 'dimension' | 'metric';
|
||||
index: number;
|
||||
source: 'context' | 'attributes';
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Google Analytics API provider for the Backstage Analytics API.
|
||||
*/
|
||||
export class GoogleAnalytics implements AnalyticsApi {
|
||||
private readonly cdmConfig: CustomDimensionOrMetricConfig[];
|
||||
|
||||
/**
|
||||
* Instantiate the implementation and initialize ReactGA.
|
||||
*/
|
||||
private constructor({
|
||||
cdmConfig,
|
||||
trackingId,
|
||||
testMode,
|
||||
debug,
|
||||
}: {
|
||||
cdmConfig: CustomDimensionOrMetricConfig[];
|
||||
trackingId: string;
|
||||
testMode: boolean;
|
||||
debug: boolean;
|
||||
}) {
|
||||
this.cdmConfig = cdmConfig;
|
||||
|
||||
// Initialize Google Analytics.
|
||||
ReactGA.initialize(trackingId, { testMode, debug, titleCase: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate a fully configured GA Analytics API implementation.
|
||||
*/
|
||||
static fromConfig(config: Config) {
|
||||
// Get all necessary configuration.
|
||||
const trackingId = config.getString('app.analytics.ga.trackingId');
|
||||
const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false;
|
||||
const testMode =
|
||||
config.getOptionalBoolean('app.analytics.ga.testMode') ?? false;
|
||||
const cdmConfig =
|
||||
config
|
||||
.getOptionalConfigArray('app.analytics.ga.customDimensionsMetrics')
|
||||
?.map(c => {
|
||||
return {
|
||||
type: c.getString('type') as CustomDimensionOrMetricConfig['type'],
|
||||
index: c.getNumber('index'),
|
||||
source: c.getString(
|
||||
'source',
|
||||
) as CustomDimensionOrMetricConfig['source'],
|
||||
key: c.getString('key'),
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Return an implementation instance.
|
||||
return new GoogleAnalytics({
|
||||
trackingId,
|
||||
cdmConfig,
|
||||
testMode,
|
||||
debug,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary event capture implementation. Handles core navigate event as a
|
||||
* pageview and the rest as custom events. All custom dimensions/metrics are
|
||||
* applied as they should be (set on pageview, merged object on events).
|
||||
*/
|
||||
captureEvent({
|
||||
context,
|
||||
action,
|
||||
subject,
|
||||
value,
|
||||
attributes,
|
||||
}: AnalyticsEvent) {
|
||||
const customMetadata = this.getCustomDimensionMetrics(context, attributes);
|
||||
|
||||
if (action === 'navigate' && context.extension === 'App') {
|
||||
// Set any/all custom dimensions.
|
||||
if (Object.keys(customMetadata).length) {
|
||||
ReactGA.set(customMetadata);
|
||||
}
|
||||
|
||||
ReactGA.pageview(subject);
|
||||
return;
|
||||
}
|
||||
|
||||
ReactGA.event({
|
||||
category: context.extension || 'App',
|
||||
action,
|
||||
label: subject,
|
||||
value,
|
||||
...customMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object of dimensions/metrics given an Analytics Context and an
|
||||
* Event Attributes, e.g. { dimension1: "some value", metric8: 42 }
|
||||
*/
|
||||
private getCustomDimensionMetrics(
|
||||
context: AnalyticsContextValue,
|
||||
attributes: AnalyticsEventAttributes = {},
|
||||
) {
|
||||
const customDimensionsMetrics: { [x: string]: string | number | boolean } =
|
||||
{};
|
||||
|
||||
this.cdmConfig.forEach(config => {
|
||||
const value =
|
||||
config.source === 'context'
|
||||
? context[config.key]
|
||||
: attributes[config.key];
|
||||
|
||||
// Never pass a non-numeric value on a metric.
|
||||
if (config.type === 'metric' && typeof value !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set defined values.
|
||||
if (value !== undefined) {
|
||||
customDimensionsMetrics[`${config.type}${config.index}`] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return customDimensionsMetrics;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { GoogleAnalytics } from './GoogleAnalytics';
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { analyticsModuleGA } from './plugin';
|
||||
export { GoogleAnalytics } from './apis/implementations/AnalyticsApi';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { analyticsModuleGA } from './plugin';
|
||||
|
||||
describe('google-analytics', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(analyticsModuleGA).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { createPlugin } from '@backstage/core-plugin-api';
|
||||
|
||||
export const analyticsModuleGA = createPlugin({
|
||||
id: 'analytics-provider-ga',
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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';
|
||||
import 'cross-fetch/polyfill';
|
||||
@@ -1,5 +1,27 @@
|
||||
# @backstage/plugin-api-docs
|
||||
|
||||
## 0.6.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata.
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.6.1
|
||||
- @backstage/core-plugin-api@0.1.10
|
||||
- @backstage/plugin-catalog@0.7.0
|
||||
- @backstage/plugin-catalog-react@0.5.2
|
||||
- @backstage/catalog-model@0.9.4
|
||||
|
||||
## 0.6.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-plugin-api@0.1.9
|
||||
- @backstage/core-components@0.6.0
|
||||
- @backstage/plugin-catalog@0.6.17
|
||||
- @backstage/plugin-catalog-react@0.5.1
|
||||
|
||||
## 0.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-api-docs",
|
||||
"description": "A Backstage plugin that helps represent API entities in the frontend",
|
||||
"version": "0.6.9",
|
||||
"version": "0.6.11",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -31,11 +31,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asyncapi/react-component": "^0.23.0",
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/core-components": "^0.5.0",
|
||||
"@backstage/core-plugin-api": "^0.1.8",
|
||||
"@backstage/plugin-catalog": "^0.6.16",
|
||||
"@backstage/plugin-catalog-react": "^0.5.0",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/core-components": "^0.6.1",
|
||||
"@backstage/core-plugin-api": "^0.1.10",
|
||||
"@backstage/plugin-catalog": "^0.7.0",
|
||||
"@backstage/plugin-catalog-react": "^0.5.2",
|
||||
"@backstage/theme": "^0.2.10",
|
||||
"@material-icons/font": "^1.0.2",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
@@ -53,10 +53,10 @@
|
||||
"swagger-ui-react": "^3.37.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/core-app-api": "^0.1.14",
|
||||
"@backstage/dev-utils": "^0.2.10",
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@backstage/core-app-api": "^0.1.16",
|
||||
"@backstage/dev-utils": "^0.2.11",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
@@ -71,6 +72,11 @@ describe('<ConsumedApisCard />', () => {
|
||||
<ConsumedApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
|
||||
@@ -116,6 +122,11 @@ describe('<ConsumedApisCard />', () => {
|
||||
<ConsumedApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
@@ -71,6 +72,11 @@ describe('<HasApisCard />', () => {
|
||||
<HasApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText('APIs')).toBeInTheDocument();
|
||||
@@ -116,6 +122,11 @@ describe('<HasApisCard />', () => {
|
||||
<HasApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
@@ -71,6 +72,11 @@ describe('<ProvidedApisCard />', () => {
|
||||
<ProvidedApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText(/Provided APIs/i)).toBeInTheDocument();
|
||||
@@ -116,6 +122,11 @@ describe('<ProvidedApisCard />', () => {
|
||||
<ProvidedApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
@@ -70,6 +71,11 @@ describe('<ConsumingComponentsCard />', () => {
|
||||
<ConsumingComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText('Consumers')).toBeInTheDocument();
|
||||
@@ -121,6 +127,11 @@ describe('<ConsumingComponentsCard />', () => {
|
||||
<ConsumingComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
@@ -70,6 +71,11 @@ describe('<ProvidingComponentsCard />', () => {
|
||||
<ProvidingComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText('Providers')).toBeInTheDocument();
|
||||
@@ -121,6 +127,11 @@ describe('<ProvidingComponentsCard />', () => {
|
||||
<ProvidingComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const apiDocsPlugin = createPlugin({
|
||||
|
||||
export const ApiExplorerPage = apiDocsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'ApiExplorerPage',
|
||||
component: () =>
|
||||
import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage),
|
||||
mountPoint: rootRoute,
|
||||
@@ -59,6 +60,7 @@ export const ApiExplorerPage = apiDocsPlugin.provide(
|
||||
|
||||
export const EntityApiDefinitionCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityApiDefinitionCard',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/ApiDefinitionCard').then(m => m.ApiDefinitionCard),
|
||||
@@ -68,6 +70,7 @@ export const EntityApiDefinitionCard = apiDocsPlugin.provide(
|
||||
|
||||
export const EntityConsumedApisCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityConsumedApisCard',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/ApisCards').then(m => m.ConsumedApisCard),
|
||||
@@ -77,6 +80,7 @@ export const EntityConsumedApisCard = apiDocsPlugin.provide(
|
||||
|
||||
export const EntityConsumingComponentsCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityConsumingComponentsCard',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/ComponentsCards').then(
|
||||
@@ -88,6 +92,7 @@ export const EntityConsumingComponentsCard = apiDocsPlugin.provide(
|
||||
|
||||
export const EntityProvidedApisCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityProvidedApisCard',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/ApisCards').then(m => m.ProvidedApisCard),
|
||||
@@ -97,6 +102,7 @@ export const EntityProvidedApisCard = apiDocsPlugin.provide(
|
||||
|
||||
export const EntityProvidingComponentsCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityProvidingComponentsCard',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/ComponentsCards').then(
|
||||
@@ -108,6 +114,7 @@ export const EntityProvidingComponentsCard = apiDocsPlugin.provide(
|
||||
|
||||
export const EntityHasApisCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityHasApisCard',
|
||||
component: {
|
||||
lazy: () => import('./components/ApisCards').then(m => m.HasApisCard),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# @backstage/plugin-auth-backend
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4c3eea7788: Bitbucket Cloud authentication - based on the existing GitHub authentication + changes around BB apis and updated scope.
|
||||
|
||||
- BitbucketAuth added to core-app-api.
|
||||
- Bitbucket provider added to plugin-auth-backend.
|
||||
- Cosmetic entry for Bitbucket connection in user-settings Authentication Providers tab.
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/test-utils@0.1.18
|
||||
- @backstage/catalog-model@0.9.4
|
||||
- @backstage/backend-common@0.9.6
|
||||
- @backstage/catalog-client@0.5.0
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 88622e6422: Allow users to override callback url of GitHub provider
|
||||
- c46396ebb0: Update OAuth refresh handler to pass updated refresh token to ensure cookie is updated with new value.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-common@0.9.5
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -84,6 +84,64 @@ export type BackstageIdentity = {
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "BitbucketOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export type BitbucketOAuthResult = {
|
||||
fullProfile: BitbucketPassportProfile;
|
||||
params: {
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
expires_in: number;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "BitbucketPassportProfile" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export type BitbucketPassportProfile = Profile & {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
_json?: {
|
||||
links?: {
|
||||
avatar?: {
|
||||
href?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "BitbucketProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export type BitbucketProviderOptions = {
|
||||
authHandler?: AuthHandler<OAuthResult>;
|
||||
signIn?: {
|
||||
resolver: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "bitbucketUserIdSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const bitbucketUserIdSignInResolver: SignInResolver<BitbucketOAuthResult>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "bitbucketUsernameSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const bitbucketUsernameSignInResolver: SignInResolver<BitbucketOAuthResult>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createBitbucketProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const createBitbucketProvider: (
|
||||
options?: BitbucketProviderOptions | undefined,
|
||||
) => AuthProviderFactory;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createGithubProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -468,8 +526,8 @@ export type WebMessageResponse =
|
||||
//
|
||||
// src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
|
||||
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/github/provider.d.ts:50:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/github/provider.d.ts:58:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/bitbucket/provider.d.ts:61:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/bitbucket/provider.d.ts:69:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-auth-backend",
|
||||
"description": "A Backstage backend plugin that handles authentication",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.3",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -30,12 +30,12 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.4",
|
||||
"@backstage/catalog-client": "^0.4.0",
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/backend-common": "^0.9.6",
|
||||
"@backstage/catalog-client": "^0.5.0",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/passport": "^1.0.3",
|
||||
"compression": "^1.7.4",
|
||||
@@ -58,6 +58,7 @@
|
||||
"node-cache": "^5.1.2",
|
||||
"openid-client": "^4.2.1",
|
||||
"passport": "^0.4.1",
|
||||
"passport-bitbucket-oauth2": "^0.1.2",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-gitlab2": "^5.0.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
@@ -71,7 +72,7 @@
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/express-session": "^1.17.2",
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('CatalogIdentityClient', () => {
|
||||
getLocationByEntity: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
refreshEntity: jest.fn(),
|
||||
getEntityAncestors: jest.fn(),
|
||||
};
|
||||
const tokenIssuer: jest.Mocked<TokenIssuer> = {
|
||||
issueToken: jest.fn(),
|
||||
|
||||
@@ -77,6 +77,7 @@ describe('AwsALBAuthProvider', () => {
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
refreshEntity: jest.fn(),
|
||||
getEntityAncestors: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
createBitbucketProvider,
|
||||
bitbucketUsernameSignInResolver,
|
||||
bitbucketUserIdSignInResolver,
|
||||
} from './provider';
|
||||
export type {
|
||||
BitbucketProviderOptions,
|
||||
BitbucketPassportProfile,
|
||||
BitbucketOAuthResult,
|
||||
} from './provider';
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { BitbucketAuthProvider, BitbucketOAuthResult } from './provider';
|
||||
import * as helpers from '../../lib/passport/PassportStrategyHelper';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { TokenIssuer } from '../../identity/types';
|
||||
import { CatalogIdentityClient } from '../../lib/catalog';
|
||||
|
||||
const mockFrameHandler = jest.spyOn(
|
||||
helpers,
|
||||
'executeFrameHandlerStrategy',
|
||||
) as unknown as jest.MockedFunction<
|
||||
() => Promise<{ result: BitbucketOAuthResult; privateInfo: any }>
|
||||
>;
|
||||
|
||||
describe('createBitbucketProvider', () => {
|
||||
it('should auth', async () => {
|
||||
const tokenIssuer = {
|
||||
issueToken: jest.fn(),
|
||||
listPublicKeys: jest.fn(),
|
||||
};
|
||||
const catalogIdentityClient = {
|
||||
findUser: jest.fn(),
|
||||
};
|
||||
|
||||
const provider = new BitbucketAuthProvider({
|
||||
logger: getVoidLogger(),
|
||||
catalogIdentityClient:
|
||||
catalogIdentityClient as unknown as CatalogIdentityClient,
|
||||
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: {
|
||||
email: fullProfile.emails![0]!.value,
|
||||
displayName: fullProfile.displayName,
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
}),
|
||||
clientId: 'mock',
|
||||
clientSecret: 'mock',
|
||||
callbackUrl: 'mock',
|
||||
});
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: {
|
||||
fullProfile: {
|
||||
_json: {
|
||||
links: {
|
||||
avatar: {
|
||||
href: 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
|
||||
},
|
||||
},
|
||||
},
|
||||
emails: [{ value: 'conrad@example.com' }],
|
||||
displayName: 'Conrad',
|
||||
id: 'conrad',
|
||||
provider: 'google',
|
||||
},
|
||||
params: {
|
||||
id_token: 'idToken',
|
||||
scope: 'scope',
|
||||
expires_in: 123,
|
||||
},
|
||||
accessToken: 'accessToken',
|
||||
},
|
||||
privateInfo: {
|
||||
refreshToken: 'wacka',
|
||||
},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual({
|
||||
providerInfo: {
|
||||
accessToken: 'accessToken',
|
||||
expiresInSeconds: 123,
|
||||
idToken: 'idToken',
|
||||
scope: 'scope',
|
||||
},
|
||||
profile: {
|
||||
email: 'conrad@example.com',
|
||||
displayName: 'Conrad',
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 express from 'express';
|
||||
import passport, { Profile as PassportProfile } from 'passport';
|
||||
import { Strategy as BitbucketStrategy } from 'passport-bitbucket-oauth2';
|
||||
import { TokenIssuer } from '../../identity/types';
|
||||
import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
|
||||
import {
|
||||
encodeState,
|
||||
OAuthAdapter,
|
||||
OAuthEnvironmentHandler,
|
||||
OAuthHandlers,
|
||||
OAuthProviderOptions,
|
||||
OAuthRefreshRequest,
|
||||
OAuthResponse,
|
||||
OAuthResult,
|
||||
OAuthStartRequest,
|
||||
} from '../../lib/oauth';
|
||||
import {
|
||||
executeFetchUserProfileStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
import {
|
||||
AuthProviderFactory,
|
||||
AuthHandler,
|
||||
RedirectInfo,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type Options = OAuthProviderOptions & {
|
||||
signInResolver?: SignInResolver<OAuthResult>;
|
||||
authHandler: AuthHandler<BitbucketOAuthResult>;
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export type BitbucketOAuthResult = {
|
||||
fullProfile: BitbucketPassportProfile;
|
||||
params: {
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
expires_in: number;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export type BitbucketPassportProfile = PassportProfile & {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
_json?: {
|
||||
links?: {
|
||||
avatar?: {
|
||||
href?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class BitbucketAuthProvider implements OAuthHandlers {
|
||||
private readonly _strategy: BitbucketStrategy;
|
||||
private readonly signInResolver?: SignInResolver<OAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OAuthResult>;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
private readonly catalogIdentityClient: CatalogIdentityClient;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.authHandler = options.authHandler;
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
this.catalogIdentityClient = options.catalogIdentityClient;
|
||||
this.logger = options.logger;
|
||||
this._strategy = new BitbucketStrategy(
|
||||
{
|
||||
clientID: options.clientId,
|
||||
clientSecret: options.clientSecret,
|
||||
callbackURL: options.callbackUrl,
|
||||
// We need passReqToCallback set to false to get params, but there's
|
||||
// no matching type signature for that, so instead behold this beauty
|
||||
passReqToCallback: false as true,
|
||||
},
|
||||
(
|
||||
accessToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
fullProfile: passport.Profile,
|
||||
done: PassportDoneCallback<OAuthResult, PrivateInfo>,
|
||||
) => {
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
{
|
||||
refreshToken,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
|
||||
return await executeRedirectStrategy(req, this._strategy, {
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
scope: req.scope,
|
||||
state: encodeState(req.state),
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
>(req, this._strategy);
|
||||
|
||||
return {
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: req.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleResult(result: BitbucketOAuthResult) {
|
||||
result.fullProfile.avatarUrl =
|
||||
result.fullProfile._json!.links!.avatar!.href;
|
||||
const { profile } = await this.authHandler(result);
|
||||
|
||||
const response: OAuthResponse = {
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
if (this.signInResolver) {
|
||||
response.backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
{
|
||||
tokenIssuer: this.tokenIssuer,
|
||||
catalogIdentityClient: this.catalogIdentityClient,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const bitbucketUsernameSignInResolver: SignInResolver<BitbucketOAuthResult> =
|
||||
async (info, ctx) => {
|
||||
const { result } = info;
|
||||
|
||||
if (!result.fullProfile.username) {
|
||||
throw new Error('Bitbucket profile contained no Username');
|
||||
}
|
||||
|
||||
const entity = await ctx.catalogIdentityClient.findUser({
|
||||
annotations: {
|
||||
'bitbucket.org/username': result.fullProfile.username,
|
||||
},
|
||||
});
|
||||
|
||||
const claims = getEntityClaims(entity);
|
||||
const token = await ctx.tokenIssuer.issueToken({ claims });
|
||||
|
||||
return { id: entity.metadata.name, entity, token };
|
||||
};
|
||||
|
||||
export const bitbucketUserIdSignInResolver: SignInResolver<BitbucketOAuthResult> =
|
||||
async (info, ctx) => {
|
||||
const { result } = info;
|
||||
|
||||
if (!result.fullProfile.id) {
|
||||
throw new Error('Bitbucket profile contained no User ID');
|
||||
}
|
||||
|
||||
const entity = await ctx.catalogIdentityClient.findUser({
|
||||
annotations: {
|
||||
'bitbucket.org/user-id': result.fullProfile.id,
|
||||
},
|
||||
});
|
||||
|
||||
const claims = getEntityClaims(entity);
|
||||
const token = await ctx.tokenIssuer.issueToken({ claims });
|
||||
|
||||
return { id: entity.metadata.name, entity, token };
|
||||
};
|
||||
|
||||
export type BitbucketProviderOptions = {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
*/
|
||||
authHandler?: AuthHandler<OAuthResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
*/
|
||||
signIn?: {
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*/
|
||||
resolver: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
export const createBitbucketProvider = (
|
||||
options?: BitbucketProviderOptions,
|
||||
): AuthProviderFactory => {
|
||||
return ({
|
||||
providerId,
|
||||
globalConfig,
|
||||
config,
|
||||
tokenIssuer,
|
||||
catalogApi,
|
||||
logger,
|
||||
}) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const catalogIdentityClient = new CatalogIdentityClient({
|
||||
catalogApi,
|
||||
tokenIssuer,
|
||||
});
|
||||
|
||||
const authHandler: AuthHandler<BitbucketOAuthResult> =
|
||||
options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ fullProfile, params }) => ({
|
||||
profile: makeProfileInfo(fullProfile, params.id_token),
|
||||
});
|
||||
|
||||
const provider = new BitbucketAuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
signInResolver: options?.signIn?.resolver,
|
||||
authHandler,
|
||||
tokenIssuer,
|
||||
catalogIdentityClient,
|
||||
logger,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
disableRefresh: false,
|
||||
providerId,
|
||||
tokenIssuer,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
declare module 'passport-bitbucket-oauth2' {
|
||||
import { StrategyCreated } from 'passport';
|
||||
import express = require('express');
|
||||
|
||||
export class Strategy {
|
||||
name?: string | undefined;
|
||||
authenticate(
|
||||
this: StrategyCreated<this>,
|
||||
req: express.Request,
|
||||
options?: any,
|
||||
): any;
|
||||
constructor(options: any, verify: any);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { createMicrosoftProvider } from './microsoft';
|
||||
import { createOneLoginProvider } from './onelogin';
|
||||
import { AuthProviderFactory } from './types';
|
||||
import { createAwsAlbProvider } from './aws-alb';
|
||||
import { createBitbucketProvider } from './bitbucket';
|
||||
|
||||
export const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
google: createGoogleProvider(),
|
||||
@@ -39,4 +40,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
oidc: createOidcProvider(),
|
||||
onelogin: createOneLoginProvider(),
|
||||
awsalb: createAwsAlbProvider(),
|
||||
bitbucket: createBitbucketProvider(),
|
||||
};
|
||||
|
||||
@@ -223,6 +223,7 @@ export const createGithubProvider = (
|
||||
const enterpriseInstanceUrl = envConfig.getOptionalString(
|
||||
'enterpriseInstanceUrl',
|
||||
);
|
||||
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
|
||||
const authorizationUrl = enterpriseInstanceUrl
|
||||
? `${enterpriseInstanceUrl}/login/oauth/authorize`
|
||||
: undefined;
|
||||
@@ -232,7 +233,9 @@ export const createGithubProvider = (
|
||||
const userProfileUrl = enterpriseInstanceUrl
|
||||
? `${enterpriseInstanceUrl}/api/v3/user`
|
||||
: undefined;
|
||||
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
const callbackUrl =
|
||||
customCallbackUrl ||
|
||||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const catalogIdentityClient = new CatalogIdentityClient({
|
||||
catalogApi,
|
||||
|
||||
@@ -20,6 +20,7 @@ export * from './google';
|
||||
export * from './microsoft';
|
||||
export * from './oauth2';
|
||||
export * from './okta';
|
||||
export * from './bitbucket';
|
||||
|
||||
export { factories as defaultAuthProviderFactories } from './factories';
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# @backstage/plugin-azure-devops-backend
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 299b43f052: Marked all configuration values as required in the schema.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-common@0.9.5
|
||||
@@ -0,0 +1,24 @@
|
||||
# Azure DevOps Backend
|
||||
|
||||
Simple plugin that proxies requests to the [Azure DevOps](https://docs.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-6.1) API.
|
||||
|
||||
## Setup
|
||||
|
||||
The following values are read from the configuration file:
|
||||
|
||||
```yaml
|
||||
azureDevOps:
|
||||
host: dev.azure.com
|
||||
token: ${AZURE_TOKEN}
|
||||
organization: my-company
|
||||
```
|
||||
|
||||
Configuration Details:
|
||||
|
||||
- `host` and `token` can be the same as the ones used for the `integration` section
|
||||
- `AZURE_TOKEN` environment variable must be set to a [Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with read access to both Code and Build
|
||||
- `organization` is your Azure DevOps Organization name or for Azure DevOps Server (on-premise) this will be your Collection name
|
||||
|
||||
## Links
|
||||
|
||||
- [The Backstage homepage](https://backstage.io)
|
||||
@@ -0,0 +1,70 @@
|
||||
## API Report File for "@backstage/plugin-azure-devops-backend"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
import { BuildResult } from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
import { BuildStatus } from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { WebApi } from 'azure-devops-node-api';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export class AzureDevOpsApi {
|
||||
constructor(logger: Logger_2, webApi: WebApi);
|
||||
// (undocumented)
|
||||
getBuildList(
|
||||
projectName: string,
|
||||
repoId: string,
|
||||
top: number,
|
||||
): Promise<Build[]>;
|
||||
// (undocumented)
|
||||
getGitRepository(
|
||||
projectName: string,
|
||||
repoName: string,
|
||||
): Promise<GitRepository>;
|
||||
// (undocumented)
|
||||
getRepoBuilds(
|
||||
projectName: string,
|
||||
repoName: string,
|
||||
top: number,
|
||||
): Promise<RepoBuild[]>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export type RepoBuild = {
|
||||
id?: number;
|
||||
title: string;
|
||||
link: string;
|
||||
status?: BuildStatus;
|
||||
result?: BuildResult;
|
||||
queueTime?: Date;
|
||||
source: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
azureDevOpsApi?: AzureDevOpsApi;
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
// (undocumented)
|
||||
logger: Logger_2;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 interface Config {
|
||||
/** Configuration options for the azure-devops-backend plugin */
|
||||
azureDevOps: {
|
||||
/**
|
||||
* The hostname of the given Azure instance
|
||||
*/
|
||||
host: string;
|
||||
/**
|
||||
* Token used to authenticate requests.
|
||||
* @visibility secret
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* The organization of the given Azure instance
|
||||
*/
|
||||
organization: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@backstage/plugin-azure-devops-backend",
|
||||
"version": "0.1.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.5",
|
||||
"@backstage/config": "^0.1.9",
|
||||
"@types/express": "^4.17.6",
|
||||
"azure-devops-node-api": "^11.0.1",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.14",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^4.0.2",
|
||||
"msw": "^0.29.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { repoBuildFromBuild } from './AzureDevOpsApi';
|
||||
import { RepoBuild } from './types';
|
||||
import {
|
||||
Build,
|
||||
BuildResult,
|
||||
BuildStatus,
|
||||
DefinitionReference,
|
||||
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
|
||||
describe('AzureDevOpsApi', () => {
|
||||
describe('repoBuildFromBuild', () => {
|
||||
it('should return RepoBuild from Build', () => {
|
||||
const inputBuildDefinition: DefinitionReference = {
|
||||
name: 'My Build Definition',
|
||||
};
|
||||
|
||||
const inputLinks: any = {
|
||||
web: {
|
||||
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
|
||||
},
|
||||
};
|
||||
|
||||
const inputBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
definition: inputBuildDefinition,
|
||||
_links: inputLinks,
|
||||
};
|
||||
|
||||
const outputRepoBuild: RepoBuild = {
|
||||
id: 1,
|
||||
title: 'My Build Definition - Build-1',
|
||||
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
};
|
||||
|
||||
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repoBuildFromBuild with no Build definition name', () => {
|
||||
it('should return RepoBuild with only Build Number for title', () => {
|
||||
const inputLinks: any = {
|
||||
web: {
|
||||
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
|
||||
},
|
||||
};
|
||||
|
||||
const inputBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
definition: undefined,
|
||||
_links: inputLinks,
|
||||
};
|
||||
|
||||
const outputRepoBuild: RepoBuild = {
|
||||
id: 1,
|
||||
title: 'Build-1',
|
||||
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
};
|
||||
|
||||
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { WebApi } from 'azure-devops-node-api';
|
||||
import { RepoBuild } from './types';
|
||||
import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
|
||||
export class AzureDevOpsApi {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly webApi: WebApi,
|
||||
) {}
|
||||
|
||||
async getGitRepository(projectName: string, repoName: string) {
|
||||
if (this.logger) {
|
||||
this.logger.debug(
|
||||
`Calling Azure DevOps REST API, getting Repository ${repoName} for Project ${projectName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const client = await this.webApi.getGitApi();
|
||||
return client.getRepository(repoName, projectName);
|
||||
}
|
||||
|
||||
async getBuildList(projectName: string, repoId: string, top: number) {
|
||||
if (this.logger) {
|
||||
this.logger.debug(
|
||||
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const client = await this.webApi.getBuildApi();
|
||||
return client.getBuilds(
|
||||
projectName,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
top,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
repoId,
|
||||
'TfsGit',
|
||||
);
|
||||
}
|
||||
|
||||
async getRepoBuilds(projectName: string, repoName: string, top: number) {
|
||||
if (this.logger) {
|
||||
this.logger.debug(
|
||||
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository ${repoName} for Project ${projectName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const gitRepository = await this.getGitRepository(projectName, repoName);
|
||||
const buildList = await this.getBuildList(
|
||||
projectName,
|
||||
gitRepository.id as string,
|
||||
top,
|
||||
);
|
||||
|
||||
const repoBuilds: RepoBuild[] = buildList.map(build => {
|
||||
return repoBuildFromBuild(build);
|
||||
});
|
||||
|
||||
return repoBuilds;
|
||||
}
|
||||
}
|
||||
|
||||
export function repoBuildFromBuild(build: Build) {
|
||||
return {
|
||||
id: build.id,
|
||||
title: [build.definition?.name, build.buildNumber]
|
||||
.filter(Boolean)
|
||||
.join(' - '),
|
||||
link: build._links?.web.href,
|
||||
status: build.status,
|
||||
result: build.result,
|
||||
queueTime: build.queueTime,
|
||||
source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { AzureDevOpsApi } from './AzureDevOpsApi';
|
||||
export type { RepoBuild } from './types';
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
BuildResult,
|
||||
BuildStatus,
|
||||
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
|
||||
export type RepoBuild = {
|
||||
id?: number;
|
||||
title: string;
|
||||
link: string;
|
||||
status?: BuildStatus;
|
||||
result?: BuildResult;
|
||||
queueTime?: Date;
|
||||
source: string;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { AzureDevOpsApi } from './api';
|
||||
export type { RepoBuild } from './api';
|
||||
export * from './service/router';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { AzureDevOpsApi } from '../api';
|
||||
import { createRouter } from './router';
|
||||
import { RepoBuild } from '../api/types';
|
||||
import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
import {
|
||||
Build,
|
||||
BuildResult,
|
||||
BuildStatus,
|
||||
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
|
||||
describe('createRouter', () => {
|
||||
let azureDevOpsApi: jest.Mocked<AzureDevOpsApi>;
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
azureDevOpsApi = {
|
||||
getGitRepository: jest.fn(),
|
||||
getBuildList: jest.fn(),
|
||||
getRepoBuilds: jest.fn(),
|
||||
} as any;
|
||||
const router = await createRouter({
|
||||
azureDevOpsApi,
|
||||
logger: getVoidLogger(),
|
||||
config: new ConfigReader({
|
||||
azureDevOps: {
|
||||
token: 'foo',
|
||||
host: 'host.com',
|
||||
organization: 'myOrg',
|
||||
top: 5,
|
||||
},
|
||||
}),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns ok', async () => {
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /repository/:projectName/:repoName', () => {
|
||||
it('fetches a single repository', async () => {
|
||||
const gitRepository: GitRepository = {
|
||||
id: 'af4ae3af-e747-4129-9bbc-d1329f6b0998',
|
||||
name: 'myRepo',
|
||||
url: 'https://host.com/repo',
|
||||
defaultBranch: 'refs/heads/develop',
|
||||
sshUrl: 'ssh://host.com/repo',
|
||||
webUrl: 'https://host.com/webRepo',
|
||||
};
|
||||
|
||||
azureDevOpsApi.getGitRepository.mockResolvedValueOnce(gitRepository);
|
||||
|
||||
const response = await request(app).get('/repository/myProject/myRepo');
|
||||
|
||||
expect(azureDevOpsApi.getGitRepository).toHaveBeenCalledWith(
|
||||
'myProject',
|
||||
'myRepo',
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(gitRepository);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /builds/:projectName/:repoId', () => {
|
||||
it('fetches a list of builds', async () => {
|
||||
const firstBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: undefined,
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: '9bedf67800b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
};
|
||||
|
||||
const secondBuild: Build = {
|
||||
id: 2,
|
||||
buildNumber: 'Build-2',
|
||||
status: BuildStatus.InProgress,
|
||||
result: BuildResult.None,
|
||||
queueTime: undefined,
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: '13c988d4f15e06bcdd0b0af290086a3079cdadb0',
|
||||
};
|
||||
|
||||
const thirdBuild: Build = {
|
||||
id: 3,
|
||||
buildNumber: 'Build-3',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.PartiallySucceeded,
|
||||
queueTime: undefined,
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b319c308600eab015a5d6529add21660dc1',
|
||||
};
|
||||
|
||||
const builds: Build[] = [firstBuild, secondBuild, thirdBuild];
|
||||
|
||||
azureDevOpsApi.getBuildList.mockResolvedValueOnce(builds);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/builds/myProject/af4ae3af-e747-4129-9bbc-d1329f6b0998')
|
||||
.query({ top: '40' });
|
||||
|
||||
expect(azureDevOpsApi.getBuildList).toHaveBeenCalledWith(
|
||||
'myProject',
|
||||
'af4ae3af-e747-4129-9bbc-d1329f6b0998',
|
||||
40,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(builds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /repo-builds/:projectName/:repoName', () => {
|
||||
it('fetches a list of repo builds', async () => {
|
||||
const firstRepoBuild: RepoBuild = {
|
||||
id: 1,
|
||||
title: 'My Build Definition - Build 1',
|
||||
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.PartiallySucceeded,
|
||||
queueTime: undefined,
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
};
|
||||
|
||||
const secondRepoBuild: RepoBuild = {
|
||||
id: 2,
|
||||
title: 'My Build Definition - Build 2',
|
||||
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=2',
|
||||
status: BuildStatus.InProgress,
|
||||
result: BuildResult.None,
|
||||
queueTime: undefined,
|
||||
source: 'refs/heads/develop (13c988d4)',
|
||||
};
|
||||
|
||||
const thirdRepoBuild: RepoBuild = {
|
||||
id: 3,
|
||||
title: 'My Build Definition - Build 3',
|
||||
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=3',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: undefined,
|
||||
source: 'refs/heads/develop (9bedf678)',
|
||||
};
|
||||
|
||||
const repoBuilds: RepoBuild[] = [
|
||||
firstRepoBuild,
|
||||
secondRepoBuild,
|
||||
thirdRepoBuild,
|
||||
];
|
||||
|
||||
azureDevOpsApi.getRepoBuilds.mockResolvedValueOnce(repoBuilds);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/repo-builds/myProject/myRepo')
|
||||
.query({ top: '50' });
|
||||
|
||||
expect(azureDevOpsApi.getRepoBuilds).toHaveBeenCalledWith(
|
||||
'myProject',
|
||||
'myRepo',
|
||||
50,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(repoBuilds);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { errorHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { Config } from '@backstage/config';
|
||||
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
|
||||
import { AzureDevOpsApi } from '../api';
|
||||
|
||||
const DEFAULT_TOP: number = 10;
|
||||
|
||||
export interface RouterOptions {
|
||||
azureDevOpsApi?: AzureDevOpsApi;
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { logger } = options;
|
||||
const config = options.config.getConfig('azureDevOps');
|
||||
|
||||
const token = config.getString('token');
|
||||
const host = config.getString('host');
|
||||
const organization = config.getString('organization');
|
||||
|
||||
const authHandler = getPersonalAccessTokenHandler(token);
|
||||
const webApi = new WebApi(`https://${host}/${organization}`, authHandler);
|
||||
|
||||
const azureDevOpsApi =
|
||||
options.azureDevOpsApi || new AzureDevOpsApi(logger, webApi);
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
router.get('/repository/:projectName/:repoName', async (req, res) => {
|
||||
const { projectName, repoName } = req.params;
|
||||
const gitRepository = await azureDevOpsApi.getGitRepository(
|
||||
projectName,
|
||||
repoName,
|
||||
);
|
||||
res.status(200).json(gitRepository);
|
||||
});
|
||||
|
||||
router.get('/builds/:projectName/:repoId', async (req, res) => {
|
||||
const { projectName, repoId } = req.params;
|
||||
const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP;
|
||||
const buildList = await azureDevOpsApi.getBuildList(
|
||||
projectName,
|
||||
repoId,
|
||||
top,
|
||||
);
|
||||
res.status(200).json(buildList);
|
||||
});
|
||||
|
||||
router.get('/repo-builds/:projectName/:repoName', async (req, res) => {
|
||||
const { projectName, repoName } = req.params;
|
||||
const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP;
|
||||
const gitRepository = await azureDevOpsApi.getRepoBuilds(
|
||||
projectName,
|
||||
repoName,
|
||||
top,
|
||||
);
|
||||
res.status(200).json(gitRepository);
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
createServiceBuilder,
|
||||
loadBackendConfig,
|
||||
} from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'azure-devops-backend' });
|
||||
const config = await loadBackendConfig({ logger, argv: process.argv });
|
||||
|
||||
logger.debug('Starting application server...');
|
||||
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
config,
|
||||
});
|
||||
|
||||
let service = createServiceBuilder(module)
|
||||
.setPort(options.port)
|
||||
.addRouter('/azure-devops', router);
|
||||
if (options.enableCors) {
|
||||
service = service.enableCors({ origin: 'http://localhost:3000' });
|
||||
}
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 {};
|
||||
@@ -1,5 +1,14 @@
|
||||
# @backstage/plugin-badges-backend
|
||||
|
||||
## 0.1.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@0.9.4
|
||||
- @backstage/backend-common@0.9.6
|
||||
- @backstage/catalog-client@0.5.0
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-badges-backend",
|
||||
"description": "A Backstage backend plugin that generates README badges for your entities",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -31,9 +31,9 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.4",
|
||||
"@backstage/catalog-client": "^0.4.0",
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/backend-common": "^0.9.6",
|
||||
"@backstage/catalog-client": "^0.5.0",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@types/express": "^4.17.6",
|
||||
@@ -46,7 +46,7 @@
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('createRouter', () => {
|
||||
removeLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
refreshEntity: jest.fn(),
|
||||
getEntityAncestors: jest.fn(),
|
||||
};
|
||||
|
||||
config = new ConfigReader({
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @backstage/plugin-badges
|
||||
|
||||
## 0.2.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead.
|
||||
- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata.
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.6.1
|
||||
- @backstage/core-plugin-api@0.1.10
|
||||
- @backstage/plugin-catalog-react@0.5.2
|
||||
- @backstage/catalog-model@0.9.4
|
||||
|
||||
## 0.2.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 504bcb2214: Added details on how to setup the Badges plugin
|
||||
- Updated dependencies
|
||||
- @backstage/core-plugin-api@0.1.9
|
||||
- @backstage/core-components@0.6.0
|
||||
- @backstage/plugin-catalog-react@0.5.1
|
||||
|
||||
## 0.2.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+140
-2
@@ -10,8 +10,146 @@ link below for more details.
|
||||
## Entity badges
|
||||
|
||||
To get markdown code for the entity badges, access the `Badges` context menu
|
||||
(three dots in the upper right corner) of an entity page, which will popup a
|
||||
badges dialog showing all available badges for that entity.
|
||||
(three dots in the upper right corner) of an entity page like this:
|
||||
|
||||

|
||||
|
||||
This will popup a badges dialog showing all available badges for that entity like this:
|
||||
|
||||

|
||||
|
||||
## Sample Badges
|
||||
|
||||
Here are some samples of badges for the `artists-lookup` service in the Demo Backstage site:
|
||||
|
||||
- Component: [](https://demo.backstage.io/catalog/default/component/artist-lookup)
|
||||
- Lifecycle: [](https://demo.backstage.io/catalog/default/component/artist-lookup)
|
||||
- Owner: [](https://demo.backstage.io/catalog/default/component/artist-lookup)
|
||||
- Docs: [](https://demo.backstage.io/catalog/default/component/artist-lookup/docs)
|
||||
|
||||
## Usage
|
||||
|
||||
### Install the package
|
||||
|
||||
```bash
|
||||
yarn add @backstage/plugin-badges
|
||||
```
|
||||
|
||||
### Register plugin
|
||||
|
||||
This plugin requires explicit registration, so you will need to add it to your App's `plugins.ts` file:
|
||||
|
||||
```ts
|
||||
// ...
|
||||
export { badgesPlugin } from '@backstage/plugin-badges';
|
||||
```
|
||||
|
||||
If you don't have a `plugins.ts` file see: [troubleshooting](#troubleshooting)
|
||||
|
||||
### Update your EntityPage
|
||||
|
||||
In your `EntityPage.tsx` file located in `packages\app\src\components\catalog` we'll need to make a few changes to get the Badges context menu added to the UI.
|
||||
|
||||
First we need to add the following imports:
|
||||
|
||||
```ts
|
||||
import { EntityBadgesDialog } from '@backstage/plugin-badges';
|
||||
import BadgeIcon from '@material-ui/icons/CallToAction';
|
||||
```
|
||||
|
||||
Next we'll update the React import that looks like this:
|
||||
|
||||
```ts
|
||||
import React from 'react';
|
||||
```
|
||||
|
||||
To look like this:
|
||||
|
||||
```ts
|
||||
import React, { ReactNode, useMemo, useState } from 'react';
|
||||
```
|
||||
|
||||
Then we have to add this chunk of code after all the imports but before any of the other code:
|
||||
|
||||
```ts
|
||||
const EntityLayoutWrapper = (props: { children?: ReactNode }) => {
|
||||
const [badgesDialogOpen, setBadgesDialogOpen] = useState(false);
|
||||
|
||||
const extraMenuItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: 'Badges',
|
||||
Icon: BadgeIcon,
|
||||
onClick: () => setBadgesDialogOpen(true),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EntityLayout UNSTABLE_extraContextMenuItems={extraMenuItems}>
|
||||
{props.children}
|
||||
</EntityLayout>
|
||||
<EntityBadgesDialog
|
||||
open={badgesDialogOpen}
|
||||
onClose={() => setBadgesDialogOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The last step is to wrap all the entity pages in the `EntityLayoutWrapper` like this:
|
||||
|
||||
```diff
|
||||
const defaultEntityPage = (
|
||||
+ <EntityLayoutWrapper>
|
||||
<EntityLayout.Route path="/" title="Overview">
|
||||
{overviewContent}
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/docs" title="Docs">
|
||||
<EntityTechdocsContent />
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/todos" title="TODOs">
|
||||
<EntityTodoContent />
|
||||
</EntityLayout.Route>
|
||||
+ </EntityLayoutWrapper>
|
||||
);
|
||||
```
|
||||
|
||||
Note: the above only shows an example for the `defaultEntityPage` for a full example of this you can look at [this EntityPage](https://github.com/backstage/backstage/blob/1fd9e6f601cabe42af8eb20b5d200ad1988ba309/packages/app/src/components/catalog/EntityPage.tsx#L318)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you don't have a `plugins.ts` file, you can create it with the path `packages/app/src/plugins.ts` and then import it into your `App.tsx`:
|
||||
|
||||
```diff
|
||||
+ import * as plugins from './plugins';
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
+ plugins: Object.values(plugins),
|
||||
bindRoutes({ bind }) {
|
||||
/* ... */
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Or simply edit `App.tsx` with:
|
||||
|
||||
```diff
|
||||
+ import { badgesPlugin } from '@backstage/plugin-badges'
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
+ plugins: [badgesPlugin],
|
||||
bindRoutes({ bind }) {
|
||||
/* ... */
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-badges",
|
||||
"description": "A Backstage plugin that generates README badges for your entities",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.12",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -10,6 +10,12 @@
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/badges"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"start": "backstage-cli plugin:serve",
|
||||
@@ -21,11 +27,11 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/core-components": "^0.5.0",
|
||||
"@backstage/core-plugin-api": "^0.1.8",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/core-components": "^0.6.1",
|
||||
"@backstage/core-plugin-api": "^0.1.10",
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/plugin-catalog-react": "^0.5.0",
|
||||
"@backstage/plugin-catalog-react": "^0.5.2",
|
||||
"@backstage/theme": "^0.2.10",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
@@ -36,10 +42,10 @@
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/core-app-api": "^0.1.14",
|
||||
"@backstage/dev-utils": "^0.2.10",
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@backstage/core-app-api": "^0.1.16",
|
||||
"@backstage/dev-utils": "^0.2.11",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
|
||||
@@ -61,9 +61,10 @@ export class BadgesClient implements BadgesApi {
|
||||
|
||||
private getEntityRouteParams(entity: Entity) {
|
||||
return {
|
||||
kind: entity.kind.toLowerCase(),
|
||||
kind: entity.kind.toLocaleLowerCase('en-US'),
|
||||
namespace:
|
||||
entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE,
|
||||
entity.metadata.namespace?.toLocaleLowerCase('en-US') ??
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
name: entity.metadata.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const badgesPlugin = createPlugin({
|
||||
|
||||
export const EntityBadgesDialog = badgesPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityBadgesDialog',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/EntityBadgesDialog').then(
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @backstage/plugin-bitrise
|
||||
|
||||
## 0.1.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata.
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.6.1
|
||||
- @backstage/core-plugin-api@0.1.10
|
||||
- @backstage/plugin-catalog-react@0.5.2
|
||||
- @backstage/catalog-model@0.9.4
|
||||
|
||||
## 0.1.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-plugin-api@0.1.9
|
||||
- @backstage/core-components@0.6.0
|
||||
- @backstage/plugin-catalog-react@0.5.1
|
||||
|
||||
## 0.1.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-bitrise",
|
||||
"description": "A Backstage plugin that integrates towards Bitrise",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.15",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -21,10 +21,10 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/core-components": "^0.5.0",
|
||||
"@backstage/core-plugin-api": "^0.1.8",
|
||||
"@backstage/plugin-catalog-react": "^0.5.0",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/core-components": "^0.6.1",
|
||||
"@backstage/core-plugin-api": "^0.1.10",
|
||||
"@backstage/plugin-catalog-react": "^0.5.2",
|
||||
"@backstage/theme": "^0.2.10",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
@@ -39,10 +39,10 @@
|
||||
"recharts": "^1.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/core-app-api": "^0.1.14",
|
||||
"@backstage/dev-utils": "^0.2.10",
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@backstage/core-app-api": "^0.1.16",
|
||||
"@backstage/dev-utils": "^0.2.11",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createComponentExtension } from '@backstage/core-plugin-api';
|
||||
|
||||
export const EntityBitriseContent = bitrisePlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityBitriseContent',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/BitriseBuildsComponent').then(
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @backstage/plugin-catalog-backend-module-ldap
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a31afc5b62: Replace slash stripping regexp with trimEnd to remove CodeQL warning
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-catalog-backend@0.16.0
|
||||
- @backstage/catalog-model@0.9.4
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-catalog-backend@0.15.0
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-catalog-backend-module-ldap",
|
||||
"description": "A Backstage catalog backend modules that helps integrate towards LDAP",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -29,19 +29,17 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/plugin-catalog-backend": "^0.14.0",
|
||||
"@backstage/plugin-catalog-backend": "^0.16.0",
|
||||
"@types/ldapjs": "^2.2.0",
|
||||
"ldapjs": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/test-utils": "^0.1.16",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"msw": "^0.29.0"
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@types/lodash": "^4.14.151"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Config, JsonValue } from '@backstage/config';
|
||||
import { SearchOptions } from 'ldapjs';
|
||||
import mergeWith from 'lodash/mergeWith';
|
||||
import { RecursivePartial } from '@backstage/plugin-catalog-backend';
|
||||
import { trimEnd } from 'lodash';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single LDAP provider.
|
||||
@@ -286,7 +287,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] {
|
||||
const providerConfigs = config.getOptionalConfigArray('providers') ?? [];
|
||||
return providerConfigs.map(c => {
|
||||
const newConfig = {
|
||||
target: c.getString('target').replace(/\/+$/, ''),
|
||||
target: trimEnd(c.getString('target'), '/'),
|
||||
bind: readBindConfig(c.getOptionalConfig('bind')),
|
||||
users: readUserConfig(c.getConfig('users')),
|
||||
groups: readGroupConfig(c.getConfig('groups')),
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @backstage/plugin-catalog-backend-module-msgraph
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ff7c6cec1a: Allow loading users using group membership
|
||||
- 95869261ed: Adding some documentation for the `msgraph` client
|
||||
- a31afc5b62: Replace slash stripping regexp with trimEnd to remove CodeQL warning
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-catalog-backend@0.16.0
|
||||
- @backstage/catalog-model@0.9.4
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 664bba5c45: Bumped `@microsoft/microsoft-graph-types` to v2
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-catalog-backend@0.15.0
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -55,12 +55,20 @@ catalog:
|
||||
# Optional filter for user, see Microsoft Graph API for the syntax
|
||||
# See https://docs.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0#properties
|
||||
# and for the syntax https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter
|
||||
# This and userGroupMemberFilter are mutually exclusive, only one can be specified
|
||||
userFilter: accountEnabled eq true and userType eq 'member'
|
||||
# Optional filter for users, use group membership to get users.
|
||||
# This and userFilter are mutually exclusive, only one can be specified
|
||||
userGroupMemberFilter: "displayName eq 'Backstage Users'"
|
||||
# Optional filter for group, see Microsoft Graph API for the syntax
|
||||
# See https://docs.microsoft.com/en-us/graph/api/resources/group?view=graph-rest-1.0#properties
|
||||
groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
|
||||
```
|
||||
|
||||
`userFilter` and `userGroupMemberFilter` are mutually exclusive, only one can be provided. If both are provided, an error will be thrown.
|
||||
|
||||
By default, all users are loaded. If you want to filter users based on their attributes, use `userFilter`. `userGroupMemberFilter` can be used if you want to load users based on their group membership.
|
||||
|
||||
5. Add a location that ingests from Microsoft Graph:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -61,46 +61,35 @@ export const MICROSOFT_GRAPH_TENANT_ID_ANNOTATION =
|
||||
// @public
|
||||
export const MICROSOFT_GRAPH_USER_ID_ANNOTATION = 'graph.microsoft.com/user-id';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "MicrosoftGraphClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export class MicrosoftGraphClient {
|
||||
constructor(baseUrl: string, pca: msal.ConfidentialClientApplication);
|
||||
// (undocumented)
|
||||
static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient;
|
||||
// Warning: (ae-forgotten-export) The symbol "GroupMember" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// (undocumented)
|
||||
getGroupMembers(groupId: string): AsyncIterable<GroupMember>;
|
||||
// (undocumented)
|
||||
getGroupPhoto(groupId: string, sizeId?: string): Promise<string | undefined>;
|
||||
// (undocumented)
|
||||
getGroupPhotoWithSizeLimit(
|
||||
groupId: string,
|
||||
maxSize: number,
|
||||
): Promise<string | undefined>;
|
||||
// (undocumented)
|
||||
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery"
|
||||
getGroups(query?: ODataQuery): AsyncIterable<MicrosoftGraph.Group>;
|
||||
// (undocumented)
|
||||
getOrganization(tenantId: string): Promise<MicrosoftGraph.Organization>;
|
||||
// (undocumented)
|
||||
getUserPhoto(userId: string, sizeId?: string): Promise<string | undefined>;
|
||||
// (undocumented)
|
||||
getUserPhotoWithSizeLimit(
|
||||
userId: string,
|
||||
maxSize: number,
|
||||
): Promise<string | undefined>;
|
||||
// (undocumented)
|
||||
getUserProfile(userId: string): Promise<MicrosoftGraph.User>;
|
||||
// (undocumented)
|
||||
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery"
|
||||
getUsers(query?: ODataQuery): AsyncIterable<MicrosoftGraph.User>;
|
||||
// (undocumented)
|
||||
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery"
|
||||
requestApi(path: string, query?: ODataQuery): Promise<Response>;
|
||||
// Warning: (ae-forgotten-export) The symbol "ODataQuery" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// (undocumented)
|
||||
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery"
|
||||
requestCollection<T>(path: string, query?: ODataQuery): AsyncIterable<T>;
|
||||
// (undocumented)
|
||||
requestRaw(url: string): Promise<Response>;
|
||||
}
|
||||
|
||||
@@ -143,6 +132,7 @@ export type MicrosoftGraphProviderConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
userFilter?: string;
|
||||
userGroupMemberFilter?: string;
|
||||
groupFilter?: string;
|
||||
};
|
||||
|
||||
@@ -173,6 +163,7 @@ export function readMicrosoftGraphOrg(
|
||||
tenantId: string,
|
||||
options: {
|
||||
userFilter?: string;
|
||||
userGroupMemberFilter?: string;
|
||||
groupFilter?: string;
|
||||
userTransformer?: UserTransformer;
|
||||
groupTransformer?: GroupTransformer;
|
||||
|
||||
@@ -73,6 +73,12 @@ export interface Config {
|
||||
* E.g. "securityEnabled eq false and mailEnabled eq true"
|
||||
*/
|
||||
groupFilter?: string;
|
||||
/**
|
||||
* The filter to apply to extract users by groups memberships.
|
||||
*
|
||||
* E.g. "displayName eq 'Backstage Users'"
|
||||
*/
|
||||
userGroupMemberFilter?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-catalog-backend-module-msgraph",
|
||||
"description": "A Backstage catalog backend modules that helps integrate towards Microsoft Graph",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.6",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -30,10 +30,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^1.1.0",
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/plugin-catalog-backend": "^0.14.0",
|
||||
"@microsoft/microsoft-graph-types": "^1.25.0",
|
||||
"@backstage/plugin-catalog-backend": "^0.16.0",
|
||||
"@microsoft/microsoft-graph-types": "^2.6.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"lodash": "^4.17.21",
|
||||
"p-limit": "^3.0.2",
|
||||
@@ -41,9 +41,9 @@
|
||||
"qs": "^6.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "^0.9.4",
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/test-utils": "^0.1.14",
|
||||
"@backstage/backend-common": "^0.9.6",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"msw": "^0.29.0"
|
||||
},
|
||||
|
||||
@@ -20,9 +20,24 @@ import fetch from 'cross-fetch';
|
||||
import qs from 'qs';
|
||||
import { MicrosoftGraphProviderConfig } from './config';
|
||||
|
||||
/**
|
||||
* OData (Open Data Protocol) Query
|
||||
*
|
||||
* {@link https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview}
|
||||
* @public
|
||||
*/
|
||||
export type ODataQuery = {
|
||||
/**
|
||||
* filter a collection of resources
|
||||
*/
|
||||
filter?: string;
|
||||
/**
|
||||
* specifies the related resources or media streams to be included in line with retrieved resources
|
||||
*/
|
||||
expand?: string[];
|
||||
/**
|
||||
* request a specific set of properties for each entity or complex type
|
||||
*/
|
||||
select?: string[];
|
||||
};
|
||||
|
||||
@@ -30,7 +45,23 @@ export type GroupMember =
|
||||
| (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.user' })
|
||||
| (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.group' });
|
||||
|
||||
/**
|
||||
* A HTTP Client that communicates with Microsoft Graph API.
|
||||
* Simplify Authentication and API calls to get `User` and `Group` from Azure Active Directory
|
||||
*
|
||||
* Uses `msal-node` for authentication
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MicrosoftGraphClient {
|
||||
/**
|
||||
* Factory method that instantiate `msal` client and return
|
||||
* an instance of `MicrosoftGraphClient`
|
||||
*
|
||||
* @public
|
||||
*
|
||||
* @param config - Configuration for Interacting with Graph API
|
||||
*/
|
||||
static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient {
|
||||
const clientConfig: msal.Configuration = {
|
||||
auth: {
|
||||
@@ -43,11 +74,25 @@ export class MicrosoftGraphClient {
|
||||
return new MicrosoftGraphClient(config.target, pca);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param baseUrl - baseUrl of Graph API {@link MicrosoftGraphProviderConfig.target}
|
||||
* @param pca - instance of `msal.ConfidentialClientApplication` that is used to acquire token for Graph API calls
|
||||
*
|
||||
*/
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly pca: msal.ConfidentialClientApplication,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get a collection of resource from Graph API and
|
||||
* return an `AsyncIterable` of that resource
|
||||
*
|
||||
* @public
|
||||
* @param path - Resource in Microsoft Graph
|
||||
* @param query - OData Query {@link ODataQuery}
|
||||
*
|
||||
*/
|
||||
async *requestCollection<T>(
|
||||
path: string,
|
||||
query?: ODataQuery,
|
||||
@@ -60,6 +105,8 @@ export class MicrosoftGraphClient {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Graph API return array of collections
|
||||
const elements: T[] = result.value;
|
||||
|
||||
yield* elements;
|
||||
@@ -73,6 +120,13 @@ export class MicrosoftGraphClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract on top of {@link MicrosoftGraphClient.requestRaw}
|
||||
*
|
||||
* @public
|
||||
* @param path - Resource in Microsoft Graph
|
||||
* @param query - OData Query {@link ODataQuery}
|
||||
*/
|
||||
async requestApi(path: string, query?: ODataQuery): Promise<Response> {
|
||||
const queryString = qs.stringify(
|
||||
{
|
||||
@@ -90,6 +144,11 @@ export class MicrosoftGraphClient {
|
||||
return await this.requestRaw(`${this.baseUrl}/${path}${queryString}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a HTTP call to Graph API with token
|
||||
*
|
||||
* @param url - HTTP Endpoint of Graph API
|
||||
*/
|
||||
async requestRaw(url: string): Promise<Response> {
|
||||
// Make sure that we always have a valid access token (might be cached)
|
||||
const token = await this.pca.acquireTokenByClientCredential({
|
||||
@@ -107,6 +166,14 @@ export class MicrosoftGraphClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link https://docs.microsoft.com/en-us/graph/api/resources/user | User}
|
||||
* from Graph API
|
||||
*
|
||||
* @public
|
||||
* @param userId - The unique identifier for the `User` resource
|
||||
*
|
||||
*/
|
||||
async getUserProfile(userId: string): Promise<MicrosoftGraph.User> {
|
||||
const response = await this.requestApi(`users/${userId}`);
|
||||
|
||||
@@ -117,6 +184,14 @@ export class MicrosoftGraphClient {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link https://docs.microsoft.com/en-us/graph/api/resources/profilephoto | profilePhoto}
|
||||
* of `User` from Graph API with size limit
|
||||
*
|
||||
* @param userId - The unique identifier for the `User` resource
|
||||
* @param maxSize - Maximum pixel height of the photo
|
||||
*
|
||||
*/
|
||||
async getUserPhotoWithSizeLimit(
|
||||
userId: string,
|
||||
maxSize: number,
|
||||
@@ -131,10 +206,27 @@ export class MicrosoftGraphClient {
|
||||
return await this.getPhoto('users', userId, sizeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of
|
||||
* {@link https://docs.microsoft.com/en-us/graph/api/resources/user | User}
|
||||
* from Graph API and return as `AsyncIterable`
|
||||
*
|
||||
* @public
|
||||
* @param query - OData Query {@link ODataQuery}
|
||||
*
|
||||
*/
|
||||
async *getUsers(query?: ODataQuery): AsyncIterable<MicrosoftGraph.User> {
|
||||
yield* this.requestCollection<MicrosoftGraph.User>(`users`, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link https://docs.microsoft.com/en-us/graph/api/resources/profilephoto | profilePhoto}
|
||||
* of `Group` from Graph API with size limit
|
||||
*
|
||||
* @param groupId - The unique identifier for the `Group` resource
|
||||
* @param maxSize - Maximum pixel height of the photo
|
||||
*
|
||||
*/
|
||||
async getGroupPhotoWithSizeLimit(
|
||||
groupId: string,
|
||||
maxSize: number,
|
||||
@@ -149,14 +241,37 @@ export class MicrosoftGraphClient {
|
||||
return await this.getPhoto('groups', groupId, sizeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of
|
||||
* {@link https://docs.microsoft.com/en-us/graph/api/resources/group | Group}
|
||||
* from Graph API and return as `AsyncIterable`
|
||||
* @public
|
||||
* @param query - OData Query {@link ODataQuery}
|
||||
*
|
||||
*/
|
||||
async *getGroups(query?: ODataQuery): AsyncIterable<MicrosoftGraph.Group> {
|
||||
yield* this.requestCollection<MicrosoftGraph.Group>(`groups`, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of
|
||||
* {@link https://docs.microsoft.com/en-us/graph/api/resources/user | User}
|
||||
* belonging to a `Group` from Graph API and return as `AsyncIterable`
|
||||
* @public
|
||||
* @param groupId - The unique identifier for the `Group` resource
|
||||
*
|
||||
*/
|
||||
async *getGroupMembers(groupId: string): AsyncIterable<GroupMember> {
|
||||
yield* this.requestCollection<GroupMember>(`groups/${groupId}/members`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link https://docs.microsoft.com/en-us/graph/api/resources/organization | Organization}
|
||||
* from Graph API
|
||||
* @public
|
||||
* @param tenantId - The unique identifier for the `Organization` resource
|
||||
*
|
||||
*/
|
||||
async getOrganization(
|
||||
tenantId: string,
|
||||
): Promise<MicrosoftGraph.Organization> {
|
||||
@@ -169,6 +284,15 @@ export class MicrosoftGraphClient {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link https://docs.microsoft.com/en-us/graph/api/resources/profilephoto | profilePhoto}
|
||||
* from Graph API
|
||||
*
|
||||
* @param entityName - type of parent resource, either `User` or `Group`
|
||||
* @param id - The unique identifier for the {@link entityName | entityName} resource
|
||||
* @param maxSize - Maximum pixel height of the photo
|
||||
*
|
||||
*/
|
||||
private async getPhotoWithSizeLimit(
|
||||
entityName: string,
|
||||
id: string,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { trimEnd } from 'lodash';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single Microsoft Graph provider.
|
||||
@@ -51,6 +52,12 @@ export type MicrosoftGraphProviderConfig = {
|
||||
* E.g. "accountEnabled eq true and userType eq 'member'"
|
||||
*/
|
||||
userFilter?: string;
|
||||
/**
|
||||
* The filter to apply to extract users by groups memberships.
|
||||
*
|
||||
* E.g. "displayName eq 'Backstage Users'"
|
||||
*/
|
||||
userGroupMemberFilter?: string;
|
||||
/**
|
||||
* The filter to apply to extract groups.
|
||||
*
|
||||
@@ -66,14 +73,18 @@ export function readMicrosoftGraphConfig(
|
||||
const providerConfigs = config.getOptionalConfigArray('providers') ?? [];
|
||||
|
||||
for (const providerConfig of providerConfigs) {
|
||||
const target = providerConfig.getString('target').replace(/\/+$/, '');
|
||||
const authority =
|
||||
providerConfig.getOptionalString('authority')?.replace(/\/+$/, '') ||
|
||||
'https://login.microsoftonline.com';
|
||||
const target = trimEnd(providerConfig.getString('target'), '/');
|
||||
|
||||
const authority = providerConfig.getOptionalString('authority')
|
||||
? trimEnd(providerConfig.getOptionalString('authority'), '/')
|
||||
: 'https://login.microsoftonline.com';
|
||||
const tenantId = providerConfig.getString('tenantId');
|
||||
const clientId = providerConfig.getString('clientId');
|
||||
const clientSecret = providerConfig.getString('clientSecret');
|
||||
const userFilter = providerConfig.getOptionalString('userFilter');
|
||||
const userGroupMemberFilter = providerConfig.getOptionalString(
|
||||
'userGroupMemberFilter',
|
||||
);
|
||||
const groupFilter = providerConfig.getOptionalString('groupFilter');
|
||||
|
||||
providers.push({
|
||||
@@ -83,6 +94,7 @@ export function readMicrosoftGraphConfig(
|
||||
clientId,
|
||||
clientSecret,
|
||||
userFilter,
|
||||
userGroupMemberFilter,
|
||||
groupFilter,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import merge from 'lodash/merge';
|
||||
import { GroupMember, MicrosoftGraphClient } from './client';
|
||||
import {
|
||||
readMicrosoftGraphGroups,
|
||||
readMicrosoftGraphOrg,
|
||||
readMicrosoftGraphOrganization,
|
||||
readMicrosoftGraphUsers,
|
||||
readMicrosoftGraphUsersInGroups,
|
||||
resolveRelations,
|
||||
} from './read';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
@@ -59,6 +61,7 @@ function group(data: Partial<GroupEntity>): GroupEntity {
|
||||
describe('read microsoft graph', () => {
|
||||
const client: jest.Mocked<MicrosoftGraphClient> = {
|
||||
getUsers: jest.fn(),
|
||||
getUserProfile: jest.fn(),
|
||||
getGroups: jest.fn(),
|
||||
getGroupMembers: jest.fn(),
|
||||
getUserPhotoWithSizeLimit: jest.fn(),
|
||||
@@ -158,6 +161,144 @@ describe('read microsoft graph', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('readMicrosoftGraphUsersInGroups', () => {
|
||||
it('should read users from Groups', async () => {
|
||||
async function* getExampleGroups() {
|
||||
yield {
|
||||
id: 'groupid',
|
||||
displayName: 'Group Name',
|
||||
description: 'Group Description',
|
||||
mail: 'group@example.com',
|
||||
};
|
||||
}
|
||||
|
||||
async function* getExampleGroupMembers(): AsyncIterable<GroupMember> {
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.group',
|
||||
id: 'childgroupid',
|
||||
};
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.user',
|
||||
id: 'userid',
|
||||
};
|
||||
}
|
||||
|
||||
client.getGroups.mockImplementation(getExampleGroups);
|
||||
client.getGroupMembers.mockImplementation(getExampleGroupMembers);
|
||||
|
||||
client.getUserProfile.mockResolvedValue({
|
||||
id: 'userid',
|
||||
displayName: 'User Name',
|
||||
mail: 'user.name@example.com',
|
||||
});
|
||||
client.getUserPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
const { users } = await readMicrosoftGraphUsersInGroups(client, {
|
||||
userGroupMemberFilter: 'securityEnabled eq true',
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(users).toEqual([
|
||||
user({
|
||||
metadata: {
|
||||
annotations: {
|
||||
'graph.microsoft.com/user-id': 'userid',
|
||||
},
|
||||
name: 'user.name_example.com',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'User Name',
|
||||
email: 'user.name@example.com',
|
||||
picture: 'data:image/jpeg;base64,...',
|
||||
},
|
||||
memberOf: [],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(client.getGroups).toBeCalledTimes(1);
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
filter: 'securityEnabled eq true',
|
||||
});
|
||||
expect(client.getGroupMembers).toBeCalledTimes(1);
|
||||
expect(client.getGroupMembers).toBeCalledWith('groupid');
|
||||
|
||||
expect(client.getUserProfile).toBeCalledTimes(1);
|
||||
expect(client.getUserProfile).toBeCalledWith('userid');
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1);
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120);
|
||||
});
|
||||
|
||||
it('should read users with custom transformer', async () => {
|
||||
async function* getExampleGroups() {
|
||||
yield {
|
||||
id: 'groupid',
|
||||
displayName: 'Group Name',
|
||||
description: 'Group Description',
|
||||
mail: 'group@example.com',
|
||||
};
|
||||
}
|
||||
|
||||
async function* getExampleGroupMembers(): AsyncIterable<GroupMember> {
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.group',
|
||||
id: 'childgroupid',
|
||||
};
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.user',
|
||||
id: 'userid',
|
||||
};
|
||||
}
|
||||
|
||||
client.getGroups.mockImplementation(getExampleGroups);
|
||||
client.getGroupMembers.mockImplementation(getExampleGroupMembers);
|
||||
|
||||
client.getUserProfile.mockResolvedValue({
|
||||
id: 'userid',
|
||||
displayName: 'User Name',
|
||||
mail: 'user.name@example.com',
|
||||
});
|
||||
client.getUserPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
const { users } = await readMicrosoftGraphUsersInGroups(client, {
|
||||
userGroupMemberFilter: 'securityEnabled eq true',
|
||||
transformer: async () => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: 'x' },
|
||||
spec: { memberOf: [] },
|
||||
}),
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(users).toEqual([
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: 'x' },
|
||||
spec: { memberOf: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(client.getGroups).toBeCalledTimes(1);
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
filter: 'securityEnabled eq true',
|
||||
});
|
||||
expect(client.getGroupMembers).toBeCalledTimes(1);
|
||||
expect(client.getGroupMembers).toBeCalledWith('groupid');
|
||||
|
||||
expect(client.getUserProfile).toBeCalledTimes(1);
|
||||
expect(client.getUserProfile).toBeCalledWith('userid');
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1);
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readMicrosoftGraphOrganization', () => {
|
||||
it('should read organization', async () => {
|
||||
client.getOrganization.mockResolvedValue({
|
||||
@@ -397,4 +538,132 @@ describe('read microsoft graph', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readMicrosoftGraphOrg', () => {
|
||||
async function* getExampleUsers() {
|
||||
yield {
|
||||
id: 'userid',
|
||||
displayName: 'User Name',
|
||||
mail: 'user.name@example.com',
|
||||
};
|
||||
}
|
||||
|
||||
async function* getExampleGroups() {
|
||||
yield {
|
||||
id: 'groupid',
|
||||
displayName: 'Group Name',
|
||||
description: 'Group Description',
|
||||
mail: 'group@example.com',
|
||||
};
|
||||
}
|
||||
|
||||
async function* getExampleGroupMembers(): AsyncIterable<GroupMember> {
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.group',
|
||||
id: 'childgroupid',
|
||||
};
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.user',
|
||||
id: 'userid',
|
||||
};
|
||||
}
|
||||
|
||||
it('should read all users if no filter provided', async () => {
|
||||
client.getOrganization.mockResolvedValue({
|
||||
id: 'tenantid',
|
||||
displayName: 'Organization Name',
|
||||
});
|
||||
|
||||
client.getUsers.mockImplementation(getExampleUsers);
|
||||
client.getUserPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
client.getGroups.mockImplementation(getExampleGroups);
|
||||
client.getGroupMembers.mockImplementation(getExampleGroupMembers);
|
||||
client.getGroupPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
await readMicrosoftGraphOrg(client, 'tenantid', {
|
||||
logger: getVoidLogger(),
|
||||
groupFilter: 'securityEnabled eq false',
|
||||
});
|
||||
|
||||
expect(client.getUsers).toBeCalledTimes(1);
|
||||
expect(client.getUsers).toBeCalledWith({
|
||||
filter: undefined,
|
||||
});
|
||||
expect(client.getGroups).toBeCalledTimes(1);
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
filter: 'securityEnabled eq false',
|
||||
});
|
||||
});
|
||||
|
||||
it('should read users using userFilter', async () => {
|
||||
client.getOrganization.mockResolvedValue({
|
||||
id: 'tenantid',
|
||||
displayName: 'Organization Name',
|
||||
});
|
||||
|
||||
client.getUsers.mockImplementation(getExampleUsers);
|
||||
client.getUserPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
client.getGroups.mockImplementation(getExampleGroups);
|
||||
client.getGroupMembers.mockImplementation(getExampleGroupMembers);
|
||||
client.getGroupPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
await readMicrosoftGraphOrg(client, 'tenantid', {
|
||||
logger: getVoidLogger(),
|
||||
userFilter: 'accountEnabled eq true',
|
||||
groupFilter: 'securityEnabled eq false',
|
||||
});
|
||||
|
||||
expect(client.getUsers).toBeCalledTimes(1);
|
||||
expect(client.getUsers).toBeCalledWith({
|
||||
filter: 'accountEnabled eq true',
|
||||
});
|
||||
expect(client.getGroups).toBeCalledTimes(1);
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
filter: 'securityEnabled eq false',
|
||||
});
|
||||
});
|
||||
|
||||
it('should read users using userGroupMemberFilter', async () => {
|
||||
client.getOrganization.mockResolvedValue({
|
||||
id: 'tenantid',
|
||||
displayName: 'Organization Name',
|
||||
});
|
||||
|
||||
client.getUsers.mockImplementation(getExampleUsers);
|
||||
client.getUserPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
client.getGroups.mockImplementation(getExampleGroups);
|
||||
client.getGroupMembers.mockImplementation(getExampleGroupMembers);
|
||||
client.getGroupPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
await readMicrosoftGraphOrg(client, 'tenantid', {
|
||||
logger: getVoidLogger(),
|
||||
userGroupMemberFilter: 'name eq backstage-group',
|
||||
groupFilter: 'securityEnabled eq false',
|
||||
});
|
||||
|
||||
expect(client.getUsers).toBeCalledTimes(0);
|
||||
expect(client.getGroups).toBeCalledTimes(2);
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
filter: 'name eq backstage-group',
|
||||
});
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
filter: 'securityEnabled eq false',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,89 @@ export async function readMicrosoftGraphUsers(
|
||||
return { users };
|
||||
}
|
||||
|
||||
export async function readMicrosoftGraphUsersInGroups(
|
||||
client: MicrosoftGraphClient,
|
||||
options: {
|
||||
userGroupMemberFilter?: string;
|
||||
transformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
},
|
||||
): Promise<{
|
||||
users: UserEntity[]; // With all relations empty
|
||||
}> {
|
||||
const users: UserEntity[] = [];
|
||||
|
||||
const limiter = limiterFactory(10);
|
||||
|
||||
const transformer = options?.transformer ?? defaultUserTransformer;
|
||||
const userGroupMemberPromises: Promise<void>[] = [];
|
||||
const userPromises: Promise<void>[] = [];
|
||||
|
||||
const groupMemberUsers: Set<string> = new Set();
|
||||
|
||||
for await (const group of client.getGroups({
|
||||
filter: options?.userGroupMemberFilter,
|
||||
})) {
|
||||
// Process all groups in parallel, otherwise it can take quite some time
|
||||
userGroupMemberPromises.push(
|
||||
limiter(async () => {
|
||||
for await (const member of client.getGroupMembers(group.id!)) {
|
||||
if (!member.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (member['@odata.type'] === '#microsoft.graph.user') {
|
||||
groupMemberUsers.add(member.id);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all group members
|
||||
await Promise.all(userGroupMemberPromises);
|
||||
|
||||
options.logger.info(`groupMemberUsers ${groupMemberUsers.size}`);
|
||||
for (const userId of groupMemberUsers) {
|
||||
// Process all users in parallel, otherwise it can take quite some time
|
||||
userPromises.push(
|
||||
limiter(async () => {
|
||||
let user;
|
||||
let userPhoto;
|
||||
try {
|
||||
user = await client.getUserProfile(userId);
|
||||
} catch (e) {
|
||||
options.logger.warn(`Unable to load user for ${userId}`);
|
||||
}
|
||||
if (user) {
|
||||
try {
|
||||
userPhoto = await client.getUserPhotoWithSizeLimit(
|
||||
user.id!,
|
||||
// We are limiting the photo size, as users with full resolution photos
|
||||
// can make the Backstage API slow
|
||||
120,
|
||||
);
|
||||
} catch (e) {
|
||||
options.logger.warn(`Unable to load userphoto for ${userId}`);
|
||||
}
|
||||
|
||||
const entity = await transformer(user, userPhoto);
|
||||
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
users.push(entity);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all users and photos to be downloaded
|
||||
await Promise.all(userPromises);
|
||||
|
||||
return { users };
|
||||
}
|
||||
|
||||
export async function defaultOrganizationTransformer(
|
||||
organization: MicrosoftGraph.Organization,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
@@ -387,6 +470,7 @@ export async function readMicrosoftGraphOrg(
|
||||
tenantId: string,
|
||||
options: {
|
||||
userFilter?: string;
|
||||
userGroupMemberFilter?: string;
|
||||
groupFilter?: string;
|
||||
userTransformer?: UserTransformer;
|
||||
groupTransformer?: GroupTransformer;
|
||||
@@ -394,11 +478,26 @@ export async function readMicrosoftGraphOrg(
|
||||
logger: Logger;
|
||||
},
|
||||
): Promise<{ users: UserEntity[]; groups: GroupEntity[] }> {
|
||||
const { users } = await readMicrosoftGraphUsers(client, {
|
||||
userFilter: options.userFilter,
|
||||
transformer: options.userTransformer,
|
||||
logger: options.logger,
|
||||
});
|
||||
const users: UserEntity[] = [];
|
||||
|
||||
if (options.userGroupMemberFilter) {
|
||||
const { users: usersInGroups } = await readMicrosoftGraphUsersInGroups(
|
||||
client,
|
||||
{
|
||||
userGroupMemberFilter: options.userGroupMemberFilter,
|
||||
transformer: options.userTransformer,
|
||||
logger: options.logger,
|
||||
},
|
||||
);
|
||||
users.push(...usersInGroups);
|
||||
} else {
|
||||
const { users: usersWithFilter } = await readMicrosoftGraphUsers(client, {
|
||||
userFilter: options.userFilter,
|
||||
transformer: options.userTransformer,
|
||||
logger: options.logger,
|
||||
});
|
||||
users.push(...usersWithFilter);
|
||||
}
|
||||
const { groups, rootGroup, groupMember, groupMemberOf } =
|
||||
await readMicrosoftGraphGroups(client, tenantId, {
|
||||
groupFilter: options?.groupFilter,
|
||||
|
||||
+7
@@ -90,6 +90,12 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.userFilter && provider.userGroupMemberFilter) {
|
||||
throw new Error(
|
||||
`userFilter and userGroupMemberFilter are mutually exclusive, only one can be specified.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Read out all of the raw data
|
||||
const startTimestamp = Date.now();
|
||||
this.logger.info('Reading Microsoft Graph users and groups');
|
||||
@@ -101,6 +107,7 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor {
|
||||
provider.tenantId,
|
||||
{
|
||||
userFilter: provider.userFilter,
|
||||
userGroupMemberFilter: provider.userGroupMemberFilter,
|
||||
groupFilter: provider.groupFilter,
|
||||
userTransformer: this.userTransformer,
|
||||
groupTransformer: this.groupTransformer,
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# @backstage/plugin-catalog-backend
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 2c5bab2f82: Errors emitted from processors are now considered a failure during entity processing and will prevent entities from being updated. The impact of this change is that when errors are emitted while for example reading a location, then ingestion effectively stops there. If you emit a number of entities along with just one error, then the error will be persisted on the current entity but the emitted entities will _not_ be stored. This fixes [a bug](https://github.com/backstage/backstage/issues/6973) where entities would get marked as orphaned rather than put in an error state when the catalog failed to read a location.
|
||||
|
||||
In previous versions of the catalog, an emitted error was treated as a less severe problem than an exception thrown by the processor. We are now ensuring that the behavior is consistent for these two cases. Even though both thrown and emitted errors are treated the same, emitted errors stay around as they allow you to highlight multiple errors related to an entity at once. An emitted error will also only prevent the writing of the processing result, while a thrown error will skip the rest of the processing steps.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 957e4b3351: Updated dependencies
|
||||
- f66c38148a: Avoid duplicate logging of entity processing errors.
|
||||
- 426d5031a6: A number of classes and types, that were part of the old catalog engine implementation, are now formally marked as deprecated. They will be removed entirely from the code base in a future release.
|
||||
|
||||
After upgrading to this version, it is recommended that you take a look inside your `packages/backend/src/plugins/catalog.ts` file (using a code editor), to see if you are using any functionality that it marks as deprecated. If you do, please migrate away from it at your earliest convenience.
|
||||
|
||||
Migrating to using the new engine implementation is typically a matter of calling `CatalogBuilder.create({ ... })` instead of `new CatalogBuilder({ ... })`.
|
||||
|
||||
If you are seeing deprecation warnings for `createRouter`, you can either use the `router` field from the return value from updated catalog builder, or temporarily call `createNextRouter`. The latter will however also be deprecated at a later time.
|
||||
|
||||
- 7b78dd17e6: Replace slash stripping regexp with trimEnd to remove CodeQL warning
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@0.9.4
|
||||
- @backstage/backend-common@0.9.6
|
||||
- @backstage/catalog-client@0.5.0
|
||||
- @backstage/integration@0.6.7
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1572d02b63: Introduced a new `CatalogProcessorCache` that is available to catalog processors. It allows arbitrary values to be saved that will then be visible during the next run. The cache is scoped to each individual processor and entity, but is shared across processing steps in a single processor.
|
||||
|
||||
The cache is available as a new argument to each of the processing steps, except for `validateEntityKind` and `handleError`.
|
||||
|
||||
This also introduces an optional `getProcessorName` to the `CatalogProcessor` interface, which is used to provide a stable identifier for the processor. While it is currently optional it will move to be required in the future.
|
||||
|
||||
The breaking part of this change is the modification of the `state` field in the `EntityProcessingRequest` and `EntityProcessingResult` types. This is unlikely to have any impact as the `state` field was previously unused, but could require some minor updates.
|
||||
|
||||
- c1836728e0: Add `/entities/by-name/:kind/:namespace/:name/ancestry` to get the "processing parents" lineage of an entity.
|
||||
|
||||
This involves a breaking change of adding the method `entityAncestry` to `EntitiesCatalog`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3d10360c82: When issuing a `full` update from an entity provider, entities with updates are now properly persisted.
|
||||
- 9ea4565b00: Fixed a bug where internal references within the catalog were broken when new entities where added through entity providers, such as registering a new location or adding one in configuration. These broken references then caused some entities to be incorrectly marked as orphaned and prevented refresh from working properly.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-common@0.9.5
|
||||
- @backstage/integration@0.6.6
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -35,7 +35,7 @@ import { Validators } from '@backstage/catalog-model';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AddLocationResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type AddLocationResult = {
|
||||
location: Location_2;
|
||||
entities: Entity[];
|
||||
@@ -91,7 +91,7 @@ export type AnalyzeLocationResponse = {
|
||||
// @public (undocumented)
|
||||
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
|
||||
// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts
|
||||
constructor(options: Options_2);
|
||||
constructor(options: Options);
|
||||
// (undocumented)
|
||||
preProcessEntity(
|
||||
entity: Entity,
|
||||
@@ -225,6 +225,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
|
||||
//
|
||||
// @public
|
||||
export class CatalogBuilder {
|
||||
// @deprecated
|
||||
constructor(env: CatalogEnvironment);
|
||||
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
addEntityPolicy(...policies: EntityPolicy[]): CatalogBuilder;
|
||||
@@ -522,7 +523,7 @@ export function createRandomRefreshInterval(options: {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "Database" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -573,7 +574,7 @@ export type Database = {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "DatabaseEntitiesCatalog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
constructor(database: Database, logger: Logger_2);
|
||||
// (undocumented)
|
||||
@@ -588,6 +589,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
// (undocumented)
|
||||
entities(request?: EntitiesRequest): Promise<EntitiesResponse>;
|
||||
// (undocumented)
|
||||
entityAncestry(): Promise<never>;
|
||||
// (undocumented)
|
||||
removeEntityByUid(uid: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -805,8 +808,6 @@ export type DeferredEntity = {
|
||||
// @public
|
||||
export function durationText(startTimestamp: [number, number]): string;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntitiesCatalog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export type EntitiesCatalog = {
|
||||
entities(request?: EntitiesRequest): Promise<EntitiesResponse>;
|
||||
@@ -819,6 +820,7 @@ export type EntitiesCatalog = {
|
||||
outputEntities?: boolean;
|
||||
},
|
||||
): Promise<EntityUpsertResponse[]>;
|
||||
entityAncestry(entityRef: string): Promise<EntityAncestryResponse>;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -855,6 +857,15 @@ function entity(
|
||||
newEntity: Entity,
|
||||
): CatalogProcessorResult;
|
||||
|
||||
// @public (undocumented)
|
||||
export type EntityAncestryResponse = {
|
||||
rootEntityRef: string;
|
||||
items: Array<{
|
||||
entity: Entity;
|
||||
parentEntityRefs: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntityFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
@@ -1051,7 +1062,7 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "HigherOrderOperation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type HigherOrderOperation = {
|
||||
addLocation(
|
||||
spec: LocationSpec,
|
||||
@@ -1064,7 +1075,7 @@ export type HigherOrderOperation = {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "HigherOrderOperations" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export class HigherOrderOperations implements HigherOrderOperation {
|
||||
constructor(
|
||||
entitiesCatalog: EntitiesCatalog,
|
||||
@@ -1129,17 +1140,17 @@ export type LocationEntityProcessorOptions = {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "LocationReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type LocationReader = {
|
||||
read(location: LocationSpec): Promise<ReadLocationResult>;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "LocationReaders" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export class LocationReaders implements LocationReader {
|
||||
// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts
|
||||
constructor(options: Options);
|
||||
constructor(options: Options_3);
|
||||
// (undocumented)
|
||||
read(location: LocationSpec): Promise<ReadLocationResult>;
|
||||
}
|
||||
@@ -1364,7 +1375,7 @@ export type PlaceholderResolverResolveUrl = (
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ReadLocationEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type ReadLocationEntity = {
|
||||
location: LocationSpec;
|
||||
entity: Entity;
|
||||
@@ -1373,7 +1384,7 @@ export type ReadLocationEntity = {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ReadLocationError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type ReadLocationError = {
|
||||
location: LocationSpec;
|
||||
error: Error;
|
||||
@@ -1381,7 +1392,7 @@ export type ReadLocationError = {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ReadLocationResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type ReadLocationResult = {
|
||||
entities: ReadLocationEntity[];
|
||||
errors: ReadLocationError[];
|
||||
@@ -1432,7 +1443,7 @@ export { results };
|
||||
|
||||
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
@@ -1486,7 +1497,7 @@ export type Transaction = {
|
||||
// @public (undocumented)
|
||||
export class UrlReaderProcessor implements CatalogProcessor {
|
||||
// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts
|
||||
constructor(options: Options_3);
|
||||
constructor(options: Options_2);
|
||||
// (undocumented)
|
||||
getProcessorName(): string;
|
||||
// (undocumented)
|
||||
@@ -1501,12 +1512,9 @@ export class UrlReaderProcessor implements CatalogProcessor {
|
||||
|
||||
// Warnings were encountered during analysis:
|
||||
//
|
||||
// src/catalog/types.d.ts:30:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/catalog/types.d.ts:36:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/catalog/types.d.ts:42:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/catalog/types.d.ts:43:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
|
||||
// src/catalog/types.d.ts:44:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
|
||||
// src/catalog/types.d.ts:45:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
|
||||
// src/catalog/types.d.ts:52:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
|
||||
// src/catalog/types.d.ts:53:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
|
||||
// src/catalog/types.d.ts:54:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
|
||||
// src/database/types.d.ts:125:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/database/types.d.ts:131:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/database/types.d.ts:132:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
@@ -1518,6 +1526,6 @@ export class UrlReaderProcessor implements CatalogProcessor {
|
||||
// src/database/types.d.ts:164:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/database/types.d.ts:165:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/ingestion/processors/GithubMultiOrgReaderProcessor.d.ts:23:9 - (ae-forgotten-export) The symbol "GithubMultiOrgConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/ingestion/types.d.ts:17:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/ingestion/types.d.ts:41:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/ingestion/types.d.ts:8:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// src/legacy/ingestion/types.d.ts:19:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
```
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.alterTable('refresh_state', table => {
|
||||
table
|
||||
.text('unprocessed_hash')
|
||||
.nullable()
|
||||
.comment('A hash of the unprocessed contents, used to detect changes');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.alterTable('refresh_state', table => {
|
||||
table.dropColumn('unprocessed_hash');
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-catalog-backend",
|
||||
"description": "The Backstage backend plugin that provides the Backstage catalog",
|
||||
"version": "0.14.0",
|
||||
"version": "0.16.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -30,13 +30,12 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.4",
|
||||
"@backstage/catalog-client": "^0.4.0",
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/backend-common": "^0.9.6",
|
||||
"@backstage/catalog-client": "^0.5.0",
|
||||
"@backstage/catalog-model": "^0.9.4",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/errors": "^0.1.2",
|
||||
"@backstage/integration": "^0.6.5",
|
||||
"@backstage/plugin-search-backend-node": "^0.4.2",
|
||||
"@backstage/integration": "^0.6.7",
|
||||
"@backstage/search-common": "^0.2.0",
|
||||
"@octokit/graphql": "^4.5.8",
|
||||
"@types/express": "^4.17.6",
|
||||
@@ -53,26 +52,24 @@
|
||||
"knex": "^0.95.1",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"p-limit": "^3.0.2",
|
||||
"prom-client": "^13.2.0",
|
||||
"qs": "^6.9.4",
|
||||
"uuid": "^8.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"yaml": "^1.9.2",
|
||||
"yn": "^4.0.0",
|
||||
"yup": "^0.29.3"
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "^0.1.7",
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@backstage/cli": "^0.7.15",
|
||||
"@backstage/test-utils": "^0.1.18",
|
||||
"@types/core-js": "^2.5.4",
|
||||
"@types/git-url-parse": "^9.0.0",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/yup": "^0.29.8",
|
||||
"@types/yup": "^0.29.13",
|
||||
"aws-sdk-mock": "^5.2.1",
|
||||
"msw": "^0.29.0",
|
||||
"sqlite3": "^5.0.1",
|
||||
|
||||
@@ -14,17 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
|
||||
export { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
|
||||
export type {
|
||||
EntitiesCatalog,
|
||||
LocationsCatalog,
|
||||
EntityUpsertRequest,
|
||||
EntityUpsertResponse,
|
||||
EntitiesRequest,
|
||||
EntitiesResponse,
|
||||
EntityAncestryResponse,
|
||||
EntityUpsertRequest,
|
||||
EntityUpsertResponse,
|
||||
LocationResponse,
|
||||
PageInfo,
|
||||
LocationsCatalog,
|
||||
LocationUpdateLogEvent,
|
||||
LocationUpdateStatus,
|
||||
PageInfo,
|
||||
} from './types';
|
||||
|
||||
@@ -51,28 +51,38 @@ export type EntityUpsertResponse = {
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type EntityAncestryResponse = {
|
||||
rootEntityRef: string;
|
||||
items: Array<{
|
||||
entity: Entity;
|
||||
parentEntityRefs: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type EntitiesCatalog = {
|
||||
/**
|
||||
* Fetch entities.
|
||||
*
|
||||
* @param request Request options
|
||||
* @param request - Request options
|
||||
*/
|
||||
entities(request?: EntitiesRequest): Promise<EntitiesResponse>;
|
||||
|
||||
/**
|
||||
* Removes a single entity.
|
||||
*
|
||||
* @param uid The metadata.uid of the entity
|
||||
* @param uid - The metadata.uid of the entity
|
||||
*/
|
||||
removeEntityByUid(uid: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Writes a number of entities efficiently to storage.
|
||||
*
|
||||
* @param requests The entities and their relations
|
||||
* @param options.locationId The location that they all belong to (default none)
|
||||
* @param options.dryRun Whether to throw away the results (default false)
|
||||
* @param options.outputEntities Whether to return the resulting entities (default false)
|
||||
* @param requests - The entities and their relations
|
||||
* @param options.locationId - The location that they all belong to (default none)
|
||||
* @param options.dryRun - Whether to throw away the results (default false)
|
||||
* @param options.outputEntities - Whether to return the resulting entities (default false)
|
||||
*/
|
||||
batchAddOrUpdateEntities(
|
||||
requests: EntityUpsertRequest[],
|
||||
@@ -82,6 +92,13 @@ export type EntitiesCatalog = {
|
||||
outputEntities?: boolean;
|
||||
},
|
||||
): Promise<EntityUpsertResponse[]>;
|
||||
|
||||
/**
|
||||
* Returns the full ancestry tree upward along reference edges.
|
||||
*
|
||||
* @param entityRef - An entity reference to the root of the tree
|
||||
*/
|
||||
entityAncestry(entityRef: string): Promise<EntityAncestryResponse>;
|
||||
};
|
||||
|
||||
//
|
||||
@@ -93,6 +110,7 @@ export type LocationUpdateStatus = {
|
||||
status: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export type LocationUpdateLogEvent = {
|
||||
id: string;
|
||||
status: 'fail' | 'success';
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
export * from './catalog';
|
||||
export * from './database';
|
||||
export * from './ingestion';
|
||||
export * from './legacy';
|
||||
export * from './search';
|
||||
export * from './service';
|
||||
export * from './util';
|
||||
export * from './next';
|
||||
|
||||
@@ -16,18 +16,10 @@
|
||||
|
||||
export type { CatalogRule, CatalogRulesEnforcer } from './CatalogRules';
|
||||
export { DefaultCatalogRulesEnforcer } from './CatalogRules';
|
||||
export { HigherOrderOperations } from './HigherOrderOperations';
|
||||
export { LocationReaders } from './LocationReaders';
|
||||
export type {
|
||||
AddLocationResult,
|
||||
AnalyzeLocationRequest,
|
||||
AnalyzeLocationResponse,
|
||||
HigherOrderOperation,
|
||||
LocationAnalyzer,
|
||||
LocationReader,
|
||||
ReadLocationEntity,
|
||||
ReadLocationError,
|
||||
ReadLocationResult,
|
||||
AnalyzeLocationEntityField,
|
||||
AnalyzeLocationExistingEntity,
|
||||
AnalyzeLocationGenerateEntity,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { trimEnd } from 'lodash';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single GitHub API provider.
|
||||
@@ -52,12 +53,12 @@ export function readGithubConfig(config: Config): ProviderConfig[] {
|
||||
|
||||
// First read all the explicit providers
|
||||
for (const providerConfig of providerConfigs) {
|
||||
const target = providerConfig.getString('target').replace(/\/+$/, '');
|
||||
const target = trimEnd(providerConfig.getString('target'), '/');
|
||||
let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl');
|
||||
const token = providerConfig.getOptionalString('token');
|
||||
|
||||
if (apiBaseUrl) {
|
||||
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
|
||||
apiBaseUrl = trimEnd(apiBaseUrl, '/');
|
||||
} else if (target === 'https://github.com') {
|
||||
apiBaseUrl = 'https://api.github.com';
|
||||
}
|
||||
|
||||
@@ -14,62 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityRelationSpec,
|
||||
Location,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { RecursivePartial } from '../util/RecursivePartial';
|
||||
|
||||
//
|
||||
// HigherOrderOperation
|
||||
//
|
||||
|
||||
export type HigherOrderOperation = {
|
||||
addLocation(
|
||||
spec: LocationSpec,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<AddLocationResult>;
|
||||
refreshAllLocations(): Promise<void>;
|
||||
};
|
||||
|
||||
export type AddLocationResult = {
|
||||
location: Location;
|
||||
entities: Entity[];
|
||||
};
|
||||
|
||||
//
|
||||
// LocationReader
|
||||
//
|
||||
|
||||
export type LocationReader = {
|
||||
/**
|
||||
* Reads the contents of a location.
|
||||
*
|
||||
* @param location The location to read
|
||||
* @throws An error if the location was handled by this reader, but could not
|
||||
* be read
|
||||
*/
|
||||
read(location: LocationSpec): Promise<ReadLocationResult>;
|
||||
};
|
||||
|
||||
export type ReadLocationResult = {
|
||||
entities: ReadLocationEntity[];
|
||||
errors: ReadLocationError[];
|
||||
};
|
||||
|
||||
export type ReadLocationEntity = {
|
||||
location: LocationSpec;
|
||||
entity: Entity;
|
||||
relations: EntityRelationSpec[];
|
||||
};
|
||||
|
||||
export type ReadLocationError = {
|
||||
location: LocationSpec;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
//
|
||||
// LocationAnalyzer
|
||||
//
|
||||
|
||||
+3
-3
@@ -16,10 +16,10 @@
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model';
|
||||
import { Database, DatabaseManager, Transaction } from '../database';
|
||||
import { basicEntityFilter } from '../service/request';
|
||||
import { Database, DatabaseManager, Transaction } from '../../database';
|
||||
import { basicEntityFilter } from '../../service/request';
|
||||
import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
|
||||
import { EntityUpsertRequest } from './types';
|
||||
import { EntityUpsertRequest } from '../../catalog/types';
|
||||
|
||||
describe('DatabaseEntitiesCatalog', () => {
|
||||
let db: jest.Mocked<Database>;
|
||||
+10
-5
@@ -26,17 +26,17 @@ import { ConflictError } from '@backstage/errors';
|
||||
import { chunk, groupBy } from 'lodash';
|
||||
import limiterFactory from 'p-limit';
|
||||
import { Logger } from 'winston';
|
||||
import type { Database, DbEntityResponse, Transaction } from '../database';
|
||||
import { DbEntitiesRequest } from '../database/types';
|
||||
import { basicEntityFilter } from '../service/request';
|
||||
import { durationText } from '../util/timing';
|
||||
import type { Database, DbEntityResponse, Transaction } from '../../database';
|
||||
import { DbEntitiesRequest } from '../../database/types';
|
||||
import { basicEntityFilter } from '../../service/request';
|
||||
import { durationText } from '../../util/timing';
|
||||
import type {
|
||||
EntitiesCatalog,
|
||||
EntitiesRequest,
|
||||
EntitiesResponse,
|
||||
EntityUpsertRequest,
|
||||
EntityUpsertResponse,
|
||||
} from './types';
|
||||
} from '../../catalog/types';
|
||||
|
||||
type BatchContext = {
|
||||
kind: string;
|
||||
@@ -57,6 +57,7 @@ const BATCH_ATTEMPTS = 3;
|
||||
// The number of batches that may be ongoing at the same time.
|
||||
const BATCH_CONCURRENCY = 3;
|
||||
|
||||
/** @deprecated This was part of the legacy catalog engine */
|
||||
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
constructor(
|
||||
private readonly database: Database,
|
||||
@@ -369,4 +370,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
async entityAncestry(): Promise<never> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 './catalog';
|
||||
export * from './ingestion';
|
||||
export * from './service';
|
||||
+4
-3
@@ -16,9 +16,9 @@
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
import { LocationUpdateStatus } from '../catalog/types';
|
||||
import { DatabaseLocationUpdateLogStatus } from '../database/types';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../../catalog';
|
||||
import { LocationUpdateStatus } from '../../catalog/types';
|
||||
import { DatabaseLocationUpdateLogStatus } from '../../database/types';
|
||||
import { HigherOrderOperations } from './HigherOrderOperations';
|
||||
import { LocationReader } from './types';
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('HigherOrderOperations', () => {
|
||||
entities: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
batchAddOrUpdateEntities: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
};
|
||||
locationsCatalog = {
|
||||
addLocation: jest.fn(),
|
||||
+3
-4
@@ -21,8 +21,8 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
import { durationText } from '../util';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../../catalog';
|
||||
import { durationText } from '../../util';
|
||||
import {
|
||||
AddLocationResult,
|
||||
HigherOrderOperation,
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
* Placeholder for operations that span several catalogs and/or stretches out
|
||||
* in time.
|
||||
*
|
||||
* TODO(freben): Find a better home for these, possibly refactoring to use the
|
||||
* database more directly.
|
||||
* @deprecated This was part of the legacy catalog engine
|
||||
*/
|
||||
export class HigherOrderOperations implements HigherOrderOperation {
|
||||
constructor(
|
||||
+5
-3
@@ -26,8 +26,8 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Logger } from 'winston';
|
||||
import { CatalogRulesEnforcer } from './CatalogRules';
|
||||
import * as result from './processors/results';
|
||||
import { CatalogRulesEnforcer } from '../../ingestion/CatalogRules';
|
||||
import * as result from '../../ingestion/processors/results';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorEmit,
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
CatalogProcessorLocationResult,
|
||||
CatalogProcessorParser,
|
||||
CatalogProcessorResult,
|
||||
} from './processors/types';
|
||||
} from '../../ingestion/processors/types';
|
||||
import { LocationReader, ReadLocationResult } from './types';
|
||||
|
||||
// The max amount of nesting depth of generated work items
|
||||
@@ -61,6 +61,8 @@ const noopCache = {
|
||||
|
||||
/**
|
||||
* Implements the reading of a location through a series of processor tasks.
|
||||
*
|
||||
* @deprecated This was part of the legacy catalog engine
|
||||
*/
|
||||
export class LocationReaders implements LocationReader {
|
||||
private readonly options: Options;
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { HigherOrderOperations } from './HigherOrderOperations';
|
||||
export { LocationReaders } from './LocationReaders';
|
||||
export type {
|
||||
AddLocationResult,
|
||||
HigherOrderOperation,
|
||||
LocationReader,
|
||||
ReadLocationEntity,
|
||||
ReadLocationError,
|
||||
ReadLocationResult,
|
||||
} from './types';
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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,
|
||||
EntityRelationSpec,
|
||||
Location,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
//
|
||||
// LocationReader
|
||||
//
|
||||
|
||||
/** @deprecated This was part of the legacy catalog engine */
|
||||
export type HigherOrderOperation = {
|
||||
addLocation(
|
||||
spec: LocationSpec,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<AddLocationResult>;
|
||||
refreshAllLocations(): Promise<void>;
|
||||
};
|
||||
|
||||
/** @deprecated This was part of the legacy catalog engine */
|
||||
export type AddLocationResult = {
|
||||
location: Location;
|
||||
entities: Entity[];
|
||||
};
|
||||
|
||||
//
|
||||
// LocationReader
|
||||
//
|
||||
|
||||
/** @deprecated This was part of the legacy catalog engine */
|
||||
export type LocationReader = {
|
||||
/**
|
||||
* Reads the contents of a location.
|
||||
*
|
||||
* @param location The location to read
|
||||
* @throws An error if the location was handled by this reader, but could not
|
||||
* be read
|
||||
*/
|
||||
read(location: LocationSpec): Promise<ReadLocationResult>;
|
||||
};
|
||||
|
||||
/** @deprecated This was part of the legacy catalog engine */
|
||||
export type ReadLocationResult = {
|
||||
entities: ReadLocationEntity[];
|
||||
errors: ReadLocationError[];
|
||||
};
|
||||
|
||||
/** @deprecated This was part of the legacy catalog engine */
|
||||
export type ReadLocationEntity = {
|
||||
location: LocationSpec;
|
||||
entity: Entity;
|
||||
relations: EntityRelationSpec[];
|
||||
};
|
||||
|
||||
/** @deprecated This was part of the legacy catalog engine */
|
||||
export type ReadLocationError = {
|
||||
location: LocationSpec;
|
||||
error: Error;
|
||||
};
|
||||
+5
-4
@@ -19,10 +19,11 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { Knex } from 'knex';
|
||||
import yaml from 'yaml';
|
||||
import { DatabaseManager } from '../database';
|
||||
import { CatalogProcessorParser } from '../ingestion';
|
||||
import * as result from '../ingestion/processors/results';
|
||||
import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder';
|
||||
import { DatabaseManager } from '../../database';
|
||||
import { CatalogProcessorParser } from '../../ingestion';
|
||||
import * as result from '../../ingestion/processors/results';
|
||||
import { CatalogBuilder } from './CatalogBuilder';
|
||||
import { CatalogEnvironment } from '../../next';
|
||||
|
||||
const dummyEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
+19
-22
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common';
|
||||
import {
|
||||
DefaultNamespaceEntityPolicy,
|
||||
EntityPolicies,
|
||||
@@ -25,17 +24,15 @@ import {
|
||||
SchemaValidEntityPolicy,
|
||||
Validators,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import lodash from 'lodash';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
DatabaseEntitiesCatalog,
|
||||
DatabaseLocationsCatalog,
|
||||
EntitiesCatalog,
|
||||
LocationsCatalog,
|
||||
} from '../catalog';
|
||||
import { DatabaseManager } from '../database';
|
||||
} from '../../catalog';
|
||||
import { DatabaseEntitiesCatalog } from '../catalog';
|
||||
import { DatabaseManager } from '../../database';
|
||||
import {
|
||||
AnnotateLocationEntityProcessor,
|
||||
BitbucketDiscoveryProcessor,
|
||||
@@ -47,32 +44,27 @@ import {
|
||||
GithubDiscoveryProcessor,
|
||||
GithubOrgReaderProcessor,
|
||||
GitLabDiscoveryProcessor,
|
||||
HigherOrderOperation,
|
||||
HigherOrderOperations,
|
||||
LocationEntityProcessor,
|
||||
LocationReaders,
|
||||
PlaceholderProcessor,
|
||||
PlaceholderResolver,
|
||||
StaticLocationProcessor,
|
||||
UrlReaderProcessor,
|
||||
} from '../../ingestion';
|
||||
import {
|
||||
HigherOrderOperation,
|
||||
HigherOrderOperations,
|
||||
LocationReaders,
|
||||
} from '../ingestion';
|
||||
import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules';
|
||||
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
|
||||
import { DefaultCatalogRulesEnforcer } from '../../ingestion/CatalogRules';
|
||||
import { RepoLocationAnalyzer } from '../../ingestion/LocationAnalyzer';
|
||||
import {
|
||||
jsonPlaceholderResolver,
|
||||
textPlaceholderResolver,
|
||||
yamlPlaceholderResolver,
|
||||
} from '../ingestion/processors/PlaceholderProcessor';
|
||||
import { defaultEntityDataParser } from '../ingestion/processors/util/parse';
|
||||
import { LocationAnalyzer } from '../ingestion/types';
|
||||
import { NextCatalogBuilder } from '../next';
|
||||
|
||||
export type CatalogEnvironment = {
|
||||
logger: Logger;
|
||||
database: PluginDatabaseManager;
|
||||
config: Config;
|
||||
reader: UrlReader;
|
||||
};
|
||||
} from '../../ingestion/processors/PlaceholderProcessor';
|
||||
import { defaultEntityDataParser } from '../../ingestion/processors/util/parse';
|
||||
import { LocationAnalyzer } from '../../ingestion/types';
|
||||
import { CatalogEnvironment, NextCatalogBuilder } from '../../next';
|
||||
|
||||
/**
|
||||
* A builder that helps wire up all of the component parts of the catalog.
|
||||
@@ -92,6 +84,10 @@ export type CatalogEnvironment = {
|
||||
* - Processors can be added or replaced. These implement the functionality of
|
||||
* reading, parsing, validating, and processing the entity data before it is
|
||||
* persisted in the catalog.
|
||||
*
|
||||
* NOTE(freben): Not actually marking the class as deprecated formally, since
|
||||
* it would appear to end users that even using `create` is deprecated. We will
|
||||
* instead hot-swap the entire exported class when we are ready.
|
||||
*/
|
||||
export class CatalogBuilder {
|
||||
private readonly env: CatalogEnvironment;
|
||||
@@ -107,6 +103,7 @@ export class CatalogBuilder {
|
||||
return new NextCatalogBuilder(env);
|
||||
}
|
||||
|
||||
/** @deprecated Please use CatalogBuilder.create() instead */
|
||||
constructor(env: CatalogEnvironment) {
|
||||
this.env = env;
|
||||
this.entityPolicies = [];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user