Merge branch 'master' into jhaals/dep-favoriteEntityTooltip
This commit is contained in:
@@ -24,7 +24,7 @@ This method of serving the plugin provides quicker iteration speed and a faster
|
||||
|
||||
3. Go into the plugin's directory and run it in standalone mode by running `yarn start`.
|
||||
|
||||
Access it from http://localhost:7007/api/airbrake. Or use the Airbrake plugin which will talk to it automatically.
|
||||
Access it from http://localhost:7007/api/airbrake. Or use the [Airbrake plugin in standalone mode](../airbrake/README.md#local-development) which will talk to it automatically.
|
||||
|
||||
Here are some example endpoints:
|
||||
|
||||
|
||||
+51
-13
@@ -20,7 +20,7 @@ The Airbrake plugin provides connectivity between Backstage and Airbrake (https:
|
||||
yarn add @backstage/plugin-airbrake-backend
|
||||
```
|
||||
|
||||
3. Add the `EntityAirbrakeContent` to `packages/app/src/components/catalog/EntityPage.tsx`:
|
||||
3. Add the `EntityAirbrakeContent` to `packages/app/src/components/catalog/EntityPage.tsx` for all the entity pages you want Airbrake to be in:
|
||||
|
||||
```typescript jsx
|
||||
import { EntityAirbrakeContent } from '@backstage/plugin-airbrake';
|
||||
@@ -32,40 +32,78 @@ The Airbrake plugin provides connectivity between Backstage and Airbrake (https:
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
const websiteEntityPage = (
|
||||
<EntityLayoutWrapper>
|
||||
<EntityLayout.Route path="/airbrake" title="Airbrake">
|
||||
<EntityAirbrakeContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
const defaultEntityPage = (
|
||||
<EntityLayoutWrapper>
|
||||
<EntityLayout.Route path="/airbrake" title="Airbrake">
|
||||
<EntityAirbrakeContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
```
|
||||
|
||||
4. Setup the Backend code in `packages/backend/src/index.ts`:
|
||||
4. Create `packages/backend/src/plugins/airbrake.ts` with these contents:
|
||||
|
||||
```typescript
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
import {
|
||||
createRouter as createAirbrakeRouter,
|
||||
createRouter,
|
||||
extractAirbrakeConfig,
|
||||
} from '@backstage/plugin-airbrake-backend';
|
||||
|
||||
async function main() {
|
||||
//... After const config = await loadBackendConfig({ ...
|
||||
|
||||
const airbrakeRouter = await createAirbrakeRouter({
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return createRouter({
|
||||
logger,
|
||||
airbrakeConfig: extractAirbrakeConfig(config),
|
||||
});
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
// ... Add the airbrakeRouter here
|
||||
.addRouter('/api/airbrake', airbrakeRouter);
|
||||
}
|
||||
```
|
||||
|
||||
5. Add this config as a top level section in your `app-config.yaml`:
|
||||
5. Setup the Backend code in `packages/backend/src/index.ts`:
|
||||
|
||||
```typescript
|
||||
import airbrake from './plugins/airbrake';
|
||||
|
||||
async function main() {
|
||||
//... After const createEnv = makeCreateEnv(config) ...
|
||||
|
||||
const airbrakeEnv = useHotMemoize(module, () => createEnv('airbrake'));
|
||||
|
||||
//... After const apiRouter = Router() ...
|
||||
apiRouter.use('/airbrake', await airbrake(airbrakeEnv));
|
||||
}
|
||||
```
|
||||
|
||||
6. Add this config as a top level section in your `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
airbrake:
|
||||
apiKey: ${AIRBRAKE_API_KEY}
|
||||
```
|
||||
|
||||
6. Set an environment variable `AIRBRAKE_API_KEY` with your [API key](https://airbrake.io/docs/api/#authentication)
|
||||
7. Set an environment variable `AIRBRAKE_API_KEY` with your [API key](https://airbrake.io/docs/api/#authentication)
|
||||
before starting Backstage backend.
|
||||
|
||||
8. Add the following annotation to the `catalog-info.yaml` for a repo you want to link to an Airbrake project:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
airbrake.io/project-id: '123456'
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
Start this plugin in standalone mode by running `yarn start` inside the plugin directory. This method of serving the plugin provides quicker
|
||||
|
||||
@@ -53,8 +53,8 @@ createDevApp()
|
||||
items: mockEntities.slice(),
|
||||
};
|
||||
},
|
||||
async getEntityByName(name: string) {
|
||||
return mockEntities.find(e => e.metadata.name === name);
|
||||
async getEntityByRef(ref: string) {
|
||||
return mockEntities.find(e => e.metadata.name === ref);
|
||||
},
|
||||
} as unknown as typeof catalogApiRef.T),
|
||||
})
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
RELATION_MEMBER_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import { TableColumn, TableProps } from '@backstage/core-components';
|
||||
import {
|
||||
@@ -60,11 +64,11 @@ describe('DefaultApiExplorerPage', () => {
|
||||
}),
|
||||
getLocationByRef: () =>
|
||||
Promise.resolve({ id: 'id', type: 'url', target: 'url' }),
|
||||
getEntityByName: async entityName => {
|
||||
getEntityByRef: async entityRef => {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: entityName.name },
|
||||
metadata: { name: parseEntityRef(entityRef).name },
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_MEMBER_OF,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MemoryKeyStore } from './MemoryKeyStore';
|
||||
import { TokenFactory } from './TokenFactory';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { JWKS, JSONWebKey, JWT } from 'jose';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
@@ -28,6 +29,12 @@ function jwtKid(jwt: string): string {
|
||||
return header.kid;
|
||||
}
|
||||
|
||||
const entityRef = stringifyEntityRef({
|
||||
kind: 'User',
|
||||
namespace: 'default',
|
||||
name: 'JackFrost',
|
||||
});
|
||||
|
||||
describe('TokenFactory', () => {
|
||||
it('should issue valid tokens signed by a listed key', async () => {
|
||||
const keyDurationSeconds = 5;
|
||||
@@ -39,7 +46,7 @@ describe('TokenFactory', () => {
|
||||
});
|
||||
|
||||
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const token = await factory.issueToken({ claims: { sub: entityRef } });
|
||||
|
||||
const { keys } = await factory.listPublicKeys();
|
||||
const keyStore = JWKS.asKeyStore({
|
||||
@@ -53,7 +60,7 @@ describe('TokenFactory', () => {
|
||||
expect(payload).toEqual({
|
||||
iss: 'my-issuer',
|
||||
aud: 'backstage',
|
||||
sub: 'foo',
|
||||
sub: entityRef,
|
||||
iat: expect.any(Number),
|
||||
exp: expect.any(Number),
|
||||
});
|
||||
@@ -71,8 +78,12 @@ describe('TokenFactory', () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
const token1 = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const token2 = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const token1 = await factory.issueToken({
|
||||
claims: { sub: entityRef },
|
||||
});
|
||||
const token2 = await factory.issueToken({
|
||||
claims: { sub: entityRef },
|
||||
});
|
||||
expect(jwtKid(token1)).toBe(jwtKid(token2));
|
||||
|
||||
await expect(factory.listPublicKeys()).resolves.toEqual({
|
||||
@@ -89,7 +100,9 @@ describe('TokenFactory', () => {
|
||||
keys: [],
|
||||
});
|
||||
|
||||
const token3 = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const token3 = await factory.issueToken({
|
||||
claims: { sub: entityRef },
|
||||
});
|
||||
expect(jwtKid(token3)).not.toBe(jwtKid(token2));
|
||||
|
||||
await expect(factory.listPublicKeys()).resolves.toEqual({
|
||||
@@ -100,4 +113,20 @@ describe('TokenFactory', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error with a non entityRef sub claim', async () => {
|
||||
const keyDurationSeconds = 5;
|
||||
const factory = new TokenFactory({
|
||||
issuer: 'my-issuer',
|
||||
keyStore: new MemoryKeyStore(),
|
||||
keyDurationSeconds,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(() => {
|
||||
return factory.issueToken({
|
||||
claims: { sub: 'UserId' },
|
||||
});
|
||||
}).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { JSONWebKey, JWK, JWS } from 'jose';
|
||||
import { Logger } from 'winston';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { DateTime } from 'luxon';
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
@@ -72,6 +73,15 @@ export class TokenFactory implements TokenIssuer {
|
||||
const iat = Math.floor(Date.now() / MS_IN_S);
|
||||
const exp = iat + this.keyDurationSeconds;
|
||||
|
||||
// Validate that the subject claim is a valid EntityRef
|
||||
try {
|
||||
parseEntityRef(sub);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'"sub" claim provided by the auth resolver is not a valid EntityRef.',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
|
||||
|
||||
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CatalogIdentityClient } from './CatalogIdentityClient';
|
||||
describe('CatalogIdentityClient', () => {
|
||||
const catalogApi: jest.Mocked<CatalogApi> = {
|
||||
getLocationById: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getEntities: jest.fn(),
|
||||
addLocation: jest.fn(),
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Logger } from 'winston';
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
parseEntityRef,
|
||||
RELATION_MEMBER_OF,
|
||||
stringifyEntityRef,
|
||||
@@ -96,7 +96,7 @@ export class CatalogIdentityClient {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((ref): ref is EntityName => ref !== null);
|
||||
.filter((ref): ref is CompoundEntityRef => ref !== null);
|
||||
|
||||
const filter = resolvedEntityRefs.map(ref => ({
|
||||
kind: ref.kind,
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('createRouter', () => {
|
||||
catalog = {
|
||||
addLocation: jest.fn(),
|
||||
getEntities: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
@@ -103,7 +104,7 @@ describe('createRouter', () => {
|
||||
|
||||
describe('GET /entity/:namespace/:kind/:name/badge-specs', () => {
|
||||
it('returns all badge specs for entity', async () => {
|
||||
catalog.getEntityByName.mockResolvedValueOnce(entity);
|
||||
catalog.getEntityByRef.mockResolvedValueOnce(entity);
|
||||
|
||||
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
|
||||
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
|
||||
@@ -115,8 +116,8 @@ describe('createRouter', () => {
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toEqual(JSON.stringify([badge], null, 2));
|
||||
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledWith(
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
|
||||
{
|
||||
namespace: 'default',
|
||||
kind: 'service',
|
||||
@@ -142,7 +143,7 @@ describe('createRouter', () => {
|
||||
|
||||
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
|
||||
it('returns badge for entity', async () => {
|
||||
catalog.getEntityByName.mockResolvedValueOnce(entity);
|
||||
catalog.getEntityByRef.mockResolvedValueOnce(entity);
|
||||
|
||||
const image = '<svg>...</svg>';
|
||||
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
|
||||
@@ -154,8 +155,8 @@ describe('createRouter', () => {
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(Buffer.from(image));
|
||||
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledWith(
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
|
||||
{
|
||||
namespace: 'default',
|
||||
kind: 'service',
|
||||
@@ -179,7 +180,7 @@ describe('createRouter', () => {
|
||||
});
|
||||
|
||||
it('returns badge spec for entity', async () => {
|
||||
catalog.getEntityByName.mockResolvedValueOnce(entity);
|
||||
catalog.getEntityByRef.mockResolvedValueOnce(entity);
|
||||
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
|
||||
|
||||
const url = '/entity/default/service/test/badge/test-badge?format=json';
|
||||
@@ -192,7 +193,7 @@ describe('createRouter', () => {
|
||||
|
||||
describe('Errors', () => {
|
||||
it('returns 404 for unknown entities', async () => {
|
||||
catalog.getEntityByName.mockResolvedValue(undefined);
|
||||
catalog.getEntityByRef.mockResolvedValue(undefined);
|
||||
async function testUrl(url: string) {
|
||||
const response = await request(app).get(url);
|
||||
expect(response.status).toEqual(404);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function createRouter(
|
||||
|
||||
router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => {
|
||||
const { namespace, kind, name } = req.params;
|
||||
const entity = await catalog.getEntityByName(
|
||||
const entity = await catalog.getEntityByRef(
|
||||
{ namespace, kind, name },
|
||||
{
|
||||
token: getBearerToken(req.headers.authorization),
|
||||
@@ -84,7 +84,7 @@ export async function createRouter(
|
||||
'/entity/:namespace/:kind/:name/badge/:badgeId',
|
||||
async (req, res) => {
|
||||
const { namespace, kind, name, badgeId } = req.params;
|
||||
const entity = await catalog.getEntityByName(
|
||||
const entity = await catalog.getEntityByRef(
|
||||
{ namespace, kind, name },
|
||||
{
|
||||
token: getBearerToken(req.headers.authorization),
|
||||
|
||||
@@ -35,17 +35,38 @@ catalog:
|
||||
# the App registration in the Microsoft Azure Portal.
|
||||
clientId: ${MICROSOFT_GRAPH_CLIENT_ID}
|
||||
clientSecret: ${MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN}
|
||||
# Optional parameter to include the expanded resource or collection referenced
|
||||
# by a single relationship (navigation property) in your results.
|
||||
# Only one relationship can be expanded in a single request.
|
||||
# See https://docs.microsoft.com/en-us/graph/query-parameters#expand-parameter
|
||||
# Can be combined with userGroupMember[...] instead of userFilter.
|
||||
userExpand: manager
|
||||
# Optional filter for user, see Microsoft Graph API for the syntax
|
||||
# See https://docs.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0#properties
|
||||
# and for the syntax https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter
|
||||
# This and userGroupMemberFilter are mutually exclusive, only one can be specified
|
||||
userFilter: accountEnabled eq true and userType eq 'member'
|
||||
# Optional filter for users, use group membership to get users.
|
||||
# (Filtered groups and fetch their members.)
|
||||
# This and userFilter are mutually exclusive, only one can be specified
|
||||
# See https://docs.microsoft.com/en-us/graph/search-query-parameter
|
||||
userGroupMemberFilter: "displayName eq 'Backstage Users'"
|
||||
# Optional parameter to include the expanded resource or collection referenced
|
||||
# by a single relationship (navigation property) in your results.
|
||||
# Only one relationship can be expanded in a single request.
|
||||
# See https://docs.microsoft.com/en-us/graph/query-parameters#expand-parameter
|
||||
# Can be combined with userGroupMember[...] instead of userFilter.
|
||||
groupExpand: member
|
||||
# Optional search for users, use group membership to get users.
|
||||
# (Search for groups and fetch their members.)
|
||||
# This and userFilter are mutually exclusive, only one can be specified
|
||||
userGroupMemberSearch: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
|
||||
# Optional filter for group, see Microsoft Graph API for the syntax
|
||||
# See https://docs.microsoft.com/en-us/graph/api/resources/group?view=graph-rest-1.0#properties
|
||||
groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
|
||||
# Optional search for groups, see Microsoft Graph API for the syntax
|
||||
# See https://docs.microsoft.com/en-us/graph/search-query-parameter
|
||||
groupSearch: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
|
||||
```
|
||||
|
||||
`userFilter` and `userGroupMemberFilter` are mutually exclusive, only one can be provided. If both are provided, an error will be thrown.
|
||||
|
||||
@@ -78,7 +78,10 @@ export class MicrosoftGraphClient {
|
||||
userId: string,
|
||||
maxSize: number,
|
||||
): Promise<string | undefined>;
|
||||
getUserProfile(userId: string): Promise<MicrosoftGraph.User>;
|
||||
getUserProfile(
|
||||
userId: string,
|
||||
query?: ODataQuery,
|
||||
): Promise<MicrosoftGraph.User>;
|
||||
getUsers(query?: ODataQuery): AsyncIterable<MicrosoftGraph.User>;
|
||||
requestApi(
|
||||
path: string,
|
||||
@@ -158,9 +161,10 @@ export type MicrosoftGraphProviderConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
userFilter?: string;
|
||||
userExpand?: string[];
|
||||
userExpand?: string;
|
||||
userGroupMemberFilter?: string;
|
||||
userGroupMemberSearch?: string;
|
||||
groupExpand?: string;
|
||||
groupFilter?: string;
|
||||
groupSearch?: string;
|
||||
};
|
||||
@@ -172,7 +176,7 @@ export function normalizeEntityName(name: string): string;
|
||||
export type ODataQuery = {
|
||||
search?: string;
|
||||
filter?: string;
|
||||
expand?: string[];
|
||||
expand?: string;
|
||||
select?: string[];
|
||||
};
|
||||
|
||||
@@ -191,10 +195,11 @@ export function readMicrosoftGraphOrg(
|
||||
client: MicrosoftGraphClient,
|
||||
tenantId: string,
|
||||
options: {
|
||||
userExpand?: string[];
|
||||
userExpand?: string;
|
||||
userFilter?: string;
|
||||
userGroupMemberSearch?: string;
|
||||
userGroupMemberFilter?: string;
|
||||
groupExpand?: string;
|
||||
groupSearch?: string;
|
||||
groupFilter?: string;
|
||||
userTransformer?: UserTransformer;
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('MicrosoftGraphClient', () => {
|
||||
|
||||
const response = await client.requestApi('users', {
|
||||
filter: 'test eq true',
|
||||
expand: ['children'],
|
||||
expand: 'children',
|
||||
select: ['id', 'children'],
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ODataQuery = {
|
||||
/**
|
||||
* specifies the related resources or media streams to be included in line with retrieved resources
|
||||
*/
|
||||
expand?: string[];
|
||||
expand?: string;
|
||||
/**
|
||||
* request a specific set of properties for each entity or complex type
|
||||
*/
|
||||
@@ -155,7 +155,7 @@ export class MicrosoftGraphClient {
|
||||
$search: query?.search,
|
||||
$filter: query?.filter,
|
||||
$select: query?.select?.join(','),
|
||||
$expand: query?.expand?.join(','),
|
||||
$expand: query?.expand,
|
||||
},
|
||||
{
|
||||
addQueryPrefix: true,
|
||||
@@ -203,10 +203,14 @@ export class MicrosoftGraphClient {
|
||||
*
|
||||
* @public
|
||||
* @param userId - The unique identifier for the `User` resource
|
||||
* @param query - OData Query {@link ODataQuery}
|
||||
*
|
||||
*/
|
||||
async getUserProfile(userId: string): Promise<MicrosoftGraph.User> {
|
||||
const response = await this.requestApi(`users/${userId}`);
|
||||
async getUserProfile(
|
||||
userId: string,
|
||||
query?: ODataQuery,
|
||||
): Promise<MicrosoftGraph.User> {
|
||||
const response = await this.requestApi(`users/${userId}`, query);
|
||||
|
||||
if (response.status !== 200) {
|
||||
await this.handleError('user profile', response);
|
||||
|
||||
@@ -53,7 +53,9 @@ describe('readMicrosoftGraphConfig', () => {
|
||||
clientId: 'clientId',
|
||||
clientSecret: 'clientSecret',
|
||||
authority: 'https://login.example.com/',
|
||||
userExpand: 'manager',
|
||||
userFilter: 'accountEnabled eq true',
|
||||
groupExpand: 'member',
|
||||
groupFilter: 'securityEnabled eq false',
|
||||
},
|
||||
],
|
||||
@@ -66,7 +68,9 @@ describe('readMicrosoftGraphConfig', () => {
|
||||
clientId: 'clientId',
|
||||
clientSecret: 'clientSecret',
|
||||
authority: 'https://login.example.com',
|
||||
userExpand: 'manager',
|
||||
userFilter: 'accountEnabled eq true',
|
||||
groupExpand: 'member',
|
||||
groupFilter: 'securityEnabled eq false',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,7 +57,7 @@ export type MicrosoftGraphProviderConfig = {
|
||||
*
|
||||
* E.g. "manager"
|
||||
*/
|
||||
userExpand?: string[];
|
||||
userExpand?: string;
|
||||
/**
|
||||
* The filter to apply to extract users by groups memberships.
|
||||
*
|
||||
@@ -70,6 +70,12 @@ export type MicrosoftGraphProviderConfig = {
|
||||
* E.g. "\"displayName:-team\"" would only match groups which contain '-team'
|
||||
*/
|
||||
userGroupMemberSearch?: string;
|
||||
/**
|
||||
* The "expand" argument to apply to groups.
|
||||
*
|
||||
* E.g. "member"
|
||||
*/
|
||||
groupExpand?: string;
|
||||
/**
|
||||
* The filter to apply to extract groups.
|
||||
*
|
||||
@@ -106,6 +112,8 @@ export function readMicrosoftGraphConfig(
|
||||
const tenantId = providerConfig.getString('tenantId');
|
||||
const clientId = providerConfig.getString('clientId');
|
||||
const clientSecret = providerConfig.getString('clientSecret');
|
||||
|
||||
const userExpand = providerConfig.getOptionalString('userExpand');
|
||||
const userFilter = providerConfig.getOptionalString('userFilter');
|
||||
const userGroupMemberFilter = providerConfig.getOptionalString(
|
||||
'userGroupMemberFilter',
|
||||
@@ -113,6 +121,7 @@ export function readMicrosoftGraphConfig(
|
||||
const userGroupMemberSearch = providerConfig.getOptionalString(
|
||||
'userGroupMemberSearch',
|
||||
);
|
||||
const groupExpand = providerConfig.getOptionalString('groupExpand');
|
||||
const groupFilter = providerConfig.getOptionalString('groupFilter');
|
||||
const groupSearch = providerConfig.getOptionalString('groupSearch');
|
||||
|
||||
@@ -133,9 +142,11 @@ export function readMicrosoftGraphConfig(
|
||||
tenantId,
|
||||
clientId,
|
||||
clientSecret,
|
||||
userExpand,
|
||||
userFilter,
|
||||
userGroupMemberFilter,
|
||||
userGroupMemberSearch,
|
||||
groupExpand,
|
||||
groupFilter,
|
||||
groupSearch,
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('read microsoft graph', () => {
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120);
|
||||
});
|
||||
|
||||
it('should read users with custom transformer', async () => {
|
||||
it('should read users with userExpand and custom transformer', async () => {
|
||||
async function* getExampleUsers() {
|
||||
yield {
|
||||
id: 'userid',
|
||||
@@ -133,6 +133,7 @@ describe('read microsoft graph', () => {
|
||||
);
|
||||
|
||||
const { users } = await readMicrosoftGraphUsers(client, {
|
||||
userExpand: 'manager',
|
||||
userFilter: 'accountEnabled eq true',
|
||||
transformer: async () => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -154,6 +155,7 @@ describe('read microsoft graph', () => {
|
||||
|
||||
expect(client.getUsers).toBeCalledTimes(1);
|
||||
expect(client.getUsers).toBeCalledWith({
|
||||
expand: 'manager',
|
||||
filter: 'accountEnabled eq true',
|
||||
});
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1);
|
||||
@@ -227,12 +229,14 @@ describe('read microsoft graph', () => {
|
||||
expect(client.getGroupMembers).toBeCalledWith('groupid');
|
||||
|
||||
expect(client.getUserProfile).toBeCalledTimes(1);
|
||||
expect(client.getUserProfile).toBeCalledWith('userid');
|
||||
expect(client.getUserProfile).toBeCalledWith('userid', {
|
||||
expand: undefined,
|
||||
});
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1);
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120);
|
||||
});
|
||||
|
||||
it('should read users with custom transformer', async () => {
|
||||
it('should read users with userExpand, groupExpand and custom transformer', async () => {
|
||||
async function* getExampleGroups() {
|
||||
yield {
|
||||
id: 'groupid',
|
||||
@@ -266,7 +270,9 @@ describe('read microsoft graph', () => {
|
||||
);
|
||||
|
||||
const { users } = await readMicrosoftGraphUsersInGroups(client, {
|
||||
userExpand: 'manager',
|
||||
userGroupMemberFilter: 'securityEnabled eq true',
|
||||
groupExpand: 'member',
|
||||
transformer: async () => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
@@ -287,13 +293,16 @@ describe('read microsoft graph', () => {
|
||||
|
||||
expect(client.getGroups).toBeCalledTimes(1);
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
expand: 'member',
|
||||
filter: 'securityEnabled eq true',
|
||||
});
|
||||
expect(client.getGroupMembers).toBeCalledTimes(1);
|
||||
expect(client.getGroupMembers).toBeCalledWith('groupid');
|
||||
|
||||
expect(client.getUserProfile).toBeCalledTimes(1);
|
||||
expect(client.getUserProfile).toBeCalledWith('userid');
|
||||
expect(client.getUserProfile).toBeCalledWith('userid', {
|
||||
expand: 'manager',
|
||||
});
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1);
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120);
|
||||
});
|
||||
@@ -446,6 +455,100 @@ describe('read microsoft graph', () => {
|
||||
// expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120);
|
||||
});
|
||||
|
||||
it('should read groups with groupExpand', async () => {
|
||||
async function* getExampleGroups() {
|
||||
yield {
|
||||
id: 'groupid',
|
||||
displayName: 'Group Name',
|
||||
description: 'Group Description',
|
||||
mail: 'group@example.com',
|
||||
};
|
||||
}
|
||||
|
||||
async function* getExampleGroupMembers(): AsyncIterable<GroupMember> {
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.group',
|
||||
id: 'childgroupid',
|
||||
};
|
||||
yield {
|
||||
'@odata.type': '#microsoft.graph.user',
|
||||
id: 'userid',
|
||||
};
|
||||
}
|
||||
|
||||
client.getGroups.mockImplementation(getExampleGroups);
|
||||
client.getGroupMembers.mockImplementation(getExampleGroupMembers);
|
||||
client.getOrganization.mockResolvedValue({
|
||||
id: 'tenantid',
|
||||
displayName: 'Organization Name',
|
||||
});
|
||||
client.getGroupPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
|
||||
const { groups, groupMember, groupMemberOf, rootGroup } =
|
||||
await readMicrosoftGraphGroups(client, 'tenantid', {
|
||||
groupExpand: 'member',
|
||||
groupFilter: 'securityEnabled eq false',
|
||||
});
|
||||
|
||||
const expectedRootGroup = group({
|
||||
metadata: {
|
||||
annotations: {
|
||||
'graph.microsoft.com/tenant-id': 'tenantid',
|
||||
},
|
||||
name: 'organization_name',
|
||||
description: 'Organization Name',
|
||||
},
|
||||
spec: {
|
||||
type: 'root',
|
||||
profile: {
|
||||
displayName: 'Organization Name',
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
});
|
||||
expect(groups).toEqual([
|
||||
expectedRootGroup,
|
||||
group({
|
||||
metadata: {
|
||||
annotations: {
|
||||
'graph.microsoft.com/group-id': 'groupid',
|
||||
},
|
||||
name: 'group_name',
|
||||
description: 'Group Description',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
profile: {
|
||||
displayName: 'Group Name',
|
||||
email: 'group@example.com',
|
||||
// TODO: Loading groups photos doesn't work right now as Microsoft
|
||||
// Graph doesn't allows this yet
|
||||
/* picture: 'data:image/jpeg;base64,...',*/
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(rootGroup).toEqual(expectedRootGroup);
|
||||
expect(groupMember.get('groupid')).toEqual(new Set(['childgroupid']));
|
||||
expect(groupMemberOf.get('userid')).toEqual(new Set(['groupid']));
|
||||
expect(groupMember.get('organization_name')).toEqual(new Set());
|
||||
|
||||
expect(client.getGroups).toBeCalledTimes(1);
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
expand: 'member',
|
||||
filter: 'securityEnabled eq false',
|
||||
});
|
||||
expect(client.getGroupMembers).toBeCalledTimes(1);
|
||||
expect(client.getGroupMembers).toBeCalledWith('groupid');
|
||||
// TODO: Loading groups photos doesn't work right now as Microsoft Graph
|
||||
// doesn't allows this yet
|
||||
// expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1);
|
||||
// expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120);
|
||||
});
|
||||
|
||||
it('should read security groups', async () => {
|
||||
async function* getExampleGroups() {
|
||||
yield {
|
||||
@@ -634,6 +737,14 @@ describe('read microsoft graph', () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function getExampleUserProfile(userId: string) {
|
||||
return {
|
||||
id: userId,
|
||||
displayName: 'User Name',
|
||||
mail: 'user.name@example.com',
|
||||
};
|
||||
}
|
||||
|
||||
async function* getExampleGroups() {
|
||||
yield {
|
||||
id: 'groupid',
|
||||
@@ -686,7 +797,7 @@ describe('read microsoft graph', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should read users using userFilter', async () => {
|
||||
it('should read users using userExpand and userFilter', async () => {
|
||||
client.getOrganization.mockResolvedValue({
|
||||
id: 'tenantid',
|
||||
displayName: 'Organization Name',
|
||||
@@ -705,12 +816,14 @@ describe('read microsoft graph', () => {
|
||||
|
||||
await readMicrosoftGraphOrg(client, 'tenantid', {
|
||||
logger: getVoidLogger(),
|
||||
userExpand: 'manager',
|
||||
userFilter: 'accountEnabled eq true',
|
||||
groupFilter: 'securityEnabled eq false',
|
||||
});
|
||||
|
||||
expect(client.getUsers).toBeCalledTimes(1);
|
||||
expect(client.getUsers).toBeCalledWith({
|
||||
expand: 'manager',
|
||||
filter: 'accountEnabled eq true',
|
||||
});
|
||||
expect(client.getGroups).toBeCalledTimes(1);
|
||||
@@ -719,13 +832,14 @@ describe('read microsoft graph', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should read users using userGroupMemberFilter', async () => {
|
||||
it('should read users using userExpand and userGroupMemberFilter', async () => {
|
||||
client.getOrganization.mockResolvedValue({
|
||||
id: 'tenantid',
|
||||
displayName: 'Organization Name',
|
||||
});
|
||||
|
||||
client.getUsers.mockImplementation(getExampleUsers);
|
||||
client.getUserProfile.mockImplementation(getExampleUserProfile);
|
||||
client.getUserPhotoWithSizeLimit.mockResolvedValue(
|
||||
'data:image/jpeg;base64,...',
|
||||
);
|
||||
@@ -750,6 +864,8 @@ describe('read microsoft graph', () => {
|
||||
expect(client.getGroups).toBeCalledWith({
|
||||
filter: 'securityEnabled eq false',
|
||||
});
|
||||
expect(client.getUserProfile).toBeCalledTimes(1);
|
||||
expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ export async function readMicrosoftGraphUsers(
|
||||
client: MicrosoftGraphClient,
|
||||
options: {
|
||||
userFilter?: string;
|
||||
userExpand?: string[];
|
||||
userExpand?: string;
|
||||
transformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
},
|
||||
@@ -137,8 +137,10 @@ export async function readMicrosoftGraphUsers(
|
||||
export async function readMicrosoftGraphUsersInGroups(
|
||||
client: MicrosoftGraphClient,
|
||||
options: {
|
||||
userExpand?: string;
|
||||
userGroupMemberSearch?: string;
|
||||
userGroupMemberFilter?: string;
|
||||
groupExpand?: string;
|
||||
transformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
},
|
||||
@@ -149,15 +151,16 @@ export async function readMicrosoftGraphUsersInGroups(
|
||||
|
||||
const limiter = limiterFactory(10);
|
||||
|
||||
const transformer = options?.transformer ?? defaultUserTransformer;
|
||||
const transformer = options.transformer ?? defaultUserTransformer;
|
||||
const userGroupMemberPromises: Promise<void>[] = [];
|
||||
const userPromises: Promise<void>[] = [];
|
||||
|
||||
const groupMemberUsers: Set<string> = new Set();
|
||||
|
||||
for await (const group of client.getGroups({
|
||||
search: options?.userGroupMemberSearch,
|
||||
filter: options?.userGroupMemberFilter,
|
||||
expand: options.groupExpand,
|
||||
search: options.userGroupMemberSearch,
|
||||
filter: options.userGroupMemberFilter,
|
||||
})) {
|
||||
// Process all groups in parallel, otherwise it can take quite some time
|
||||
userGroupMemberPromises.push(
|
||||
@@ -186,7 +189,9 @@ export async function readMicrosoftGraphUsersInGroups(
|
||||
let user;
|
||||
let userPhoto;
|
||||
try {
|
||||
user = await client.getUserProfile(userId);
|
||||
user = await client.getUserProfile(userId, {
|
||||
expand: options.userExpand,
|
||||
});
|
||||
} catch (e) {
|
||||
options.logger.warn(`Unable to load user for ${userId}`);
|
||||
}
|
||||
@@ -326,8 +331,9 @@ export async function readMicrosoftGraphGroups(
|
||||
client: MicrosoftGraphClient,
|
||||
tenantId: string,
|
||||
options?: {
|
||||
groupSearch?: string;
|
||||
groupExpand?: string;
|
||||
groupFilter?: string;
|
||||
groupSearch?: string;
|
||||
groupTransformer?: GroupTransformer;
|
||||
organizationTransformer?: OrganizationTransformer;
|
||||
},
|
||||
@@ -354,6 +360,7 @@ export async function readMicrosoftGraphGroups(
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for await (const group of client.getGroups({
|
||||
expand: options?.groupExpand,
|
||||
search: options?.groupSearch,
|
||||
filter: options?.groupFilter,
|
||||
})) {
|
||||
@@ -506,10 +513,11 @@ export async function readMicrosoftGraphOrg(
|
||||
client: MicrosoftGraphClient,
|
||||
tenantId: string,
|
||||
options: {
|
||||
userExpand?: string[];
|
||||
userExpand?: string;
|
||||
userFilter?: string;
|
||||
userGroupMemberSearch?: string;
|
||||
userGroupMemberFilter?: string;
|
||||
groupExpand?: string;
|
||||
groupSearch?: string;
|
||||
groupFilter?: string;
|
||||
userTransformer?: UserTransformer;
|
||||
|
||||
+1
@@ -109,6 +109,7 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor {
|
||||
userFilter: provider.userFilter,
|
||||
userGroupMemberFilter: provider.userGroupMemberFilter,
|
||||
userGroupMemberSearch: provider.userGroupMemberSearch,
|
||||
groupExpand: provider.groupExpand,
|
||||
groupFilter: provider.groupFilter,
|
||||
groupSearch: provider.groupSearch,
|
||||
userTransformer: this.userTransformer,
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
import { BitbucketIntegration } from '@backstage/integration';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { ConditionalPolicyDecision } from '@backstage/plugin-permission-node';
|
||||
import { Conditions } from '@backstage/plugin-permission-node';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DocumentCollator } from '@backstage/search-common';
|
||||
import { DocumentCollatorFactory } from '@backstage/search-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { EntityPolicy } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import { GetEntitiesRequest } from '@backstage/catalog-client';
|
||||
@@ -30,6 +30,7 @@ import { PermissionCriteria } from '@backstage/plugin-permission-common';
|
||||
import { PermissionRule } from '@backstage/plugin-permission-node';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { Readable } from 'stream';
|
||||
import { Router } from 'express';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { TokenManager } from '@backstage/backend-common';
|
||||
@@ -217,7 +218,13 @@ export class CatalogBuilder {
|
||||
key: string,
|
||||
resolver: PlaceholderResolver,
|
||||
): CatalogBuilder;
|
||||
setProcessingInterval(
|
||||
processingInterval: ProcessingIntervalFunction,
|
||||
): CatalogBuilder;
|
||||
setProcessingIntervalSeconds(seconds: number): CatalogBuilder;
|
||||
// @deprecated
|
||||
setRefreshInterval(refreshInterval: RefreshIntervalFunction): CatalogBuilder;
|
||||
// @deprecated
|
||||
setRefreshIntervalSeconds(seconds: number): CatalogBuilder;
|
||||
}
|
||||
|
||||
@@ -407,6 +414,12 @@ export const createCatalogPolicyDecision: (
|
||||
) => ConditionalPolicyDecision;
|
||||
|
||||
// @public
|
||||
export function createRandomProcessingInterval(options: {
|
||||
minSeconds: number;
|
||||
maxSeconds: number;
|
||||
}): ProcessingIntervalFunction;
|
||||
|
||||
// @public @deprecated
|
||||
export function createRandomRefreshInterval(options: {
|
||||
minSeconds: number;
|
||||
maxSeconds: number;
|
||||
@@ -415,8 +428,8 @@ export function createRandomRefreshInterval(options: {
|
||||
// @public
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// @public (undocumented)
|
||||
export class DefaultCatalogCollator implements DocumentCollator {
|
||||
// @public @deprecated (undocumented)
|
||||
export class DefaultCatalogCollator {
|
||||
constructor(options: {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
tokenManager: TokenManager;
|
||||
@@ -456,6 +469,31 @@ export class DefaultCatalogCollator implements DocumentCollator {
|
||||
readonly visibilityPermission: Permission;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory {
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
_config: Config,
|
||||
options: DefaultCatalogCollatorFactoryOptions,
|
||||
): DefaultCatalogCollatorFactory;
|
||||
// (undocumented)
|
||||
getCollator(): Promise<Readable>;
|
||||
// (undocumented)
|
||||
readonly type: string;
|
||||
// (undocumented)
|
||||
readonly visibilityPermission: Permission;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type DefaultCatalogCollatorFactoryOptions = {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
tokenManager: TokenManager;
|
||||
locationTemplate?: string;
|
||||
filter?: GetEntitiesRequest['filter'];
|
||||
batchSize?: number;
|
||||
catalogClient?: CatalogApi;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export class DefaultCatalogProcessingOrchestrator
|
||||
implements CatalogProcessingOrchestrator
|
||||
@@ -623,9 +661,9 @@ export type EntityProviderMutation =
|
||||
|
||||
// @public
|
||||
export type EntityRelationSpec = {
|
||||
source: EntityName;
|
||||
source: CompoundEntityRef;
|
||||
type: string;
|
||||
target: EntityName;
|
||||
target: CompoundEntityRef;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -963,6 +1001,9 @@ export type PlaceholderResolverResolveUrl = (
|
||||
base: string,
|
||||
) => string;
|
||||
|
||||
// @public
|
||||
export type ProcessingIntervalFunction = () => number;
|
||||
|
||||
// @public
|
||||
export const processingResult: Readonly<{
|
||||
readonly notFoundError: (
|
||||
@@ -997,7 +1038,7 @@ export type RecursivePartial<T> = {
|
||||
: T[P];
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type RefreshIntervalFunction = () => number;
|
||||
|
||||
// @public
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"@backstage/backend-test-utils": "^0.1.19",
|
||||
"@backstage/cli": "^0.14.1",
|
||||
"@backstage/plugin-permission-common": "^0.5.1",
|
||||
"@backstage/plugin-search-backend-node": "0.4.7",
|
||||
"@backstage/test-utils": "^0.2.6",
|
||||
"@types/core-js": "^2.5.4",
|
||||
"@types/git-url-parse": "^9.0.0",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
/**
|
||||
* Holds the entity location information.
|
||||
@@ -42,7 +42,7 @@ export type EntityRelationSpec = {
|
||||
/**
|
||||
* The source entity of this relation.
|
||||
*/
|
||||
source: EntityName;
|
||||
source: CompoundEntityRef;
|
||||
|
||||
/**
|
||||
* The type of the relation.
|
||||
@@ -52,5 +52,5 @@ export type EntityRelationSpec = {
|
||||
/**
|
||||
* The target entity of this relation.
|
||||
*/
|
||||
target: EntityName;
|
||||
target: CompoundEntityRef;
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
DbRefreshStateRow,
|
||||
DbRelationsRow,
|
||||
} from './tables';
|
||||
import { createRandomRefreshInterval } from '../processing/refresh';
|
||||
import { createRandomProcessingInterval } from '../processing/refresh';
|
||||
import { timestampToDateTime } from './conversion';
|
||||
import { generateStableHash } from './util';
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Default Processing Database', () => {
|
||||
db: new DefaultProcessingDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
refreshInterval: createRandomRefreshInterval({
|
||||
refreshInterval: createRandomProcessingInterval({
|
||||
minSeconds: 100,
|
||||
maxSeconds: 150,
|
||||
}),
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
DomainEntity,
|
||||
domainEntityV1alpha1Validator,
|
||||
Entity,
|
||||
getEntityName,
|
||||
getCompoundEntityRef,
|
||||
GroupEntity,
|
||||
groupEntityV1alpha1Validator,
|
||||
locationEntityV1alpha1Validator,
|
||||
@@ -93,7 +93,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
|
||||
_location: LocationSpec,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity> {
|
||||
const selfRef = getEntityName(entity);
|
||||
const selfRef = getCompoundEntityRef(entity);
|
||||
|
||||
/*
|
||||
* Utilities
|
||||
|
||||
@@ -21,7 +21,7 @@ import { createCatalogPermissionRule } from './util';
|
||||
* A catalog {@link @backstage/plugin-permission-node#PermissionRule} which
|
||||
* filters for the presence of an annotation on a given entity.
|
||||
*
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export const hasAnnotation = createCatalogPermissionRule({
|
||||
name: 'HAS_ANNOTATION',
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createCatalogPermissionRule } from './util';
|
||||
/**
|
||||
* A catalog {@link @backstage/plugin-permission-node#PermissionRule} which
|
||||
* filters for entities with a specified label in its metadata.
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export const hasLabel = createCatalogPermissionRule({
|
||||
name: 'HAS_LABEL',
|
||||
|
||||
@@ -23,6 +23,6 @@ import { createPropertyRule } from './createPropertyRule';
|
||||
*
|
||||
* The key argument to the `apply` and `toQuery` methods can be nested, such as
|
||||
* 'field.nestedfield'.
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export const hasMetadata = createPropertyRule('metadata');
|
||||
|
||||
@@ -23,6 +23,6 @@ import { createPropertyRule } from './createPropertyRule';
|
||||
*
|
||||
* The key argument to the `apply` and `toQuery` methods can be nested, such as
|
||||
* 'field.nestedfield'.
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export const hasSpec = createPropertyRule('spec');
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createCatalogPermissionRule } from './util';
|
||||
/**
|
||||
* A catalog {@link @backstage/plugin-permission-node#PermissionRule} which
|
||||
* filters for entities with a specified kind.
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export const isEntityKind = createCatalogPermissionRule({
|
||||
name: 'IS_ENTITY_KIND',
|
||||
|
||||
@@ -21,7 +21,7 @@ import { createCatalogPermissionRule } from './util';
|
||||
* A catalog {@link @backstage/plugin-permission-node#PermissionRule} which
|
||||
* filters for entities with a specified owner.
|
||||
*
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export const isEntityOwner = createCatalogPermissionRule({
|
||||
name: 'IS_ENTITY_OWNER',
|
||||
|
||||
@@ -23,5 +23,11 @@ export type {
|
||||
} from './types';
|
||||
export { DefaultCatalogProcessingOrchestrator } from './DefaultCatalogProcessingOrchestrator';
|
||||
|
||||
export { createRandomRefreshInterval } from './refresh';
|
||||
export type { RefreshIntervalFunction } from './refresh';
|
||||
export {
|
||||
createRandomRefreshInterval,
|
||||
createRandomProcessingInterval,
|
||||
} from './refresh';
|
||||
export type {
|
||||
RefreshIntervalFunction,
|
||||
ProcessingIntervalFunction,
|
||||
} from './refresh';
|
||||
|
||||
@@ -16,13 +16,21 @@
|
||||
|
||||
/**
|
||||
* Function that returns the catalog refresh interval in seconds.
|
||||
* @deprecated use {@link ProcessingIntervalFunction} instead
|
||||
* @public
|
||||
*/
|
||||
export type RefreshIntervalFunction = () => number;
|
||||
|
||||
/**
|
||||
* Function that returns the catalog processing interval in seconds.
|
||||
* @public
|
||||
*/
|
||||
export type ProcessingIntervalFunction = () => number;
|
||||
|
||||
/**
|
||||
* Creates a function that returns a random refresh interval between minSeconds and maxSeconds.
|
||||
* @returns A {@link RefreshIntervalFunction} that provides the next refresh interval
|
||||
* @deprecated use {@link createRandomProcessingInterval} instead
|
||||
* @public
|
||||
*/
|
||||
export function createRandomRefreshInterval(options: {
|
||||
@@ -34,3 +42,18 @@ export function createRandomRefreshInterval(options: {
|
||||
return Math.random() * (maxSeconds - minSeconds) + minSeconds;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a function that returns a random processing interval between minSeconds and maxSeconds.
|
||||
* @returns A {@link ProcessingIntervalFunction} that provides the next processing interval
|
||||
* @public
|
||||
*/
|
||||
export function createRandomProcessingInterval(options: {
|
||||
minSeconds: number;
|
||||
maxSeconds: number;
|
||||
}): ProcessingIntervalFunction {
|
||||
const { minSeconds, maxSeconds } = options;
|
||||
return () => {
|
||||
return Math.random() * (maxSeconds - minSeconds) + minSeconds;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { IndexableDocument, DocumentCollator } from '@backstage/search-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
CatalogApi,
|
||||
@@ -31,18 +30,14 @@ import {
|
||||
GetEntitiesRequest,
|
||||
} from '@backstage/catalog-client';
|
||||
import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common';
|
||||
import { CatalogEntityDocument } from './DefaultCatalogCollatorFactory';
|
||||
|
||||
/** @public */
|
||||
export interface CatalogEntityDocument extends IndexableDocument {
|
||||
componentType: string;
|
||||
namespace: string;
|
||||
kind: string;
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class DefaultCatalogCollator implements DocumentCollator {
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Upgrade to a more recent `@backstage/search-backend-node` and
|
||||
* use `DefaultCatalogCollatorFactory` instead.
|
||||
*/
|
||||
export class DefaultCatalogCollator {
|
||||
protected discovery: PluginEndpointDiscovery;
|
||||
protected locationTemplate: string;
|
||||
protected filter?: GetEntitiesRequest['filter'];
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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 {
|
||||
PluginEndpointDiscovery,
|
||||
TokenManager,
|
||||
} from '@backstage/backend-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { TestPipeline } from '@backstage/plugin-search-backend-node';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { Readable } from 'stream';
|
||||
import { DefaultCatalogCollatorFactory } from './DefaultCatalogCollatorFactory';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
const expectedEntities: Entity[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test-entity',
|
||||
description: 'The expected description',
|
||||
},
|
||||
spec: {
|
||||
type: 'some-type',
|
||||
lifecycle: 'experimental',
|
||||
owner: 'someone',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
title: 'Test Entity',
|
||||
name: 'test-entity-2',
|
||||
description: 'The expected description 2',
|
||||
},
|
||||
spec: {
|
||||
type: 'some-type',
|
||||
lifecycle: 'experimental',
|
||||
owner: 'someone',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('DefaultCatalogCollatorFactory', () => {
|
||||
const config = new ConfigReader({});
|
||||
const mockDiscoveryApi: jest.Mocked<PluginEndpointDiscovery> = {
|
||||
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007'),
|
||||
getExternalBaseUrl: jest.fn(),
|
||||
};
|
||||
const mockTokenManager: jest.Mocked<TokenManager> = {
|
||||
getToken: jest.fn().mockResolvedValue({ token: '' }),
|
||||
authenticate: jest.fn(),
|
||||
};
|
||||
const options = {
|
||||
discovery: mockDiscoveryApi,
|
||||
tokenManager: mockTokenManager,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get('http://localhost:7007/entities', (req, res, ctx) => {
|
||||
if (req.url.searchParams.has('filter')) {
|
||||
const filter = req.url.searchParams.get('filter');
|
||||
if (filter === 'kind=Foo,kind=Bar') {
|
||||
// When filtering on the 'Foo,Bar' kinds we simply return no items, to simulate a filter
|
||||
return res(ctx.json([]));
|
||||
}
|
||||
throw new Error('Unexpected filter parameter');
|
||||
}
|
||||
|
||||
// Imitate offset/limit pagination.
|
||||
const offset = parseInt(req.url.searchParams.get('offset') || '0', 10);
|
||||
const limit = parseInt(req.url.searchParams.get('limit') || '500', 10);
|
||||
return res(ctx.json(expectedEntities.slice(offset, limit + offset)));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
it('has expected type', () => {
|
||||
const factory = DefaultCatalogCollatorFactory.fromConfig(config, options);
|
||||
expect(factory.type).toBe('software-catalog');
|
||||
});
|
||||
|
||||
describe('getCollator', () => {
|
||||
let factory: DefaultCatalogCollatorFactory;
|
||||
let collator: Readable;
|
||||
|
||||
beforeEach(async () => {
|
||||
factory = DefaultCatalogCollatorFactory.fromConfig(config, options);
|
||||
collator = await factory.getCollator();
|
||||
});
|
||||
|
||||
it('returns a readable stream', async () => {
|
||||
expect(collator).toBeInstanceOf(Readable);
|
||||
});
|
||||
|
||||
it('fetches from the configured catalog service', async () => {
|
||||
const pipeline = TestPipeline.withSubject(collator);
|
||||
const { documents } = await pipeline.execute();
|
||||
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog');
|
||||
expect(documents).toHaveLength(expectedEntities.length);
|
||||
});
|
||||
|
||||
it('maps a returned entity to an expected CatalogEntityDocument', async () => {
|
||||
const pipeline = TestPipeline.withSubject(collator);
|
||||
const { documents } = await pipeline.execute();
|
||||
|
||||
expect(documents[0]).toMatchObject({
|
||||
title: expectedEntities[0].metadata.name,
|
||||
location: '/catalog/default/component/test-entity',
|
||||
text: expectedEntities[0].metadata.description,
|
||||
namespace: 'default',
|
||||
componentType: expectedEntities[0]!.spec!.type,
|
||||
lifecycle: expectedEntities[0]!.spec!.lifecycle,
|
||||
owner: expectedEntities[0]!.spec!.owner,
|
||||
authorization: {
|
||||
resourceRef: 'component:default/test-entity',
|
||||
},
|
||||
});
|
||||
expect(documents[1]).toMatchObject({
|
||||
title: expectedEntities[1].metadata.title,
|
||||
location: '/catalog/default/component/test-entity-2',
|
||||
text: expectedEntities[1].metadata.description,
|
||||
namespace: 'default',
|
||||
componentType: expectedEntities[1]!.spec!.type,
|
||||
lifecycle: expectedEntities[1]!.spec!.lifecycle,
|
||||
owner: expectedEntities[1]!.spec!.owner,
|
||||
authorization: {
|
||||
resourceRef: 'component:default/test-entity-2',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a returned entity with a custom locationTemplate', async () => {
|
||||
// Provide an alternate location template.
|
||||
factory = DefaultCatalogCollatorFactory.fromConfig(new ConfigReader({}), {
|
||||
discovery: mockDiscoveryApi,
|
||||
tokenManager: mockTokenManager,
|
||||
locationTemplate: '/software/:name',
|
||||
});
|
||||
collator = await factory.getCollator();
|
||||
|
||||
const pipeline = TestPipeline.withSubject(collator);
|
||||
const { documents } = await pipeline.execute();
|
||||
expect(documents[0]).toMatchObject({
|
||||
location: '/software/test-entity',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows filtering of the retrieved catalog entities', async () => {
|
||||
// Provide a custom filter.
|
||||
factory = DefaultCatalogCollatorFactory.fromConfig(new ConfigReader({}), {
|
||||
discovery: mockDiscoveryApi,
|
||||
tokenManager: mockTokenManager,
|
||||
filter: {
|
||||
kind: ['Foo', 'Bar'],
|
||||
},
|
||||
});
|
||||
collator = await factory.getCollator();
|
||||
|
||||
const pipeline = TestPipeline.withSubject(collator);
|
||||
const { documents } = await pipeline.execute();
|
||||
|
||||
// The simulated 'Foo,Bar' filter should return in an empty list
|
||||
expect(documents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('paginates through catalog entities using batchSize', async () => {
|
||||
factory = DefaultCatalogCollatorFactory.fromConfig(config, {
|
||||
...options,
|
||||
batchSize: 1,
|
||||
});
|
||||
collator = await factory.getCollator();
|
||||
|
||||
const pipeline = TestPipeline.withSubject(collator);
|
||||
const { documents } = await pipeline.execute();
|
||||
|
||||
expect(documents).toHaveLength(expectedEntities.length);
|
||||
expect(documents[0].location).toBe(
|
||||
'/catalog/default/component/test-entity',
|
||||
);
|
||||
expect(documents[1].location).toBe(
|
||||
'/catalog/default/component/test-entity-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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 {
|
||||
PluginEndpointDiscovery,
|
||||
TokenManager,
|
||||
} from '@backstage/backend-common';
|
||||
import {
|
||||
CatalogApi,
|
||||
CatalogClient,
|
||||
GetEntitiesRequest,
|
||||
} from '@backstage/catalog-client';
|
||||
import {
|
||||
Entity,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
DocumentCollatorFactory,
|
||||
IndexableDocument,
|
||||
} from '@backstage/search-common';
|
||||
import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
/** @public */
|
||||
export interface CatalogEntityDocument extends IndexableDocument {
|
||||
componentType: string;
|
||||
namespace: string;
|
||||
kind: string;
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type DefaultCatalogCollatorFactoryOptions = {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
tokenManager: TokenManager;
|
||||
locationTemplate?: string;
|
||||
filter?: GetEntitiesRequest['filter'];
|
||||
batchSize?: number;
|
||||
catalogClient?: CatalogApi;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory {
|
||||
public readonly type: string = 'software-catalog';
|
||||
public readonly visibilityPermission = catalogEntityReadPermission;
|
||||
|
||||
private locationTemplate: string;
|
||||
private filter?: GetEntitiesRequest['filter'];
|
||||
private batchSize: number;
|
||||
private readonly catalogClient: CatalogApi;
|
||||
private tokenManager: TokenManager;
|
||||
|
||||
static fromConfig(
|
||||
_config: Config,
|
||||
options: DefaultCatalogCollatorFactoryOptions,
|
||||
) {
|
||||
return new DefaultCatalogCollatorFactory(options);
|
||||
}
|
||||
|
||||
private constructor(options: DefaultCatalogCollatorFactoryOptions) {
|
||||
const {
|
||||
batchSize,
|
||||
discovery,
|
||||
locationTemplate,
|
||||
filter,
|
||||
catalogClient,
|
||||
tokenManager,
|
||||
} = options;
|
||||
|
||||
this.locationTemplate =
|
||||
locationTemplate || '/catalog/:namespace/:kind/:name';
|
||||
this.filter = filter;
|
||||
this.batchSize = batchSize || 500;
|
||||
this.catalogClient =
|
||||
catalogClient || new CatalogClient({ discoveryApi: discovery });
|
||||
this.tokenManager = tokenManager;
|
||||
}
|
||||
|
||||
async getCollator(): Promise<Readable> {
|
||||
return Readable.from(this.execute());
|
||||
}
|
||||
|
||||
private applyArgsToFormat(
|
||||
format: string,
|
||||
args: Record<string, string>,
|
||||
): string {
|
||||
let formatted = format;
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
formatted = formatted.replace(`:${key}`, value);
|
||||
}
|
||||
return formatted.toLowerCase();
|
||||
}
|
||||
|
||||
private isUserEntity(entity: Entity): entity is UserEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'USER';
|
||||
}
|
||||
|
||||
private getDocumentText(entity: Entity): string {
|
||||
let documentText = entity.metadata.description || '';
|
||||
if (this.isUserEntity(entity)) {
|
||||
if (entity.spec?.profile?.displayName && documentText) {
|
||||
// combine displayName and description
|
||||
const displayName = entity.spec?.profile?.displayName;
|
||||
documentText = displayName.concat(' : ', documentText);
|
||||
} else {
|
||||
documentText = entity.spec?.profile?.displayName || documentText;
|
||||
}
|
||||
}
|
||||
return documentText;
|
||||
}
|
||||
|
||||
private async *execute(): AsyncGenerator<CatalogEntityDocument> {
|
||||
const { token } = await this.tokenManager.getToken();
|
||||
let entitiesRetrieved = 0;
|
||||
let moreEntitiesToGet = true;
|
||||
|
||||
// Offset/limit pagination is used on the Catalog Client in order to
|
||||
// limit (and allow some control over) memory used by the search backend
|
||||
// at index-time.
|
||||
while (moreEntitiesToGet) {
|
||||
const entities = (
|
||||
await this.catalogClient.getEntities(
|
||||
{
|
||||
filter: this.filter,
|
||||
limit: this.batchSize,
|
||||
offset: entitiesRetrieved,
|
||||
},
|
||||
{ token },
|
||||
)
|
||||
).items;
|
||||
|
||||
// Control looping through entity batches.
|
||||
moreEntitiesToGet = entities.length === this.batchSize;
|
||||
entitiesRetrieved += entities.length;
|
||||
|
||||
for (const entity of entities) {
|
||||
yield {
|
||||
title: entity.metadata.title ?? entity.metadata.name,
|
||||
location: this.applyArgsToFormat(this.locationTemplate, {
|
||||
namespace: entity.metadata.namespace || 'default',
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
}),
|
||||
text: this.getDocumentText(entity),
|
||||
componentType: entity.spec?.type?.toString() || 'other',
|
||||
namespace: entity.metadata.namespace || 'default',
|
||||
kind: entity.kind,
|
||||
lifecycle: (entity.spec?.lifecycle as string) || '',
|
||||
owner: (entity.spec?.owner as string) || '',
|
||||
authorization: {
|
||||
resourceRef: stringifyEntityRef(entity),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DefaultCatalogCollatorFactory } from './DefaultCatalogCollatorFactory';
|
||||
export type { DefaultCatalogCollatorFactoryOptions } from './DefaultCatalogCollatorFactory';
|
||||
export type { CatalogEntityDocument } from './DefaultCatalogCollatorFactory';
|
||||
|
||||
/**
|
||||
* todo(backstage/techdocs-core): stop exporting this in a future release.
|
||||
*/
|
||||
export { DefaultCatalogCollator } from './DefaultCatalogCollator';
|
||||
export type { CatalogEntityDocument } from './DefaultCatalogCollator';
|
||||
|
||||
@@ -76,8 +76,9 @@ import { DefaultEntitiesCatalog } from './DefaultEntitiesCatalog';
|
||||
import { DefaultCatalogProcessingOrchestrator } from '../processing/DefaultCatalogProcessingOrchestrator';
|
||||
import { Stitcher } from '../stitching/Stitcher';
|
||||
import {
|
||||
createRandomRefreshInterval,
|
||||
createRandomProcessingInterval,
|
||||
RefreshIntervalFunction,
|
||||
ProcessingIntervalFunction,
|
||||
} from '../processing/refresh';
|
||||
import { createRouter } from './createRouter';
|
||||
import { DefaultRefreshService } from './DefaultRefreshService';
|
||||
@@ -139,8 +140,8 @@ export class CatalogBuilder {
|
||||
private processors: CatalogProcessor[];
|
||||
private processorsReplace: boolean;
|
||||
private parser: CatalogProcessorParser | undefined;
|
||||
private refreshInterval: RefreshIntervalFunction =
|
||||
createRandomRefreshInterval({
|
||||
private processingInterval: ProcessingIntervalFunction =
|
||||
createRandomProcessingInterval({
|
||||
minSeconds: 100,
|
||||
maxSeconds: 150,
|
||||
});
|
||||
@@ -192,9 +193,28 @@ export class CatalogBuilder {
|
||||
* Seconds provided will be multiplied by 1.5
|
||||
* The default refresh duration is 100-150 seconds.
|
||||
* setting this too low will potentially deplete request quotas to upstream services.
|
||||
*
|
||||
* @deprecated use {@link CatalogBuilder#setProcessingIntervalSeconds} instead
|
||||
*/
|
||||
setRefreshIntervalSeconds(seconds: number): CatalogBuilder {
|
||||
this.refreshInterval = createRandomRefreshInterval({
|
||||
this.env.logger.warn(
|
||||
'[DEPRECATION] - CatalogBuilder.setRefreshIntervalSeconds is deprecated. Use CatalogBuilder.setProcessingIntervalSeconds instead.',
|
||||
);
|
||||
this.processingInterval = createRandomProcessingInterval({
|
||||
minSeconds: seconds,
|
||||
maxSeconds: seconds * 1.5,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing interval determines how often entities should be processed.
|
||||
* Seconds provided will be multiplied by 1.5
|
||||
* The default processing interval is 100-150 seconds.
|
||||
* setting this too low will potentially deplete request quotas to upstream services.
|
||||
*/
|
||||
setProcessingIntervalSeconds(seconds: number): CatalogBuilder {
|
||||
this.processingInterval = createRandomProcessingInterval({
|
||||
minSeconds: seconds,
|
||||
maxSeconds: seconds * 1.5,
|
||||
});
|
||||
@@ -204,9 +224,25 @@ export class CatalogBuilder {
|
||||
/**
|
||||
* Overwrites the default refresh interval function used to spread
|
||||
* entity updates in the catalog.
|
||||
*
|
||||
* @deprecated use {@link CatalogBuilder#setProcessingInterval} instead
|
||||
*/
|
||||
setRefreshInterval(refreshInterval: RefreshIntervalFunction): CatalogBuilder {
|
||||
this.refreshInterval = refreshInterval;
|
||||
this.env.logger.warn(
|
||||
'[DEPRECATION] - CatalogBuilder.setRefreshInterval is deprecated. Use CatalogBuilder.setProcessingInterval instead.',
|
||||
);
|
||||
this.processingInterval = refreshInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites the default processing interval function used to spread
|
||||
* entity updates in the catalog.
|
||||
*/
|
||||
setProcessingInterval(
|
||||
processingInterval: ProcessingIntervalFunction,
|
||||
): CatalogBuilder {
|
||||
this.processingInterval = processingInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -396,7 +432,7 @@ export class CatalogBuilder {
|
||||
const processingDatabase = new DefaultProcessingDatabase({
|
||||
database: dbClient,
|
||||
logger,
|
||||
refreshInterval: this.refreshInterval,
|
||||
refreshInterval: this.processingInterval,
|
||||
});
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
const rulesEnforcer = DefaultCatalogRulesEnforcer.fromConfig(config);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
/// <reference types="react" />
|
||||
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { DependencyGraphTypes } from '@backstage/core-components';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { ExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
import { InfoCardVariants } from '@backstage/core-components';
|
||||
import { MouseEvent as MouseEvent_2 } from 'react';
|
||||
@@ -129,7 +129,7 @@ export const EntityRelationsGraph: ({
|
||||
className,
|
||||
zoom,
|
||||
}: {
|
||||
rootEntityNames: EntityName | EntityName[];
|
||||
rootEntityNames: CompoundEntityRef | CompoundEntityRef[];
|
||||
maxDepth?: number | undefined;
|
||||
unidirectional?: boolean | undefined;
|
||||
mergeRelations?: boolean | undefined;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { GetEntitiesResponse } from '@backstage/catalog-client';
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
RELATION_API_CONSUMED_BY,
|
||||
RELATION_API_PROVIDED_BY,
|
||||
@@ -139,8 +139,12 @@ createDevApp()
|
||||
deps: {},
|
||||
factory() {
|
||||
return {
|
||||
async getEntityByName(name: EntityName): Promise<Entity | undefined> {
|
||||
return entities[stringifyEntityRef(name)];
|
||||
async getEntityByRef(
|
||||
ref: string | CompoundEntityRef,
|
||||
): Promise<Entity | undefined> {
|
||||
return entities[
|
||||
typeof ref === 'string' ? ref : stringifyEntityRef(ref)
|
||||
];
|
||||
},
|
||||
async getEntities(): Promise<GetEntitiesResponse> {
|
||||
return { items: Object.values(entities) };
|
||||
|
||||
@@ -57,7 +57,8 @@ describe('<CatalogGraphCard/>', () => {
|
||||
};
|
||||
catalog = {
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(async _ => ({ ...entity, relations: [] })),
|
||||
getEntityByRef: jest.fn(async _ => ({ ...entity, relations: [] })),
|
||||
getEntityByName: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
@@ -88,7 +89,7 @@ describe('<CatalogGraphCard/>', () => {
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(1);
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(1);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders with custom title', async () => {
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
getEntityName,
|
||||
getCompoundEntityRef,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
|
||||
import { useAnalytics, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
useEntity,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
@@ -77,7 +77,7 @@ export const CatalogGraphCard = ({
|
||||
zoom?: 'enabled' | 'disabled' | 'enable-on-click';
|
||||
}) => {
|
||||
const { entity } = useEntity();
|
||||
const entityName = getEntityName(entity);
|
||||
const entityName = getCompoundEntityRef(entity);
|
||||
const catalogEntityRoute = useRouteRef(entityRouteRef);
|
||||
const catalogGraphRoute = useRouteRef(catalogGraphRouteRef);
|
||||
const navigate = useNavigate();
|
||||
@@ -94,7 +94,7 @@ export const CatalogGraphCard = ({
|
||||
});
|
||||
analytics.captureEvent(
|
||||
'click',
|
||||
node.title ?? formatEntityRefTitle(nodeEntityName),
|
||||
node.title ?? humanizeEntityRef(nodeEntityName),
|
||||
{ attributes: { to: path } },
|
||||
);
|
||||
navigate(path);
|
||||
|
||||
@@ -88,7 +88,10 @@ describe('<CatalogGraphPage/>', () => {
|
||||
};
|
||||
catalog = {
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(async n => (n.name === 'e' ? entityE : entityC)),
|
||||
getEntityByRef: jest.fn(async (n: any) =>
|
||||
n === 'b:d/e' ? entityE : entityC,
|
||||
),
|
||||
getEntityByName: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
@@ -128,7 +131,7 @@ describe('<CatalogGraphPage/>', () => {
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/e')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(2);
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(2);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should toggle filters', async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { useAnalytics, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
entityRouteRef,
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { Grid, makeStyles, Paper, Typography } from '@material-ui/core';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
@@ -149,14 +149,14 @@ export const CatalogGraphPage = ({
|
||||
|
||||
analytics.captureEvent(
|
||||
'click',
|
||||
node.title ?? formatEntityRefTitle(nodeEntityName),
|
||||
node.title ?? humanizeEntityRef(nodeEntityName),
|
||||
{ attributes: { to: path } },
|
||||
);
|
||||
navigate(path);
|
||||
} else {
|
||||
analytics.captureEvent(
|
||||
'click',
|
||||
node.title ?? formatEntityRefTitle(nodeEntityName),
|
||||
node.title ?? humanizeEntityRef(nodeEntityName),
|
||||
);
|
||||
setRootEntityNames([nodeEntityName]);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export const CatalogGraphPage = ({
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title="Catalog Graph"
|
||||
subtitle={rootEntityNames.map(e => formatEntityRefTitle(e)).join(', ')}
|
||||
subtitle={rootEntityNames.map(e => humanizeEntityRef(e)).join(', ')}
|
||||
/>
|
||||
<Content stretch className={classes.content}>
|
||||
<ContentHeader
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -32,8 +32,8 @@ import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { Direction } from '../EntityRelationsGraph';
|
||||
|
||||
export type CatalogGraphPageValue = {
|
||||
rootEntityNames: EntityName[];
|
||||
setRootEntityNames: Dispatch<React.SetStateAction<EntityName[]>>;
|
||||
rootEntityNames: CompoundEntityRef[];
|
||||
setRootEntityNames: Dispatch<React.SetStateAction<CompoundEntityRef[]>>;
|
||||
maxDepth: number;
|
||||
setMaxDepth: Dispatch<React.SetStateAction<number>>;
|
||||
selectedRelations: string[] | undefined;
|
||||
@@ -82,11 +82,12 @@ export function useCatalogGraphPage({
|
||||
);
|
||||
|
||||
// Initial state
|
||||
const [rootEntityNames, setRootEntityNames] = useState<EntityName[]>(() =>
|
||||
(Array.isArray(query.rootEntityRefs)
|
||||
? query.rootEntityRefs
|
||||
: initialState?.rootEntityRefs ?? []
|
||||
).map(r => parseEntityRef(r)),
|
||||
const [rootEntityNames, setRootEntityNames] = useState<CompoundEntityRef[]>(
|
||||
() =>
|
||||
(Array.isArray(query.rootEntityRefs)
|
||||
? query.rootEntityRefs
|
||||
: initialState?.rootEntityRefs ?? []
|
||||
).map(r => parseEntityRef(r)),
|
||||
);
|
||||
const [maxDepth, setMaxDepth] = useState<number>(() =>
|
||||
typeof query.maxDepth === 'string'
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { DependencyGraphTypes } from '@backstage/core-components';
|
||||
import { formatEntityRefTitle } from '@backstage/plugin-catalog-react';
|
||||
import { humanizeEntityRef } from '@backstage/plugin-catalog-react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import classNames from 'classnames';
|
||||
@@ -95,7 +95,7 @@ export function CustomNode({
|
||||
const displayTitle =
|
||||
title ??
|
||||
(kind && name && namespace
|
||||
? formatEntityRefTitle({ kind, name, namespace })
|
||||
? humanizeEntityRef({ kind, name, namespace })
|
||||
: id);
|
||||
|
||||
return (
|
||||
|
||||
+16
-15
@@ -13,13 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
RELATION_HAS_PART,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_OWNER_OF,
|
||||
RELATION_PART_OF,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
@@ -155,7 +155,8 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
};
|
||||
catalog = {
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(async n => entities[stringifyEntityRef(n)]),
|
||||
getEntityByRef: jest.fn(async n => entities[n as string]),
|
||||
getEntityByName: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
@@ -178,7 +179,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
});
|
||||
|
||||
test('renders a single node without exploding', async () => {
|
||||
catalog.getEntityByName.mockResolvedValue({
|
||||
catalog.getEntityByRef.mockResolvedValue({
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
@@ -198,11 +199,11 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(1);
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(1);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders a progress indicator while loading', async () => {
|
||||
catalog.getEntityByName.mockImplementation(() => new Promise(() => {}));
|
||||
catalog.getEntityByRef.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { findByRole } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
@@ -213,12 +214,12 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
);
|
||||
|
||||
expect(await findByRole('progressbar')).toBeInTheDocument();
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(1);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not explode if an entity is missing', async () => {
|
||||
catalog.getEntityByName.mockImplementation(async n => {
|
||||
if (n.name === 'c') {
|
||||
catalog.getEntityByRef.mockImplementation(async (n: any) => {
|
||||
if (n === 'b:d/c') {
|
||||
return {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
@@ -253,7 +254,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(1);
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(2);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
test('renders at max depth of one', async () => {
|
||||
@@ -276,7 +277,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
expect(await findAllByText('hasPart')).toHaveLength(1);
|
||||
expect(await findAllByTestId('label')).toHaveLength(2);
|
||||
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(3);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(3);
|
||||
});
|
||||
|
||||
test('renders simplied graph at full depth', async () => {
|
||||
@@ -301,7 +302,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
expect(await findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(3);
|
||||
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(4);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
test('renders full graph at full depth', async () => {
|
||||
@@ -328,7 +329,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
expect(await findAllByText('partOf')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(8);
|
||||
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(4);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
test('renders full graph at full depth with merged relations', async () => {
|
||||
@@ -353,7 +354,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
expect(await findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(4);
|
||||
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(4);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
test('renders a graph with multiple root nodes', async () => {
|
||||
@@ -379,7 +380,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
expect(await findAllByText('partOf')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(3);
|
||||
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(4);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
test('renders a graph with filtered kinds and relations', async () => {
|
||||
@@ -401,7 +402,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
expect(await findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await findAllByTestId('label')).toHaveLength(1);
|
||||
|
||||
expect(catalog.getEntityByName).toBeCalledTimes(2);
|
||||
expect(catalog.getEntityByRef).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
test('handle clicks on a node', async () => {
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { EntityName, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
DependencyGraph,
|
||||
DependencyGraphTypes,
|
||||
@@ -77,7 +80,7 @@ export const EntityRelationsGraph = ({
|
||||
className,
|
||||
zoom = 'enabled',
|
||||
}: {
|
||||
rootEntityNames: EntityName | EntityName[];
|
||||
rootEntityNames: CompoundEntityRef | CompoundEntityRef[];
|
||||
maxDepth?: number;
|
||||
unidirectional?: boolean;
|
||||
mergeRelations?: boolean;
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('useEntityStore', () => {
|
||||
beforeEach(() => {
|
||||
catalogApi = {
|
||||
getEntities: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
@@ -64,7 +65,7 @@ describe('useEntityStore', () => {
|
||||
},
|
||||
};
|
||||
|
||||
catalogApi.getEntityByName.mockResolvedValue(entity);
|
||||
catalogApi.getEntityByRef.mockResolvedValue(entity);
|
||||
|
||||
const { result, waitFor } = renderHook(() => useEntityStore());
|
||||
|
||||
@@ -84,7 +85,7 @@ describe('useEntityStore', () => {
|
||||
|
||||
test('handles request failures', async () => {
|
||||
const err = new Error('Hello World');
|
||||
catalogApi.getEntityByName.mockRejectedValue(err);
|
||||
catalogApi.getEntityByRef.mockRejectedValue(err);
|
||||
|
||||
const { result, waitFor } = renderHook(() => useEntityStore());
|
||||
|
||||
@@ -101,7 +102,7 @@ describe('useEntityStore', () => {
|
||||
});
|
||||
|
||||
test('handles loading', async () => {
|
||||
catalogApi.getEntityByName.mockReturnValue(new Promise(() => {}));
|
||||
catalogApi.getEntityByRef.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useEntityStore());
|
||||
|
||||
@@ -133,7 +134,7 @@ describe('useEntityStore', () => {
|
||||
},
|
||||
};
|
||||
|
||||
catalogApi.getEntityByName.mockResolvedValue(entity1);
|
||||
catalogApi.getEntityByRef.mockResolvedValue(entity1);
|
||||
|
||||
const { result, waitFor } = renderHook(() => useEntityStore());
|
||||
|
||||
@@ -150,7 +151,7 @@ describe('useEntityStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
catalogApi.getEntityByName.mockResolvedValue(entity2);
|
||||
catalogApi.getEntityByRef.mockResolvedValue(entity2);
|
||||
|
||||
act(() => {
|
||||
result.current.requestEntities([
|
||||
@@ -188,7 +189,7 @@ describe('useEntityStore', () => {
|
||||
},
|
||||
};
|
||||
|
||||
catalogApi.getEntityByName.mockResolvedValue(entity1);
|
||||
catalogApi.getEntityByRef.mockResolvedValue(entity1);
|
||||
|
||||
const { result, waitFor } = renderHook(() => useEntityStore());
|
||||
|
||||
@@ -205,7 +206,7 @@ describe('useEntityStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
catalogApi.getEntityByName.mockResolvedValue(entity2);
|
||||
catalogApi.getEntityByRef.mockResolvedValue(entity2);
|
||||
|
||||
act(() => {
|
||||
result.current.requestEntities(['kind:namespace/name2']);
|
||||
@@ -233,6 +234,6 @@ describe('useEntityStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(catalogApi.getEntityByName).toBeCalledTimes(2);
|
||||
expect(catalogApi.getEntityByRef).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Entity, parseEntityRef } from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import limiterFactory from 'p-limit';
|
||||
@@ -73,9 +73,7 @@ export function useEntityStore(): {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = catalogClient.getEntityByName(
|
||||
parseEntityRef(entityRef),
|
||||
);
|
||||
const promise = catalogClient.getEntityByRef(entityRef);
|
||||
|
||||
outstandingEntities.set(entityRef, promise);
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { FieldErrors } from 'react-hook-form';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { InfoCardVariants } from '@backstage/core-components';
|
||||
@@ -33,7 +33,7 @@ export type AnalyzeResult =
|
||||
locations: Array<{
|
||||
target: string;
|
||||
exists?: boolean;
|
||||
entities: EntityName[];
|
||||
entities: CompoundEntityRef[];
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
@@ -165,7 +165,7 @@ export interface EntityListComponentProps {
|
||||
// (undocumented)
|
||||
locations: Array<{
|
||||
target: string;
|
||||
entities: (Entity | EntityName)[];
|
||||
entities: (Entity | CompoundEntityRef)[];
|
||||
}>;
|
||||
// (undocumented)
|
||||
onItemClick?: (target: string) => void;
|
||||
@@ -246,7 +246,7 @@ export type PrepareResult =
|
||||
locations: Array<{
|
||||
exists?: boolean;
|
||||
target: string;
|
||||
entities: EntityName[];
|
||||
entities: CompoundEntityRef[];
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
@@ -258,7 +258,7 @@ export type PrepareResult =
|
||||
};
|
||||
locations: Array<{
|
||||
target: string;
|
||||
entities: EntityName[];
|
||||
entities: CompoundEntityRef[];
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { Grid, ListItem, ListItemIcon, ListItemText } from '@material-ui/core';
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { ImportPage } from '../src/components/ImportPage';
|
||||
import { Content, Header, InfoCard, Page } from '@backstage/core-components';
|
||||
|
||||
const getEntityNames = (url: string): EntityName[] => [
|
||||
const getEntityNames = (url: string): CompoundEntityRef[] => [
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { PartialEntity } from '../types';
|
||||
|
||||
@@ -38,7 +38,7 @@ export type AnalyzeResult =
|
||||
locations: Array<{
|
||||
target: string;
|
||||
exists?: boolean;
|
||||
entities: EntityName[];
|
||||
entities: CompoundEntityRef[];
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('CatalogImportClient', () => {
|
||||
getEntities: jest.fn(),
|
||||
addLocation: jest.fn(),
|
||||
removeLocationById: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
ConfigApi,
|
||||
DiscoveryApi,
|
||||
@@ -212,7 +212,7 @@ the component will become available.\n\nFor more information, read an \
|
||||
}): Promise<
|
||||
Array<{
|
||||
target: string;
|
||||
entities: EntityName[];
|
||||
entities: CompoundEntityRef[];
|
||||
}>
|
||||
> {
|
||||
const { url, owner, repo, githubIntegrationConfig } = options;
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { useApp } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
EntityRefLink,
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Collapse,
|
||||
@@ -41,9 +41,9 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function sortEntities(entities: Array<EntityName | Entity>) {
|
||||
function sortEntities(entities: Array<CompoundEntityRef | Entity>) {
|
||||
return entities.sort((a, b) =>
|
||||
formatEntityRefTitle(a).localeCompare(formatEntityRefTitle(b)),
|
||||
humanizeEntityRef(a).localeCompare(humanizeEntityRef(b)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ function sortEntities(entities: Array<EntityName | Entity>) {
|
||||
* @public
|
||||
*/
|
||||
export interface EntityListComponentProps {
|
||||
locations: Array<{ target: string; entities: (Entity | EntityName)[] }>;
|
||||
locations: Array<{
|
||||
target: string;
|
||||
entities: (Entity | CompoundEntityRef)[];
|
||||
}>;
|
||||
locationListItemIcon: (target: string) => React.ReactElement;
|
||||
collapsed?: boolean;
|
||||
firstListItem?: React.ReactElement;
|
||||
@@ -130,7 +133,7 @@ export const EntityListComponent = (props: EntityListComponentProps) => {
|
||||
) ?? WorkIcon;
|
||||
return (
|
||||
<ListItem
|
||||
key={formatEntityRefTitle(entity)}
|
||||
key={humanizeEntityRef(entity)}
|
||||
className={classes.nested}
|
||||
{...(withLinks
|
||||
? {
|
||||
@@ -143,7 +146,7 @@ export const EntityListComponent = (props: EntityListComponentProps) => {
|
||||
<ListItemIcon>
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={formatEntityRefTitle(entity)} />
|
||||
<ListItemText primary={humanizeEntityRef(entity)} />
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ describe('<StepPrepareCreatePullRequest />', () => {
|
||||
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
|
||||
getEntities: jest.fn(),
|
||||
addLocation: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import {
|
||||
catalogApiRef,
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { Box, FormHelperText, Grid, Typography } from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
@@ -139,7 +139,7 @@ export const StepPrepareCreatePullRequest = (
|
||||
});
|
||||
|
||||
return groupEntities.items
|
||||
.map(e => formatEntityRefTitle(e, { defaultKind: 'group' }))
|
||||
.map(e => humanizeEntityRef(e, { defaultKind: 'group' }))
|
||||
.sort();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { AnalyzeResult } from '../api';
|
||||
@@ -37,7 +37,7 @@ describe('useImportState', () => {
|
||||
locations: [
|
||||
{
|
||||
target: 'https://0',
|
||||
entities: [] as EntityName[],
|
||||
entities: [] as CompoundEntityRef[],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { useReducer } from 'react';
|
||||
import { AnalyzeResult } from '../api';
|
||||
|
||||
@@ -43,7 +43,7 @@ export type PrepareResult =
|
||||
locations: Array<{
|
||||
exists?: boolean;
|
||||
target: string;
|
||||
entities: EntityName[];
|
||||
entities: CompoundEntityRef[];
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
@@ -55,7 +55,7 @@ export type PrepareResult =
|
||||
};
|
||||
locations: Array<{
|
||||
target: string;
|
||||
entities: EntityName[];
|
||||
entities: CompoundEntityRef[];
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { ComponentEntity } from '@backstage/catalog-model';
|
||||
import { ComponentProps } from 'react';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { GetEntitiesResponse } from '@backstage/catalog-client';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import { LinkProps } from '@backstage/core-components';
|
||||
@@ -250,7 +250,7 @@ export const EntityRefLink: (props: EntityRefLinkProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type EntityRefLinkProps = {
|
||||
entityRef: Entity | EntityName | string;
|
||||
entityRef: Entity | CompoundEntityRef | string;
|
||||
defaultKind?: string;
|
||||
title?: string;
|
||||
children?: React_2.ReactNode;
|
||||
@@ -265,7 +265,7 @@ export const EntityRefLinks: ({
|
||||
|
||||
// @public
|
||||
export type EntityRefLinksProps = {
|
||||
entityRefs: (Entity | EntityName)[];
|
||||
entityRefs: (Entity | CompoundEntityRef)[];
|
||||
defaultKind?: string;
|
||||
} & Omit<LinkProps, 'to'>;
|
||||
|
||||
@@ -413,13 +413,8 @@ export const favoriteEntityTooltip: (
|
||||
isStarred: boolean,
|
||||
) => 'Remove from favorites' | 'Add to favorites';
|
||||
|
||||
// @public (undocumented)
|
||||
export function formatEntityRefTitle(
|
||||
entityRef: Entity | EntityName,
|
||||
opts?: {
|
||||
defaultKind?: string;
|
||||
},
|
||||
): string;
|
||||
// @public @deprecated (undocumented)
|
||||
export const formatEntityRefTitle: typeof humanizeEntityRef;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export function getEntityMetadataEditUrl(entity: Entity): string | undefined;
|
||||
@@ -434,7 +429,7 @@ export function getEntityRelations(
|
||||
filter?: {
|
||||
kind: string;
|
||||
},
|
||||
): EntityName[];
|
||||
): CompoundEntityRef[];
|
||||
|
||||
// @public (undocumented)
|
||||
export function getEntitySourceLocation(
|
||||
@@ -442,6 +437,14 @@ export function getEntitySourceLocation(
|
||||
scmIntegrationsApi: ScmIntegrationRegistry,
|
||||
): EntitySourceLocation | undefined;
|
||||
|
||||
// @public (undocumented)
|
||||
export function humanizeEntityRef(
|
||||
entityRef: Entity | CompoundEntityRef,
|
||||
opts?: {
|
||||
defaultKind?: string;
|
||||
},
|
||||
): string;
|
||||
|
||||
// @public
|
||||
export function InspectEntityDialog(props: {
|
||||
open: boolean;
|
||||
@@ -449,8 +452,8 @@ export function InspectEntityDialog(props: {
|
||||
onClose: () => void;
|
||||
}): JSX.Element | null;
|
||||
|
||||
// @public
|
||||
export function isOwnerOf(owner: Entity, owned: Entity): boolean;
|
||||
// @alpha
|
||||
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
|
||||
|
||||
// @public @deprecated
|
||||
export function loadCatalogOwnerRefs(
|
||||
@@ -624,12 +627,18 @@ export type UserListPickerProps = {
|
||||
// @public (undocumented)
|
||||
export function useStarredEntities(): {
|
||||
starredEntities: Set<string>;
|
||||
toggleStarredEntity: (entityOrRef: Entity | EntityName | string) => void;
|
||||
isStarredEntity: (entityOrRef: Entity | EntityName | string) => boolean;
|
||||
toggleStarredEntity: (
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
) => void;
|
||||
isStarredEntity: (
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function useStarredEntity(entityOrRef: Entity | EntityName | string): {
|
||||
export function useStarredEntity(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
): {
|
||||
toggleStarredEntity: () => void;
|
||||
isStarredEntity: boolean;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useEntityList } from '../../hooks/useEntityListProvider';
|
||||
import { EntityOwnerFilter } from '../../filters';
|
||||
import { getEntityRelations } from '../../utils';
|
||||
import { formatEntityRefTitle } from '../EntityRefLink';
|
||||
import { humanizeEntityRef } from '../EntityRefLink';
|
||||
|
||||
/** @public */
|
||||
export type CatalogReactEntityOwnerPickerClassKey = 'input';
|
||||
@@ -86,7 +86,7 @@ export const EntityOwnerPicker = () => {
|
||||
backendEntities
|
||||
.flatMap((e: Entity) =>
|
||||
getEntityRelations(e, RELATION_OWNED_BY).map(o =>
|
||||
formatEntityRefTitle(o, { defaultKind: 'group' }),
|
||||
humanizeEntityRef(o, { defaultKind: 'group' }),
|
||||
),
|
||||
)
|
||||
.filter(Boolean) as string[],
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { entityRouteRef } from '../../routes';
|
||||
import { formatEntityRefTitle } from './format';
|
||||
import { humanizeEntityRef } from './humanize';
|
||||
import { Link, LinkProps } from '@backstage/core-components';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Tooltip } from '@material-ui/core';
|
||||
@@ -33,7 +33,7 @@ import { Tooltip } from '@material-ui/core';
|
||||
* @public
|
||||
*/
|
||||
export type EntityRefLinkProps = {
|
||||
entityRef: Entity | EntityName | string;
|
||||
entityRef: Entity | CompoundEntityRef | string;
|
||||
defaultKind?: string;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
@@ -72,7 +72,7 @@ export const EntityRefLink = forwardRef<any, EntityRefLinkProps>(
|
||||
namespace = namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE;
|
||||
|
||||
const routeParams = { kind, namespace, name };
|
||||
const formattedEntityRefTitle = formatEntityRefTitle(
|
||||
const formattedEntityRefTitle = humanizeEntityRef(
|
||||
{ kind, namespace, name },
|
||||
{ defaultKind },
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import React from 'react';
|
||||
import { EntityRefLink } from './EntityRefLink';
|
||||
import { LinkProps } from '@backstage/core-components';
|
||||
@@ -25,7 +25,7 @@ import { LinkProps } from '@backstage/core-components';
|
||||
* @public
|
||||
*/
|
||||
export type EntityRefLinksProps = {
|
||||
entityRefs: (Entity | EntityName)[];
|
||||
entityRefs: (Entity | CompoundEntityRef)[];
|
||||
defaultKind?: string;
|
||||
} & Omit<LinkProps, 'to'>;
|
||||
|
||||
|
||||
+7
-7
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { formatEntityRefTitle } from './format';
|
||||
import { humanizeEntityRef } from './humanize';
|
||||
|
||||
describe('formatEntityRefTitle', () => {
|
||||
it('formats entity in default namespace', () => {
|
||||
@@ -30,7 +30,7 @@ describe('formatEntityRefTitle', () => {
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
const title = formatEntityRefTitle(entity);
|
||||
const title = humanizeEntityRef(entity);
|
||||
expect(title).toEqual('component:software');
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('formatEntityRefTitle', () => {
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
const title = formatEntityRefTitle(entity);
|
||||
const title = humanizeEntityRef(entity);
|
||||
expect(title).toEqual('component:test/software');
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('formatEntityRefTitle', () => {
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
const title = formatEntityRefTitle(entity, { defaultKind: 'Component' });
|
||||
const title = humanizeEntityRef(entity, { defaultKind: 'Component' });
|
||||
expect(title).toEqual('test/software');
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('formatEntityRefTitle', () => {
|
||||
namespace: 'default',
|
||||
name: 'software',
|
||||
};
|
||||
const title = formatEntityRefTitle(entityName);
|
||||
const title = humanizeEntityRef(entityName);
|
||||
expect(title).toEqual('component:software');
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('formatEntityRefTitle', () => {
|
||||
name: 'software',
|
||||
};
|
||||
|
||||
const title = formatEntityRefTitle(entityName);
|
||||
const title = humanizeEntityRef(entityName);
|
||||
expect(title).toEqual('component:test/software');
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('formatEntityRefTitle', () => {
|
||||
name: 'software',
|
||||
};
|
||||
|
||||
const title = formatEntityRefTitle(entityName, {
|
||||
const title = humanizeEntityRef(entityName, {
|
||||
defaultKind: 'component',
|
||||
});
|
||||
expect(title).toEqual('test/software');
|
||||
+6
-3
@@ -16,13 +16,16 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
/** @public @deprecated please use {@link humanizeEntityRef} instead */
|
||||
export const formatEntityRefTitle = humanizeEntityRef;
|
||||
|
||||
/** @public */
|
||||
export function formatEntityRefTitle(
|
||||
entityRef: Entity | EntityName,
|
||||
export function humanizeEntityRef(
|
||||
entityRef: Entity | CompoundEntityRef,
|
||||
opts?: { defaultKind?: string },
|
||||
) {
|
||||
const defaultKind = opts?.defaultKind;
|
||||
@@ -18,4 +18,4 @@ export { EntityRefLink } from './EntityRefLink';
|
||||
export type { EntityRefLinkProps } from './EntityRefLink';
|
||||
export { EntityRefLinks } from './EntityRefLinks';
|
||||
export type { EntityRefLinksProps } from './EntityRefLinks';
|
||||
export { formatEntityRefTitle } from './format';
|
||||
export { humanizeEntityRef, formatEntityRefTitle } from './humanize';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -26,7 +26,7 @@ import { getEntityRelations } from '../../utils';
|
||||
import {
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
} from '../EntityRefLink';
|
||||
|
||||
/** @public */
|
||||
@@ -38,7 +38,7 @@ export const columnFactories = Object.freeze({
|
||||
function formatContent(entity: T): string {
|
||||
return (
|
||||
entity.metadata?.title ||
|
||||
formatEntityRefTitle(entity, {
|
||||
humanizeEntityRef(entity, {
|
||||
defaultKind,
|
||||
})
|
||||
);
|
||||
@@ -81,13 +81,13 @@ export const columnFactories = Object.freeze({
|
||||
defaultKind?: string;
|
||||
filter?: { kind: string };
|
||||
}): TableColumn<T> {
|
||||
function getRelations(entity: T): EntityName[] {
|
||||
function getRelations(entity: T): CompoundEntityRef[] {
|
||||
return getEntityRelations(entity, relation, entityFilter);
|
||||
}
|
||||
|
||||
function formatContent(entity: T): string {
|
||||
return getRelations(entity)
|
||||
.map(r => formatEntityRefTitle(r, { defaultKind }))
|
||||
.map(r => humanizeEntityRef(r, { defaultKind }))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../../api';
|
||||
import { formatEntityRefTitle } from '../../../components/EntityRefLink/format';
|
||||
import { humanizeEntityRef } from '../../EntityRefLink';
|
||||
import { entityRouteRef } from '../../../routes';
|
||||
import { EntityKindIcon } from './EntityKindIcon';
|
||||
|
||||
@@ -132,7 +132,7 @@ function CustomNode({ node }: DependencyGraphTypes.RenderNodeProps<NodeType>) {
|
||||
const displayTitle =
|
||||
node.metadata.title ||
|
||||
(node.kind && node.metadata.name && node.metadata.namespace
|
||||
? formatEntityRefTitle({
|
||||
? humanizeEntityRef({
|
||||
kind: node.kind,
|
||||
name: node.metadata.name,
|
||||
namespace: node.metadata.namespace || '',
|
||||
|
||||
+4
-4
@@ -16,8 +16,8 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
getEntityName,
|
||||
CompoundEntityRef,
|
||||
getCompoundEntityRef,
|
||||
ANNOTATION_ORIGIN_LOCATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import { catalogApiRef } from '../../api';
|
||||
@@ -44,7 +44,7 @@ export type UseUnregisterEntityDialogState =
|
||||
| {
|
||||
type: 'unregister';
|
||||
location: string;
|
||||
colocatedEntities: EntityName[];
|
||||
colocatedEntities: CompoundEntityRef[];
|
||||
unregisterLocation: () => Promise<void>;
|
||||
deleteEntity: () => Promise<void>;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function useUnregisterEntityDialogState(
|
||||
return {
|
||||
type: 'unregister',
|
||||
location: locationRef!,
|
||||
colocatedEntities: colocatedEntities.map(getEntityName),
|
||||
colocatedEntities: colocatedEntities.map(getCompoundEntityRef),
|
||||
unregisterLocation,
|
||||
deleteEntity,
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ const mockConfigApi = {
|
||||
} as Partial<ConfigApi>;
|
||||
|
||||
const mockCatalogApi = {
|
||||
getEntityByName: () => Promise.resolve(mockUser),
|
||||
getEntityByRef: () => Promise.resolve(mockUser),
|
||||
} as Partial<CatalogApi>;
|
||||
|
||||
const mockIdentityApi = {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { formatEntityRefTitle } from './components/EntityRefLink';
|
||||
import { humanizeEntityRef } from './components/EntityRefLink';
|
||||
import { EntityFilter, UserListFilterKind } from './types';
|
||||
import { getEntityRelations } from './utils';
|
||||
|
||||
@@ -107,7 +107,7 @@ export class EntityOwnerFilter implements EntityFilter {
|
||||
filterEntity(entity: Entity): boolean {
|
||||
return this.values.some(v =>
|
||||
getEntityRelations(entity, RELATION_OWNED_BY).some(
|
||||
o => formatEntityRefTitle(o, { defaultKind: 'group' }) === v,
|
||||
o => humanizeEntityRef(o, { defaultKind: 'group' }) === v,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const useEntityFromUrl = (): EntityLoadingStatus => {
|
||||
loading,
|
||||
retry: refresh,
|
||||
} = useAsyncRetry(
|
||||
() => catalogApi.getEntityByName({ kind, namespace, name }),
|
||||
() => catalogApi.getEntityByRef({ kind, namespace, name }),
|
||||
[catalogApi, kind, namespace, name],
|
||||
);
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ const mockIdentityApi: Partial<IdentityApi> = {
|
||||
};
|
||||
const mockCatalogApi: Partial<CatalogApi> = {
|
||||
getEntities: jest.fn().mockImplementation(async () => ({ items: entities })),
|
||||
getEntityByName: async () => undefined,
|
||||
getEntityByRef: async () => undefined,
|
||||
};
|
||||
|
||||
const wrapper = ({
|
||||
|
||||
@@ -30,13 +30,13 @@ import { loadCatalogOwnerRefs, useEntityOwnership } from './useEntityOwnership';
|
||||
|
||||
describe('useEntityOwnership', () => {
|
||||
type MockIdentityApi = jest.Mocked<Pick<IdentityApi, 'getBackstageIdentity'>>;
|
||||
type MockCatalogApi = jest.Mocked<Pick<CatalogApi, 'getEntityByName'>>;
|
||||
type MockCatalogApi = jest.Mocked<Pick<CatalogApi, 'getEntityByRef'>>;
|
||||
|
||||
const mockIdentityApi: MockIdentityApi = {
|
||||
getBackstageIdentity: jest.fn(),
|
||||
};
|
||||
const mockCatalogApi: MockCatalogApi = {
|
||||
getEntityByName: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
};
|
||||
|
||||
const identityApi = mockIdentityApi as unknown as IdentityApi;
|
||||
@@ -102,11 +102,11 @@ describe('useEntityOwnership', () => {
|
||||
|
||||
describe('loadCatalogOwnerRefs', () => {
|
||||
it('loads the first user from the catalog', async () => {
|
||||
mockCatalogApi.getEntityByName.mockResolvedValueOnce(user2Entity);
|
||||
mockCatalogApi.getEntityByRef.mockResolvedValueOnce(user2Entity);
|
||||
await expect(
|
||||
loadCatalogOwnerRefs(catalogApi, ['user:default/user2']),
|
||||
).resolves.toEqual(['group:default/group1']);
|
||||
expect(mockCatalogApi.getEntityByName).toBeCalledWith({
|
||||
expect(mockCatalogApi.getEntityByRef).toBeCalledWith({
|
||||
kind: 'user',
|
||||
namespace: 'default',
|
||||
name: 'user2',
|
||||
@@ -114,11 +114,11 @@ describe('useEntityOwnership', () => {
|
||||
});
|
||||
|
||||
it('gracefully handles missing user', async () => {
|
||||
mockCatalogApi.getEntityByName.mockResolvedValueOnce(undefined);
|
||||
mockCatalogApi.getEntityByRef.mockResolvedValueOnce(undefined);
|
||||
await expect(
|
||||
loadCatalogOwnerRefs(catalogApi, ['user:default/user2']),
|
||||
).resolves.toEqual([]);
|
||||
expect(mockCatalogApi.getEntityByName).toBeCalledWith({
|
||||
expect(mockCatalogApi.getEntityByRef).toBeCalledWith({
|
||||
kind: 'user',
|
||||
namespace: 'default',
|
||||
name: 'user2',
|
||||
@@ -133,7 +133,7 @@ describe('useEntityOwnership', () => {
|
||||
userEntityRef: 'user:default/user1',
|
||||
ownershipEntityRefs: ['user:default/user1', 'group:default/group1'],
|
||||
});
|
||||
mockCatalogApi.getEntityByName.mockResolvedValue(undefined);
|
||||
mockCatalogApi.getEntityByRef.mockResolvedValue(undefined);
|
||||
|
||||
const { result, waitForValueToChange } = renderHook(
|
||||
() => useEntityOwnership(),
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function loadCatalogOwnerRefs(
|
||||
|
||||
const primaryUserRef = identityOwnerRefs.find(ref => ref.startsWith('user:'));
|
||||
if (primaryUserRef) {
|
||||
const entity = await catalogApi.getEntityByName(
|
||||
const entity = await catalogApi.getEntityByRef(
|
||||
parseEntityRef(primaryUserRef),
|
||||
);
|
||||
if (entity) {
|
||||
|
||||
@@ -34,7 +34,12 @@ export function useOwnUser(): AsyncState<UserEntity | undefined> {
|
||||
|
||||
return useAsync(async () => {
|
||||
const identity = await identityApi.getBackstageIdentity();
|
||||
return catalogApi.getEntityByName(
|
||||
// TODO(freben): Defensively parse with defaults even though getEntityByRef
|
||||
// supports the string form, since some auth resolvers have been known to
|
||||
// return incomplete refs (just the name part) historically. This can be
|
||||
// simplified in the future to just pass the ref immediately to
|
||||
// getEntityByRef.
|
||||
return catalogApi.getEntityByRef(
|
||||
parseEntityRef(identity.userEntityRef, {
|
||||
defaultKind: 'User',
|
||||
defaultNamespace: DEFAULT_NAMESPACE,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
@@ -24,7 +24,9 @@ import { useCallback } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { starredEntitiesApiRef } from '../apis';
|
||||
|
||||
function getEntityRef(entityOrRef: Entity | EntityName | string): string {
|
||||
function getEntityRef(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
): string {
|
||||
return typeof entityOrRef === 'string'
|
||||
? entityOrRef
|
||||
: stringifyEntityRef(entityOrRef);
|
||||
@@ -33,8 +35,12 @@ function getEntityRef(entityOrRef: Entity | EntityName | string): string {
|
||||
/** @public */
|
||||
export function useStarredEntities(): {
|
||||
starredEntities: Set<string>;
|
||||
toggleStarredEntity: (entityOrRef: Entity | EntityName | string) => void;
|
||||
isStarredEntity: (entityOrRef: Entity | EntityName | string) => boolean;
|
||||
toggleStarredEntity: (
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
) => void;
|
||||
isStarredEntity: (
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
) => boolean;
|
||||
} {
|
||||
const starredEntitiesApi = useApi(starredEntitiesApiRef);
|
||||
|
||||
@@ -44,13 +50,13 @@ export function useStarredEntities(): {
|
||||
);
|
||||
|
||||
const isStarredEntity = useCallback(
|
||||
(entityOrRef: Entity | EntityName | string) =>
|
||||
(entityOrRef: Entity | CompoundEntityRef | string) =>
|
||||
starredEntities.has(getEntityRef(entityOrRef)),
|
||||
[starredEntities],
|
||||
);
|
||||
|
||||
const toggleStarredEntity = useCallback(
|
||||
(entityOrRef: Entity | EntityName | string) =>
|
||||
(entityOrRef: Entity | CompoundEntityRef | string) =>
|
||||
starredEntitiesApi.toggleStarred(getEntityRef(entityOrRef)).then(),
|
||||
[starredEntitiesApi],
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
@@ -44,7 +44,7 @@ describe('useStarredEntity', () => {
|
||||
describe.each`
|
||||
title | entityOrRef
|
||||
${'entity reference'} | ${'component:default/mock'}
|
||||
${'entity name'} | ${{ kind: 'component', namespace: 'default', name: 'mock' } as EntityName}
|
||||
${'entity name'} | ${{ kind: 'component', namespace: 'default', name: 'mock' } as CompoundEntityRef}
|
||||
${'entity'} | ${{ apiVersion: '1', kind: 'Component', metadata: { name: 'mock' } } as Entity}
|
||||
`('with $title', ({ entityOrRef }) => {
|
||||
describe('toggleStarredEntity', () => {
|
||||
|
||||
@@ -16,21 +16,25 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { starredEntitiesApiRef } from '../apis';
|
||||
|
||||
function getEntityRef(entityOrRef: Entity | EntityName | string): string {
|
||||
function getEntityRef(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
): string {
|
||||
return typeof entityOrRef === 'string'
|
||||
? entityOrRef
|
||||
: stringifyEntityRef(entityOrRef);
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function useStarredEntity(entityOrRef: Entity | EntityName | string): {
|
||||
export function useStarredEntity(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
): {
|
||||
toggleStarredEntity: () => void;
|
||||
isStarredEntity: boolean;
|
||||
} {
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName, parseEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
CompoundEntityRef,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
// TODO(freben): This should be returning entity refs instead
|
||||
/**
|
||||
@@ -26,7 +30,7 @@ export function getEntityRelations(
|
||||
entity: Entity | undefined,
|
||||
relationType: string,
|
||||
filter?: { kind: string },
|
||||
): EntityName[] {
|
||||
): CompoundEntityRef[] {
|
||||
let entityNames =
|
||||
entity?.relations
|
||||
?.filter(r => r.type === relationType)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
getEntityName,
|
||||
getCompoundEntityRef,
|
||||
RELATION_MEMBER_OF,
|
||||
RELATION_OWNED_BY,
|
||||
stringifyEntityRef,
|
||||
@@ -24,18 +24,23 @@ import {
|
||||
import { getEntityRelations } from './getEntityRelations';
|
||||
|
||||
/**
|
||||
* Get the related entity references.
|
||||
* @public
|
||||
* Returns true if the `owner` argument is a direct owner on the `entity` argument.
|
||||
*
|
||||
* @alpha
|
||||
* @remarks
|
||||
*
|
||||
* Note that this ownership is not the same as using the claims in the auth-resolver, it only will take into account ownership as expressed by direct entity relations.
|
||||
* It doesn't know anything about the additional groups that a user might belong to which the claims contain.
|
||||
*/
|
||||
export function isOwnerOf(owner: Entity, owned: Entity) {
|
||||
export function isOwnerOf(owner: Entity, entity: Entity) {
|
||||
const possibleOwners = new Set(
|
||||
[
|
||||
...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }),
|
||||
...(owner ? [getEntityName(owner)] : []),
|
||||
...(owner ? [getCompoundEntityRef(owner)] : []),
|
||||
].map(stringifyEntityRef),
|
||||
);
|
||||
|
||||
const owners = getEntityRelations(owned, RELATION_OWNED_BY).map(
|
||||
const owners = getEntityRelations(entity, RELATION_OWNED_BY).map(
|
||||
stringifyEntityRef,
|
||||
);
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { ExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { IndexableDocument } from '@backstage/search-common';
|
||||
@@ -157,9 +157,9 @@ export interface CatalogTableRow {
|
||||
resolved: {
|
||||
name: string;
|
||||
partOfSystemRelationTitle?: string;
|
||||
partOfSystemRelations: EntityName[];
|
||||
partOfSystemRelations: CompoundEntityRef[];
|
||||
ownedByRelationsTitle?: string;
|
||||
ownedByRelations: EntityName[];
|
||||
ownedByRelations: CompoundEntityRef[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export const useEntityFromUrl = (): EntityLoadingStatus => {
|
||||
loading,
|
||||
retry: refresh,
|
||||
} = useAsyncRetry(
|
||||
() => catalogApi.getEntityByName({ kind, namespace, name }),
|
||||
() => catalogApi.getEntityByRef({ kind, namespace, name }),
|
||||
[catalogApi, kind, namespace, name],
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
RELATION_MEMBER_OF,
|
||||
RELATION_OWNED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -104,11 +105,11 @@ describe('DefaultCatalogPage', () => {
|
||||
}),
|
||||
getLocationByRef: () =>
|
||||
Promise.resolve({ id: 'id', type: 'url', target: 'url' }),
|
||||
getEntityByName: async entityName => {
|
||||
getEntityByRef: async entityRef => {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: entityName.name },
|
||||
metadata: { name: parseEntityRef(entityRef).name },
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_MEMBER_OF,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
getEntityRelations,
|
||||
useEntityList,
|
||||
useStarredEntities,
|
||||
@@ -139,16 +139,16 @@ export const CatalogTable = (props: CatalogTableProps) => {
|
||||
return {
|
||||
entity,
|
||||
resolved: {
|
||||
name: formatEntityRefTitle(entity, {
|
||||
name: humanizeEntityRef(entity, {
|
||||
defaultKind: 'Component',
|
||||
}),
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
|
||||
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
|
||||
.join(', '),
|
||||
ownedByRelations,
|
||||
partOfSystemRelationTitle: partOfSystemRelations
|
||||
.map(r =>
|
||||
formatEntityRefTitle(r, {
|
||||
humanizeEntityRef(r, {
|
||||
defaultKind: 'system',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
@@ -34,7 +34,7 @@ export const columnFactories = Object.freeze({
|
||||
function formatContent(entity: Entity): string {
|
||||
return (
|
||||
entity.metadata?.title ||
|
||||
formatEntityRefTitle(entity, {
|
||||
humanizeEntityRef(entity, {
|
||||
defaultKind: options?.defaultKind,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
/** @public */
|
||||
export interface CatalogTableRow {
|
||||
@@ -22,8 +22,8 @@ export interface CatalogTableRow {
|
||||
resolved: {
|
||||
name: string;
|
||||
partOfSystemRelationTitle?: string;
|
||||
partOfSystemRelations: EntityName[];
|
||||
partOfSystemRelations: CompoundEntityRef[];
|
||||
ownedByRelationsTitle?: string;
|
||||
ownedByRelations: EntityName[];
|
||||
ownedByRelations: CompoundEntityRef[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import xmlparser from 'express-xml-bodyparser';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
|
||||
import {
|
||||
errorHandler,
|
||||
PluginDatabaseManager,
|
||||
@@ -33,10 +33,7 @@ import { aggregateCoverage, CoverageUtils } from './CoverageUtils';
|
||||
import { Cobertura } from './converter/cobertura';
|
||||
import { Jacoco } from './converter/jacoco';
|
||||
import { Converter } from './converter';
|
||||
import {
|
||||
getEntitySourceLocation,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { getEntitySourceLocation } from '@backstage/catalog-model';
|
||||
|
||||
export interface RouterOptions {
|
||||
config: Config;
|
||||
@@ -59,7 +56,7 @@ export const makeRouter = async (
|
||||
await database.getClient(),
|
||||
);
|
||||
const codecovUrl = await discovery.getExternalBaseUrl('code-coverage');
|
||||
const catalogApi = new CatalogClient({ discoveryApi: discovery });
|
||||
const catalogApi: CatalogApi = new CatalogClient({ discoveryApi: discovery });
|
||||
const scm = ScmIntegrations.fromConfig(config);
|
||||
|
||||
const router = Router();
|
||||
@@ -77,8 +74,7 @@ export const makeRouter = async (
|
||||
*/
|
||||
router.get('/report', async (req, res) => {
|
||||
const { entity } = req.query;
|
||||
const entityName = parseEntityRef(entity as string);
|
||||
const entityLookup = await catalogApi.getEntityByName(entityName);
|
||||
const entityLookup = await catalogApi.getEntityByRef(entity as string);
|
||||
if (!entityLookup) {
|
||||
throw new NotFoundError(`No entity found matching ${entity}`);
|
||||
}
|
||||
@@ -100,8 +96,7 @@ export const makeRouter = async (
|
||||
*/
|
||||
router.get('/history', async (req, res) => {
|
||||
const { entity } = req.query;
|
||||
const entityName = parseEntityRef(entity as string);
|
||||
const entityLookup = await catalogApi.getEntityByName(entityName);
|
||||
const entityLookup = await catalogApi.getEntityByRef(entity as string);
|
||||
if (!entityLookup) {
|
||||
throw new NotFoundError(`No entity found matching ${entity}`);
|
||||
}
|
||||
@@ -119,8 +114,7 @@ export const makeRouter = async (
|
||||
*/
|
||||
router.get('/file-content', async (req, res) => {
|
||||
const { entity, path } = req.query;
|
||||
const entityName = parseEntityRef(entity as string);
|
||||
const entityLookup = await catalogApi.getEntityByName(entityName);
|
||||
const entityLookup = await catalogApi.getEntityByRef(entity as string);
|
||||
if (!entityLookup) {
|
||||
throw new NotFoundError(`No entity found matching ${entity}`);
|
||||
}
|
||||
@@ -171,8 +165,7 @@ export const makeRouter = async (
|
||||
*/
|
||||
router.post('/report', async (req, res) => {
|
||||
const { entity, coverageType } = req.query;
|
||||
const entityName = parseEntityRef(entity as string);
|
||||
const entityLookup = await catalogApi.getEntityByName(entityName);
|
||||
const entityLookup = await catalogApi.getEntityByRef(entity as string);
|
||||
if (!entityLookup) {
|
||||
throw new NotFoundError(`No entity found matching ${entity}`);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
export type JsonCodeCoverage = {
|
||||
metadata: CoverageMetadata;
|
||||
entity: EntityName;
|
||||
entity: CompoundEntityRef;
|
||||
files: Array<FileEntry>;
|
||||
};
|
||||
|
||||
export type JsonCoverageHistory = {
|
||||
entity: EntityName;
|
||||
entity: CompoundEntityRef;
|
||||
history: Array<AggregateCoverage>;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,20 +14,25 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntityName, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { JsonCodeCoverage, JsonCoverageHistory } from './types';
|
||||
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
|
||||
export type CodeCoverageApi = {
|
||||
discovery: DiscoveryApi;
|
||||
getCoverageForEntity: (entity: EntityName) => Promise<JsonCodeCoverage>;
|
||||
getCoverageForEntity: (
|
||||
entity: CompoundEntityRef,
|
||||
) => Promise<JsonCodeCoverage>;
|
||||
getFileContentFromEntity: (
|
||||
entity: EntityName,
|
||||
entity: CompoundEntityRef,
|
||||
filePath: string,
|
||||
) => Promise<string>;
|
||||
getCoverageHistoryForEntity: (
|
||||
entity: EntityName,
|
||||
entity: CompoundEntityRef,
|
||||
limit?: number,
|
||||
) => Promise<JsonCoverageHistory>;
|
||||
};
|
||||
@@ -59,7 +64,7 @@ export class CodeCoverageRestApi implements CodeCoverageApi {
|
||||
}
|
||||
|
||||
async getCoverageForEntity(
|
||||
entityName: EntityName,
|
||||
entityName: CompoundEntityRef,
|
||||
): Promise<JsonCodeCoverage> {
|
||||
const entity = encodeURIComponent(stringifyEntityRef(entityName));
|
||||
return (await this.fetch<JsonCodeCoverage>(
|
||||
@@ -68,7 +73,7 @@ export class CodeCoverageRestApi implements CodeCoverageApi {
|
||||
}
|
||||
|
||||
async getFileContentFromEntity(
|
||||
entityName: EntityName,
|
||||
entityName: CompoundEntityRef,
|
||||
filePath: string,
|
||||
): Promise<string> {
|
||||
const entity = encodeURIComponent(stringifyEntityRef(entityName));
|
||||
@@ -78,7 +83,7 @@ export class CodeCoverageRestApi implements CodeCoverageApi {
|
||||
}
|
||||
|
||||
async getCoverageHistoryForEntity(
|
||||
entityName: EntityName,
|
||||
entityName: CompoundEntityRef,
|
||||
limit?: number,
|
||||
): Promise<JsonCoverageHistory> {
|
||||
const entity = encodeURIComponent(stringifyEntityRef(entityName));
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
export type JsonCodeCoverage = {
|
||||
metadata: CoverageMetadata;
|
||||
entity: EntityName;
|
||||
entity: CompoundEntityRef;
|
||||
files: Array<FileEntry>;
|
||||
};
|
||||
|
||||
export type JsonCoverageHistory = {
|
||||
entity: EntityName;
|
||||
entity: CompoundEntityRef;
|
||||
history: Array<AggregateCoverage>;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('<DefaultExplorePage />', () => {
|
||||
getLocationById: jest.fn(),
|
||||
removeLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
refreshEntity: jest.fn(),
|
||||
getEntityAncestors: jest.fn(),
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('<DomainExplorerContent />', () => {
|
||||
getLocationById: jest.fn(),
|
||||
removeLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
refreshEntity: jest.fn(),
|
||||
getEntityAncestors: jest.fn(),
|
||||
|
||||
@@ -31,7 +31,7 @@ import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
entityRouteRef,
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
@@ -193,7 +193,7 @@ export function GroupsDiagram() {
|
||||
kind: catalogItem.kind,
|
||||
name:
|
||||
(catalogItem as GroupEntity).spec?.profile?.displayName ||
|
||||
formatEntityRefTitle(catalogItem, { defaultKind: 'Group' }),
|
||||
humanizeEntityRef(catalogItem, { defaultKind: 'Group' }),
|
||||
});
|
||||
|
||||
// Edge to parent
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('<GroupsExplorerContent />', () => {
|
||||
getLocationById: jest.fn(),
|
||||
removeLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
refreshEntity: jest.fn(),
|
||||
getEntityAncestors: jest.fn(),
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('<FossaPage />', () => {
|
||||
const catalogApi: jest.Mocked<CatalogApi> = {
|
||||
addLocation: jest.fn(),
|
||||
getEntities: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
EntityName,
|
||||
CompoundEntityRef,
|
||||
RELATION_OWNED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
catalogApiRef,
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
formatEntityRefTitle,
|
||||
humanizeEntityRef,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { Tooltip } from '@material-ui/core';
|
||||
@@ -56,7 +56,7 @@ type FossaRow = {
|
||||
resolved: {
|
||||
name: string;
|
||||
ownedByRelationsTitle?: string;
|
||||
ownedByRelations: EntityName[];
|
||||
ownedByRelations: CompoundEntityRef[];
|
||||
loading: boolean;
|
||||
details?: FindingSummary;
|
||||
};
|
||||
@@ -222,10 +222,10 @@ export const FossaPage = ({
|
||||
return {
|
||||
entity,
|
||||
resolved: {
|
||||
name: formatEntityRefTitle(entity),
|
||||
name: humanizeEntityRef(entity),
|
||||
ownedByRelations,
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
|
||||
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
|
||||
.join(', '),
|
||||
loading: summariesLoading,
|
||||
details: summary,
|
||||
|
||||
@@ -15,11 +15,7 @@
|
||||
*/
|
||||
|
||||
import { HomePageStarredEntities } from '../../plugin';
|
||||
import {
|
||||
wrapInTestApp,
|
||||
TestApiProvider,
|
||||
MockStorageApi,
|
||||
} from '@backstage/test-utils';
|
||||
import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
starredEntitiesApiRef,
|
||||
MockStarredEntitiesApi,
|
||||
@@ -28,24 +24,18 @@ import {
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
|
||||
const mockStorageApi = MockStorageApi.create();
|
||||
mockStorageApi
|
||||
.forBucket('starredEntities')
|
||||
.set('entityRefs', [
|
||||
'component:default/example-starred-entity',
|
||||
'component:default/example-starred-entity-2',
|
||||
'component:default/example-starred-entity-3',
|
||||
'component:default/example-starred-entity-4',
|
||||
]);
|
||||
const starredEntitiesApi = new MockStarredEntitiesApi();
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-2');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-3');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-4');
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Home/Components/StarredEntities',
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) =>
|
||||
wrapInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[[starredEntitiesApiRef, new MockStarredEntitiesApi()]]}
|
||||
>
|
||||
<TestApiProvider apis={[[starredEntitiesApiRef, starredEntitiesApi]]}>
|
||||
<Story />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
HomePageCompanyLogo,
|
||||
HomePageStarredEntities,
|
||||
} from '../plugin';
|
||||
import { wrapInTestApp, TestApiProvider, MockStorageApi} from '@backstage/test-utils';
|
||||
import { wrapInTestApp, TestApiProvider} from '@backstage/test-utils';
|
||||
import { Content, Page, InfoCard } from '@backstage/core-components';
|
||||
import {
|
||||
starredEntitiesApiRef,
|
||||
@@ -37,15 +37,11 @@ import {
|
||||
import { Grid, makeStyles } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
|
||||
const mockStorageApi = MockStorageApi.create();
|
||||
mockStorageApi
|
||||
.forBucket('starredEntities')
|
||||
.set('entityRefs', [
|
||||
'component:default/example-starred-entity',
|
||||
'component:default/example-starred-entity-2',
|
||||
'component:default/example-starred-entity-3',
|
||||
'component:default/example-starred-entity-4'
|
||||
]);
|
||||
const starredEntitiesApi = new MockStarredEntitiesApi();
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-2');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-3');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-4');
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Home/Templates',
|
||||
@@ -57,7 +53,7 @@ export default {
|
||||
apis={[
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
new MockStarredEntitiesApi(),
|
||||
starredEntitiesApi,
|
||||
],
|
||||
[searchApiRef, { query: () => Promise.resolve({ results: [] }) }],
|
||||
]}
|
||||
|
||||
@@ -166,7 +166,7 @@ class AcmeJenkinsInfoProvider implements JenkinsInfoProvider {
|
||||
const PAAS_ANNOTATION = 'acme.example.com/paas-project-name';
|
||||
|
||||
// lookup pass-project-name from entity annotation
|
||||
const entity = await this.catalog.getEntityByName(opt.entityRef);
|
||||
const entity = await this.catalog.getEntityByRef(opt.entityRef);
|
||||
if (!entity) {
|
||||
throw new Error(
|
||||
`Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user