Merge master into adr-plugin-adjustments

This commit is contained in:
Robert Bunning
2023-01-03 10:33:04 -05:00
114 changed files with 3020 additions and 1222 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Added `RootLifecycleService` and `rootLifecycleServiceRef`, as well as added a `labels` option to the existing `LifecycleServiceShutdownHook`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Removed unnecessary usage of `ThemeProvider` from the `ExampleComponent` test in the plugin template.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-test-utils': patch
'@backstage/backend-defaults': patch
---
Include implementations for the new `rootLifecycleServiceRef`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-bazaar': patch
---
Added `isBazaarAvailable` helper to be used with the `EntitySwitch`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-graph': patch
---
The link from the `CatalogGraphCard` to the `CatalogGraphPage` no longer includes an explicit `maxDepth` parameter, letting the `CatalogGraphPage` choose the initial `maxDepth` instead.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/backend-common': minor
---
**BREAKING**: Removed deprecated `read` method from the `UrlReader` interface. All implementations should use the `readUrl` method instead.
Migrated `UrlReader` and related types to `backend/backend-plugin-api`, types remain re-exported from `backend-common` for now.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-catalog-backend-module-github': patch
---
Added support for event based updates in the `GithubOrgEntityProvider`!
Based on webhook events from GitHub the affected `User` or `Group` entity will be refreshed.
This includes adding new entities, refreshing existing ones, and removing obsolete ones.
Please find more information at
https://backstage.io/docs/integrations/github/org#installation-with-events-support
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Migrate `UrlReader` into this package to gradually remove the dependency on backend-common.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-explore-backend': patch
'@backstage/plugin-explore': patch
---
Updated `README.md` examples
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-events-backend': patch
---
The default event broker will now catch and log errors thrown by the `onEvent` method of subscribers. The returned promise from `publish` method will also not resolve until all subscribers have handled the event.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': patch
---
The warning for missing app contents is now logged as an error instead, but only in production.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-openapi': patch
---
Updated internal usage of UrlReader interface.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/config': patch
---
Adds the ability to coerce values to their boolean representatives.
Values such as `"true"` `1` `on` and `y` will become `true` when using `getBoolean` and the opposites `false`.
This happens particularly when such parameters are used with environmental substitution as environment variables are always strings.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Updated implementations for the new `RootLifecycleService`.
+1 -1
View File
@@ -224,4 +224,4 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/
| [FanDuel](https://fanduel.com) | [Diego Herrera](https://github.com/diegoh), [Christy Campbell](https://github.com/FD-ChristopherCampbell) | We use backstage as our developer portal to provide visibility of our software, ownership, strategy, and the state of maturity across disciplines. |
| [Operate First](https://www.operate-first.cloud/) | [Tom Coufal](https://github.com/tumido), [Sam Kopecky](https://github.com/samokopecky) | Backstage provides us with a public service catalog and serves as a gateway to our community cloud. Our instance is publicly available to everyone [here](https://service-catalog.operate-first.cloud/) ([source](https://github.com/operate-first/service-catalog)) |
| [Tractable AI](https://tractable.ai/) | [Stephan Schielke](https://github.com/stephanschielke) | We are hitting a critical point in our scale (100+ engineers) and need to get a handle on discoverability and ownership. The Service Catalog, TechDocs and Search are essential to us to achieve that. |
| [Garanti BBVA](https://www.garantibbva.com.tr/) | [Caglar Cataloglu](https://github.com/crozwise) | We are using Backstage focusing on improving experience of developer, minimizing friction from idea to code. We call our portal as "Hyperspace" and very excited for our community (2000+ engineers) that finally we have a platform to boost our productivity!
| [Garanti BBVA Teknoloji](https://www.linkedin.com/company/garanti-teknoloji/) | [Caglar Cataloglu](https://github.com/crozwise) | We are using Backstage focusing on improving experience of developers, minimizing friction from idea to production. We call our portal as "Hyperspace" and very excited for our community (2000+ engineers) that finally we have a platform to boost our productivity!
@@ -111,10 +111,10 @@ export class FrobsProvider implements EntityProvider {
throw new Error('Not initialized');
}
const raw = await this.reader.read(
const response = await this.reader.readUrl(
`https://frobs-${this.env}.example.com/data`,
);
const data = JSON.parse(raw.toString());
const data = JSON.parse(await response.buffer()).toString();
/** [5] **/
const entities: Entity[] = frobsToEntities(data);
@@ -525,8 +525,8 @@ export class SystemXReaderProcessor implements CatalogProcessor {
// API. If you prefer, you can just use plain fetch here
// (from the node-fetch package), or any other method of
// your choosing.
const data = await this.reader.read(location.target);
const json = JSON.parse(data.toString());
const response = await this.reader.readUrl(location.target);
const json = JSON.parse((await response.buffer()).toString());
// Repeatedly call emit(processingResult.entity(location, <entity>))
} catch (error) {
const message = `Unable to read ${location.type}, ${error}`;
@@ -627,7 +627,7 @@ export class SystemXReaderProcessor implements CatalogProcessor {
// We send the ETag from the previous run if it exists.
// The previous ETag will be set in the headers for the outgoing request and system-x
// is going to throw NOT_MODIFIED (HTTP 304) if the ETag matches.
const response = await this.reader.readUrl?.(location.target, {
const response = await this.reader.readUrl(location.target, {
etag: cacheItem?.etag,
});
if (!response) {
+47 -1
View File
@@ -17,7 +17,7 @@ entities that mirror your org setup.
> provide authentication. See the
> [GitHub auth provider](../../auth/github/provider.md) for that.
## Installation
## Installation without Events Support
This guide will use the Entity Provider method. If you for some reason prefer
the Processor method (not recommended), it is described separately below.
@@ -60,6 +60,52 @@ schedule it:
+ );
```
## Installation with Events Support
Please follow the installation instructions at
- https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md
- https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-github/README.md
Additionally, you need to decide how you want to receive events from external sources like
- [via HTTP endpoint](https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md)
- [via an AWS SQS queue](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md)
Set up your provider
```diff
// packages/backend/src/plugins/catalogEventBasedProviders.ts
+import { GithubOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-github';
import { EntityProvider } from '@backstage/plugin-catalog-node';
import { EventSubscriber } from '@backstage/plugin-events-node';
import { PluginEnvironment } from '../types';
export default async function createCatalogEventBasedProviders(
- _: PluginEnvironment,
+ env: PluginEnvironment,
): Promise<Array<EntityProvider & EventSubscriber>> {
const providers: Array<
(EntityProvider & EventSubscriber) | Array<EntityProvider & EventSubscriber>
> = [];
- // add your event-based entity providers here
+ providers.push(
+ GithubOrgEntityProvider.fromConfig(env.config, {
+ id: 'production',
+ orgUrl: 'https://github.com/backstage',
+ logger: env.logger,
+ schedule: env.scheduler.createScheduledTaskRunner({
+ frequency: { minutes: 60 },
+ timeout: { minutes: 15 },
+ }),
+ }),
+ );
return providers.flat();
}
```
You can check the official docs to [configure your webhook](https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks) and to [secure your request](https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks).
The webhook will need to be configured to forward `organization`,`team` and `membership` events.
## Configuration
As mentioned above, you also must have some configuration in your app-config
+2 -3
View File
@@ -116,9 +116,8 @@ For packages at version `1.0.0` or above, the following policy also applies:
before it can be removed.
- The release of breaking changes document a clear upgrade path in the
changelog, both when deprecations are introduced and when they are removed.
- Exports that have been marked as `@alpha` or `@beta` may receive breaking
changes without a deprecation period, but the changes must still adhere to
semver.
- Breaking changes to `@alpha` or `@beta` exports must result in at least a minor
version bump, and may be done without a deprecation period.
### Changes that are Not Considered Breaking
+7 -25
View File
@@ -60,15 +60,7 @@ The generic interface of a URL Reader instance looks like this.
```ts
export type UrlReader = {
/* Used to read a single file and return its content. */
read(url: string): Promise<Buffer>;
/**
* A replacement for the read method that supports options and complex responses.
*
* Use this whenever it is available, as the read method will be deprecated and
* eventually removed in the future.
*/
readUrl?(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
/* Used to read a file tree and download as a directory. */
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
/* Used to search a file in a tree. */
@@ -102,8 +94,8 @@ backend plugins.
Once the reader instance is available inside the plugin, one of its methods can
directly be used with a URL. Some example usages -
- [`read`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts#L24-L33) -
Catalog using the `read` method to read the CODEOWNERS file in a repository.
- [`readUrl`](https://github.com/backstage/backstage/blob/a7607b5/plugins/catalog-backend/src/modules/codeowners/lib/read.ts#L24-L33) -
Catalog using the `readUrl` method to read the CODEOWNERS file in a repository.
- [`readTree`](https://github.com/backstage/backstage/blob/84a8788/plugins/techdocs-node/src/helpers.ts#L146-L167) -
TechDocs using the `readTree` method to download markdown files in order to
generate the documentation site.
@@ -155,11 +147,9 @@ all the methods of the `UrlReader` interface should be implemented. However it
is okay to start by implementing just one of them and create issues for the
remaining.
#### read
#### `readUrl`
NOTE: Use `readUrl` instead of `read`.
`read` method expects a user-friendly URL, something which can be copied from
`readUrl` method expects a user-friendly URL, something which can be copied from
the browser naturally when a person is browsing the provider in their browser.
- ✅ Valid URL :
@@ -168,18 +158,10 @@ the browser naturally when a person is browsing the provider in their browser.
`https://raw.githubusercontent.com/backstage/backstage/master/ADOPTERS.md`
- ❌ Not a valid URL : `https://github.com/backstage/backstage/ADOPTERS.md`
Upon receiving the URL, `read` converts the user-friendly URL into an API URL
Upon receiving the URL, `readUrl` converts the user-friendly URL into an API URL
which can be used to request the provider's API.
`read` then makes an authenticated request to the provider API and returns the
file's content.
#### `readUrl`
`readUrl` is a new interface that allows complex response objects and is
intended to replace the `read` method. This new method is currently optional to
implement which allows for a soft migration to `readUrl` instead of `read` in
the future.
`readUrl` then makes an authenticated request to the provider API and returns the response containing the file's contents and ETag(if the provider supports it).
#### `readTree`
+3 -3
View File
@@ -6749,11 +6749,11 @@ __metadata:
linkType: hard
"json5@npm:^2.1.2, json5@npm:^2.2.1":
version: 2.2.1
resolution: "json5@npm:2.2.1"
version: 2.2.3
resolution: "json5@npm:2.2.3"
bin:
json5: lib/cli.js
checksum: 74b8a23b102a6f2bf2d224797ae553a75488b5adbaee9c9b6e5ab8b510a2fc6e38f876d4c77dea672d4014a44b2399e15f2051ac2b37b87f74c0c7602003543b
checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349
languageName: node
linkType: hard
+5
View File
@@ -83,6 +83,11 @@ export const permissionsFactory: (
options?: undefined,
) => ServiceFactory<PermissionsService>;
// @public
export const rootLifecycleFactory: (
options?: undefined,
) => ServiceFactory<LifecycleService>;
// @public (undocumented)
export const rootLoggerFactory: (
options?: undefined,
@@ -26,4 +26,5 @@ export { tokenManagerFactory } from './tokenManagerService';
export { urlReaderFactory } from './urlReaderService';
export { httpRouterFactory } from './httpRouterService';
export { lifecycleFactory } from './lifecycleService';
export { rootLifecycleFactory } from './rootLifecycleService';
export type { HttpRouterFactoryOptions } from './httpRouterService';
@@ -14,65 +14,10 @@
* limitations under the License.
*/
import {
LifecycleService,
createServiceFactory,
coreServices,
loggerToWinstonLogger,
LifecycleServiceShutdownHook,
} from '@backstage/backend-plugin-api';
import { Logger } from 'winston';
const CALLBACKS = ['SIGTERM', 'SIGINT', 'beforeExit'];
export class BackendLifecycleImpl {
constructor(private readonly logger: Logger) {
CALLBACKS.map(signal => process.on(signal, () => this.shutdown()));
}
#isCalled = false;
#shutdownTasks: Array<LifecycleServiceShutdownHook & { pluginId: string }> =
[];
addShutdownHook(
options: LifecycleServiceShutdownHook & { pluginId: string },
): void {
this.#shutdownTasks.push(options);
}
async shutdown(): Promise<void> {
if (this.#isCalled) {
return;
}
this.#isCalled = true;
this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`);
await Promise.all(
this.#shutdownTasks.map(hook =>
Promise.resolve()
.then(() => hook.fn())
.catch(e => {
this.logger.error(
`Shutdown hook registered by plugin '${hook.pluginId}' failed with: ${e}`,
);
})
.then(() =>
this.logger.info(
`Successfully ran shutdown hook registered by plugin ${hook.pluginId}`,
),
),
),
);
}
}
class PluginScopedLifecycleImpl implements LifecycleService {
constructor(
private readonly lifecycle: BackendLifecycleImpl,
private readonly pluginId: string,
) {}
addShutdownHook(options: LifecycleServiceShutdownHook): void {
this.lifecycle.addShutdownHook({ ...options, pluginId: this.pluginId });
}
}
/**
* Allows plugins to register shutdown hooks that are run when the process is about to exit.
@@ -80,15 +25,20 @@ class PluginScopedLifecycleImpl implements LifecycleService {
export const lifecycleFactory = createServiceFactory({
service: coreServices.lifecycle,
deps: {
logger: coreServices.rootLogger,
plugin: coreServices.pluginMetadata,
rootLifecycle: coreServices.rootLifecycle,
pluginMetadata: coreServices.pluginMetadata,
},
async factory({ logger }) {
const rootLifecycle = new BackendLifecycleImpl(
loggerToWinstonLogger(logger),
);
return async ({ plugin }) => {
return new PluginScopedLifecycleImpl(rootLifecycle, plugin.getId());
async factory({ rootLifecycle }) {
return async ({ pluginMetadata }) => {
const plugin = pluginMetadata.getId();
return {
addShutdownHook(options: LifecycleServiceShutdownHook): void {
rootLifecycle.addShutdownHook({
...options,
labels: { ...options?.labels, plugin },
});
},
};
};
},
});
@@ -15,14 +15,14 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { BackendLifecycleImpl } from './lifecycleService';
import { BackendLifecycleImpl } from './rootLifecycleService';
describe('lifecycleService', () => {
it('should execute registered shutdown hook', async () => {
const service = new BackendLifecycleImpl(getVoidLogger());
const hook = jest.fn();
service.addShutdownHook({
pluginId: 'test',
labels: { plugin: 'test' },
fn: async () => {
hook();
},
@@ -37,7 +37,7 @@ describe('lifecycleService', () => {
it('should not throw errors', async () => {
const service = new BackendLifecycleImpl(getVoidLogger());
service.addShutdownHook({
pluginId: 'test',
labels: { plugin: 'test' },
fn: async () => {
throw new Error('oh no');
},
@@ -0,0 +1,69 @@
/*
* Copyright 2022 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 {
createServiceFactory,
coreServices,
loggerToWinstonLogger,
LifecycleServiceShutdownHook,
RootLifecycleService,
} from '@backstage/backend-plugin-api';
import { Logger } from 'winston';
const CALLBACKS = ['SIGTERM', 'SIGINT', 'beforeExit'];
export class BackendLifecycleImpl implements RootLifecycleService {
constructor(private readonly logger: Logger) {
CALLBACKS.map(signal => process.on(signal, () => this.shutdown()));
}
#isCalled = false;
#shutdownTasks: Array<LifecycleServiceShutdownHook> = [];
addShutdownHook(options: LifecycleServiceShutdownHook): void {
this.#shutdownTasks.push(options);
}
async shutdown(): Promise<void> {
if (this.#isCalled) {
return;
}
this.#isCalled = true;
this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`);
await Promise.all(
this.#shutdownTasks.map(async hook => {
try {
await hook.fn();
this.logger.info(`Shutdown hook succeeded`, hook.labels);
} catch (error) {
this.logger.error(`Shutdown hook failed, ${error}`, hook.labels);
}
}),
);
}
}
/**
* Allows plugins to register shutdown hooks that are run when the process is about to exit.
* @public */
export const rootLifecycleFactory = createServiceFactory({
service: coreServices.rootLifecycle,
deps: {
logger: coreServices.rootLogger,
},
async factory({ logger }) {
return new BackendLifecycleImpl(loggerToWinstonLogger(logger));
},
});
@@ -20,7 +20,7 @@ import {
coreServices,
ServiceRef,
} from '@backstage/backend-plugin-api';
import { BackendLifecycleImpl } from '../services/implementations/lifecycleService';
import { BackendLifecycleImpl } from '../services/implementations/rootLifecycleService';
import {
BackendRegisterInit,
EnumerableServiceHolder,
@@ -182,14 +182,13 @@ export class BackendInitializer {
}
const lifecycleService = await this.#serviceHolder.get(
coreServices.lifecycle,
coreServices.rootLifecycle,
'root',
);
// TODO(Rugvip): Find a better way to do this
const lifecycle = (lifecycleService as any)?.lifecycle;
if (lifecycle instanceof BackendLifecycleImpl) {
await lifecycle.shutdown();
if (lifecycleService instanceof BackendLifecycleImpl) {
await lifecycleService.shutdown();
} else {
throw new Error('Unexpected lifecycle service implementation');
}
+20 -60
View File
@@ -33,9 +33,19 @@ import { MergeResult } from 'isomorphic-git';
import { PushResult } from 'isomorphic-git';
import { Readable } from 'stream';
import { ReadCommitResult } from 'isomorphic-git';
import { ReadTreeOptions } from '@backstage/backend-plugin-api';
import { ReadTreeResponse } from '@backstage/backend-plugin-api';
import { ReadTreeResponseDirOptions } from '@backstage/backend-plugin-api';
import { ReadTreeResponseFile } from '@backstage/backend-plugin-api';
import { ReadUrlOptions } from '@backstage/backend-plugin-api';
import { ReadUrlResponse } from '@backstage/backend-plugin-api';
import { RequestHandler } from 'express';
import { Router } from 'express';
import { SearchOptions } from '@backstage/backend-plugin-api';
import { SearchResponse } from '@backstage/backend-plugin-api';
import { SearchResponseFile } from '@backstage/backend-plugin-api';
import { Server } from 'http';
import { UrlReaderService as UrlReader } from '@backstage/backend-plugin-api';
import { V1PodTemplateSpec } from '@kubernetes/client-node';
import * as winston from 'winston';
import { Writable } from 'stream';
@@ -533,30 +543,11 @@ export type ReaderFactory = (options: {
treeResponseFactory: ReadTreeResponseFactory;
}) => UrlReaderPredicateTuple[];
// @public
export type ReadTreeOptions = {
filter?(
path: string,
info?: {
size: number;
},
): boolean;
etag?: string;
signal?: AbortSignal;
};
export { ReadTreeOptions };
// @public
export type ReadTreeResponse = {
files(): Promise<ReadTreeResponseFile[]>;
archive(): Promise<NodeJS.ReadableStream>;
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
etag: string;
};
export { ReadTreeResponse };
// @public
export type ReadTreeResponseDirOptions = {
targetDir?: string;
};
export { ReadTreeResponseDirOptions };
// @public
export interface ReadTreeResponseFactory {
@@ -587,24 +578,11 @@ export type ReadTreeResponseFactoryOptions = {
) => boolean;
};
// @public
export type ReadTreeResponseFile = {
path: string;
content(): Promise<Buffer>;
};
export { ReadTreeResponseFile };
// @public
export type ReadUrlOptions = {
etag?: string;
signal?: AbortSignal;
};
export { ReadUrlOptions };
// @public
export type ReadUrlResponse = {
buffer(): Promise<Buffer>;
stream?(): Readable;
etag?: string;
};
export { ReadUrlResponse };
// @public
export class ReadUrlResponseFactory {
@@ -652,23 +630,11 @@ export type RunContainerOptions = {
pullImage?: boolean;
};
// @public
export type SearchOptions = {
etag?: string;
signal?: AbortSignal;
};
export { SearchOptions };
// @public
export type SearchResponse = {
files: SearchResponseFile[];
etag: string;
};
export { SearchResponse };
// @public
export type SearchResponseFile = {
url: string;
content(): Promise<Buffer>;
};
export { SearchResponseFile };
// @public
export class ServerTokenManager implements TokenManager {
@@ -755,13 +721,7 @@ export interface TokenManager {
}>;
}
// @public
export type UrlReader = {
read(url: string): Promise<Buffer>;
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
};
export { UrlReader };
// @public
export type UrlReaderPredicateTuple = {
+1
View File
@@ -34,6 +34,7 @@
"test:kubernetes": "backstage-cli package test -t KubernetesContainerRunner --no-watch"
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/cli-common": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/config-loader": "workspace:^",
@@ -117,7 +117,7 @@ export function parseUrl(
}
/**
* Implements a {@link UrlReader} for AWS S3 buckets.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for AWS S3 buckets.
*
* @public
*/
@@ -40,7 +40,7 @@ import {
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link UrlReader} for Azure repos.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for Azure repos.
*
* @public
*/
@@ -42,7 +42,7 @@ import {
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link UrlReader} for files from Bitbucket Cloud.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket Cloud.
*
* @public
*/
@@ -41,7 +41,7 @@ import {
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link UrlReader} for files from Bitbucket Server APIs.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket Server APIs.
*
* @public
*/
@@ -43,7 +43,7 @@ import {
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link UrlReader} for files from Bitbucket v1 and v2 APIs, such
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket v1 and v2 APIs, such
* as the one exposed by Bitbucket Cloud itself.
*
* @public
@@ -63,7 +63,7 @@ const parsePortPredicate = (port: string | undefined) => {
};
/**
* A {@link UrlReader} that does a plain fetch of the URL.
* A {@link @backstage/backend-plugin-api#UrlReaderService} that does a plain fetch of the URL.
*
* @public
*/
@@ -52,7 +52,7 @@ const createTemporaryDirectory = async (workDir: string): Promise<string> =>
await fs.mkdtemp(joinPath(workDir, '/gerrit-clone-'));
/**
* Implements a {@link UrlReader} for files in Gerrit.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files in Gerrit.
*
* @remarks
* To be able to link to Git contents for Gerrit providers in a user friendly
@@ -36,7 +36,7 @@ import {
import { Readable } from 'stream';
/**
* Implements a {@link UrlReader} for the Gitea v1 api.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for the Gitea v1 api.
*
* @public
*/
@@ -51,7 +51,7 @@ export type GhBlobResponse =
RestEndpointMethodTypes['git']['getBlob']['response']['data'];
/**
* Implements a {@link UrlReader} for files through the GitHub v3 APIs, such as
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files through the GitHub v3 APIs, such as
* the one exposed by GitHub itself.
*
* @public
@@ -42,7 +42,7 @@ import { trimEnd, trimStart } from 'lodash';
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link UrlReader} for files on GitLab.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files on GitLab.
*
* @public
*/
@@ -51,7 +51,7 @@ const parseURL = (
};
/**
* Implements a {@link UrlReader} for files on Google GCS.
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files on Google GCS.
*
* @public
*/
@@ -70,7 +70,6 @@ describe('UrlReaderPredicateMux', () => {
mux.register({
predicate: url => url.hostname === 'foo',
reader: {
read: jest.fn(),
readUrl: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
@@ -45,18 +45,6 @@ export class UrlReaderPredicateMux implements UrlReader {
this.readers.push(tuple);
}
async read(url: string): Promise<Buffer> {
const parsed = new URL(url);
for (const { predicate, reader } of this.readers) {
if (predicate(parsed)) {
return reader.read(url);
}
}
throw new NotAllowedError(notAllowedMessage(url));
}
async readUrl(
url: string,
options?: ReadUrlOptions,
@@ -32,7 +32,7 @@ import { AwsS3UrlReader } from './AwsS3UrlReader';
import { GiteaUrlReader } from './GiteaUrlReader';
/**
* Creation options for {@link UrlReaders}.
* Creation options for {@link @backstage/backend-plugin-api#UrlReaderService}.
*
* @public
*/
@@ -46,13 +46,13 @@ export type UrlReadersOptions = {
};
/**
* Helps construct {@link UrlReader}s.
* Helps construct {@link @backstage/backend-plugin-api#UrlReaderService}s.
*
* @public
*/
export class UrlReaders {
/**
* Creates a custom {@link UrlReader} wrapper for your own set of factories.
* Creates a custom {@link @backstage/backend-plugin-api#UrlReaderService} wrapper for your own set of factories.
*/
static create(options: UrlReadersOptions): UrlReader {
const { logger, config, factories } = options;
@@ -73,7 +73,7 @@ export class UrlReaders {
}
/**
* Creates a {@link UrlReader} wrapper that includes all the default factories
* Creates a {@link @backstage/backend-plugin-api#UrlReaderService} wrapper that includes all the default factories
* from this package.
*
* Any additional factories passed will be loaded before the default ones.
+20 -280
View File
@@ -17,56 +17,38 @@
import { Readable } from 'stream';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import {
UrlReaderService,
ReadTreeResponse,
} from '@backstage/backend-plugin-api';
export type {
UrlReaderService as UrlReader,
ReadTreeOptions,
ReadTreeResponse,
ReadTreeResponseDirOptions,
ReadTreeResponseFile,
ReadUrlResponse,
ReadUrlOptions,
SearchOptions,
SearchResponse,
SearchResponseFile,
} from '@backstage/backend-plugin-api';
/**
* A generic interface for fetching plain data from URLs.
*
* @public
*/
export type UrlReader = {
/**
* Reads a single file and return its content.
* @deprecated use readUrl instead.
*/
read(url: string): Promise<Buffer>;
/**
* Reads a single file and return its content.
*
* @remarks
*
* This is a replacement for the read method that supports options and
* complex responses.
*
* Use this as the read method will be removed in a future release.
*/
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
/**
* Reads a full or partial file tree.
*/
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
/**
* Searches for a file in a tree using a glob pattern.
*/
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
};
/**
* A predicate that decides whether a specific {@link UrlReader} can handle a
* A predicate that decides whether a specific {@link @backstage/backend-plugin-api#UrlReaderService} can handle a
* given URL.
*
* @public
*/
export type UrlReaderPredicateTuple = {
predicate: (url: URL) => boolean;
reader: UrlReader;
reader: UrlReaderService;
};
/**
* A factory function that can read config to construct zero or more
* {@link UrlReader}s along with a predicate for when it should be used.
* {@link @backstage/backend-plugin-api#UrlReaderService}s along with a predicate for when it should be used.
*
* @public
*/
@@ -76,72 +58,6 @@ export type ReaderFactory = (options: {
treeResponseFactory: ReadTreeResponseFactory;
}) => UrlReaderPredicateTuple[];
/**
* An options object for readUrl operations.
*
* @public
*/
export type ReadUrlOptions = {
/**
* An ETag which can be provided to check whether a
* {@link UrlReader.readUrl} response has changed from a previous execution.
*
* @remarks
*
* In the {@link UrlReader.readUrl} response, an ETag is returned along with
* the data. The ETag is a unique identifier of the data, usually the commit
* SHA or ETag from the target.
*
* When an ETag is given in ReadUrlOptions, {@link UrlReader.readUrl} will
* first compare the ETag against the ETag of the target. If they match,
* {@link UrlReader.readUrl} will throw a
* {@link @backstage/errors#NotModifiedError} indicating that the response
* will not differ from the previous response which included this particular
* ETag. If they do not match, {@link UrlReader.readUrl} will return the rest
* of the response along with a new ETag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
* A response object for {@link UrlReader.readUrl} operations.
*
* @public
*/
export type ReadUrlResponse = {
/**
* Returns the data that was read from the remote URL.
*/
buffer(): Promise<Buffer>;
/**
* Returns the data that was read from the remote URL as a Readable stream.
*
* @remarks
*
* This method will be required in a future release.
*/
stream?(): Readable;
/**
* Etag returned by content provider.
*
* @remarks
*
* Can be used to compare and cache responses when doing subsequent calls.
*/
etag?: string;
};
/**
* An options object for {@link ReadUrlResponseFactory} factory methods.
*
@@ -151,119 +67,6 @@ export type ReadUrlResponseFactoryFromStreamOptions = {
etag?: string;
};
/**
* An options object for {@link UrlReader.readTree} operations.
*
* @public
*/
export type ReadTreeOptions = {
/**
* A filter that can be used to select which files should be included.
*
* @remarks
*
* The path passed to the filter function is the relative path from the URL
* that the file tree is fetched from, without any leading '/'.
*
* For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file
* at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will
* be represented as my-subdir/my-file.txt
*
* If no filter is provided, all files are extracted.
*/
filter?(path: string, info?: { size: number }): boolean;
/**
* An ETag which can be provided to check whether a
* {@link UrlReader.readTree} response has changed from a previous execution.
*
* @remarks
*
* In the {@link UrlReader.readTree} response, an ETag is returned along with
* the tree blob. The ETag is a unique identifier of the tree blob, usually
* the commit SHA or ETag from the target.
*
* When an ETag is given as a request option, {@link UrlReader.readTree} will
* first compare the ETag against the ETag on the target branch. If they
* match, {@link UrlReader.readTree} will throw a
* {@link @backstage/errors#NotModifiedError} indicating that the response
* will not differ from the previous response which included this particular
* ETag. If they do not match, {@link UrlReader.readTree} will return the
* rest of the response along with a new ETag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
* Options that control {@link ReadTreeResponse.dir} execution.
*
* @public
*/
export type ReadTreeResponseDirOptions = {
/**
* The directory to write files to.
*
* @remarks
*
* Defaults to the OS tmpdir, or `backend.workingDirectory` if set in config.
*/
targetDir?: string;
};
/**
* A response object for {@link UrlReader.readTree} operations.
*
* @public
*/
export type ReadTreeResponse = {
/**
* Returns an array of all the files inside the tree, and corresponding
* functions to read their content.
*/
files(): Promise<ReadTreeResponseFile[]>;
/**
* Returns the tree contents as a binary archive, using a stream.
*/
archive(): Promise<NodeJS.ReadableStream>;
/**
* Extracts the tree response into a directory and returns the path of the
* directory.
*
* **NOTE**: It is the responsibility of the caller to remove the directory after use.
*/
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
/**
* Etag returned by content provider.
*
* @remarks
*
* Can be used to compare and cache responses when doing subsequent calls.
*/
etag: string;
};
/**
* Represents a single file in a {@link UrlReader.readTree} response.
*
* @public
*/
export type ReadTreeResponseFile = {
path: string;
content(): Promise<Buffer>;
};
/**
* Options that control execution of {@link ReadTreeResponseFactory} methods.
*
@@ -315,66 +118,3 @@ export interface ReadTreeResponseFactory {
options: FromReadableArrayOptions,
): Promise<ReadTreeResponse>;
}
/**
* An options object for search operations.
*
* @public
*/
export type SearchOptions = {
/**
* An etag can be provided to check whether the search response has changed from a previous execution.
*
* In the search() response, an etag is returned along with the files. The etag is a unique identifier
* of the current tree, usually the commit SHA or etag from the target.
*
* When an etag is given in SearchOptions, search will first compare the etag against the etag
* on the target branch. If they match, search will throw a NotModifiedError indicating that the search
* response will not differ from the previous response which included this particular etag. If they mismatch,
* search will return the rest of SearchResponse along with a new etag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
* The output of a search operation.
*
* @public
*/
export type SearchResponse = {
/**
* The files that matched the search query.
*/
files: SearchResponseFile[];
/**
* A unique identifier of the current remote tree, usually the commit SHA or etag from the target.
*/
etag: string;
};
/**
* Represents a single file in a search response.
*
* @public
*/
export type SearchResponseFile = {
/**
* The full URL to the file.
*/
url: string;
/**
* The binary contents of the file.
*/
content(): Promise<Buffer>;
};
@@ -23,6 +23,7 @@ import {
discoveryFactory,
httpRouterFactory,
lifecycleFactory,
rootLifecycleFactory,
loggerFactory,
permissionsFactory,
rootLoggerFactory,
@@ -45,6 +46,7 @@ export const defaultServiceFactories = [
urlReaderFactory,
httpRouterFactory,
lifecycleFactory,
rootLifecycleFactory,
];
/**
+80 -4
View File
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { Config } from '@backstage/config';
import { Handler } from 'express';
import { Logger } from 'winston';
@@ -12,9 +14,9 @@ import { PluginCacheManager } from '@backstage/backend-common';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { Readable } from 'stream';
import { TokenManager } from '@backstage/backend-common';
import { TransportStreamOptions } from 'winston-transport';
import { UrlReader } from '@backstage/backend-common';
// @public (undocumented)
export interface BackendFeature {
@@ -89,6 +91,7 @@ declare namespace coreServices {
tokenManagerServiceRef as tokenManager,
permissionsServiceRef as permissions,
schedulerServiceRef as scheduler,
rootLifecycleServiceRef as rootLifecycle,
rootLoggerServiceRef as rootLogger,
pluginMetadataServiceRef as pluginMetadata,
lifecycleServiceRef as lifecycle,
@@ -200,6 +203,7 @@ const lifecycleServiceRef: ServiceRef<LifecycleService, 'plugin'>;
// @public (undocumented)
export type LifecycleServiceShutdownHook = {
fn: () => void | Promise<void>;
labels?: Record<string, string>;
};
// @public (undocumented)
@@ -245,6 +249,56 @@ export interface PluginMetadataService {
// @public (undocumented)
const pluginMetadataServiceRef: ServiceRef<PluginMetadataService, 'plugin'>;
// @public
export type ReadTreeOptions = {
filter?(
path: string,
info?: {
size: number;
},
): boolean;
etag?: string;
signal?: AbortSignal;
};
// @public
export type ReadTreeResponse = {
files(): Promise<ReadTreeResponseFile[]>;
archive(): Promise<NodeJS.ReadableStream>;
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
etag: string;
};
// @public
export type ReadTreeResponseDirOptions = {
targetDir?: string;
};
// @public
export type ReadTreeResponseFile = {
path: string;
content(): Promise<Buffer>;
};
// @public
export type ReadUrlOptions = {
etag?: string;
signal?: AbortSignal;
};
// @public
export type ReadUrlResponse = {
buffer(): Promise<Buffer>;
stream?(): Readable;
etag?: string;
};
// @public (undocumented)
export type RootLifecycleService = LifecycleService;
// @public (undocumented)
const rootLifecycleServiceRef: ServiceRef<LifecycleService, 'root'>;
// @public (undocumented)
export type RootLoggerService = LoggerService;
@@ -257,6 +311,24 @@ export type SchedulerService = PluginTaskScheduler;
// @public (undocumented)
const schedulerServiceRef: ServiceRef<PluginTaskScheduler, 'plugin'>;
// @public
export type SearchOptions = {
etag?: string;
signal?: AbortSignal;
};
// @public
export type SearchResponse = {
files: SearchResponseFile[];
etag: string;
};
// @public
export type SearchResponseFile = {
url: string;
content(): Promise<Buffer>;
};
// @public (undocumented)
export type ServiceFactory<TService = unknown> =
| {
@@ -307,9 +379,13 @@ export type TypesToServiceRef<T> = {
[key in keyof T]: ServiceRef<T[key]>;
};
// @public (undocumented)
export type UrlReaderService = UrlReader;
// @public
export type UrlReaderService = {
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
};
// @public (undocumented)
const urlReaderServiceRef: ServiceRef<UrlReader, 'plugin'>;
const urlReaderServiceRef: ServiceRef<UrlReaderService, 'plugin'>;
```
@@ -24,6 +24,7 @@ export { discoveryServiceRef as discovery } from './discoveryServiceRef';
export { tokenManagerServiceRef as tokenManager } from './tokenManagerServiceRef';
export { permissionsServiceRef as permissions } from './permissionsServiceRef';
export { schedulerServiceRef as scheduler } from './schedulerServiceRef';
export { rootLifecycleServiceRef as rootLifecycle } from './rootLifecycleServiceRef';
export { rootLoggerServiceRef as rootLogger } from './rootLoggerServiceRef';
export { pluginMetadataServiceRef as pluginMetadata } from './pluginMetadataServiceRef';
export { lifecycleServiceRef as lifecycle } from './lifecycleServiceRef';
@@ -29,7 +29,19 @@ export type {
export type { LoggerService, LogMeta } from './loggerServiceRef';
export type { PermissionsService } from './permissionsServiceRef';
export type { PluginMetadataService } from './pluginMetadataServiceRef';
export type { RootLifecycleService } from './rootLifecycleServiceRef';
export type { RootLoggerService } from './rootLoggerServiceRef';
export type { SchedulerService } from './schedulerServiceRef';
export type { TokenManagerService } from './tokenManagerServiceRef';
export type { UrlReaderService } from './urlReaderServiceRef';
export type {
ReadTreeOptions,
ReadTreeResponse,
ReadTreeResponseDirOptions,
ReadTreeResponseFile,
ReadUrlResponse,
ReadUrlOptions,
SearchOptions,
SearchResponse,
SearchResponseFile,
UrlReaderService,
} from './urlReaderServiceRef';
@@ -21,6 +21,9 @@ import { createServiceRef } from '../system/types';
**/
export type LifecycleServiceShutdownHook = {
fn: () => void | Promise<void>;
/** Labels to help identify the shutdown hook */
labels?: Record<string, string>;
};
/**
@@ -0,0 +1,29 @@
/*
* Copyright 2022 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 { createServiceRef } from '../system/types';
import { LifecycleService } from './lifecycleServiceRef';
/** @public */
export type RootLifecycleService = LifecycleService;
/**
* @public
*/
export const rootLifecycleServiceRef = createServiceRef<RootLifecycleService>({
id: 'core.rootLifecycle',
scope: 'root',
});
@@ -15,10 +15,271 @@
*/
import { createServiceRef } from '../system/types';
import { UrlReader } from '@backstage/backend-common';
import { Readable } from 'stream';
/** @public */
export type UrlReaderService = UrlReader;
/**
* A generic interface for fetching plain data from URLs.
*
* @public
*/
export type UrlReaderService = {
/**
* Reads a single file and return its content.
*/
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
/**
* Reads a full or partial file tree.
*/
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
/**
* Searches for a file in a tree using a glob pattern.
*/
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
};
/**
* An options object for readUrl operations.
*
* @public
*/
export type ReadUrlOptions = {
/**
* An ETag which can be provided to check whether a
* {@link UrlReaderService.readUrl} response has changed from a previous execution.
*
* @remarks
*
* In the {@link UrlReaderService.readUrl} response, an ETag is returned along with
* the data. The ETag is a unique identifier of the data, usually the commit
* SHA or ETag from the target.
*
* When an ETag is given in ReadUrlOptions, {@link UrlReaderService.readUrl} will
* first compare the ETag against the ETag of the target. If they match,
* {@link UrlReaderService.readUrl} will throw a
* {@link @backstage/errors#NotModifiedError} indicating that the response
* will not differ from the previous response which included this particular
* ETag. If they do not match, {@link UrlReaderService.readUrl} will return the rest
* of the response along with a new ETag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
* A response object for {@link UrlReaderService.readUrl} operations.
*
* @public
*/
export type ReadUrlResponse = {
/**
* Returns the data that was read from the remote URL.
*/
buffer(): Promise<Buffer>;
/**
* Returns the data that was read from the remote URL as a Readable stream.
*
* @remarks
*
* This method will be required in a future release.
*/
stream?(): Readable;
/**
* Etag returned by content provider.
*
* @remarks
*
* Can be used to compare and cache responses when doing subsequent calls.
*/
etag?: string;
};
/**
* An options object for {@link UrlReaderService.readTree} operations.
*
* @public
*/
export type ReadTreeOptions = {
/**
* A filter that can be used to select which files should be included.
*
* @remarks
*
* The path passed to the filter function is the relative path from the URL
* that the file tree is fetched from, without any leading '/'.
*
* For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file
* at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will
* be represented as my-subdir/my-file.txt
*
* If no filter is provided, all files are extracted.
*/
filter?(path: string, info?: { size: number }): boolean;
/**
* An ETag which can be provided to check whether a
* {@link UrlReaderService.readTree} response has changed from a previous execution.
*
* @remarks
*
* In the {@link UrlReaderService.readTree} response, an ETag is returned along with
* the tree blob. The ETag is a unique identifier of the tree blob, usually
* the commit SHA or ETag from the target.
*
* When an ETag is given as a request option, {@link UrlReaderService.readTree} will
* first compare the ETag against the ETag on the target branch. If they
* match, {@link UrlReaderService.readTree} will throw a
* {@link @backstage/errors#NotModifiedError} indicating that the response
* will not differ from the previous response which included this particular
* ETag. If they do not match, {@link UrlReaderService.readTree} will return the
* rest of the response along with a new ETag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
* Options that control {@link ReadTreeResponse.dir} execution.
*
* @public
*/
export type ReadTreeResponseDirOptions = {
/**
* The directory to write files to.
*
* @remarks
*
* Defaults to the OS tmpdir, or `backend.workingDirectory` if set in config.
*/
targetDir?: string;
};
/**
* A response object for {@link UrlReaderService.readTree} operations.
*
* @public
*/
export type ReadTreeResponse = {
/**
* Returns an array of all the files inside the tree, and corresponding
* functions to read their content.
*/
files(): Promise<ReadTreeResponseFile[]>;
/**
* Returns the tree contents as a binary archive, using a stream.
*/
archive(): Promise<NodeJS.ReadableStream>;
/**
* Extracts the tree response into a directory and returns the path of the
* directory.
*
* **NOTE**: It is the responsibility of the caller to remove the directory after use.
*/
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
/**
* Etag returned by content provider.
*
* @remarks
*
* Can be used to compare and cache responses when doing subsequent calls.
*/
etag: string;
};
/**
* Represents a single file in a {@link UrlReaderService.readTree} response.
*
* @public
*/
export type ReadTreeResponseFile = {
path: string;
content(): Promise<Buffer>;
};
/**
* An options object for search operations.
*
* @public
*/
export type SearchOptions = {
/**
* An etag can be provided to check whether the search response has changed from a previous execution.
*
* In the search() response, an etag is returned along with the files. The etag is a unique identifier
* of the current tree, usually the commit SHA or etag from the target.
*
* When an etag is given in SearchOptions, search will first compare the etag against the etag
* on the target branch. If they match, search will throw a NotModifiedError indicating that the search
* response will not differ from the previous response which included this particular etag. If they mismatch,
* search will return the rest of SearchResponse along with a new etag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
* The output of a search operation.
*
* @public
*/
export type SearchResponse = {
/**
* The files that matched the search query.
*/
files: SearchResponseFile[];
/**
* A unique identifier of the current remote tree, usually the commit SHA or etag from the target.
*/
etag: string;
};
/**
* Represents a single file in a search response.
*
* @public
*/
export type SearchResponseFile = {
/**
* The full URL to the file.
*/
url: string;
/**
* The binary contents of the file.
*/
content(): Promise<Buffer>;
};
/**
* @public
@@ -18,6 +18,7 @@ import {
Backend,
createSpecializedBackend,
lifecycleFactory,
rootLifecycleFactory,
loggerFactory,
rootLoggerFactory,
} from '@backstage/backend-app-api';
@@ -57,6 +58,7 @@ const defaultServiceFactories = [
rootLoggerFactory(),
loggerFactory(),
lifecycleFactory(),
rootLifecycleFactory(),
];
const backendInstancesToCleanUp = new Array<Backend>();
@@ -1,7 +1,5 @@
import React from 'react';
import { ExampleComponent } from './ExampleComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { screen } from '@testing-library/react';
@@ -23,11 +21,7 @@ describe('ExampleComponent', () => {
});
it('should render', async () => {
await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<ExampleComponent />
</ThemeProvider>,
);
await renderInTestApp(<ExampleComponent />);
expect(screen.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
});
});
+21 -1
View File
@@ -22,6 +22,15 @@ const DATA = {
one: 1,
true: true,
false: false,
yes: 'yes',
no: 'no',
y: 'y',
n: 'n',
on: 'on',
off: 'off',
zeroString: '0',
oneString: '1',
stringFalse: 'false',
null: null,
string: 'string',
emptyString: '',
@@ -53,6 +62,17 @@ function expectValidValues(config: ConfigReader) {
expect(config.getOptional('true')).toBe(true);
expect(config.getBoolean('true')).toBe(true);
expect(config.getBoolean('false')).toBe(false);
expect(config.getBoolean('stringFalse')).toBe(false);
expect(config.getBoolean('zero')).toBe(false);
expect(config.getBoolean('one')).toBe(true);
expect(config.getBoolean('zeroString')).toBe(false);
expect(config.getBoolean('oneString')).toBe(true);
expect(config.getBoolean('yes')).toBe(true);
expect(config.getBoolean('no')).toBe(false);
expect(config.getBoolean('y')).toBe(true);
expect(config.getBoolean('n')).toBe(false);
expect(config.getBoolean('on')).toBe(true);
expect(config.getBoolean('off')).toBe(false);
expect(config.getString('string')).toBe('string');
expect(config.get('strings')).toEqual(['string1', 'string2']);
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
@@ -86,7 +106,7 @@ function expectValidValues(config: ConfigReader) {
function expectInvalidValues(config: ConfigReader) {
expect(() => config.getBoolean('string')).toThrow(
"Invalid type in config for key 'string' in 'ctx', got string, wanted boolean",
"Unable to convert config value for key 'string' in 'ctx' to a boolean",
);
expect(() => config.getNumber('string')).toThrow(
"Unable to convert config value for key 'string' in 'ctx' to a number",
+17 -2
View File
@@ -280,10 +280,25 @@ export class ConfigReader implements Config {
/** {@inheritdoc Config.getOptionalBoolean} */
getOptionalBoolean(key: string): boolean | undefined {
return this.readConfigValue(
const value = this.readConfigValue<string | number | boolean>(
key,
value => typeof value === 'boolean' || { expected: 'boolean' },
val =>
typeof val === 'boolean' ||
typeof val === 'number' ||
typeof val === 'string' || { expected: 'boolean' },
);
if (typeof value === 'boolean' || value === undefined) {
return value;
}
const valueString = String(value).trim();
if (/^(?:y|yes|true|1|on)$/i.test(valueString)) {
return true;
}
if (/^(?:n|no|false|0|off)$/i.test(valueString)) {
return false;
}
throw new Error(errors.convert(this.fullKey(key), this.context, 'boolean'));
}
/** {@inheritdoc Config.getString} */
@@ -14,12 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import {
createTheme,
makeStyles,
MuiThemeProvider,
TextField,
} from '@material-ui/core';
import { makeStyles, TextField } from '@material-ui/core';
import { Context } from '../ContextProvider';
const useStyles = makeStyles({
@@ -28,30 +23,12 @@ const useStyles = makeStyles({
gap: '1em',
flexWrap: 'wrap',
},
});
const textFieldTheme = createTheme({
palette: {
type: 'dark',
primary: {
light: '#fff',
main: '#fff',
dark: '#fff',
contrastText: '#fff',
},
secondary: {
light: '#fff',
main: '#fff',
dark: '#fff',
contrastText: '#fff',
},
action: {
disabled: '#fff',
},
text: {
primary: '#fff',
secondary: '#fff',
},
label: {
color: '#fff !important',
},
outline: {
color: '#fff !important',
borderColor: '#fff !important',
},
});
@@ -62,16 +39,16 @@ export const ApiBar = () => {
<Context.Consumer>
{value => (
<div className={classes.root}>
<MuiThemeProvider theme={textFieldTheme}>
<TextField
label="Project ID"
variant="outlined"
defaultValue={value.projectId}
onChange={e =>
value.setProjectId?.(parseInt(e.target.value, 10) || undefined)
}
/>
</MuiThemeProvider>
<TextField
label="Project ID"
variant="outlined"
defaultValue={value.projectId}
InputLabelProps={{ classes: { root: classes.label } }}
InputProps={{ classes: { notchedOutline: classes.outline } }}
onChange={e =>
value.setProjectId?.(parseInt(e.target.value, 10) || undefined)
}
/>
</div>
)}
</Context.Consumer>
@@ -15,8 +15,6 @@
*/
import React from 'react';
import { AllureReportComponent } from './AllureReportComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
@@ -39,17 +37,15 @@ describe('ExampleComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<EntityProvider
entity={{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test' },
}}
>
<AllureReportComponent />
</EntityProvider>
</ThemeProvider>,
<EntityProvider
entity={{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test' },
}}
>
<AllureReportComponent />
</EntityProvider>,
);
expect(rendered.getByText('Missing Annotation')).toBeInTheDocument();
});
+5 -3
View File
@@ -96,9 +96,11 @@ export async function createRouter(
const staticDir = resolvePath(appDistDir, 'static');
if (!(await fs.pathExists(staticDir))) {
logger.warn(
`Can't serve static app content from ${staticDir}, directory doesn't exist`,
);
if (process.env.NODE_ENV === 'production') {
logger.error(
`Can't serve static app content from ${staticDir}, directory doesn't exist`,
);
}
return Router();
}
@@ -255,7 +255,6 @@ describe('replaceReadme', () => {
}),
readTree: jest.fn(),
search: jest.fn(),
read: jest.fn(),
};
const result = await replaceReadme(
+8 -5
View File
@@ -49,17 +49,20 @@ Add a **Bazaar icon** to the Sidebar to easily access the Bazaar. In `packages/a
Add a **Bazaar card** to the overview tab on the `packages/app/src/components/catalog/EntityPage.tsx` add:
```diff
+ import { EntityBazaarInfoCard } from '@backstage/plugin-bazaar';
+ import { EntityBazaarInfoCard, isBazaarAvailable } from '@backstage/plugin-bazaar';
const overviewContent = (
<Grid item md={8} xs={12}>
<EntityAboutCard variant="gridItem" />
</Grid>
+ <Grid item sm={6}>
+ <EntityBazaarInfoCard />
+ </Grid>
+ <EntitySwitch>
+ <EntitySwitch.Case if={isBazaarAvailable}>
+ <Grid item sm={6}>
+ <EntityBazaarInfoCard />
+ </Grid>
+ </EntitySwitch.Case>
+ </EntitySwitch>
{/* ...other entity-cards */}
```
+10
View File
@@ -5,7 +5,9 @@
```ts
/// <reference types="react" />
import { ApiHolder } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { RouteRef } from '@backstage/core-plugin-api';
// @public (undocumented)
@@ -36,6 +38,14 @@ export const bazaarPlugin: BackstagePlugin<
// @public (undocumented)
export const EntityBazaarInfoCard: () => JSX.Element | null;
// @public (undocumented)
export const isBazaarAvailable: (
entity: Entity,
context: {
apis: ApiHolder;
},
) => Promise<boolean>;
// @public (undocumented)
export const SortView: () => JSX.Element;
+21
View File
@@ -14,7 +14,9 @@
* limitations under the License.
*/
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
ApiHolder,
createApiRef,
DiscoveryApi,
FetchApi,
@@ -46,6 +48,25 @@ export interface BazaarApi {
deleteProject(id: number): Promise<void>;
}
/** @public */
export const isBazaarAvailable = async (
entity: Entity,
context: { apis: ApiHolder },
): Promise<boolean> => {
const bazaarClient = context.apis.get(bazaarApiRef);
if (bazaarClient === undefined) {
return false;
}
const entityRef = stringifyEntityRef({
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace,
});
const response = await bazaarClient.getProjectByRef(entityRef);
const project = await response.json();
return project.data.length > 0;
};
export class BazaarClient implements BazaarApi {
private readonly identityApi: IdentityApi;
private readonly discoveryApi: DiscoveryApi;
+1
View File
@@ -15,6 +15,7 @@
*/
export { bazaarPlugin, BazaarPage } from './plugin';
export { isBazaarAvailable } from './api';
export { BazaarOverviewCard } from './components/BazaarOverviewCard';
export type { BazaarOverviewCardProps } from './components/BazaarOverviewCard';
export { EntityBazaarInfoCard } from './components/EntityBazaarInfoCard';
@@ -178,7 +178,9 @@ export class GitHubOrgEntityProvider extends GithubOrgEntityProvider {
}
// @public
export class GithubOrgEntityProvider implements EntityProvider {
export class GithubOrgEntityProvider
implements EntityProvider, EventSubscriber
{
constructor(options: {
id: string;
orgUrl: string;
@@ -197,7 +199,11 @@ export class GithubOrgEntityProvider implements EntityProvider {
): GithubOrgEntityProvider;
// (undocumented)
getProviderName(): string;
// (undocumented)
onEvent(params: EventParams): Promise<void>;
read(options?: { logger?: Logger }): Promise<void>;
// (undocumented)
supportsEventTopics(): string[];
}
// @public @deprecated (undocumented)
@@ -29,6 +29,9 @@ import {
QueryResponse,
GithubUser,
GithubTeam,
createAddEntitiesOperation,
createRemoveEntitiesOperation,
createReplaceEntitiesOperation,
} from './github';
import fetch from 'node-fetch';
@@ -557,4 +560,107 @@ describe('github', () => {
).resolves.toEqual(output);
});
});
describe('createAddEntitiesOperation', () => {
it('create a function to add deferred entities to a delta operation', () => {
const operation = createAddEntitiesOperation('my-id', 'host');
const userEntity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'githubuser',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/githubuser',
'backstage.io/managed-by-origin-location':
'url:https://github.com/githubuser',
'github.com/user-login': 'githubuser',
},
},
spec: {
memberOf: ['new-team'],
},
};
expect(operation('org', [userEntity])).toEqual({
added: [
{
locationKey: 'github-org-provider:my-id',
entity: userEntity,
},
],
removed: [],
});
});
});
describe('createRemoveEntitiesOperation', () => {
it('create a function to remove deferred entities to a delta operation', () => {
const operation = createRemoveEntitiesOperation('my-id', 'host');
const userEntity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'githubuser',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/githubuser',
'backstage.io/managed-by-origin-location':
'url:https://github.com/githubuser',
'github.com/user-login': 'githubuser',
},
},
spec: {
memberOf: ['new-team'],
},
};
expect(operation('org', [userEntity])).toEqual({
removed: [
{
locationKey: 'github-org-provider:my-id',
entity: userEntity,
},
],
added: [],
});
});
});
describe('createReplaceEntitiesOperation', () => {
it('create a function to replace deferred entities to a delta operation', () => {
const operation = createReplaceEntitiesOperation('my-id', 'host');
const userEntity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'githubuser',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/githubuser',
'backstage.io/managed-by-origin-location':
'url:https://github.com/githubuser',
'github.com/user-login': 'githubuser',
},
},
spec: {
memberOf: ['new-team'],
},
};
expect(operation('org', [userEntity])).toEqual({
removed: [
{
locationKey: 'github-org-provider:my-id',
entity: userEntity,
},
],
added: [
{
locationKey: 'github-org-provider:my-id',
entity: userEntity,
},
],
});
});
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
import { Entity, GroupEntity, UserEntity } from '@backstage/catalog-model';
import { GithubCredentialType } from '@backstage/integration';
import { graphql } from '@octokit/graphql';
import {
@@ -24,6 +24,9 @@ import {
TransformerContext,
UserTransformer,
} from './defaultTransformers';
import { withLocations } from '../providers/GithubOrgEntityProvider';
import { DeferredEntity } from '@backstage/plugin-catalog-backend';
// Graphql types
@@ -191,7 +194,14 @@ export async function getOrganizationTeams(
parentTeam { slug }
members(first: 100, membership: IMMEDIATE) {
pageInfo { hasNextPage }
nodes { login }
nodes {
avatarUrl,
bio,
email,
login,
name,
organizationVerifiedDomainEmails(login: $org)
}
}
}
}
@@ -238,6 +248,164 @@ export async function getOrganizationTeams(
return { groups };
}
export async function getOrganizationTeamsFromUsers(
client: typeof graphql,
org: string,
userLogins: string[],
teamTransformer: TeamTransformer = defaultOrganizationTeamTransformer,
): Promise<{
groups: GroupEntity[];
}> {
const query = `
query teams($org: String!, $cursor: String, $userLogins: [String!] = "") {
organization(login: $org) {
teams(first: 100, after: $cursor, userLogins: $userLogins) {
pageInfo {
hasNextPage
endCursor
}
nodes {
slug
combinedSlug
name
description
avatarUrl
editTeamUrl
parentTeam {
slug
}
members(first: 100, membership: IMMEDIATE) {
pageInfo {
hasNextPage
}
nodes {
avatarUrl,
bio,
email,
login,
name,
organizationVerifiedDomainEmails(login: $org)
}
}
}
}
}
}`;
const materialisedTeams = async (
item: GithubTeamResponse,
ctx: TransformerContext,
): Promise<GroupEntity | undefined> => {
const memberNames: GithubUser[] = [];
if (!item.members.pageInfo.hasNextPage) {
// We got all the members in one go, run the fast path
for (const user of item.members.nodes) {
memberNames.push(user);
}
} else {
// There were more than a hundred immediate members - run the slow
// path of fetching them explicitly
const { members } = await getTeamMembers(ctx.client, ctx.org, item.slug);
for (const userLogin of members) {
memberNames.push(userLogin);
}
}
const team: GithubTeam = {
...item,
members: memberNames,
};
return await teamTransformer(team, ctx);
};
const groups = await queryWithPaging(
client,
query,
org,
r => r.organization?.teams,
materialisedTeams,
{ org, userLogins },
);
return { groups };
}
export async function getOrganizationTeam(
client: typeof graphql,
org: string,
teamSlug: string,
teamTransformer: TeamTransformer = defaultOrganizationTeamTransformer,
): Promise<{
group: GroupEntity;
}> {
const query = `
query teams($org: String!, $teamSlug: String!) {
organization(login: $org) {
team(slug:$teamSlug) {
slug
combinedSlug
name
description
avatarUrl
editTeamUrl
parentTeam { slug }
members(first: 100, membership: IMMEDIATE) {
pageInfo { hasNextPage }
nodes { login }
}
}
}
}`;
const materialisedTeam = async (
item: GithubTeamResponse,
ctx: TransformerContext,
): Promise<GroupEntity | undefined> => {
const memberNames: GithubUser[] = [];
if (!item.members.pageInfo.hasNextPage) {
// We got all the members in one go, run the fast path
for (const user of item.members.nodes) {
memberNames.push(user);
}
} else {
// There were more than a hundred immediate members - run the slow
// path of fetching them explicitly
const { members } = await getTeamMembers(ctx.client, ctx.org, item.slug);
for (const userLogin of members) {
memberNames.push(userLogin);
}
}
const team: GithubTeam = {
...item,
members: memberNames,
};
return await teamTransformer(team, ctx);
};
const response: QueryResponse = await client(query, {
org,
teamSlug,
});
if (!response.organization?.team)
throw new Error(`Found no match for group ${teamSlug}`);
const group = await materialisedTeam(response.organization?.team, {
query,
client,
org,
});
if (!group) throw new Error(`Can't transform for group ${teamSlug}`);
return { group };
}
export async function getOrganizationRepositories(
client: typeof graphql,
org: string,
@@ -349,6 +517,7 @@ export async function getTeamMembers(
*
* @param client - The octokit client
* @param query - The query to execute
* @param org - The slug of the org to read
* @param connection - A function that, given the response, picks out the actual
* Connection object that's being iterated
* @param transformer - A function that, given one of the nodes in the Connection,
@@ -406,3 +575,39 @@ export async function queryWithPaging<
return result;
}
export type DeferredEntitiesBuilder = (
org: string,
entities: Entity[],
) => { added: DeferredEntity[]; removed: DeferredEntity[] };
export const createAddEntitiesOperation =
(id: string, host: string) => (org: string, entities: Entity[]) => ({
removed: [],
added: entities.map(entity => ({
locationKey: `github-org-provider:${id}`,
entity: withLocations(`https://${host}`, org, entity),
})),
});
export const createRemoveEntitiesOperation =
(id: string, host: string) => (org: string, entities: Entity[]) => ({
added: [],
removed: entities.map(entity => ({
locationKey: `github-org-provider:${id}`,
entity: withLocations(`https://${host}`, org, entity),
})),
});
export const createReplaceEntitiesOperation =
(id: string, host: string) => (org: string, entities: Entity[]) => {
const entitiesToReplace = entities.map(entity => ({
locationKey: `github-org-provider:${id}`,
entity: withLocations(`https://${host}`, org, entity),
}));
return {
removed: entitiesToReplace,
added: entitiesToReplace,
};
};
@@ -28,22 +28,43 @@ import {
ScmIntegrations,
SingleInstanceGithubCredentialsProvider,
} from '@backstage/integration';
import { EventParams } from '@backstage/plugin-events-node';
import { EventSubscriber } from '@backstage/plugin-events-node';
import {
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-backend';
import { graphql } from '@octokit/graphql';
import {
OrganizationEvent,
OrganizationMemberAddedEvent,
OrganizationMemberRemovedEvent,
TeamEvent,
TeamEditedEvent,
MembershipEvent,
} from '@octokit/webhooks-types';
import { merge } from 'lodash';
import * as uuid from 'uuid';
import { Logger } from 'winston';
import {
assignGroupsToUsers,
buildOrgHierarchy,
defaultOrganizationTeamTransformer,
defaultUserTransformer,
getOrganizationTeams,
getOrganizationUsers,
GithubTeam,
parseGithubOrgUrl,
} from '../lib';
import { TeamTransformer, UserTransformer } from '../lib/defaultTransformers';
import { TeamTransformer, UserTransformer } from '../lib';
import {
createAddEntitiesOperation,
createRemoveEntitiesOperation,
createReplaceEntitiesOperation,
DeferredEntitiesBuilder,
getOrganizationTeam,
getOrganizationTeamsFromUsers,
} from '../lib/github';
/**
* Options for {@link GithubOrgEntityProvider}.
@@ -101,13 +122,14 @@ export interface GithubOrgEntityProviderOptions {
teamTransformer?: TeamTransformer;
}
// TODO: Consider supporting an (optional) webhook that reacts on org changes
/**
* Ingests org data (users and groups) from GitHub.
*
* @public
*/
export class GithubOrgEntityProvider implements EntityProvider {
export class GithubOrgEntityProvider
implements EntityProvider, EventSubscriber
{
private readonly credentialsProvider: GithubCredentialsProvider;
private connection?: EntityProviderConnection;
private scheduleFn?: () => Promise<void>;
@@ -224,6 +246,306 @@ export class GithubOrgEntityProvider implements EntityProvider {
markCommitComplete();
}
/** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.onEvent} */
async onEvent(params: EventParams): Promise<void> {
const { logger } = this.options;
logger.debug(`Received event from ${params.topic}`);
const addEntitiesOperation = createAddEntitiesOperation(
this.options.id,
this.options.gitHubConfig.host,
);
const removeEntitiesOperation = createRemoveEntitiesOperation(
this.options.id,
this.options.gitHubConfig.host,
);
const replaceEntitiesOperation = createReplaceEntitiesOperation(
this.options.id,
this.options.gitHubConfig.host,
);
// handle change users in the org
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#organization
if (params.topic.includes('organization')) {
const orgEvent = params.eventPayload as OrganizationEvent;
if (
orgEvent.action === 'member_added' ||
orgEvent.action === 'member_removed'
) {
const createDeltaOperation =
orgEvent.action === 'member_added'
? addEntitiesOperation
: removeEntitiesOperation;
await this.onMemberChangeInOrganization(orgEvent, createDeltaOperation);
}
}
// handle change teams in the org
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#team
if (params.topic.includes('team')) {
const teamEvent = params.eventPayload as TeamEvent;
if (teamEvent.action === 'created' || teamEvent.action === 'deleted') {
const createDeltaOperation =
teamEvent.action === 'created'
? addEntitiesOperation
: removeEntitiesOperation;
await this.onTeamChangeInOrganization(teamEvent, createDeltaOperation);
} else if (teamEvent.action === 'edited') {
await this.onTeamEditedInOrganization(
teamEvent,
replaceEntitiesOperation,
);
}
}
// handle change membership in the org
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#membership
if (params.topic.includes('membership')) {
const membershipEvent = params.eventPayload as MembershipEvent;
this.onMembershipChangedInOrganization(
membershipEvent,
replaceEntitiesOperation,
);
}
return;
}
/** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.supportsEventTopics} */
supportsEventTopics(): string[] {
return ['github.organization', 'github.team', 'github.membership'];
}
private async onTeamEditedInOrganization(
event: TeamEditedEvent,
createDeltaOperation: DeferredEntitiesBuilder,
) {
if (!this.connection) {
throw new Error('Not initialized');
}
const teamSlug = event.team.slug;
const { headers, type: tokenType } =
await this.credentialsProvider.getCredentials({
url: this.options.orgUrl,
});
const client = graphql.defaults({
baseUrl: this.options.gitHubConfig.apiBaseUrl,
headers,
});
const { org } = parseGithubOrgUrl(this.options.orgUrl);
const { group } = await getOrganizationTeam(
client,
org,
teamSlug,
this.options.teamTransformer,
);
const { users } = await getOrganizationUsers(
client,
org,
tokenType,
this.options.userTransformer,
);
const usersFromChangedGroup = group.spec.members || [];
const usersToRebuild = users.filter(u =>
usersFromChangedGroup.includes(u.metadata.name),
);
const { groups } = await getOrganizationTeamsFromUsers(
client,
org,
usersToRebuild.map(u => u.metadata.name),
this.options.teamTransformer,
);
assignGroupsToUsers(usersToRebuild, groups);
buildOrgHierarchy(groups);
const oldName = event.changes.name?.from || '';
const oldSlug = oldName.toLowerCase().replaceAll(/\s/gi, '-');
const { removed } = createDeltaOperation(org, [
{
...group,
metadata: {
name: oldSlug,
},
},
]);
const { added } = createDeltaOperation(org, [...usersToRebuild, ...groups]);
await this.connection.applyMutation({
type: 'delta',
removed,
added,
});
}
private async onMembershipChangedInOrganization(
event: MembershipEvent,
createDeltaOperation: DeferredEntitiesBuilder,
) {
if (!this.connection) {
throw new Error('Not initialized');
}
// The docs are saying I will receive the slug for the removed event,
// but the types don't reflect that,
// so I will just check to be sure the slug is there
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#membership
if (!('slug' in event.team)) {
return;
}
const teamSlug = event.team.slug;
const userLogin = event.member.login;
const { headers, type: tokenType } =
await this.credentialsProvider.getCredentials({
url: this.options.orgUrl,
});
const client = graphql.defaults({
baseUrl: this.options.gitHubConfig.apiBaseUrl,
headers,
});
const { org } = parseGithubOrgUrl(this.options.orgUrl);
const { group } = await getOrganizationTeam(
client,
org,
teamSlug,
this.options.teamTransformer,
);
const { users } = await getOrganizationUsers(
client,
org,
tokenType,
this.options.userTransformer,
);
const usersToRebuild = users.filter(u => u.metadata.name === userLogin);
const { groups } = await getOrganizationTeamsFromUsers(
client,
org,
[userLogin],
this.options.teamTransformer,
);
// we include group because the removed event need to update the old group too
if (!groups.some(g => g.metadata.name === group.metadata.name)) {
groups.push(group);
}
assignGroupsToUsers(usersToRebuild, groups);
buildOrgHierarchy(groups);
const { added, removed } = createDeltaOperation(org, [
...usersToRebuild,
...groups,
]);
await this.connection.applyMutation({
type: 'delta',
removed,
added,
});
}
private async onTeamChangeInOrganization(
event: TeamEvent,
createDeltaOperation: DeferredEntitiesBuilder,
) {
if (!this.connection) {
throw new Error('Not initialized');
}
const organizationTeamTransformer =
this.options.teamTransformer || defaultOrganizationTeamTransformer;
const { name, html_url: url, description, slug } = event.team;
const org = event.organization.login;
const { headers } = await this.credentialsProvider.getCredentials({
url: this.options.orgUrl,
});
const client = graphql.defaults({
baseUrl: this.options.gitHubConfig.apiBaseUrl,
headers,
});
const group = (await organizationTeamTransformer(
{
name,
slug,
editTeamUrl: `${url}/edit`,
combinedSlug: `${org}/${slug}`,
description: description || undefined,
parentTeam: { slug: event.team?.parent?.slug || '' } as GithubTeam,
// entity will be removed
members: [],
},
{
org,
client,
query: '',
},
)) as Entity;
const { added, removed } = createDeltaOperation(org, [group]);
await this.connection.applyMutation({
type: 'delta',
removed,
added,
});
}
private async onMemberChangeInOrganization(
event: OrganizationMemberAddedEvent | OrganizationMemberRemovedEvent,
createDeltaOperation: DeferredEntitiesBuilder,
) {
if (!this.connection) {
throw new Error('Not initialized');
}
const userTransformer =
this.options.userTransformer || defaultUserTransformer;
const { name, avatar_url: avatarUrl, email, login } = event.membership.user;
const org = event.organization.login;
const { headers } = await this.credentialsProvider.getCredentials({
url: this.options.orgUrl,
});
const client = graphql.defaults({
baseUrl: this.options.gitHubConfig.apiBaseUrl,
headers,
});
const user = (await userTransformer(
{
name,
avatarUrl,
login,
email: email || undefined,
// we don't have this information in the event, so the refresh will handle that for us
organizationVerifiedDomainEmails: [],
},
{
org,
client,
query: '',
},
)) as Entity;
const { added, removed } = createDeltaOperation(org, [user]);
await this.connection.applyMutation({
type: 'delta',
removed,
added,
});
}
private schedule(schedule: GithubOrgEntityProviderOptions['schedule']) {
if (!schedule || schedule === 'manual') {
return;
@@ -84,10 +84,14 @@ export class OpenApiRefProcessor implements CatalogProcessor {
this.logger.debug(`Bundling OpenAPI specification from ${location.target}`);
try {
const read = async (url: string) => {
const { buffer } = await this.reader.readUrl(url);
return await buffer();
};
const bundledSpec = await bundleFileWithRefs(
definition.toString(),
location.target,
this.reader.read,
read,
resolveUrl,
);
@@ -32,7 +32,6 @@ const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
describe('PlaceholderProcessor', () => {
const reader: jest.Mocked<UrlReader> = {
read: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
readUrl: jest.fn(),
@@ -194,7 +194,6 @@ describe('UrlReaderProcessor', () => {
const logger = getVoidLogger();
const reader: jest.Mocked<UrlReader> = {
read: jest.fn(),
readUrl: jest.fn(),
readTree: jest.fn(),
search: jest.fn().mockImplementation(async () => []),
@@ -133,7 +133,7 @@ describe('<CatalogGraphCard/>', () => {
expect(button).toBeInTheDocument();
expect(button.closest('a')).toHaveAttribute(
'href',
'/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&maxDepth=2&unidirectional=true&mergeRelations=true&direction=LR',
'/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&unidirectional=true&mergeRelations=true&direction=LR',
);
});
@@ -157,7 +157,7 @@ describe('<CatalogGraphCard/>', () => {
expect(button).toBeInTheDocument();
expect(button.closest('a')).toHaveAttribute(
'href',
'/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&maxDepth=3&unidirectional=true&mergeRelations=false&direction=LR',
'/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&unidirectional=true&mergeRelations=false&direction=LR',
);
});
@@ -111,7 +111,6 @@ export const CatalogGraphCard = (props: {
const catalogGraphParams = qs.stringify(
{
rootEntityRefs: [stringifyEntityRef(entity)],
maxDepth: maxDepth + 1,
unidirectional,
mergeRelations,
kinds,
@@ -14,23 +14,16 @@
* limitations under the License.
*/
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { renderInTestApp } from '@backstage/test-utils';
import CloudIcon from '@material-ui/icons/Cloud';
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import React from 'react';
import { IconLink } from './IconLink';
describe('IconLink', () => {
it('should render an icon link', () => {
render(
<ThemeProvider theme={lightTheme}>
<IconLink
href="https://example.com"
text="I am Link"
Icon={CloudIcon}
/>
</ThemeProvider>,
it('should render an icon link', async () => {
await renderInTestApp(
<IconLink href="https://example.com" text="I am Link" Icon={CloudIcon} />,
);
expect(screen.getByText('I am Link')).toBeInTheDocument();
@@ -15,18 +15,14 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { CodeClimateTable } from './CodeClimateTable';
import { mockData } from '../../api/mock/mock-api';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
describe('CodeClimateTable', () => {
it('should render values in a table', async () => {
const table = await render(
<ThemeProvider theme={lightTheme}>
<CodeClimateTable codeClimateData={mockData} />
</ThemeProvider>,
const table = await renderInTestApp(
<CodeClimateTable codeClimateData={mockData} />,
);
expect(await table.findByText('3 months')).toBeInTheDocument();
expect(await table.findByText('88%')).toBeInTheDocument();
@@ -15,8 +15,6 @@
*/
import React from 'react';
import { CodeScenePageComponent } from './CodeScenePageComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { rootRouteRef } from '../../routes';
@@ -54,9 +52,7 @@ describe('CodeScenePageComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ThemeProvider theme={lightTheme}>
<CodeScenePageComponent />
</ThemeProvider>
<CodeScenePageComponent />
</ApiProvider>,
{
mountedRoutes: {
@@ -15,8 +15,6 @@
*/
import React from 'react';
import { CodeSceneProjectDetailsPage } from './CodeSceneProjectDetailsPage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
@@ -90,9 +88,7 @@ describe('CodeSceneProjectDetailsPage', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ThemeProvider theme={lightTheme}>
<CodeSceneProjectDetailsPage />
</ThemeProvider>
<CodeSceneProjectDetailsPage />
</ApiProvider>,
);
expect(rendered.getByText('CodeScene: test-project')).toBeInTheDocument();
@@ -15,8 +15,6 @@
*/
import React from 'react';
import { ProjectsComponent } from './ProjectsComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { rootRouteRef } from '../../routes';
@@ -90,9 +88,7 @@ describe('ProjectsComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ThemeProvider theme={lightTheme}>
<ProjectsComponent />
</ThemeProvider>
<ProjectsComponent />
</ApiProvider>,
{
mountedRoutes: {
@@ -16,6 +16,7 @@
import { getVoidLogger } from '@backstage/backend-common';
import { TestEventSubscriber } from '@backstage/plugin-events-backend-test-utils';
import { EventParams, EventSubscriber } from '@backstage/plugin-events-node';
import { InMemoryEventBroker } from './InMemoryEventBroker';
const logger = getVoidLogger();
@@ -63,4 +64,51 @@ describe('InMemoryEventBroker', () => {
eventPayload: { test: 'topicC' },
});
});
it('logs errors from subscribers', async () => {
const topic = 'testTopic';
const subscriber1 = new (class Subscriber1 implements EventSubscriber {
supportsEventTopics() {
return [topic];
}
async onEvent(event: EventParams) {
throw new Error(`NOPE ${event.eventPayload}`);
}
})();
const subscriber2 = new (class Subscriber2 implements EventSubscriber {
supportsEventTopics() {
return [topic];
}
async onEvent(event: EventParams) {
throw new Error(`NOPE ${event.eventPayload}`);
}
})();
const errorSpy = jest.spyOn(logger, 'error');
const eventBroker = new InMemoryEventBroker(logger);
eventBroker.subscribe(subscriber1);
await eventBroker.publish({ topic, eventPayload: '1' });
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
'Subscriber "Subscriber1" failed to process event',
new Error('NOPE 1'),
);
eventBroker.subscribe(subscriber2);
await eventBroker.publish({ topic, eventPayload: '2' });
// With two subscribers we should not halt on the first error but call all subscribers
expect(errorSpy).toHaveBeenCalledTimes(3);
expect(errorSpy).toHaveBeenCalledWith(
'Subscriber "Subscriber1" failed to process event',
new Error('NOPE 2'),
);
expect(errorSpy).toHaveBeenCalledWith(
'Subscriber "Subscriber2" failed to process event',
new Error('NOPE 2'),
);
});
});
@@ -42,7 +42,18 @@ export class InMemoryEventBroker implements EventBroker {
);
const subscribed = this.subscribers[params.topic] ?? [];
subscribed.forEach(subscriber => subscriber.onEvent(params));
await Promise.all(
subscribed.map(async subscriber => {
try {
await subscriber.onEvent(params);
} catch (error) {
this.logger.error(
`Subscriber "${subscriber.constructor.name}" failed to process event`,
error,
);
}
}),
);
}
subscribe(
+5 -5
View File
@@ -11,32 +11,32 @@ for these tools.
```bash
# From your Backstage root directory
yarn add --cwd packages/backend @backstage/plugin-explore-backend
yarn add --cwd packages/backend @backstage/plugin-explore-backend @backstage/plugin-explore-common
```
### Adding the plugin to your `packages/backend`
You'll need to add the plugin to the router in your `backend` package. You can
do this by creating a file called `packages/backend/src/plugins/explore.ts`
do this by creating a file called `packages/backend/src/plugins/explore.ts` with the following content:
```ts
import {
createRouter,
StaticExploreToolProvider,
} from '@backstage/plugin-explore-backend';
import { ExploreTool } from '@backstage/plugin-explore-common';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
// List of tools you want to surface in the Explore plugin "Tools" page.
const tools: ExploreTool[] = [
const exploreTools: ExploreTool[] = [
{
title: 'New Relic',
description:'new relic plugin',
description: 'new relic plugin',
url: '/newrelic',
image: 'https://i.imgur.com/L37ikrX.jpg',
tags: ['newrelic', 'proxy', 'nerdGraph'],
},
...
];
export default async function createPlugin(
+1
View File
@@ -68,6 +68,7 @@ ready to make modifications, add the following code snippet to add the
```diff
+import { ToolSearchResultListItem } from '@backstage/plugin-explore';
+import BuildIcon from '@material-ui/icons/Build';
const SearchPage = () => {
...
@@ -16,8 +16,6 @@
import { ExploreTool } from '@backstage/plugin-explore-common';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { exploreApiRef } from '../../api';
@@ -29,11 +27,9 @@ describe('<ToolExplorerContent />', () => {
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ThemeProvider theme={lightTheme}>
<TestApiProvider apis={[[exploreApiRef, exploreApi]]}>
{children}
</TestApiProvider>
</ThemeProvider>
<TestApiProvider apis={[[exploreApiRef, exploreApi]]}>
{children}
</TestApiProvider>
);
beforeEach(() => {
@@ -15,8 +15,6 @@
*/
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import React from 'react';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
import ProfileCatalog from './ProfileCatalog';
@@ -47,11 +45,9 @@ describe('ProfileCatalog', () => {
);
const { getByText } = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<ApiProvider apis={apis}>
<ProfileCatalog />
</ApiProvider>
</ThemeProvider>,
<ApiProvider apis={apis}>
<ProfileCatalog />
</ApiProvider>,
);
expect(getByText('Create GitOps-managed Cluster')).toBeInTheDocument();
@@ -15,41 +15,33 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { GraphiQLBrowser } from './GraphiQLBrowser';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
jest.mock('graphiql', () => () => '<GraphiQL />');
jest.mock('graphiql', () => ({ GraphiQL: () => '<GraphiQL />' }));
describe('GraphiQLBrowser', () => {
it('should render error text if there are no endpoints', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<GraphiQLBrowser endpoints={[]} />
</ThemeProvider>,
);
it('should render error text if there are no endpoints', async () => {
const rendered = await renderInTestApp(<GraphiQLBrowser endpoints={[]} />);
rendered.getByText('No endpoints available');
});
it('should render endpoint tabs', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<GraphiQLBrowser
endpoints={[
{
id: 'a',
title: 'Endpoint A',
async fetcher() {},
},
{
id: 'b',
title: 'Endpoint B',
async fetcher() {},
},
]}
/>
</ThemeProvider>,
it('should render endpoint tabs', async () => {
const rendered = await renderInTestApp(
<GraphiQLBrowser
endpoints={[
{
id: 'a',
title: 'Endpoint A',
async fetcher() {},
},
{
id: 'b',
title: 'Endpoint B',
async fetcher() {},
},
]}
/>,
);
rendered.getByText('Endpoint A');
rendered.getByText('Endpoint B');
@@ -16,13 +16,9 @@
import React from 'react';
import { GraphiQLPage } from './GraphiQLPage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { act } from '@testing-library/react';
import { renderWithEffects, TestApiProvider } from '@backstage/test-utils';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { GraphQLBrowseApi, graphQlBrowseApiRef } from '../../lib/api';
import { configApiRef } from '@backstage/core-plugin-api';
import { ConfigReader } from '@backstage/core-app-api';
jest.mock('../GraphiQLBrowser', () => ({
GraphiQLBrowser: () => '<GraphiQLBrowser />',
@@ -38,17 +34,9 @@ describe('GraphiQLPage', () => {
},
};
const rendered = await renderWithEffects(
<TestApiProvider
apis={[
[graphQlBrowseApiRef, loadingApi],
[configApiRef, new ConfigReader({})],
]}
>
<ThemeProvider theme={lightTheme}>
<GraphiQLPage />
</ThemeProvider>
,
const rendered = await renderInTestApp(
<TestApiProvider apis={[[graphQlBrowseApiRef, loadingApi]]}>
<GraphiQLPage />,
</TestApiProvider>,
);
act(() => {
@@ -66,16 +54,9 @@ describe('GraphiQLPage', () => {
},
};
const rendered = await renderWithEffects(
<TestApiProvider
apis={[
[graphQlBrowseApiRef, loadingApi],
[configApiRef, new ConfigReader({})],
]}
>
<ThemeProvider theme={lightTheme}>
<GraphiQLPage />
</ThemeProvider>
const rendered = await renderInTestApp(
<TestApiProvider apis={[[graphQlBrowseApiRef, loadingApi]]}>
<GraphiQLPage />
</TestApiProvider>,
);
@@ -90,16 +71,9 @@ describe('GraphiQLPage', () => {
},
};
const rendered = await renderWithEffects(
<TestApiProvider
apis={[
[graphQlBrowseApiRef, loadingApi],
[configApiRef, new ConfigReader({})],
]}
>
<ThemeProvider theme={lightTheme}>
<GraphiQLPage />
</ThemeProvider>
const rendered = await renderInTestApp(
<TestApiProvider apis={[[graphQlBrowseApiRef, loadingApi]]}>
<GraphiQLPage />
</TestApiProvider>,
);
@@ -17,8 +17,6 @@
import { renderInTestApp } from '@backstage/test-utils';
import { HeaderWorldClock, ClockConfig } from './HeaderWorldClock';
import React from 'react';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
describe('HeaderWorldClock with valid Time Zones', () => {
it('displays Time Zones as expected', async () => {
@@ -42,9 +40,7 @@ describe('HeaderWorldClock with valid Time Zones', () => {
];
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<HeaderWorldClock clockConfigs={clockConfigs} />
</ThemeProvider>,
<HeaderWorldClock clockConfigs={clockConfigs} />,
);
expect(rendered.getByText('NYC')).toBeInTheDocument();
@@ -59,9 +55,7 @@ describe('HeaderWorldClock with no Time Zones provided', () => {
const clockConfigs: ClockConfig[] = [];
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<HeaderWorldClock clockConfigs={clockConfigs} />
</ThemeProvider>,
<HeaderWorldClock clockConfigs={clockConfigs} />,
);
expect(rendered.container).toBeEmptyDOMElement();
@@ -78,9 +72,7 @@ describe('HeaderWorldClock with invalid Time Zone', () => {
];
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<HeaderWorldClock clockConfigs={clockConfigs} />
</ThemeProvider>,
<HeaderWorldClock clockConfigs={clockConfigs} />,
);
expect(rendered.getByText('GMT')).toBeInTheDocument();
@@ -105,12 +97,10 @@ describe('HeaderWorldClock with custom Time Format', () => {
};
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>
</ThemeProvider>,
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>,
);
expect(rendered.getByText('09:10')).toBeInTheDocument();
@@ -16,11 +16,8 @@
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import {
lighthouseApiRef,
LighthouseRestApi,
@@ -30,36 +27,18 @@ import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
import * as data from '../../__fixtures__/website-list-response.json';
import { AuditListForEntity } from './AuditListForEntity';
import { ApiProvider } from '@backstage/core-app-api';
import { errorApiRef } from '@backstage/core-plugin-api';
import { TestApiRegistry } from '@backstage/test-utils';
jest.mock('../../hooks/useWebsiteForEntity', () => ({
useWebsiteForEntity: jest.fn(),
}));
const useWebsiteForEntityMock = useWebsiteForEntity as jest.Mock;
const websiteListResponse = data as WebsiteListResponse;
const entityWebsite = websiteListResponse.items[0];
describe('<AuditListTableForEntity />', () => {
let apis: TestApiRegistry;
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
beforeEach(() => {
apis = TestApiRegistry.from(
[lighthouseApiRef, new LighthouseRestApi('http://lighthouse')],
[errorApiRef, mockErrorApi],
);
(useWebsiteForEntity as jest.Mock).mockReturnValue({
value: entityWebsite,
loading: false,
error: null,
});
afterEach(() => {
jest.resetAllMocks();
});
const entity: Entity = {
@@ -78,66 +57,55 @@ describe('<AuditListTableForEntity />', () => {
},
};
const subject = () =>
render(
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AuditListForEntity />
</EntityProvider>
</ApiProvider>
</MemoryRouter>
</ThemeProvider>,
);
const subject = () => (
<TestApiProvider
apis={[[lighthouseApiRef, new LighthouseRestApi('http://lighthouse')]]}
>
<EntityProvider entity={entity}>
<AuditListForEntity />
</EntityProvider>
</TestApiProvider>
);
it('renders the audit list for the entity', async () => {
const { findByText } = subject();
useWebsiteForEntityMock.mockReturnValue({
value: entityWebsite,
loading: false,
error: null,
});
const { findByText } = await renderInTestApp(subject());
expect(await findByText(entityWebsite.url)).toBeInTheDocument();
});
describe('where the data is loading', () => {
beforeEach(() => {
(useWebsiteForEntity as jest.Mock).mockReturnValue({
value: null,
loading: true,
error: null,
});
it('renders a Progress element where the data is loading', async () => {
useWebsiteForEntityMock.mockReturnValue({
value: null,
loading: true,
error: null,
});
it('renders a Progress element', async () => {
const { findByTestId } = subject();
expect(await findByTestId('progress')).toBeInTheDocument();
});
const { findByTestId } = await renderInTestApp(subject());
expect(await findByTestId('progress')).toBeInTheDocument();
});
describe('where there is an error loading data', () => {
beforeEach(() => {
(useWebsiteForEntity as jest.Mock).mockReturnValue({
value: null,
loading: false,
error: 'error',
});
});
it('renders nothing', async () => {
const { queryByTestId } = subject();
expect(queryByTestId('AuditListTable')).toBeNull();
it('renders nothing where there is an error loading data', async () => {
useWebsiteForEntityMock.mockReturnValue({
value: null,
loading: false,
error: 'error',
});
const { queryByTestId } = await renderInTestApp(subject());
expect(queryByTestId('AuditListTable')).toBeNull();
});
describe('where there is not data', () => {
beforeEach(() => {
(useWebsiteForEntity as jest.Mock).mockReturnValue({
value: null,
loading: false,
error: null,
});
it('renders nothing where there is not data', async () => {
useWebsiteForEntityMock.mockReturnValue({
value: null,
loading: false,
error: null,
});
it('renders nothing', async () => {
const { queryByTestId } = subject();
expect(queryByTestId('AuditListTable')).toBeNull();
});
const { queryByTestId } = await renderInTestApp(subject());
expect(queryByTestId('AuditListTable')).toBeNull();
});
});
@@ -16,11 +16,8 @@
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import {
AuditCompleted,
LighthouseCategoryId,
@@ -64,22 +61,15 @@ describe('<LastLighthouseAuditCard />', () => {
},
};
const subject = () =>
render(
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<EntityProvider entity={entity}>
<LastLighthouseAuditCard />
</EntityProvider>
</MemoryRouter>
</ThemeProvider>,
);
describe('where the last audit completed successfully', () => {
const audit = entityWebsite.lastAudit as AuditCompleted;
it('renders the performance data for the audit', async () => {
const { findByText } = subject();
const { findByText } = await renderInTestApp(
<EntityProvider entity={entity}>
<LastLighthouseAuditCard />
</EntityProvider>,
);
expect(await findByText(audit.url)).toBeInTheDocument();
expect(await findByText(audit.status)).toBeInTheDocument();
for (const category of Object.keys(audit.categories)) {
@@ -101,7 +91,11 @@ describe('<LastLighthouseAuditCard />', () => {
});
it('renders the performance data for the audit', async () => {
const { findByText } = subject();
const { findByText } = await renderInTestApp(
<EntityProvider entity={entity}>
<LastLighthouseAuditCard />
</EntityProvider>,
);
expect(await findByText('N/A')).toBeInTheDocument();
});
});
@@ -119,7 +113,11 @@ describe('<LastLighthouseAuditCard />', () => {
});
it('renders the url and status of the audit', async () => {
const { findByText } = subject();
const { findByText } = await renderInTestApp(
<EntityProvider entity={entity}>
<LastLighthouseAuditCard />
</EntityProvider>,
);
expect(await findByText(audit.url)).toBeInTheDocument();
expect(await findByText(audit.status)).toBeInTheDocument();
});
@@ -135,7 +133,11 @@ describe('<LastLighthouseAuditCard />', () => {
});
it('renders a Progress element', async () => {
const { findByTestId } = subject();
const { findByTestId } = await renderInTestApp(
<EntityProvider entity={entity}>
<LastLighthouseAuditCard />
</EntityProvider>,
);
expect(await findByTestId('progress')).toBeInTheDocument();
});
});
@@ -150,7 +152,11 @@ describe('<LastLighthouseAuditCard />', () => {
});
it('renders nothing', async () => {
const { queryByTestId } = subject();
const { queryByTestId } = await renderInTestApp(
<EntityProvider entity={entity}>
<LastLighthouseAuditCard />
</EntityProvider>,
);
expect(queryByTestId('AuditListTable')).toBeNull();
});
});
@@ -165,7 +171,11 @@ describe('<LastLighthouseAuditCard />', () => {
});
it('renders nothing', async () => {
const { queryByTestId } = subject();
const { queryByTestId } = await renderInTestApp(
<EntityProvider entity={entity}>
<LastLighthouseAuditCard />
</EntityProvider>,
);
expect(queryByTestId('AuditListTable')).toBeNull();
});
});
@@ -16,8 +16,6 @@
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import React from 'react';
import { rootRouteRef } from '../../routes';
@@ -26,20 +24,18 @@ import { PlaylistCard } from './PlaylistCard';
describe('<PlaylistCard/>', () => {
it('renders playlist info', async () => {
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<PlaylistCard
playlist={{
id: 'id1',
name: 'playlist-1',
description: 'test description',
owner: 'group:default/some-owner',
public: true,
entities: 3,
followers: 2,
isFollowing: false,
}}
/>
</ThemeProvider>,
<PlaylistCard
playlist={{
id: 'id1',
name: 'playlist-1',
description: 'test description',
owner: 'group:default/some-owner',
public: true,
entities: 3,
followers: 2,
isFollowing: false,
}}
/>,
{
mountedRoutes: {
'/playlists': rootRouteRef,
@@ -75,7 +75,6 @@ describe('fetch:cookiecutter', () => {
const mockReader: UrlReader = {
readUrl: jest.fn(),
read: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
};
@@ -74,7 +74,6 @@ describe('fetch:rails', () => {
};
const mockReader: UrlReader = {
read: jest.fn(),
readUrl: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
@@ -39,7 +39,6 @@ describe('fetchContent helper', () => {
const readTree = jest.fn();
const reader: UrlReader = {
read: jest.fn(),
readUrl: jest.fn(),
readTree,
search: jest.fn(),
@@ -34,7 +34,6 @@ describe('fetch:plain', () => {
);
const reader: UrlReader = {
readUrl: jest.fn(),
read: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
};
@@ -14,23 +14,15 @@
* limitations under the License.
*/
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { renderInTestApp } from '@backstage/test-utils';
import CloudIcon from '@material-ui/icons/Cloud';
import { render } from '@testing-library/react';
import React from 'react';
import { IconLink } from './IconLink';
describe('IconLink', () => {
it('should render an icon link', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<IconLink
href="https://example.com"
text="I am Link"
Icon={CloudIcon}
/>
</ThemeProvider>,
it('should render an icon link', async () => {
const rendered = await renderInTestApp(
<IconLink href="https://example.com" text="I am Link" Icon={CloudIcon} />,
);
expect(rendered.getByText('I am Link')).toBeInTheDocument();
@@ -53,7 +53,6 @@ describe('DefaultCatalogCollatorFactory', () => {
readable._read = () => {};
reader = {
search: jest.fn(),
read: jest.fn(),
readTree: jest.fn(),
readUrl: jest.fn(),
};
@@ -16,10 +16,8 @@
import { ErrorCell } from './ErrorCell';
import React from 'react';
import { render } from '@testing-library/react';
import mockIssue from '../../api/mock/sentry-issue-mock.json';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { renderInTestApp } from '@backstage/test-utils';
describe('Sentry error cell component', () => {
it('should render a link that lead to Sentry', async () => {
@@ -33,11 +31,7 @@ describe('Sentry error cell component', () => {
userCount: 2,
permalink: 'http://example.com',
};
const cell = render(
<ThemeProvider theme={lightTheme}>
<ErrorCell sentryIssue={testIssue} />
</ThemeProvider>,
);
const cell = await renderInTestApp(<ErrorCell sentryIssue={testIssue} />);
const errorType = await cell.findByText('Exception');
expect(errorType.closest('a')).toHaveAttribute(
'href',
@@ -53,11 +47,7 @@ describe('Sentry error cell component', () => {
userCount: 2,
permalink: 'http://example.com',
};
const cell = render(
<ThemeProvider theme={lightTheme}>
<ErrorCell sentryIssue={testIssue} />
</ThemeProvider>,
);
const cell = await renderInTestApp(<ErrorCell sentryIssue={testIssue} />);
const errorType = await cell.findByText('Exception: Could not load cr...');
expect(errorType.closest('a')).toHaveAttribute(
'href',
@@ -15,12 +15,10 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import SentryIssuesTable from './SentryIssuesTable';
import { SentryIssue } from '../../api';
import mockIssue from '../../api/mock/sentry-issue-mock.json';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { renderInTestApp } from '@backstage/test-utils';
describe('SentryIssuesTable', () => {
it('should render headers in a table', async () => {
@@ -35,19 +33,17 @@ describe('SentryIssuesTable', () => {
userCount: 2,
},
];
const table = await render(
<ThemeProvider theme={lightTheme}>
<SentryIssuesTable
sentryIssues={issues}
statsFor="24h"
tableOptions={{
padding: 'dense',
paging: true,
search: false,
pageSize: 5,
}}
/>
</ThemeProvider>,
const table = await renderInTestApp(
<SentryIssuesTable
sentryIssues={issues}
statsFor="24h"
tableOptions={{
padding: 'dense',
paging: true,
search: false,
pageSize: 5,
}}
/>,
);
expect(await table.findByText('Error')).toBeInTheDocument();
expect(await table.findByText('Graph')).toBeInTheDocument();
@@ -68,19 +64,17 @@ describe('SentryIssuesTable', () => {
userCount: 202,
},
];
const table = await render(
<ThemeProvider theme={lightTheme}>
<SentryIssuesTable
sentryIssues={issues}
statsFor="24h"
tableOptions={{
padding: 'dense',
paging: true,
search: false,
pageSize: 5,
}}
/>
</ThemeProvider>,
const table = await renderInTestApp(
<SentryIssuesTable
sentryIssues={issues}
statsFor="24h"
tableOptions={{
padding: 'dense',
paging: true,
search: false,
pageSize: 5,
}}
/>,
);
expect(await table.findByText('Exception')).toBeInTheDocument();
expect(await table.findByText('exception was thrown')).toBeInTheDocument();
@@ -99,19 +93,17 @@ describe('SentryIssuesTable', () => {
userCount: 202,
},
];
const table = await render(
<ThemeProvider theme={lightTheme}>
<SentryIssuesTable
sentryIssues={issues}
statsFor="24h"
tableOptions={{
padding: 'dense',
paging: true,
search: false,
pageSize: 5,
}}
/>
</ThemeProvider>,
const table = await renderInTestApp(
<SentryIssuesTable
sentryIssues={issues}
statsFor="24h"
tableOptions={{
padding: 'dense',
paging: true,
search: false,
pageSize: 5,
}}
/>,
);
expect(await table.findByText('Last 24h')).toBeInTheDocument();
});
@@ -16,8 +16,6 @@
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import React from 'react';
import {
isSonarQubeAvailable,
@@ -44,7 +42,7 @@ const Providers = ({
kind: 'Component',
}}
>
<ThemeProvider theme={lightTheme}>{children}</ThemeProvider>
{children}
</EntityProvider>
</TestApiProvider>
);
@@ -15,9 +15,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { renderInTestApp } from '@backstage/test-utils';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import Radar, { Props } from './Radar';
@@ -48,12 +46,8 @@ describe('Radar', () => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<Radar {...minProps} />
</ThemeProvider>,
);
it('should render', async () => {
const rendered = await renderInTestApp(<Radar {...minProps} />);
const svg = rendered.container.querySelector('svg');
expect(svg).not.toBeNull();
@@ -15,9 +15,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { renderInTestApp } from '@backstage/test-utils';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarBubble, { Props } from './RadarBubble';
@@ -38,13 +36,11 @@ describe('RadarBubble', () => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarBubble {...minProps} />
</svg>
</ThemeProvider>,
it('should render', async () => {
const rendered = await renderInTestApp(
<svg>
<RadarBubble {...minProps} />
</svg>,
);
expect(rendered.getByText(minProps.text)).toBeInTheDocument();

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