implement by-refs batch fetching in the catalog client

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2022-11-14 17:02:24 +01:00
parent 3a5aefb4f8
commit 00d90b520a
6 changed files with 234 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': minor
---
**BREAKING PRODUCERS**: Added a new `getEntitiesByRefs` endpoint to `CatalogApi`, for efficient batch fetching of entities by ref.
+29 -5
View File
@@ -34,6 +34,10 @@ export interface CatalogApi {
request?: GetEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<GetEntitiesResponse>;
getEntitiesByRefs(
request: GetEntitiesByRefsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntitiesByRefsResponse>;
getEntityAncestors(
request: GetEntityAncestorsRequest,
options?: CatalogRequestOptions,
@@ -91,6 +95,10 @@ export class CatalogClient implements CatalogApi {
request?: GetEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<GetEntitiesResponse>;
getEntitiesByRefs(
request: GetEntitiesByRefsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntitiesByRefsResponse>;
getEntityAncestors(
request: GetEntityAncestorsRequest,
options?: CatalogRequestOptions,
@@ -145,14 +153,30 @@ export interface CatalogRequestOptions {
export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE =
'backstage.io/catalog-processing';
// @public
export type EntityFieldsQuery = string[];
// @public
export type EntityFilterQuery =
| Record<string, string | symbol | (string | symbol)[]>[]
| Record<string, string | symbol | (string | symbol)[]>;
// @public
export interface GetEntitiesByRefsRequest {
entityRefs: string[];
fields?: EntityFieldsQuery | undefined;
}
// @public
export interface GetEntitiesByRefsResponse {
items: Array<Entity | undefined>;
}
// @public
export interface GetEntitiesRequest {
after?: string;
fields?: string[] | undefined;
filter?:
| Record<string, string | symbol | (string | symbol)[]>[]
| Record<string, string | symbol | (string | symbol)[]>
| undefined;
fields?: EntityFieldsQuery;
filter?: EntityFilterQuery;
limit?: number;
offset?: number;
}
@@ -195,6 +195,35 @@ describe('CatalogClient', () => {
});
});
describe('getEntitiesByRefs', () => {
it('encodes and decodes the query correctly', async () => {
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'Test2',
namespace: 'test1',
},
};
server.use(
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
expect(req.url.searchParams.get('fields')).toBe('a,b');
await expect(req.json()).resolves.toEqual({
entityRefs: ['k:n/a', 'k:n/b'],
});
return res(ctx.json({ items: [entity, null] }));
}),
);
const response = await client.getEntitiesByRefs(
{ entityRefs: ['k:n/a', 'k:n/b'], fields: ['a', 'b'] },
{ token },
);
expect(response).toEqual({ items: [entity, null] });
});
});
describe('getEntityByRef', () => {
const existingEntity: Entity = {
apiVersion: 'v1',
@@ -37,6 +37,8 @@ import {
GetEntityFacetsRequest,
GetEntityFacetsResponse,
ValidateEntityResponse,
GetEntitiesByRefsRequest,
GetEntitiesByRefsResponse,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import { FetchApi } from './types/fetch';
@@ -169,6 +171,40 @@ export class CatalogClient implements CatalogApi {
return { items: entities.sort(refCompare) };
}
/**
* {@inheritdoc CatalogApi.getEntitiesByRefs}
*/
async getEntitiesByRefs(
request: GetEntitiesByRefsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntitiesByRefsResponse> {
const params: string[] = [];
if (request.fields?.length) {
params.push(`fields=${request.fields.map(encodeURIComponent).join(',')}`);
}
const baseUrl = await this.discoveryApi.getBaseUrl('catalog');
const query = params.length ? `?${params.join('&')}` : '';
const url = `${baseUrl}/entities/by-refs${query}`;
const response = await this.fetchApi.fetch(url, {
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify({ entityRefs: request.entityRefs }),
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const { items } = await response.json();
return { items };
}
/**
* {@inheritdoc CatalogApi.getEntityByRef}
*/
+130 -58
View File
@@ -29,6 +29,75 @@ export const CATALOG_FILTER_EXISTS = Symbol.for(
'CATALOG_FILTER_EXISTS_0e15b590c0b343a2bae3e787e84c2111',
);
/**
* A key-value based filter expression for entities.
*
* @remarks
*
* Each key of a record is a dot-separated path into the entity structure, e.g.
* `metadata.name`.
*
* The values are literal values to match against. 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.
*
* All matching of keys and values is case insensitive.
*
* 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' )
* ```
*
* @public
*/
export type EntityFilterQuery =
| Record<string, string | symbol | (string | symbol)[]>[]
| Record<string, string | symbol | (string | symbol)[]>;
/**
* A set of dot-separated paths into an entity's keys, showing what parts of an
* entity to include in a response, and excluding all others.
*
* @remarks
*
* Example: For an input of `['kind', 'metadata.annotations']`, then response
* objects will be shaped like
*
* ```
* {
* "kind": "Component",
* "metadata": {
* "annotations": {
* "foo": "bar"
* }
* }
* }
* ```
* @public
*/
export type EntityFieldsQuery = string[];
/**
* The request type for {@link CatalogClient.getEntities}.
*
@@ -36,67 +105,14 @@ export const CATALOG_FILTER_EXISTS = Symbol.for(
*/
export interface GetEntitiesRequest {
/**
* 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.
* If given, return only entities that match the given filter.
*/
filter?:
| Record<string, string | symbol | (string | symbol)[]>[]
| Record<string, string | symbol | (string | symbol)[]>
| undefined;
filter?: EntityFilterQuery;
/**
* If given, return only the parts of each entity that match those dot
* separated paths in each object.
*
* @remarks
*
* Example: For an input of `['kind', 'metadata.annotations']`, then response
* objects will be shaped like
*
* ```
* {
* "kind": "Component",
* "metadata": {
* "annotations": {
* "foo": "bar"
* }
* }
* }
* ```
* If given, return only the parts of each entity that match the field
* declarations.
*/
fields?: string[] | undefined;
fields?: EntityFieldsQuery;
/**
* If given, skips over the first N items in the result set.
*/
@@ -121,6 +137,45 @@ export interface GetEntitiesResponse {
items: Entity[];
}
/**
* The request type for {@link CatalogClient.getEntitiesByRefs}.
*
* @public
*/
export interface GetEntitiesByRefsRequest {
/**
* The list of entity refs to fetch.
*
* @remarks
*
* The returned list of entities will be in the same order as the refs, and
* null will be returned in those positions that were not found.
*/
entityRefs: string[];
/**
* If given, return only the parts of each entity that match the field
* declarations.
*/
fields?: EntityFieldsQuery | undefined;
}
/**
* The response type for {@link CatalogClient.getEntitiesByRefs}.
*
* @public
*/
export interface GetEntitiesByRefsResponse {
/**
* The returned list of entities.
*
* @remarks
*
* The list will be in the same order as the refs given in the request, and
* null will be returned in those positions that were not found.
*/
items: Array<Entity | undefined>;
}
/**
* The request type for {@link CatalogClient.getEntityAncestors}.
*
@@ -296,6 +351,23 @@ export interface CatalogApi {
options?: CatalogRequestOptions,
): Promise<GetEntitiesResponse>;
/**
* Gets a batch of entities, by their entity refs.
*
* @remarks
*
* The output list of entities is of the same size and in the same order as
* the requested list of entity refs. Entries that are not found are returned
* as null.
*
* @param request - Request parameters
* @param options - Additional options
*/
getEntitiesByRefs(
request: GetEntitiesByRefsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntitiesByRefsResponse>;
/**
* Gets entity ancestor information, i.e. the hierarchy of parent entities
* whose processing resulted in a given entity appearing in the catalog.
+5 -1
View File
@@ -20,13 +20,17 @@ export type {
AddLocationResponse,
CatalogApi,
CatalogRequestOptions,
EntityFieldsQuery,
EntityFilterQuery,
GetEntitiesByRefsRequest,
GetEntitiesByRefsResponse,
GetEntitiesRequest,
GetEntitiesResponse,
GetEntityAncestorsRequest,
GetEntityAncestorsResponse,
Location,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
Location,
ValidateEntityResponse,
} from './api';
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';