catalog-client: change entities interface, add fields support (#3296)

This commit is contained in:
Fredrik Adelöw
2020-11-18 19:58:36 +01:00
committed by GitHub
parent 16bb5a0c8e
commit 717e43de14
19 changed files with 298 additions and 235 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': minor
---
Changed the getEntities interface to (1) nest parameters in an object, (2) support field selection, and (3) return an object with an items field for future extension
@@ -14,11 +14,11 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { Entity } from '@backstage/catalog-model';
import { DiscoveryApi } from './types';
import { CatalogListResponse, DiscoveryApi } from './types';
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
@@ -40,7 +40,7 @@ describe('CatalogClient', () => {
});
describe('getEntities', () => {
const defaultResponse: Entity[] = [
const defaultServiceResponse: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
@@ -58,22 +58,26 @@ describe('CatalogClient', () => {
},
},
];
const defaultResponse: CatalogListResponse<Entity> = {
items: defaultServiceResponse,
};
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/entities`, (_, res, ctx) => {
return res(ctx.json(defaultResponse));
return res(ctx.json(defaultServiceResponse));
}),
);
});
it('should entities from correct endpoint', async () => {
const entities = await client.getEntities();
expect(entities).toEqual(defaultResponse);
const response = await client.getEntities();
expect(response).toEqual(defaultResponse);
});
it('builds entity search filters properly', async () => {
expect.assertions(2);
server.use(
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.url.search).toBe('?filter=a=1,b=2,b=3,%C3%B6=%3D');
@@ -81,13 +85,32 @@ describe('CatalogClient', () => {
}),
);
const entities = await client.getEntities({
a: '1',
b: ['2', '3'],
ö: '=',
const response = await client.getEntities({
filter: {
a: '1',
b: ['2', '3'],
ö: '=',
},
});
expect(entities).toEqual([]);
expect(response.items).toEqual([]);
});
it('builds entity field selectors properly', async () => {
expect.assertions(2);
server.use(
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.url.search).toBe('?fields=a.b,%C3%B6');
return res(ctx.json([]));
}),
);
const response = await client.getEntities({
fields: ['a.b', 'ö'],
});
expect(response.items).toEqual([]);
});
});
});
+55 -41
View File
@@ -25,6 +25,8 @@ import {
AddLocationRequest,
AddLocationResponse,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
DiscoveryApi,
} from './types';
@@ -35,55 +37,33 @@ export class CatalogClient implements CatalogApi {
this.discoveryApi = options.discoveryApi;
}
private async getRequired(path: string): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
private async getOptional(path: string): Promise<any | undefined> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return undefined;
}
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
async getLocationById(id: String): Promise<Location | undefined> {
return await this.getOptional(`/locations/${id}`);
}
async getEntities(
filter?: Record<string, string | string[]>,
): Promise<Entity[]> {
let path = `/entities`;
if (filter) {
const parts: string[] = [];
for (const [key, value] of Object.entries(filter)) {
for (const v of [value].flat()) {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
}
request?: CatalogEntitiesRequest,
): Promise<CatalogListResponse<Entity>> {
const { filter = {}, fields = [] } = request ?? {};
const params: string[] = [];
const filterParts: string[] = [];
for (const [key, value] of Object.entries(filter)) {
for (const v of [value].flat()) {
filterParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
}
path += `?filter=${parts.join(',')}`;
}
if (filterParts.length) {
params.push(`filter=${filterParts.join(',')}`);
}
return await this.getRequired(path);
if (fields.length) {
params.push(`fields=${fields.map(encodeURIComponent).join(',')}`);
}
const query = params.length ? `?${params.join('&')}` : '';
const entities: Entity[] = await this.getRequired(`/entities${query}`);
return { items: entities };
}
async getEntityByName(compoundName: EntityName): Promise<Entity | undefined> {
@@ -153,4 +133,38 @@ export class CatalogClient implements CatalogApi {
}
return undefined;
}
//
// Private methods
//
private async getRequired(path: string): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
private async getOptional(path: string): Promise<any | undefined> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return undefined;
}
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
}
+12 -1
View File
@@ -16,10 +16,21 @@
import { Entity, EntityName, Location } from '@backstage/catalog-model';
export type CatalogEntitiesRequest = {
filter?: Record<string, string | string[]> | undefined;
fields?: string[] | undefined;
};
export type CatalogListResponse<T> = {
items: T[];
};
export interface CatalogApi {
getLocationById(id: String): Promise<Location | undefined>;
getEntityByName(name: EntityName): Promise<Entity | undefined>;
getEntities(filter?: Record<string, string | string[]>): Promise<Entity[]>;
getEntities(
request?: CatalogEntitiesRequest,
): Promise<CatalogListResponse<Entity>>;
addLocation(location: AddLocationRequest): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
removeEntityByUid(uid: string): Promise<void>;
@@ -26,24 +26,26 @@ import { ApiExplorerPage } from './ApiExplorerPage';
describe('ApiCatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
},
spec: { type: 'openapi' },
},
spec: { type: 'openapi' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity2',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity2',
},
spec: { type: 'openapi' },
},
spec: { type: 'openapi' },
},
] as Entity[]),
] as Entity[],
}),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
@@ -25,8 +25,8 @@ import { ApiExplorerLayout } from './ApiExplorerLayout';
export const ApiExplorerPage = () => {
const catalogApi = useApi(catalogApiRef);
const { loading, error, value: matchingEntities } = useAsync(() => {
return catalogApi.getEntities({ kind: 'API' });
const { loading, error, value: catalogResponse } = useAsync(() => {
return catalogApi.getEntities({ filter: { kind: 'API' } });
}, [catalogApi]);
return (
@@ -44,7 +44,7 @@ export const ApiExplorerPage = () => {
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<ApiExplorerTable
entities={matchingEntities!}
entities={catalogResponse?.items ?? []}
loading={loading}
error={error}
/>
@@ -33,30 +33,32 @@ import { ButtonGroup, CatalogFilter } from './CatalogFilter';
describe('Catalog Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'tools@example.com',
type: 'service',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[]),
] as Entity[],
}),
};
const identityApi: Partial<IdentityApi> = {
@@ -34,37 +34,39 @@ import { CatalogPage } from './CatalogPage';
describe('CatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'tools@example.com',
type: 'service',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[]),
] as Entity[],
}),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const testProfile: Partial<ProfileInfo> = {
displayName: 'Display Name',
};
const indentityApi: Partial<IdentityApi> = {
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
getProfile: () => testProfile,
};
@@ -75,7 +77,7 @@ describe('CatalogPage', () => {
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
@@ -33,46 +33,48 @@ import { ResultsFilter } from './ResultsFilter';
describe('Results Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'tools@example.com',
type: 'service',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
] as Entity[]),
] as Entity[],
}),
};
const indentityApi: Partial<IdentityApi> = {
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
@@ -82,7 +84,7 @@ describe('Results Filter', () => {
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
@@ -44,9 +44,13 @@ function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
const catalogApi = useApi(catalogApiRef);
return useAsync(async () => {
const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION];
return myLocation
? await catalogApi.getEntities({ [LOCATION_ANNOTATION]: myLocation })
: [];
if (!myLocation) {
return [];
}
const response = await catalogApi.getEntities({
filter: { [LOCATION_ANNOTATION]: myLocation },
});
return response.items;
}, [catalogApi, entity]);
}
@@ -46,9 +46,12 @@ export const EntityFilterGroupsProvider = ({
// The hook that implements the actual context building
function useProvideEntityFilters(): FilterGroupsContext {
const catalogApi = useApi(catalogApiRef);
const [{ value: entities, error }, doReload] = useAsyncFn(() =>
catalogApi.getEntities({ kind: 'Component' }),
);
const [{ value: entities, error }, doReload] = useAsyncFn(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Component' },
});
return response.items;
});
const filterGroups = useRef<{
[filterGroupId: string]: FilterGroup;
@@ -49,7 +49,7 @@ describe('useEntityFilterGroup', () => {
});
it('works for an empty set of filters', async () => {
catalogApi.getEntities.mockResolvedValue([]);
catalogApi.getEntities.mockResolvedValue({ items: [] });
const group: FilterGroup = { filters: {} };
const { result, waitFor } = renderHook(
() => useEntityFilterGroup('g1', group),
@@ -60,13 +60,15 @@ describe('useEntityFilterGroup', () => {
});
it('works for a single group', async () => {
catalogApi.getEntities.mockResolvedValue([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
]);
catalogApi.getEntities.mockResolvedValue({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
],
});
const group: FilterGroup = {
filters: {
f1: e => e.metadata.name === 'n',
@@ -52,7 +52,7 @@ describe('RollbarHome component', () => {
catalogApiRef,
({
async getEntities() {
return [];
return { items: [] };
},
} as Partial<CatalogApi>) as CatalogApi,
],
@@ -28,8 +28,8 @@ export function useRollbarEntities() {
configApi.getString('organization.name');
const { value, loading, error } = useAsync(async () => {
const entities = await catalogApi.getEntities();
return entities.filter(entity => {
const response = await catalogApi.getEntities();
return response.items.filter(entity => {
return !!entity.metadata.annotations?.[ROLLBAR_ANNOTATION];
});
}, [catalogApi]);
@@ -53,10 +53,12 @@ export const ScaffolderPage = () => {
const { data: templates, isValidating, error } = useStaleWhileRevalidate(
'templates/all',
async () =>
catalogApi.getEntities({ kind: 'Template' }) as Promise<
TemplateEntityV1alpha1[]
>,
async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Template' },
});
return response.items as TemplateEntityV1alpha1[];
},
);
useEffect(() => {
@@ -13,18 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TemplatePage } from './TemplatePage';
import { wrapInTestApp, renderWithEffects } from '@backstage/test-utils';
import { ApiRegistry, errorApiRef, ApiProvider } from '@backstage/core';
import { scaffolderApiRef, ScaffolderApi } from '../../api';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
import { mutate } from 'swr';
import { act } from 'react-dom/test-utils';
import { Route, MemoryRouter } from 'react-router';
import { rootRoute } from '../../routes';
import { ThemeProvider } from '@material-ui/core';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp, renderWithEffects } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter, Route } from 'react-router';
import { ScaffolderApi, scaffolderApiRef } from '../../api';
import { rootRoute } from '../../routes';
import { TemplatePage } from './TemplatePage';
const templateMock = {
apiVersion: 'backstage.io/v1alpha1',
@@ -90,48 +89,43 @@ const apis = ApiRegistry.from([
]);
describe('TemplatePage', () => {
afterEach(async () => {
// Cleaning up swr's cache
await act(async () => {
await mutate('templates/test');
});
});
beforeEach(() => jest.resetAllMocks());
it('renders correctly', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce([templateMock]);
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
),
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] });
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByText('React SSR Template')).toBeInTheDocument();
// await act(async () => await mutate('templates/test'));
});
it('renders spinner while loading', async () => {
let resolve: Function;
const promise = new Promise<any>(res => {
resolve = res;
});
catalogApiMock.getEntities.mockResolvedValueOnce(promise);
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
),
catalogApiMock.getEntities.mockReturnValueOnce(promise);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument();
// Need to cleanup the promise or will timeout
resolve!();
act(() => {
resolve!({ items: [] });
});
});
it('navigates away if no template was loaded', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce([]);
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] });
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
@@ -27,28 +27,26 @@ import { catalogApiRef } from '@backstage/plugin-catalog';
import { LinearProgress } from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import useStaleWhileRevalidate from 'swr';
import { scaffolderApiRef } from '../../api';
import { JobStatusModal } from '../JobStatusModal';
import { Job } from '../../types';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { Navigate } from 'react-router';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRoute } from '../../routes';
import { Job } from '../../types';
import { JobStatusModal } from '../JobStatusModal';
import { MultistepJsonForm } from '../MultistepJsonForm';
const useTemplate = (
templateName: string,
catalogApi: typeof catalogApiRef.T,
) => {
const { data, error } = useStaleWhileRevalidate(
`templates/${templateName}`,
async () =>
catalogApi.getEntities({
kind: 'Template',
'metadata.name': templateName,
}) as Promise<TemplateEntityV1alpha1[]>,
);
return { template: data?.[0], loading: !error && !data, error };
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Template', 'metadata.name': templateName },
});
return response.items as TemplateEntityV1alpha1[];
});
return { template: value?.[0], loading, error };
};
const OWNER_REPO_SCHEMA = {
@@ -110,7 +108,7 @@ export const TemplatePage = () => {
const handleCreateComplete = async (job: Job) => {
const target = job.metadata.remoteUrl?.replace(
/\.git$/,
// TODO(Rugvip): This is not the location we want. As part of scaffodler v2 we
// TODO(Rugvip): This is not the location we want. As part of scaffolder v2 we
// want this to be more flexible, but before that we might want
// to update all templates to use catalog-info.yaml instead.
'/blob/master/component-info.yaml',
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { wrapInTestApp } from '@backstage/test-utils';
@@ -24,7 +23,7 @@ import { TechDocsHome } from './TechDocsHome';
describe('TechDocs Home', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () => Promise.resolve([] as Entity[]),
getEntities: () => Promise.resolve({ items: [] }),
};
const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]);
@@ -14,19 +14,19 @@
* limitations under the License.
*/
import React from 'react';
import { useAsync } from 'react-use';
import { useNavigate, generatePath } from 'react-router-dom';
import { Grid } from '@material-ui/core';
import {
Content,
Header,
ItemCard,
Page,
Progress,
useApi,
Content,
Page,
Header,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Grid } from '@material-ui/core';
import React from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import { useAsync } from 'react-use';
import { rootDocsRouteRef } from '../../plugin';
export const TechDocsHome = () => {
@@ -34,8 +34,8 @@ export const TechDocsHome = () => {
const navigate = useNavigate();
const { value, loading, error } = useAsync(async () => {
const entities = await catalogApi.getEntities();
return entities.filter(entity => {
const response = await catalogApi.getEntities();
return response.items.filter(entity => {
return !!entity.metadata.annotations?.['backstage.io/techdocs-ref'];
});
});