diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index fb19f04220..9d0d8fcaf7 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -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 { + 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 { - 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; } } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index e9f1512074..9effed724c 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -35,7 +35,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { } async location(id: string): Promise { - const item = await this.location(id); + const item = await this.database.location(id); return item; } } diff --git a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts index 17f7603fb7..371cdf1f76 100644 --- a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts @@ -30,7 +30,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog { return lodash.cloneDeep(this._entities); } - async entity( + async entityByUid(uid: string): Promise { + 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, diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index e5beb801d0..d5627ea86a 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -29,10 +29,11 @@ export type EntityFilters = EntityFilter[]; export type EntitiesCatalog = { entities(filters?: EntityFilters): Promise; - entity( + entityByUid(uid: string): Promise; + entityByName( kind: string, - name: string, namespace: string | undefined, + name: string, ): Promise; }; diff --git a/plugins/catalog-backend/src/database/Database.test.ts b/plugins/catalog-backend/src/database/Database.test.ts index 7f02b45eee..7e9c75d199 100644 --- a/plugins/catalog-backend/src/database/Database.test.ts +++ b/plugins/catalog-backend/src/database/Database.test.ts @@ -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[] = [ diff --git a/plugins/catalog-backend/src/database/Database.ts b/plugins/catalog-backend/src/database/Database.ts index 3b616b4202..a3aba825f4 100644 --- a/plugins/catalog-backend/src/database/Database.ts +++ b/plugins/catalog-backend/src/database/Database.ts @@ -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 { return await this.database.transaction(async tx => { const existingLocation = await tx('locations') - .where({ - target: location.target, - }) + .where({ target: location.target }) .select(); if (existingLocation?.[0]) { diff --git a/plugins/catalog-backend/src/database/DatabaseManager.test.ts b/plugins/catalog-backend/src/database/DatabaseManager.test.ts index 2da3c33a00..ca50f518cb 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.test.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.test.ts @@ -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', () => { diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 1b1f4c5034..8c29362015 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -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' }]; diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 81533adfc5..c3318a7377 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -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; +}