catalog-client: filtering and facets for in-memory client
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user