diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 4119614503..03addce840 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -50,6 +50,7 @@ "dependencies": { "@backstage/catalog-model": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", "cross-fetch": "^4.0.0", "uri-template": "^2.0.0" }, diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index 745273c6bc..a975a79b80 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -17,7 +17,9 @@ import { AddLocationRequest, AddLocationResponse, + CATALOG_FILTER_EXISTS, CatalogApi, + EntityFilterQuery, GetEntitiesByRefsRequest, GetEntitiesByRefsResponse, GetEntitiesRequest, @@ -34,9 +36,72 @@ import { import { CompoundEntityRef, Entity, + parseEntityRef, stringifyEntityRef, } from '@backstage/catalog-model'; import { NotFoundError, NotImplementedError } from '@backstage/errors'; +import { JsonObject, JsonValue } from '@backstage/types'; + +function pick(obj: JsonObject, key: string): JsonValue | undefined { + const parts = key.split('.'); + + return parts.reduce((acc, part, index) => { + if (!acc) { + return acc; + } + if (typeof acc === 'object' && acc !== null && !Array.isArray(acc)) { + const value = acc[part]; + if (value) { + return value; + } + const rest = parts.slice(index).join('.'); + const restValue = acc[rest]; + if (restValue) { + return restValue; + } + } + return undefined; + }, obj as JsonValue | undefined); +} + +function filterCompare( + expected: (string | symbol) | (string | symbol)[], + value: JsonValue, +) { + const expect = (Array.isArray(expected) ? expected : [expected]).map(e => + e.toString().toLocaleLowerCase('en-US'), + ); + return expect.includes(String(value).toLocaleLowerCase('en-US')); +} + +function createFilter( + filterOrFilters?: EntityFilterQuery, +): (entity: Entity) => boolean { + if (!filterOrFilters) { + return () => true; + } + const filters = Array.isArray(filterOrFilters) + ? filterOrFilters + : [filterOrFilters]; + + return entity => { + return filters.some(filter => { + for (const [key, expectedValue] of Object.entries(filter)) { + const entityValue = pick(entity, key); + if (entityValue === undefined) { + return false; + } + if (expectedValue === CATALOG_FILTER_EXISTS) { + continue; + } + if (!filterCompare(expectedValue, entityValue)) { + return false; + } + } + return true; + }); + }; +} /** * Implements a VERY basic fake catalog client that stores entities in memory. @@ -47,78 +112,106 @@ import { NotFoundError, NotImplementedError } from '@backstage/errors'; */ export class InMemoryCatalogClient implements CatalogApi { #entities: Entity[]; + #entitiesByRef: Map; constructor(options?: { entities?: Entity[] }) { this.#entities = options?.entities?.slice() ?? []; + this.#entitiesByRef = new Map( + this.#entities.map(e => [stringifyEntityRef(e), e]), + ); } async getEntities( - _request?: GetEntitiesRequest, + request?: GetEntitiesRequest | undefined, ): Promise { - // TODO(freben): Fields, filters etc - return { items: this.#entities.slice() }; + const filter = createFilter(request?.filter); + return { items: this.#entities.filter(filter) }; } async getEntitiesByRefs( request: GetEntitiesByRefsRequest, ): Promise { - const entitiesByRef = new Map(); - for (const entity of this.#entities) { - entitiesByRef.set(stringifyEntityRef(entity), entity); - } - - const items = request.entityRefs.map(candidateRef => - entitiesByRef.get(candidateRef), - ); - - // TODO(freben): Fields, filters etc - return { items }; + const filter = createFilter(request.filter); + return { + items: request.entityRefs + .map(ref => this.#entitiesByRef.get(ref)) + .filter(e => (e ? filter(e) : true)), + }; } async queryEntities( - _request?: QueryEntitiesRequest, + request?: QueryEntitiesRequest | undefined, ): Promise { - // TODO(freben): Fields, filters etc + if (request && 'cursor' in request) { + return { items: [], pageInfo: {}, totalItems: 0 }; + } + const filter = createFilter(request?.filter); + // TODO(Rugvip): Pagination return { - items: this.#entities.slice(), - totalItems: this.#entities.length, + items: this.#entities.filter(filter), pageInfo: {}, + totalItems: this.#entities.length, }; } async getEntityAncestors( request: GetEntityAncestorsRequest, ): Promise { - const entity = this.#entities.find( - e => stringifyEntityRef(e) === request.entityRef, - ); + const entity = this.#entitiesByRef.get(request.entityRef); if (!entity) { throw new NotFoundError(`Entity with ref ${request.entityRef} not found`); } return { - rootEntityRef: request.entityRef, items: [{ entity, parentEntityRefs: [] }], + rootEntityRef: request.entityRef, }; } async getEntityByRef( entityRef: string | CompoundEntityRef, ): Promise { - const ref = - typeof entityRef === 'string' ? entityRef : stringifyEntityRef(entityRef); - return this.#entities.find(e => stringifyEntityRef(e) === ref); + return this.#entitiesByRef.get( + stringifyEntityRef(parseEntityRef(entityRef)), + ); } async removeEntityByUid(uid: string): Promise { - this.#entities = this.#entities.filter(e => e.metadata.uid !== uid); + const index = this.#entities.findIndex(e => e.metadata.uid === uid); + if (index !== -1) { + const entity = this.#entities[index]; + this.#entitiesByRef.delete(stringifyEntityRef(entity)); + this.#entities.splice(index, 1); + } } async refreshEntity(_entityRef: string): Promise {} async getEntityFacets( - _request: GetEntityFacetsRequest, + request: GetEntityFacetsRequest, ): Promise { - throw new NotImplementedError('Method not implemented.'); + const filter = createFilter(request.filter); + const filteredEntities = this.#entities.filter(filter); + const facets = Object.fromEntries( + request.facets.map(facet => { + const facetValues = new Map(); + for (const entity of filteredEntities) { + const value = pick(entity, facet); + if (value) { + facetValues.set( + String(value), + (facetValues.get(String(value)) ?? 0) + 1, + ); + } + } + const counts = Array.from(facetValues.entries()).map( + ([value, count]) => ({ value, count }), + ); + return [facet, counts]; + }), + ); + return { + facets, + }; } async getLocationById(_id: string): Promise { diff --git a/yarn.lock b/yarn.lock index 03cb6f421c..9aec0defac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3840,6 +3840,7 @@ __metadata: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/types": "workspace:^" cross-fetch: ^4.0.0 msw: ^1.0.0 uri-template: ^2.0.0