catalog-graph: reduce the number of API requests

* use `CatalogClient.getEntitiesByRefs()` to reduce the number of requests
  to the backend;

Signed-off-by: Valentin VĂLCIU <axiac.ro@gmail.com>
This commit is contained in:
Valentin VĂLCIU
2024-01-29 12:59:38 +02:00
parent d208877118
commit f937aae0c2
5 changed files with 147 additions and 71 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-graph': patch
---
use `CatalogClient.getEntitiesByRefs()` to reduce the number of backend requests from plugin `catalog-graph`
@@ -40,6 +40,7 @@ describe('<CatalogGraphCard/>', () => {
const catalog = {
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
getEntitiesByRefs: jest.fn(),
removeEntityByUid: jest.fn(),
getLocationById: jest.fn(),
getLocationByRef: jest.fn(),
@@ -75,9 +76,13 @@ describe('<CatalogGraphCard/>', () => {
});
test('renders without exploding', async () => {
catalog.getEntityByRef.mockImplementation(async _ => ({
...entity,
relations: [],
catalog.getEntitiesByRefs.mockImplementation(async _ => ({
items: [
{
...entity,
relations: [],
},
],
}));
await renderInTestApp(wrapper, {
@@ -89,13 +94,20 @@ describe('<CatalogGraphCard/>', () => {
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
expect(await screen.findAllByTestId('node')).toHaveLength(1);
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntitiesByRefs).toHaveBeenCalledTimes(1);
expect(catalog.getEntitiesByRefs).toHaveBeenCalledWith(
expect.objectContaining({ entityRefs: ['b:d/c'] }),
);
});
test('renders with custom title', async () => {
catalog.getEntityByRef.mockImplementation(async _ => ({
...entity,
relations: [],
catalog.getEntitiesByRefs.mockImplementation(async _ => ({
items: [
{
...entity,
relations: [],
},
],
}));
await renderInTestApp(
@@ -116,9 +128,13 @@ describe('<CatalogGraphCard/>', () => {
});
test('renders link to standalone viewer', async () => {
catalog.getEntityByRef.mockImplementation(async _ => ({
...entity,
relations: [],
catalog.getEntitiesByRefs.mockImplementation(async _ => ({
items: [
{
...entity,
relations: [],
},
],
}));
await renderInTestApp(wrapper, {
@@ -138,6 +154,15 @@ describe('<CatalogGraphCard/>', () => {
});
test('renders link to standalone viewer with custom config', async () => {
catalog.getEntitiesByRefs.mockImplementation(async _ => ({
items: [
{
...entity,
relations: [],
},
],
}));
await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
@@ -162,9 +187,13 @@ describe('<CatalogGraphCard/>', () => {
});
test('captures analytics event on click', async () => {
catalog.getEntityByRef.mockImplementation(async _ => ({
...entity,
relations: [],
catalog.getEntitiesByRefs.mockImplementation(async _ => ({
items: [
{
...entity,
relations: [],
},
],
}));
const analyticsSpy = new MockAnalyticsApi();
@@ -26,6 +26,7 @@ import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { CatalogGraphPage } from './CatalogGraphPage';
import { GetEntitiesByRefsRequest } from '@backstage/catalog-client';
const navigate = jest.fn();
@@ -111,9 +112,14 @@ describe.skip('<CatalogGraphPage/>', () => {
},
],
};
const allEntities: Record<string, object> = {
'b:d/c': entityC,
'b:d/e': entityE,
};
const catalog = {
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
getEntitiesByRefs: jest.fn(),
removeEntityByUid: jest.fn(),
getLocationById: jest.fn(),
getLocationByRef: jest.fn(),
@@ -142,8 +148,10 @@ describe.skip('<CatalogGraphPage/>', () => {
afterEach(() => jest.resetAllMocks());
test('should render without exploding', async () => {
catalog.getEntityByRef.mockImplementation(async (n: any) =>
n === 'b:d/e' ? entityE : entityC,
catalog.getEntitiesByRefs.mockImplementation(
async ({ entityRefs }: GetEntitiesByRefsRequest) => ({
items: entityRefs.map(ref => allEntities[ref]),
}),
);
await renderInTestApp(wrapper, {
@@ -156,12 +164,14 @@ describe.skip('<CatalogGraphPage/>', () => {
await expect(screen.findByText('b:d/c')).resolves.toBeInTheDocument();
await expect(screen.findByText('b:d/e')).resolves.toBeInTheDocument();
await expect(screen.findAllByTestId('node')).resolves.toHaveLength(2);
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(2);
expect(catalog.getEntitiesByRefs).toHaveBeenCalledTimes(2);
});
test('should toggle filters', async () => {
catalog.getEntityByRef.mockImplementation(async (n: any) =>
n === 'b:d/e' ? entityE : entityC,
catalog.getEntitiesByRefs.mockImplementation(
async ({ entityRefs }: GetEntitiesByRefsRequest) => ({
items: entityRefs.map(ref => allEntities[ref]),
}),
);
await renderInTestApp(wrapper, {
@@ -178,8 +188,10 @@ describe.skip('<CatalogGraphPage/>', () => {
});
test('should select other entity', async () => {
catalog.getEntityByRef.mockImplementation(async (n: any) =>
n === 'b:d/e' ? entityE : entityC,
catalog.getEntitiesByRefs.mockImplementation(
async ({ entityRefs }: GetEntitiesByRefsRequest) => ({
items: entityRefs.map(ref => allEntities[ref]),
}),
);
await renderInTestApp(wrapper, {
@@ -196,8 +208,10 @@ describe.skip('<CatalogGraphPage/>', () => {
});
test('should navigate to entity', async () => {
catalog.getEntityByRef.mockImplementation(async (n: any) =>
n === 'b:d/e' ? entityE : entityC,
catalog.getEntitiesByRefs.mockImplementation(
async ({ entityRefs }: GetEntitiesByRefsRequest) => ({
items: entityRefs.map(ref => allEntities[ref]),
}),
);
await renderInTestApp(wrapper, {
@@ -215,8 +229,10 @@ describe.skip('<CatalogGraphPage/>', () => {
});
test('should capture analytics event when selecting other entity', async () => {
catalog.getEntityByRef.mockImplementation(async (n: any) =>
n === 'b:d/e' ? entityE : entityC,
catalog.getEntitiesByRefs.mockImplementation(
async ({ entityRefs }: GetEntitiesByRefsRequest) => ({
items: entityRefs.map(ref => allEntities[ref]),
}),
);
const analyticsSpy = new MockAnalyticsApi();
@@ -242,8 +258,10 @@ describe.skip('<CatalogGraphPage/>', () => {
});
test('should capture analytics event when navigating to entity', async () => {
catalog.getEntityByRef.mockImplementation(async (n: any) =>
n === 'b:d/e' ? entityE : entityC,
catalog.getEntitiesByRefs.mockImplementation(
async ({ entityRefs }: GetEntitiesByRefsRequest) => ({
items: entityRefs.map(ref => allEntities[ref]),
}),
);
const analyticsSpy = new MockAnalyticsApi();
@@ -27,6 +27,7 @@ describe('useEntityStore', () => {
const catalogApi = {
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
getEntitiesByRefs: jest.fn(),
removeEntityByUid: jest.fn(),
getLocationById: jest.fn(),
getLocationByRef: jest.fn(),
@@ -63,7 +64,7 @@ describe('useEntityStore', () => {
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity);
catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entity] });
const { result } = renderHook(() => useEntityStore());
@@ -83,7 +84,7 @@ describe('useEntityStore', () => {
test('handles request failures', async () => {
const err = new Error('Hello World');
catalogApi.getEntityByRef.mockRejectedValue(err);
catalogApi.getEntitiesByRefs.mockRejectedValue(err);
const { result } = renderHook(() => useEntityStore());
@@ -100,7 +101,7 @@ describe('useEntityStore', () => {
});
test('handles loading', async () => {
catalogApi.getEntityByRef.mockReturnValue(new Promise(() => {}));
catalogApi.getEntitiesByRefs.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useEntityStore());
@@ -131,8 +132,16 @@ describe('useEntityStore', () => {
name: 'name2',
},
};
const entity3: Entity = {
apiVersion: 'v1',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name3',
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity1);
catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entity1] });
const { result } = renderHook(() => useEntityStore());
@@ -149,12 +158,15 @@ describe('useEntityStore', () => {
});
});
catalogApi.getEntityByRef.mockResolvedValue(entity2);
catalogApi.getEntitiesByRefs.mockResolvedValue({
items: [entity2, entity3],
});
act(() => {
result.current.requestEntities([
'kind:namespace/name1',
'kind:namespace/name2',
'kind:namespace/name3',
]);
});
@@ -165,6 +177,7 @@ describe('useEntityStore', () => {
expect(entities).toEqual({
'kind:namespace/name1': entity1,
'kind:namespace/name2': entity2,
'kind:namespace/name3': entity3,
});
});
});
@@ -186,13 +199,26 @@ describe('useEntityStore', () => {
name: 'name2',
},
};
const entity3: Entity = {
apiVersion: 'v1',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name3',
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity1);
catalogApi.getEntitiesByRefs.mockResolvedValue({
items: [entity1, entity2],
});
const { result } = renderHook(() => useEntityStore());
act(() => {
result.current.requestEntities(['kind:namespace/name1']);
result.current.requestEntities([
'kind:namespace/name1',
'kind:namespace/name2',
]);
});
await waitFor(() => {
@@ -201,13 +227,22 @@ describe('useEntityStore', () => {
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name1': entity1,
'kind:namespace/name2': entity2,
});
});
catalogApi.getEntityByRef.mockResolvedValue(entity2);
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(1);
expect(catalogApi.getEntitiesByRefs).toHaveBeenLastCalledWith({
entityRefs: ['kind:namespace/name1', 'kind:namespace/name2'],
});
catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entity3] });
act(() => {
result.current.requestEntities(['kind:namespace/name2']);
result.current.requestEntities([
'kind:namespace/name2',
'kind:namespace/name3',
]);
});
await waitFor(() => {
@@ -216,9 +251,15 @@ describe('useEntityStore', () => {
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name2': entity2,
'kind:namespace/name3': entity3,
});
});
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(2);
expect(catalogApi.getEntitiesByRefs).toHaveBeenLastCalledWith({
entityRefs: ['kind:namespace/name3'],
});
act(() => {
result.current.requestEntities(['kind:namespace/name1']);
});
@@ -232,6 +273,6 @@ describe('useEntityStore', () => {
});
});
expect(catalogApi.getEntityByRef).toHaveBeenCalledTimes(2);
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(2);
});
});
@@ -13,18 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import limiterFactory from 'p-limit';
import { Dispatch, useCallback, useRef, useState } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
// TODO: This is a good use case for a graphql API, once it is available in the
// future.
const limiter = limiterFactory(10);
/**
* Ensures that a set of requested entities is loaded.
*/
@@ -58,38 +55,24 @@ export function useEntityStore(): {
}, [state, setEntities]);
const [asyncState, fetch] = useAsyncFn(async () => {
const { requestedEntities, outstandingEntities, cachedEntities } =
state.current;
await Promise.all(
Array.from(requestedEntities).map(entityRef =>
limiter(async () => {
if (cachedEntities.has(entityRef)) {
return;
}
if (outstandingEntities.has(entityRef)) {
await outstandingEntities.get(entityRef);
return;
}
const promise = catalogClient.getEntityByRef(entityRef);
outstandingEntities.set(entityRef, promise);
try {
const entity = await promise;
if (entity) {
cachedEntities.set(entityRef, entity);
updateEntities();
}
} finally {
outstandingEntities.delete(entityRef);
}
}),
),
const { requestedEntities, cachedEntities } = state.current;
const entityRefs: string[] = Array.from(requestedEntities).filter(
entityRef => !cachedEntities.has(entityRef),
);
if (entityRefs.length === 0) {
updateEntities();
return;
}
const { items } = await catalogClient.getEntitiesByRefs({ entityRefs });
items.forEach(ent => {
if (ent) {
const entityRef = stringifyEntityRef(ent);
cachedEntities.set(entityRef, ent);
}
});
updateEntities();
}, [state, updateEntities]);
const { loading, error } = asyncState;