catalog-client: filtering and facets for in-memory client

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-09-12 00:21:40 +02:00
parent 6f222ac773
commit c0300626b2
3 changed files with 123 additions and 28 deletions
+1
View File
@@ -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"
},
@@ -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<string, Entity>;
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<GetEntitiesResponse> {
// 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<GetEntitiesByRefsResponse> {
const entitiesByRef = new Map<string, Entity>();
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<QueryEntitiesResponse> {
// 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<GetEntityAncestorsResponse> {
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<Entity | undefined> {
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<void> {
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<void> {}
async getEntityFacets(
_request: GetEntityFacetsRequest,
request: GetEntityFacetsRequest,
): Promise<GetEntityFacetsResponse> {
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<string, number>();
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<Location | undefined> {
+1
View File
@@ -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