Merge pull request #1823 from spotify/freben/coal
feat(catalog-backend): add a CoalescedEntitiesCatalog
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog';
|
||||
import { EntitiesCatalog } from './types';
|
||||
|
||||
describe('CoalescedEntitiesCatalog', () => {
|
||||
const e1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'n1' },
|
||||
};
|
||||
|
||||
const e2: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'n2' },
|
||||
};
|
||||
|
||||
const c1: jest.Mocked<EntitiesCatalog> = {
|
||||
entities: jest.fn(),
|
||||
entityByUid: jest.fn(),
|
||||
entityByName: jest.fn(),
|
||||
addOrUpdateEntity: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
};
|
||||
|
||||
const c2: jest.Mocked<EntitiesCatalog> = {
|
||||
entities: jest.fn(),
|
||||
entityByUid: jest.fn(),
|
||||
entityByName: jest.fn(),
|
||||
addOrUpdateEntity: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
warn: jest.fn(),
|
||||
};
|
||||
const logger = (mockLogger as unknown) as Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('entities', () => {
|
||||
it('flattens results from multiple sources', async () => {
|
||||
c1.entities.mockResolvedValueOnce([e1]);
|
||||
c2.entities.mockResolvedValueOnce([e2]);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entities()).resolves.toEqual(
|
||||
expect.arrayContaining([e1, e2]),
|
||||
);
|
||||
expect(c1.entities).toBeCalledTimes(1);
|
||||
expect(c2.entities).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs an error if any source throws', async () => {
|
||||
c1.entities.mockResolvedValueOnce([e1]);
|
||||
c2.entities.mockRejectedValueOnce(new Error('boo'));
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entities()).resolves.toEqual([e1]);
|
||||
expect(c1.entities).toBeCalledTimes(1);
|
||||
expect(c2.entities).toBeCalledTimes(1);
|
||||
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByUid', () => {
|
||||
it('returns the first non-undefined result', async () => {
|
||||
c1.entityByUid.mockResolvedValueOnce(undefined);
|
||||
c2.entityByUid.mockResolvedValueOnce(e2);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByUid('e2')).resolves.toBe(e2);
|
||||
expect(c1.entityByUid).toBeCalledTimes(1);
|
||||
expect(c2.entityByUid).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns undefined if all results were undefined', async () => {
|
||||
c1.entityByUid.mockResolvedValueOnce(undefined);
|
||||
c2.entityByUid.mockResolvedValueOnce(undefined);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByUid('e2')).resolves.toBeUndefined();
|
||||
expect(c1.entityByUid).toBeCalledTimes(1);
|
||||
expect(c2.entityByUid).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs an error if any source throws', async () => {
|
||||
c1.entityByUid.mockResolvedValueOnce(e1);
|
||||
c2.entityByUid.mockRejectedValueOnce(new Error('boo'));
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByUid('e2')).resolves.toBe(e1);
|
||||
expect(c1.entityByUid).toBeCalledTimes(1);
|
||||
expect(c2.entityByUid).toBeCalledTimes(1);
|
||||
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByName', () => {
|
||||
it('returns the first non-undefined result', async () => {
|
||||
c1.entityByName.mockResolvedValueOnce(undefined);
|
||||
c2.entityByName.mockResolvedValueOnce(e2);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
|
||||
e2,
|
||||
);
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns undefined if all results were undefined', async () => {
|
||||
c1.entityByName.mockResolvedValueOnce(undefined);
|
||||
c2.entityByName.mockResolvedValueOnce(undefined);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(
|
||||
catalog.entityByName('k', undefined, 'n2'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs an error if any source throws', async () => {
|
||||
c1.entityByName.mockResolvedValueOnce(e1);
|
||||
c2.entityByName.mockRejectedValueOnce(new Error('boo'));
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
|
||||
e1,
|
||||
);
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { EntityFilters } from '../database';
|
||||
import { EntitiesCatalog } from './types';
|
||||
|
||||
/**
|
||||
* A simple coalescing catalog wrapper, that acts as a front for collecting
|
||||
* catalog data from multiple sources.
|
||||
*
|
||||
* One possible usage could be to have this as a front to both a
|
||||
* DatabaseEntitiesCatalog that holds Component kinds, and another company-
|
||||
* specific catalog that is a thin wrapper on top of LDAP that supplies Group
|
||||
* and User entities. That way you'll get a coherent view of two very different
|
||||
* entity sources.
|
||||
*
|
||||
* This is mainly meant as a functional example, and you may want to provide
|
||||
* your own more specialized collector if you have this distinct need. This
|
||||
* one does not support adding/updating entities through the API for example.
|
||||
* A more competent implementation may direct the writes to different catalogs
|
||||
* based on entity kind or similar.
|
||||
*/
|
||||
export class CoalescedEntitiesCatalog implements EntitiesCatalog {
|
||||
private inner: EntitiesCatalog[];
|
||||
private logger: Logger;
|
||||
|
||||
constructor(inner: EntitiesCatalog[], logger: Logger) {
|
||||
this.inner = inner;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async entities(filters?: EntityFilters): Promise<Entity[]> {
|
||||
const ops = this.inner.map(async catalog => {
|
||||
try {
|
||||
return await catalog.entities(filters);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Inner entities call failed, ${e}`);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(ops);
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
async entityByUid(uid: string): Promise<Entity | undefined> {
|
||||
const ops = this.inner.map(async catalog => {
|
||||
try {
|
||||
return await catalog.entityByUid(uid);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Inner entityByUid call failed, ${e}`);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(ops);
|
||||
return results.find(Boolean);
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<Entity | undefined> {
|
||||
const ops = this.inner.map(async catalog => {
|
||||
try {
|
||||
return await catalog.entityByName(kind, namespace, name);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Inner entityByName call failed, ${e}`);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(ops);
|
||||
return results.find(Boolean);
|
||||
}
|
||||
|
||||
addOrUpdateEntity(): Promise<Entity> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
removeEntityByUid(): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user