update the catalog client to add getEntityFacets

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2022-02-13 19:58:15 +01:00
parent 01e124ea60
commit 6e1cbc12a6
21 changed files with 216 additions and 57 deletions
+14
View File
@@ -0,0 +1,14 @@
---
'@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-todo-backend': patch
---
Updated according to the new `getEntityFacets` catalog API method
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': patch
---
Added `getEntityFacets` to the client
+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({
@@ -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> = {
@@ -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']);
@@ -27,11 +27,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 };
}
@@ -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(),
@@ -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);