Merge branch 'master' into jhaals/dep-favoriteEntityTooltip

This commit is contained in:
Patrik Oldsberg
2022-03-03 14:23:35 +01:00
committed by GitHub
295 changed files with 5790 additions and 1524 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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;
@@ -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,
+48 -7
View File
@@ -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
+1
View File
@@ -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",
+3 -3
View File
@@ -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),
},
};
}
}
}
}
+7 -1
View File
@@ -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);
+2 -2
View File
@@ -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;
+7 -3
View File
@@ -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 (
@@ -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);
+5 -5
View File
@@ -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[];
}>;
};
+2 -2
View File
@@ -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>
);
})}
@@ -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(),
@@ -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[];
}>;
};
+25 -16
View File
@@ -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'>;
@@ -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');
@@ -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(', ');
}
@@ -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 || '',
@@ -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 = {
+2 -2
View File
@@ -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)
+11 -6
View File
@@ -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,
);
+3 -3
View File
@@ -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>;
};
+12 -7
View File
@@ -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));
+4 -3
View File
@@ -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: [] }) }],
]}
+1 -1
View File
@@ -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