diff --git a/.changeset/lucky-bulldogs-own.md b/.changeset/lucky-bulldogs-own.md new file mode 100644 index 0000000000..d7b657fb83 --- /dev/null +++ b/.changeset/lucky-bulldogs-own.md @@ -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 diff --git a/.changeset/tiny-ads-knock.md b/.changeset/tiny-ads-knock.md new file mode 100644 index 0000000000..427ed7eee1 --- /dev/null +++ b/.changeset/tiny-ads-knock.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Added `getEntityFacets` to the client diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 56ba4a834f..d5991303eb 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -43,6 +43,10 @@ export interface CatalogApi { name: EntityName, options?: CatalogRequestOptions, ): Promise; + getEntityFacets( + request: GetEntityFacetsRequest, + options?: CatalogRequestOptions, + ): Promise; getLocationById( id: string, options?: CatalogRequestOptions, @@ -91,6 +95,10 @@ export class CatalogClient implements CatalogApi { compoundName: EntityName, options?: CatalogRequestOptions, ): Promise; + getEntityFacets( + request: GetEntityFacetsRequest, + options?: CatalogRequestOptions, + ): Promise; // @deprecated (undocumented) getLocationByEntity( entity: Entity, @@ -180,6 +188,26 @@ export interface GetEntityAncestorsResponse { rootEntityRef: string; } +// @public +export interface GetEntityFacetsRequest { + facets: string[]; + filter?: + | Record[] + | Record + | undefined; +} + +// @public +export interface GetEntityFacetsResponse { + facets: Record< + string, + Array<{ + value: string; + count: number; + }> + >; +} + // @public type Location_2 = { id: string; diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 583f5a85bd..bb3c9604b3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -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 { 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 { + 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} */ diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index b3d4bc2397..b070820c8b 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -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[] + | Record + | 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>; +} + /** * Options you can pass into a catalog request for additional information. * @@ -248,6 +332,17 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; + /** + * 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; + // Locations /** diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 580bb7330d..39c8962b68 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -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'; diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 83ebb5de73..4388c2f82f 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -34,6 +34,7 @@ describe('CatalogIdentityClient', () => { removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; const tokenManager: jest.Mocked = { getToken: jest.fn(), diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 4b0ae2a44a..56dbfb0655 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -67,6 +67,7 @@ describe('createRouter', () => { removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; config = new ConfigReader({ diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 0e2fa723dc..7cebdee692 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -65,6 +65,7 @@ describe('', () => { removeLocationById: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; apis = TestApiRegistry.from([catalogApiRef, catalog]); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index c509abd712..f98aca0081 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -94,6 +94,7 @@ describe('', () => { removeLocationById: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; wrapper = ( diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index 715f30e8c9..f16d06f809 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -155,6 +155,7 @@ describe('', () => { removeLocationById: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; Wrapper = ({ children }) => ( diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts index 214f1a8fb9..a5dc82eab9 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts @@ -37,6 +37,7 @@ describe('useEntityStore', () => { removeLocationById: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; useApi.mockReturnValue(catalogApi); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index d2c7771804..f689c0a883 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -99,6 +99,7 @@ describe('CatalogImportClient', () => { removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; let catalogImportClient: CatalogImportClient; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index 40c1152fe8..fc9b35136a 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -45,6 +45,7 @@ describe('', () => { removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; const errorApi: jest.Mocked = { diff --git a/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx b/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx index cdcac8c194..c96301b3c4 100644 --- a/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx @@ -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 = { - 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']); diff --git a/plugins/catalog-react/src/hooks/useEntityKinds.ts b/plugins/catalog-react/src/hooks/useEntityKinds.ts index 1d5b2fec52..4fe68268a5 100644 --- a/plugins/catalog-react/src/hooks/useEntityKinds.ts +++ b/plugins/catalog-react/src/hooks/useEntityKinds.ts @@ -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 }; } diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx index db59e28f40..53b225daaf 100644 --- a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -31,6 +31,7 @@ describe('', () => { getEntityByName: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index c431bd0c6c..1310d9f31e 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -32,6 +32,7 @@ describe('', () => { getEntityByName: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index 23ae2a3e6d..255db061aa 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -32,6 +32,7 @@ describe('', () => { getEntityByName: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx index df376895bc..2759afd585 100644 --- a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -36,6 +36,7 @@ describe('', () => { removeLocationById: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; const fossaApi: jest.Mocked = { getFindingSummary: jest.fn(), diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index fbb1341e73..985687c6bb 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -51,6 +51,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked { removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), }; if (entity) { mock.getEntityByName.mockReturnValue(entity);