chore(catalog-backend): removing redudant classes and some functions

This commit is contained in:
Fredrik Adelöw
2020-10-09 12:15:20 +02:00
parent 6e99d20134
commit 84a22ed475
9 changed files with 78 additions and 397 deletions
@@ -1,162 +0,0 @@
/*
* 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, ENTITY_DEFAULT_NAMESPACE } 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(),
addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
};
const c2: jest.Mocked<EntitiesCatalog> = {
entities: jest.fn(),
entityByUid: jest.fn(),
entityByName: jest.fn(),
addOrUpdateEntity: jest.fn(),
addEntities: 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({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: '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({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: '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({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'n2',
}),
).resolves.toBe(e1);
expect(c1.entityByName).toBeCalledTimes(1);
expect(c2.entityByName).toBeCalledTimes(1);
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
});
});
});
@@ -1,100 +0,0 @@
/*
* 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, EntityName } 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(name: EntityName): Promise<Entity | undefined> {
const ops = this.inner.map(async catalog => {
try {
return await catalog.entityByName(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.');
}
addEntities(): Promise<void> {
throw new Error('Method not implemented.');
}
removeEntityByUid(): Promise<void> {
throw new Error('Method not implemented.');
}
}
@@ -24,13 +24,12 @@ describe('DatabaseEntitiesCatalog', () => {
beforeAll(() => {
db = {
transaction: jest.fn(),
addEntity: jest.fn(),
addEntities: jest.fn(),
updateEntity: jest.fn(),
entities: jest.fn(),
entityByName: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
removeEntityByUid: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
location: jest.fn(),
@@ -57,7 +56,7 @@ describe('DatabaseEntitiesCatalog', () => {
};
db.entities.mockResolvedValue([]);
db.addEntity.mockResolvedValue({ entity });
db.addEntities.mockResolvedValue([{ entity }]);
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(entity);
@@ -68,7 +67,7 @@ describe('DatabaseEntitiesCatalog', () => {
namespace: 'd',
name: 'c',
});
expect(db.addEntity).toHaveBeenCalledTimes(1);
expect(db.addEntities).toHaveBeenCalledTimes(1);
expect(result).toBe(entity);
});
@@ -72,7 +72,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
existing.entity.metadata.generation,
);
} else {
response = await this.database.addEntity(tx, { locationId, entity });
const added = await this.database.addEntities(tx, [
{ locationId, entity },
]);
response = added[0];
}
return response.entity;
@@ -105,7 +108,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
await this.database.removeEntityByUid(
tx,
dbResponse?.entity.metadata.uid!,
);
}
if (entityResponse.locationId) {
@@ -1,60 +0,0 @@
/*
* 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, EntityName, getEntityName } from '@backstage/catalog-model';
import lodash from 'lodash';
import type { EntitiesCatalog } from './types';
export class StaticEntitiesCatalog implements EntitiesCatalog {
private _entities: Entity[];
constructor(entities: Entity[]) {
this._entities = entities;
}
async entities(): Promise<Entity[]> {
return lodash.cloneDeep(this._entities);
}
async entityByUid(uid: string): Promise<Entity | undefined> {
const item = this._entities.find(e => uid === e.metadata.uid);
return item ? lodash.cloneDeep(item) : undefined;
}
async entityByName(name: EntityName): Promise<Entity | undefined> {
const item = this._entities.find(e => {
const candidate = getEntityName(e);
return (
name.kind.toLowerCase() === candidate.kind.toLowerCase() &&
name.namespace.toLowerCase() === candidate.namespace.toLowerCase() &&
name.name.toLowerCase() === candidate.name.toLowerCase()
);
});
return item ? lodash.cloneDeep(item) : undefined;
}
async addOrUpdateEntity(): Promise<Entity> {
throw new Error('Not supported');
}
async addEntities(): Promise<void> {
throw new Error('Not supported');
}
async removeEntityByUid(): Promise<void> {
throw new Error('Not supported');
}
}
@@ -16,5 +16,4 @@
export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
export { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
export { StaticEntitiesCatalog } from './StaticEntitiesCatalog';
export type { EntitiesCatalog, LocationsCatalog } from './types';
@@ -17,12 +17,12 @@
import { ConflictError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import { DatabaseManager } from './DatabaseManager';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
import type {
DbEntityRequest,
DbEntityResponse,
DbLocationsRowWithStatus,
} from './types';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
const bootstrapLocation = {
id: expect.any(String),
@@ -135,43 +135,12 @@ describe('CommonDatabase', () => {
await expect(db.location(location.id)).rejects.toThrow(/Found no location/);
});
describe('addEntity', () => {
it('happy path: adds entity to empty database', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
expect(added).toStrictEqual(entityResponse);
expect(added.entity.metadata.generation).toBe(1);
});
it('rejects adding the same-named entity twice', async () => {
await db.transaction(tx => db.addEntity(tx, entityRequest));
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-namespace entity twice', async () => {
entityRequest.entity.metadata.namespace = undefined;
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = '';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
entityRequest.entity.metadata.namespace = 'namespace1';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = 'namespace2';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).resolves.toBeDefined();
});
});
describe('addEntities', () => {
it('happy path: adds entities to empty database', async () => {
await db.transaction(tx => db.addEntities(tx, [entityRequest]));
expect(true).toBeTruthy();
const result = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
expect(result).toEqual([entityResponse]);
});
it('rejects adding the same-named entity twice', async () => {
@@ -237,7 +206,28 @@ describe('CommonDatabase', () => {
];
await expect(
db.transaction(tx => db.addEntities(tx, req)),
).resolves.toBeUndefined();
).resolves.toEqual([
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
namespace: 'ns1',
uid: expect.any(String),
etag: expect.any(String),
generation: expect.any(Number),
}),
}),
},
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
namespace: 'ns2',
uid: expect.any(String),
etag: expect.any(String),
generation: expect.any(Number),
}),
}),
},
]);
});
});
@@ -289,7 +279,9 @@ describe('CommonDatabase', () => {
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
@@ -306,7 +298,9 @@ describe('CommonDatabase', () => {
});
it('can update name if uid matches', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
added.entity.metadata.name! = 'new!';
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
@@ -315,7 +309,9 @@ describe('CommonDatabase', () => {
});
it('fails to update an entity if etag does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
await expect(
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, 'garbage'),
@@ -324,7 +320,9 @@ describe('CommonDatabase', () => {
});
it('fails to update an entity if generation does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
await expect(
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, undefined, 1e20),
@@ -347,8 +345,7 @@ describe('CommonDatabase', () => {
spec: { c: null },
};
await db.transaction(async tx => {
await db.addEntity(tx, { entity: e1 });
await db.addEntity(tx, { entity: e2 });
await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]);
});
const result = await db.transaction(async tx => db.entities(tx, []));
expect(result.length).toEqual(2);
@@ -384,9 +381,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
await expect(
@@ -422,9 +420,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
const rows = await db.transaction(async tx =>
@@ -471,9 +470,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
const rows = await db.transaction(async tx =>
@@ -519,9 +519,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
const e1 = await db.transaction(async tx =>
@@ -109,9 +109,10 @@ export class CommonDatabase implements Database {
async addEntities(
txOpaque: unknown,
request: DbEntityRequest[],
): Promise<void> {
): Promise<DbEntityResponse[]> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result: DbEntityResponse[] = [];
const entityRows: DbEntitiesRow[] = [];
const searchRows: DbEntitiesSearchRow[] = [];
@@ -134,6 +135,7 @@ export class CommonDatabase implements Database {
},
};
result.push({ entity: newEntity, locationId });
entityRows.push(this.toEntityRow(locationId, newEntity));
searchRows.push(...buildEntitySearch(newEntity.metadata.uid, newEntity));
}
@@ -146,6 +148,8 @@ export class CommonDatabase implements Database {
)
.del();
await tx.batchInsert('entities_search', searchRows, BATCH_SIZE);
return result;
}
async updateEntity(
@@ -315,7 +319,7 @@ export class CommonDatabase implements Database {
return this.toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
async removeEntityByUid(txOpaque: unknown, uid: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result = await tx<DbEntitiesRow>('entities').where({ id: uid }).del();
+5 -11
View File
@@ -88,22 +88,16 @@ export type Database = {
*/
transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T>;
/**
* Adds a new entity to the catalog.
*
* @param tx An ongoing transaction
* @param request The entity being added
* @returns The added entity, with uid, etag and generation set
*/
addEntity(tx: unknown, request: DbEntityRequest): Promise<DbEntityResponse>;
/**
* Adds a set of new entities to the catalog.
*
* @param tx An ongoing transaction
* @param request The entities being added
*/
addEntities(tx: unknown, request: DbEntityRequest[]): Promise<void>;
addEntities(
tx: unknown,
request: DbEntityRequest[],
): Promise<DbEntityResponse[]>;
/**
* Updates an existing entity in the catalog.
@@ -140,7 +134,7 @@ export type Database = {
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
removeEntity(tx: unknown, uid: string): Promise<void>;
removeEntityByUid(tx: unknown, uid: string): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;