Individual entity fetches
This commit is contained in:
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import { Database } from '../database';
|
||||
import { DescriptorEnvelope } from '../ingestion/types';
|
||||
import { EntitiesCatalog, EntityFilters } from './types';
|
||||
@@ -29,17 +28,33 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
return items.map(i => i.entity);
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
|
||||
);
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
): Promise<DescriptorEnvelope | undefined> {
|
||||
const item = await this.database.transaction(tx =>
|
||||
this.database.entity(tx, kind, name, namespace),
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [
|
||||
{ key: 'kind', values: [kind] },
|
||||
{ key: 'name', values: [name] },
|
||||
{
|
||||
key: 'namespace',
|
||||
values:
|
||||
!namespace || namespace === 'default'
|
||||
? [null, 'default']
|
||||
: [namespace],
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
}
|
||||
return item.entity;
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
|
||||
}
|
||||
|
||||
async location(id: string): Promise<Location> {
|
||||
const item = await this.location(id);
|
||||
const item = await this.database.location(id);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
|
||||
return lodash.cloneDeep(this._entities);
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
|
||||
const item = this._entities.find(e => uid === e.metadata?.uid);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
}
|
||||
return lodash.cloneDeep(item);
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
|
||||
@@ -29,10 +29,11 @@ export type EntityFilters = EntityFilter[];
|
||||
|
||||
export type EntitiesCatalog = {
|
||||
entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]>;
|
||||
entity(
|
||||
entityByUid(uid: string): Promise<DescriptorEnvelope | undefined>;
|
||||
entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<DescriptorEnvelope | undefined>;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import path from 'path';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
import { Database } from './Database';
|
||||
import {
|
||||
AddDatabaseLocation,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
DbEntityResponse,
|
||||
DbLocationsRow,
|
||||
} from './types';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
|
||||
describe('Database', () => {
|
||||
let database: Knex;
|
||||
@@ -264,6 +264,7 @@ describe('Database', () => {
|
||||
{ locationId: undefined, entity: expect.objectContaining(e2) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters (naive case)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: DescriptorEnvelope[] = [
|
||||
|
||||
@@ -276,7 +276,7 @@ export class Database {
|
||||
? oldRow.generation
|
||||
: oldRow.generation + 1;
|
||||
const newEntity = lodash.cloneDeep(request.entity);
|
||||
newEntity.metadata = Object.assign({}, request.entity.metadata, {
|
||||
newEntity.metadata = Object.assign({}, newEntity.metadata, {
|
||||
uid: oldRow.id,
|
||||
etag: newEtag,
|
||||
generation: newGeneration,
|
||||
@@ -357,9 +357,7 @@ export class Database {
|
||||
async addLocation(location: AddDatabaseLocation): Promise<DbLocationsRow> {
|
||||
return await this.database.transaction<DbLocationsRow>(async tx => {
|
||||
const existingLocation = await tx<DbLocationsRow>('locations')
|
||||
.where({
|
||||
target: location.target,
|
||||
})
|
||||
.where({ target: location.target })
|
||||
.select();
|
||||
|
||||
if (existingLocation?.[0]) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import {
|
||||
ComponentDescriptor,
|
||||
DescriptorParser,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
import { Database } from './Database';
|
||||
import { DatabaseManager } from './DatabaseManager';
|
||||
import { DatabaseLocationUpdateLogStatus, DbLocationsRow } from './types';
|
||||
import Knex from 'knex';
|
||||
|
||||
describe('DatabaseManager', () => {
|
||||
describe('refreshLocations', () => {
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
errorHandler,
|
||||
getVoidLogger,
|
||||
InputError,
|
||||
} from '@backstage/backend-common';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
|
||||
@@ -27,7 +23,8 @@ import { createRouter } from './router';
|
||||
|
||||
class MockEntitiesCatalog implements EntitiesCatalog {
|
||||
entities = jest.fn();
|
||||
entity = jest.fn();
|
||||
entityByUid = jest.fn();
|
||||
entityByName = jest.fn();
|
||||
}
|
||||
|
||||
class MockLocationsCatalog implements LocationsCatalog {
|
||||
@@ -77,6 +74,89 @@ describe('createRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByUid', () => {
|
||||
it('can fetch entity by uid', async () => {
|
||||
const entity: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
},
|
||||
};
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByUid.mockResolvedValue(entity);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByUid.mockResolvedValue(undefined);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/uid/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByName', () => {
|
||||
it('can fetch entity by name', async () => {
|
||||
const entity: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
};
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByName.mockResolvedValue(entity);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-name/b/d/c');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByName.mockResolvedValue(undefined);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-name//b/d/c');
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locations', () => {
|
||||
it('happy path: lists locations', async () => {
|
||||
const locations: Location[] = [{ id: 'a', type: 'b', target: 'c' }];
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { errorHandler, InputError } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
@@ -41,26 +41,36 @@ export async function createRouter(
|
||||
router.use(express.json());
|
||||
|
||||
if (entitiesCatalog) {
|
||||
router.get('/entities', async (req, res) => {
|
||||
const filters: EntityFilters = [];
|
||||
for (const [key, valueOrValues] of Object.entries(req.query)) {
|
||||
const values = Array.isArray(valueOrValues)
|
||||
? valueOrValues
|
||||
: [valueOrValues];
|
||||
if (values.some(v => typeof v !== 'string')) {
|
||||
res.status(400).send('Complex query parameters are not supported');
|
||||
return;
|
||||
router
|
||||
.get('/entities', async (req, res) => {
|
||||
const filters = translateQueryToEntityFilters(req);
|
||||
const entities = await entitiesCatalog.entities(filters);
|
||||
res.status(200).send(entities);
|
||||
})
|
||||
.get('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
const entity = await entitiesCatalog.entityByUid(uid);
|
||||
if (!entity) {
|
||||
res.status(404).send(`No entity with uid ${uid}`);
|
||||
}
|
||||
filters.push({
|
||||
key,
|
||||
values: values.map(v => v || null) as string[],
|
||||
});
|
||||
}
|
||||
|
||||
const entities = await entitiesCatalog.entities(filters);
|
||||
|
||||
res.status(200).send(entities);
|
||||
});
|
||||
res.status(200).send(entity);
|
||||
})
|
||||
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const entity = await entitiesCatalog.entityByName(
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
);
|
||||
if (!entity) {
|
||||
res
|
||||
.status(404)
|
||||
.send(
|
||||
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
|
||||
);
|
||||
}
|
||||
res.status(200).send(entity);
|
||||
});
|
||||
}
|
||||
|
||||
if (locationsCatalog) {
|
||||
@@ -89,3 +99,26 @@ export async function createRouter(
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
function translateQueryToEntityFilters(
|
||||
request: express.Request,
|
||||
): EntityFilters {
|
||||
const filters: EntityFilters = [];
|
||||
|
||||
for (const [key, valueOrValues] of Object.entries(request.query)) {
|
||||
const values = Array.isArray(valueOrValues)
|
||||
? valueOrValues
|
||||
: [valueOrValues];
|
||||
|
||||
if (values.some(v => typeof v !== 'string')) {
|
||||
throw new InputError('Complex query parameters are not supported');
|
||||
}
|
||||
|
||||
filters.push({
|
||||
key,
|
||||
values: values.map(v => v || null) as string[],
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user