Merge pull request #9491 from backstage/freben/facets

catalog-backend: add support for querying facets
This commit is contained in:
Fredrik Adelöw
2022-02-18 14:14:36 +01:00
committed by GitHub
36 changed files with 652 additions and 102 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-catalog-backend': patch
---
Added an `/entity-facets` endpoint, which lets you query the distribution of
possible values for fields of entities.
This can be useful for example when populating a dropdown in the user interface,
such as listing all tag values that are actually being used right now in your
catalog instance, along with putting the most common ones at the top.
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/plugin-auth-backend': patch
'@backstage/plugin-badges-backend': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-explore': patch
'@backstage/plugin-fossa': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-todo-backend': patch
---
Updated according to the new `getEntityFacets` catalog API method
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/catalog-client': patch
---
Added `CatalogApi.getEntityFacets`. Marking this as a breaking change since it
is a non-optional addition to the API and depends on the backend being in place.
If you are mocking this interface in your tests, you will need to add an extra
`getEntityFacets: jest.fn()` or similar to that interface.
+28
View File
@@ -43,6 +43,10 @@ export interface CatalogApi {
name: EntityName,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
getEntityFacets(
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse>;
getLocationById(
id: string,
options?: CatalogRequestOptions,
@@ -91,6 +95,10 @@ export class CatalogClient implements CatalogApi {
compoundName: EntityName,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
getEntityFacets(
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse>;
// @deprecated (undocumented)
getLocationByEntity(
entity: Entity,
@@ -180,6 +188,26 @@ export interface GetEntityAncestorsResponse {
rootEntityRef: string;
}
// @public
export interface GetEntityFacetsRequest {
facets: string[];
filter?:
| Record<string, string | symbol | (string | symbol)[]>[]
| Record<string, string | symbol | (string | symbol)[]>
| undefined;
}
// @public
export interface GetEntityFacetsResponse {
facets: Record<
string,
Array<{
value: string;
count: number;
}>
>;
}
// @public
type Location_2 = {
id: string;
+44 -2
View File
@@ -36,6 +36,8 @@ import {
GetEntityAncestorsRequest,
GetEntityAncestorsResponse,
Location,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import { FetchApi } from './types/fetch';
@@ -97,14 +99,13 @@ export class CatalogClient implements CatalogApi {
options?: CatalogRequestOptions,
): Promise<GetEntitiesResponse> {
const { filter = [], fields = [], offset, limit, after } = request ?? {};
const filterItems = [filter].flat();
const params: string[] = [];
// filter param can occur multiple times, for example
// /api/catalog/entities?filter=metadata.name=wayback-search,kind=component&filter=metadata.name=www-artist,kind=component'
// the "outer array" defined by `filter` occurrences corresponds to "anyOf" filters
// the "inner array" defined within a `filter` param corresponds to "allOf" filters
for (const filterItem of filterItems) {
for (const filterItem of [filter].flat()) {
const filterParts: string[] = [];
for (const [key, value] of Object.entries(filterItem)) {
for (const v of [value].flat()) {
@@ -207,6 +208,47 @@ export class CatalogClient implements CatalogApi {
}
}
/**
* {@inheritdoc CatalogApi.getEntityFacets}
*/
async getEntityFacets(
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse> {
const { filter = [], facets } = request;
const params: string[] = [];
// filter param can occur multiple times, for example
// /api/catalog/entities?filter=metadata.name=wayback-search,kind=component&filter=metadata.name=www-artist,kind=component'
// the "outer array" defined by `filter` occurrences corresponds to "anyOf" filters
// the "inner array" defined within a `filter` param corresponds to "allOf" filters
for (const filterItem of [filter].flat()) {
const filterParts: string[] = [];
for (const [key, value] of Object.entries(filterItem)) {
for (const v of [value].flat()) {
if (v === CATALOG_FILTER_EXISTS) {
filterParts.push(encodeURIComponent(key));
} else if (typeof v === 'string') {
filterParts.push(
`${encodeURIComponent(key)}=${encodeURIComponent(v)}`,
);
}
}
}
if (filterParts.length) {
params.push(`filter=${filterParts.join(',')}`);
}
}
for (const facet of facets) {
params.push(`facet=${encodeURIComponent(facet)}`);
}
const query = params.length ? `?${params.join('&')}` : '';
return await this.requestOptional('GET', `/entity-facets${query}`, options);
}
/**
* {@inheritdoc CatalogApi.addLocation}
*/
+95
View File
@@ -142,6 +142,90 @@ export interface GetEntityAncestorsResponse {
}>;
}
/**
* The request type for {@link CatalogClient.getEntityFacets}.
*
* @public
*/
export interface GetEntityFacetsRequest {
/**
* If given, return only entities that match the given patterns.
*
* @remarks
*
* If multiple filter sets are given as an array, then there is effectively an
* OR between each filter set.
*
* Within one filter set, there is effectively an AND between the various
* keys.
*
* Within one key, if there are more than one value, then there is effectively
* an OR between them.
*
* Example: For an input of
*
* ```
* [
* { kind: ['API', 'Component'] },
* { 'metadata.name': 'a', 'metadata.namespace': 'b' }
* ]
* ```
*
* This effectively means
*
* ```
* (kind = EITHER 'API' OR 'Component')
* OR
* (metadata.name = 'a' AND metadata.namespace = 'b' )
* ```
*
* Each key is a dot separated path in each object.
*
* As a value you can also pass in the symbol `CATALOG_FILTER_EXISTS`
* (exported from this package), which means that you assert on the existence
* of that key, no matter what its value is.
*/
filter?:
| Record<string, string | symbol | (string | symbol)[]>[]
| Record<string, string | symbol | (string | symbol)[]>
| undefined;
/**
* Dot separated paths for the facets to extract from each entity.
*
* @remarks
*
* Example: For an input of `['kind', 'metadata.annotations.backstage.io/orphan']`, then the
* response will be shaped like
*
* ```
* {
* "facets": {
* "kind": [
* { "key": "Component", "count": 22 },
* { "key": "API", "count": 13 }
* ],
* "metadata.annotations.backstage.io/orphan": [
* { "key": "true", "count": 2 }
* ]
* }
* }
* ```
*/
facets: string[];
}
/**
* The response type for {@link CatalogClient.getEntityFacets}.
*
* @public
*/
export interface GetEntityFacetsResponse {
/**
* The computed facets, one entry per facet in the request.
*/
facets: Record<string, Array<{ value: string; count: number }>>;
}
/**
* Options you can pass into a catalog request for additional information.
*
@@ -248,6 +332,17 @@ export interface CatalogApi {
options?: CatalogRequestOptions,
): Promise<void>;
/**
* Gets a summary of field facets of entities in the catalog.
*
* @param request - Request parameters
* @param options - Additional options
*/
getEntityFacets(
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse>;
// Locations
/**
+3 -1
View File
@@ -25,6 +25,8 @@ export type {
GetEntityAncestorsRequest,
GetEntityAncestorsResponse,
Location,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
} from './api';
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
export * from './deprecated';
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
@@ -34,6 +34,7 @@ describe('CatalogIdentityClient', () => {
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
const tokenManager: jest.Mocked<TokenManager> = {
getToken: jest.fn(),
@@ -67,6 +67,7 @@ describe('createRouter', () => {
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
config = new ConfigReader({
+19
View File
@@ -559,6 +559,7 @@ export type EntitiesCatalog = {
authorizationToken?: string;
},
): Promise<EntityAncestryResponse>;
facets(request: EntityFacetsRequest): Promise<EntityFacetsResponse>;
};
// Warning: (ae-missing-release-tag) "EntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -604,6 +605,24 @@ export type EntityAncestryResponse = {
}>;
};
// @public
export interface EntityFacetsRequest {
authorizationToken?: string;
facets: string[];
filter?: EntityFilter;
}
// @public
export interface EntityFacetsResponse {
facets: Record<
string,
Array<{
value: string;
count: number;
}>
>;
}
// Warning: (ae-missing-release-tag) "EntityFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
+4 -2
View File
@@ -18,9 +18,11 @@ export type {
EntitiesCatalog,
EntitiesRequest,
EntitiesResponse,
EntityAncestryResponse,
PageInfo,
EntitiesSearchFilter,
EntityAncestryResponse,
EntityFacetsRequest,
EntityFacetsResponse,
EntityFilter,
EntityPagination,
PageInfo,
} from './types';
@@ -87,6 +87,44 @@ export type EntityAncestryResponse = {
}>;
};
/**
* The request shape for {@link EntitiesCatalog.facets}.
*
* @public
*/
export interface EntityFacetsRequest {
/**
* A filter to apply on the full list of entities before computing the facets.
*/
filter?: EntityFilter;
/**
* The facets to compute.
*
* @remarks
*
* This is a list of strings corresponding to paths within individual entity
* shapes. For example, to compute the facets for all available tags, you
* would pass in the string 'metadata.tags'.
*/
facets: string[];
/**
* The optional token that authorizes the action.
*/
authorizationToken?: string;
}
/**
* The response shape for {@link EntitiesCatalog.facets}.
*
* @public
*/
export interface EntityFacetsResponse {
/**
* The computed facets, one entry per facet in the request.
*/
facets: Record<string, Array<{ value: string; count: number }>>;
}
/** @public */
export type EntitiesCatalog = {
/**
@@ -115,4 +153,12 @@ export type EntitiesCatalog = {
entityRef: string,
options?: { authorizationToken?: string },
): Promise<EntityAncestryResponse>;
/**
* Computes facets for a set of entities, e.g. for populating filter lists
* or driving insights or similar.
*
* @param request - Request options
*/
facets(request: EntityFacetsRequest): Promise<EntityFacetsResponse>;
};
@@ -30,6 +30,7 @@ describe('AuthorizedEntitiesCatalog', () => {
entities: jest.fn(),
removeEntityByUid: jest.fn(),
entityAncestry: jest.fn(),
facets: jest.fn(),
};
const fakePermissionApi = {
authorize: jest.fn(),
@@ -254,4 +255,54 @@ describe('AuthorizedEntitiesCatalog', () => {
});
});
});
describe('facets', () => {
it('returns empty response on DENY', async () => {
fakePermissionApi.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const catalog = createCatalog();
expect(
await catalog.facets({
facets: ['a'],
authorizationToken: 'abcd',
}),
).toEqual({
facets: { a: [] },
});
});
it('calls underlying catalog method with correct filter on CONDITIONAL', async () => {
fakePermissionApi.authorize.mockResolvedValue([
{
result: AuthorizeResult.CONDITIONAL,
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
},
]);
const catalog = createCatalog(isEntityKind);
await catalog.facets({ facets: ['a'], authorizationToken: 'abcd' });
expect(fakeCatalog.facets).toHaveBeenCalledWith({
facets: ['a'],
authorizationToken: 'abcd',
filter: { key: 'kind', values: ['b'] },
});
});
it('calls underlying catalog method on ALLOW', async () => {
fakePermissionApi.authorize.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
const catalog = createCatalog();
await catalog.facets({ facets: ['a'], authorizationToken: 'abcd' });
expect(fakeCatalog.facets).toHaveBeenCalledWith({
facets: ['a'],
authorizationToken: 'abcd',
});
});
});
});
@@ -30,6 +30,8 @@ import {
EntitiesRequest,
EntitiesResponse,
EntityAncestryResponse,
EntityFacetsRequest,
EntityFacetsResponse,
EntityFilter,
} from '../catalog/types';
import { basicEntityFilter } from './request/basicEntityFilter';
@@ -151,6 +153,35 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
};
}
async facets(request: EntityFacetsRequest): Promise<EntityFacetsResponse> {
const authorizeDecision = (
await this.permissionApi.authorize(
[{ permission: catalogEntityReadPermission }],
{ token: request?.authorizationToken },
)
)[0];
if (authorizeDecision.result === AuthorizeResult.DENY) {
return {
facets: Object.fromEntries(request.facets.map(f => [f, []])),
};
}
if (authorizeDecision.result === AuthorizeResult.CONDITIONAL) {
const permissionFilter: EntityFilter = this.transformConditions(
authorizeDecision.conditions,
);
return this.entitiesCatalog.facets({
...request,
filter: request?.filter
? { allOf: [permissionFilter, request.filter] }
: permissionFilter,
});
}
return this.entitiesCatalog.facets(request);
}
private findParents(
entityRef: string,
allAncestryItems: { entity: Entity; parentEntityRefs: string[] }[],
@@ -535,4 +535,129 @@ describe('DefaultEntitiesCatalog', () => {
},
);
});
describe('facets', () => {
it.each(databases.eachSupportedId())(
'can filter and collect properly',
async databaseId => {
const { knex } = await createDatabase(databaseId);
await addEntityToSearch(knex, {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'one' },
spec: {},
});
await addEntityToSearch(knex, {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'two' },
spec: {},
});
await addEntityToSearch(knex, {
apiVersion: 'a',
kind: 'k2',
metadata: { name: 'two' },
spec: {},
});
const catalog = new DefaultEntitiesCatalog(knex);
await expect(catalog.facets({ facets: ['kind'] })).resolves.toEqual({
facets: {
kind: [
{ value: 'k', count: 2 },
{ value: 'k2', count: 1 },
],
},
});
},
);
it.each(databases.eachSupportedId())(
'can match on annotations and labels with dots in them',
async databaseId => {
const { knex } = await createDatabase(databaseId);
await addEntityToSearch(knex, {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'one',
annotations: { 'a.b/c.d': 'annotation1' },
labels: { 'e.f/g.h': 'label1' },
},
spec: {},
});
await addEntityToSearch(knex, {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'two',
annotations: { 'a.b/c.d': 'annotation2' },
labels: { 'e.f/g.h': 'label2' },
},
spec: {},
});
const catalog = new DefaultEntitiesCatalog(knex);
await expect(
catalog.facets({
facets: ['metadata.annotations.a.b/c.d', 'metadata.labels.e.f/g.h'],
}),
).resolves.toEqual({
facets: {
'metadata.annotations.a.b/c.d': [
{ value: 'annotation1', count: 1 },
{ value: 'annotation2', count: 1 },
],
'metadata.labels.e.f/g.h': [
{ value: 'label1', count: 1 },
{ value: 'label2', count: 1 },
],
},
});
},
);
it.each(databases.eachSupportedId())(
'can match on strings in arrays',
async databaseId => {
const { knex } = await createDatabase(databaseId);
await addEntityToSearch(knex, {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'one',
tags: ['java', 'rust'],
},
spec: {},
});
await addEntityToSearch(knex, {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'two',
tags: ['java', 'node'],
},
spec: {},
});
const catalog = new DefaultEntitiesCatalog(knex);
await expect(
catalog.facets({
facets: ['metadata.tags'],
}),
).resolves.toEqual({
facets: {
'metadata.tags': expect.arrayContaining([
{ value: 'java', count: 2 },
{ value: 'rust', count: 1 },
{ value: 'node', count: 1 },
]),
},
});
},
);
});
});
@@ -17,21 +17,24 @@
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { InputError, NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import lodash from 'lodash';
import {
EntitiesCatalog,
EntitiesRequest,
EntitiesResponse,
EntityAncestryResponse,
EntityPagination,
EntityFilter,
EntitiesSearchFilter,
EntityAncestryResponse,
EntityFacetsRequest,
EntityFacetsResponse,
EntityFilter,
EntityPagination,
} from '../catalog/types';
import {
DbFinalEntitiesRow,
DbPageInfo,
DbRefreshStateReferencesRow,
DbRefreshStateRow,
DbSearchRow,
DbPageInfo,
} from '../database/tables';
function parsePagination(input?: EntityPagination): {
@@ -295,4 +298,49 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
items,
};
}
async facets(request: EntityFacetsRequest): Promise<EntityFacetsResponse> {
const { entities } = await this.entities({
filter: request.filter,
authorizationToken: request.authorizationToken,
});
const facets: EntityFacetsResponse['facets'] = {};
for (const facet of request.facets) {
const values = entities
.map(entity => {
// TODO(freben): Generalize this code to handle any field that may
// have dots in its key?
if (facet.startsWith('metadata.annotations.')) {
return entity.metadata.annotations?.[
facet.substring('metadata.annotations.'.length)
];
} else if (facet.startsWith('metadata.labels.')) {
return entity.metadata.labels?.[
facet.substring('metadata.labels.'.length)
];
}
return lodash.get(entity, facet);
})
.flatMap(field => {
if (typeof field === 'string') {
return [field];
} else if (Array.isArray(field)) {
return field.filter(i => typeof i === 'string');
}
return [];
})
.sort();
const counts = lodash.countBy(values, lodash.identity);
facets[facet] = Object.entries(counts).map(([value, count]) => ({
value,
count,
}));
}
return { facets };
}
}
@@ -40,6 +40,7 @@ describe('createRouter readonly disabled', () => {
entities: jest.fn(),
removeEntityByUid: jest.fn(),
entityAncestry: jest.fn(),
facets: jest.fn(),
};
locationService = {
getLocation: jest.fn(),
@@ -394,6 +395,7 @@ describe('createRouter readonly enabled', () => {
entities: jest.fn(),
removeEntityByUid: jest.fn(),
entityAncestry: jest.fn(),
facets: jest.fn(),
};
locationService = {
getLocation: jest.fn(),
@@ -578,6 +580,7 @@ describe('NextRouter permissioning', () => {
entities: jest.fn(),
removeEntityByUid: jest.fn(),
entityAncestry: jest.fn(),
facets: jest.fn(),
};
locationService = {
getLocation: jest.fn(),
@@ -37,6 +37,7 @@ import {
} from './util';
import { RefreshOptions, LocationService, RefreshService } from './types';
import { z } from 'zod';
import { parseEntityFacetParams } from './request/parseEntityFacetParams';
/**
* Options used by {@link createRouter}.
@@ -160,7 +161,15 @@ export async function createRouter(
const response = await entitiesCatalog.entityAncestry(entityRef);
res.status(200).json(response);
},
);
)
.get('/entity-facets', async (req, res) => {
const response = await entitiesCatalog.facets({
filter: parseEntityFilterParams(req.query),
facets: parseEntityFacetParams(req.query),
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(200).json(response);
});
}
if (locationService) {
@@ -0,0 +1,37 @@
/*
* 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 { InputError } from '@backstage/errors';
import { parseStringsParam } from './common';
/**
* Parses the facets part of a facet query, like
* /entity-facets?filter=metadata.namespace=default,kind=Component&facet=metadata.namespace
*/
export function parseEntityFacetParams(
params: Record<string, unknown>,
): string[] {
// Each facet string is on the form a.b.c
const facetStrings = parseStringsParam(params.facet, 'facet');
if (facetStrings) {
const filtered = facetStrings.filter(Boolean);
if (filtered.length) {
return filtered;
}
}
throw new InputError('Missing facet parameter');
}
@@ -65,6 +65,7 @@ describe('<CatalogGraphCard/>', () => {
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
apis = TestApiRegistry.from([catalogApiRef, catalog]);
@@ -94,6 +94,7 @@ describe('<CatalogGraphPage/>', () => {
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
wrapper = (
@@ -155,6 +155,7 @@ describe('<EntityRelationsGraph/>', () => {
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
Wrapper = ({ children }) => (
@@ -37,6 +37,7 @@ describe('useEntityStore', () => {
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
useApi.mockReturnValue(catalogApi);
@@ -99,6 +99,7 @@ describe('CatalogImportClient', () => {
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
let catalogImportClient: CatalogImportClient;
@@ -45,6 +45,7 @@ describe('<StepPrepareCreatePullRequest />', () => {
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
const errorApi: jest.Mocked<typeof errorApiRef.T> = {
@@ -26,6 +26,7 @@ import { EntityKindFilter, EntityTypeFilter } from '../../filters';
import { alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
const entities: Entity[] = [
{
@@ -64,7 +65,14 @@ const apis = TestApiRegistry.from(
[
catalogApiRef,
{
getEntities: jest.fn().mockResolvedValue({ items: entities }),
getEntityFacets: jest.fn().mockResolvedValue({
facets: {
'spec.type': entities.map(e => ({
value: (e.spec as any).type,
count: 1,
})),
},
} as GetEntityFacetsResponse),
},
],
[
@@ -16,45 +16,21 @@
import React, { PropsWithChildren } from 'react';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { catalogApiRef } from '../api';
import { renderHook } from '@testing-library/react-hooks';
import { useEntityKinds } from './useEntityKinds';
import { TestApiProvider } from '@backstage/test-utils';
const entities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
},
{
apiVersion: '1',
kind: 'Template',
metadata: {
name: 'template',
},
},
{
apiVersion: '1',
kind: 'System',
metadata: {
name: 'system',
},
},
];
const mockCatalogApi: Partial<CatalogApi> = {
getEntities: jest.fn().mockResolvedValue({ items: entities }),
getEntityFacets: jest.fn().mockResolvedValue({
facets: {
kind: [
{ value: 'Template', count: 2 },
{ value: 'System', count: 1 },
{ value: 'Component', count: 3 },
],
},
}),
};
const wrapper = ({ children }: PropsWithChildren<{}>) => {
@@ -66,24 +42,10 @@ const wrapper = ({ children }: PropsWithChildren<{}>) => {
};
describe('useEntityKinds', () => {
it('does not return duplicate kinds', async () => {
it('gets entity kinds', async () => {
const { result, waitForValueToChange } = renderHook(
() => useEntityKinds(),
{
wrapper,
},
);
await waitForValueToChange(() => result.current);
expect(result.current.kinds).toBeDefined();
expect(result.current.kinds!.length).toBe(3);
});
it('sorts entity kinds', async () => {
const { result, waitForValueToChange } = renderHook(
() => useEntityKinds(),
{
wrapper,
},
{ wrapper },
);
await waitForValueToChange(() => result.current);
expect(result.current.kinds).toEqual(['Component', 'System', 'Template']);
@@ -30,11 +30,9 @@ export function useEntityKinds() {
loading,
value: kinds,
} = useAsync(async () => {
const entities = await catalogApi
.getEntities({ fields: ['kind'] })
.then(response => response.items);
return [...new Set(entities.map(e => e.kind))].sort();
return await catalogApi
.getEntityFacets({ facets: ['kind'] })
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
});
return { error, loading, kinds };
}
@@ -17,6 +17,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import isEqual from 'lodash/isEqual';
import sortBy from 'lodash/sortBy';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '../api';
import { useEntityListProvider } from './useEntityListProvider';
@@ -69,51 +70,34 @@ export function useEntityTypeFilter(): EntityTypeReturn {
const {
error,
loading,
value: entities,
value: facets,
} = useAsync(async () => {
if (kind) {
const items = await catalogApi
.getEntities({
.getEntityFacets({
filter: { kind },
fields: ['spec.type'],
facets: ['spec.type'],
})
.then(response => response.items);
.then(response => response.facets['spec.type'] || []);
return items;
}
return [];
}, [kind, catalogApi]);
const entitiesRef = useRef(entities);
const facetsRef = useRef(facets);
useEffect(() => {
const oldEntities = entitiesRef.current;
entitiesRef.current = entities;
// Delay processing hook until kind and entity load updates have settled to generate list of types;
// This prevents reseting the type filter due to saved type value from query params not matching the
const oldFacets = facetsRef.current;
facetsRef.current = facets;
// Delay processing hook until kind and facets load updates have settled to generate list of types;
// This prevents resetting the type filter due to saved type value from query params not matching the
// empty set of type values while values are still being loaded; also only run this hook on changes
// to entities
if (loading || !kind || oldEntities === entities) {
// to facets
if (loading || !kind || oldFacets === facets || !facets) {
return;
}
// Resolve the unique set of types from returned entities; could be optimized by a new endpoint
// in the catalog-backend that does this, rather than loading entities with redundant types.
if (!entities) return;
// Sort by entity count descending, so the most common types appear on top
const countByType = entities.reduce((acc, entity) => {
if (typeof entity.spec?.type !== 'string') return acc;
const entityType = entity.spec.type.toLocaleLowerCase('en-US');
if (!acc[entityType]) {
acc[entityType] = 0;
}
acc[entityType] += 1;
return acc;
}, {} as Record<string, number>);
const newTypes = Object.entries(countByType)
.sort(([, count1], [, count2]) => count2 - count1)
.map(([type]) => type);
// Sort by facet count descending, so the most common types appear on top
const newTypes = sortBy(facets, f => -f.count).map(f => f.value);
setAvailableTypes(newTypes);
// Update type filter to only valid values when the list of available types has changed
@@ -123,7 +107,7 @@ export function useEntityTypeFilter(): EntityTypeReturn {
if (!isEqual(selectedTypes, stillValidTypes)) {
setSelectedTypes(stillValidTypes);
}
}, [loading, kind, selectedTypes, setSelectedTypes, entities]);
}, [loading, kind, selectedTypes, setSelectedTypes, facets]);
useEffect(() => {
updateFilters({
@@ -16,6 +16,7 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
catalogApiRef,
@@ -60,7 +61,15 @@ const entities: Entity[] = [
const apis = TestApiRegistry.from([
catalogApiRef,
{
getEntities: jest.fn().mockResolvedValue({ items: entities }),
getEntityFacets: jest.fn().mockResolvedValue({
facets: {
kind: [
{ value: 'Component', count: 2 },
{ value: 'Template', count: 1 },
{ value: 'System', count: 1 },
],
},
} as GetEntityFacetsResponse),
},
]);
@@ -31,6 +31,7 @@ describe('<DefaultExplorePage />', () => {
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -32,6 +32,7 @@ describe('<DomainExplorerContent />', () => {
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -32,6 +32,7 @@ describe('<GroupsExplorerContent />', () => {
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -36,6 +36,7 @@ describe('<FossaPage />', () => {
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
const fossaApi: jest.Mocked<FossaApi> = {
getFindingSummary: jest.fn(),
@@ -17,7 +17,6 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { capitalize } from 'lodash';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { TemplateTypePicker } from './TemplateTypePicker';
import {
@@ -28,6 +27,7 @@ import {
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
const entities: Entity[] = [
{
@@ -66,10 +66,15 @@ const apis = TestApiRegistry.from(
[
catalogApiRef,
{
getEntities: jest
.fn()
.mockImplementation(() => Promise.resolve({ items: entities })),
} as unknown as CatalogApi,
getEntityFacets: jest.fn().mockResolvedValue({
facets: {
'spec.type': entities.map(e => ({
value: (e.spec as any).type,
count: 1,
})),
},
} as GetEntityFacetsResponse),
},
],
[
alertApiRef,
@@ -51,6 +51,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked<CatalogApi> {
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
};
if (entity) {
mock.getEntityByName.mockReturnValue(entity);