diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 697da0ea35..fb19f04220 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -17,14 +17,14 @@ import { NotFoundError } from '@backstage/backend-common'; import { Database } from '../database'; import { DescriptorEnvelope } from '../ingestion/types'; -import { EntitiesCatalog } from './types'; +import { EntitiesCatalog, EntityFilters } from './types'; export class DatabaseEntitiesCatalog implements EntitiesCatalog { constructor(private readonly database: Database) {} - async entities(): Promise { + async entities(filters?: EntityFilters): Promise { const items = await this.database.transaction(tx => - this.database.entities(tx), + this.database.entities(tx, filters), ); return items.map(i => i.entity); } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 92c059d25b..e5beb801d0 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -18,11 +18,17 @@ import * as yup from 'yup'; import { DescriptorEnvelope } from '../ingestion'; // -// Items +// Entities // +export type EntityFilter = { + key: string; + values: (string | null)[]; +}; +export type EntityFilters = EntityFilter[]; + export type EntitiesCatalog = { - entities(): Promise; + entities(filters?: EntityFilters): Promise; entity( kind: string, name: string, diff --git a/plugins/catalog-backend/src/database/Database.test.ts b/plugins/catalog-backend/src/database/Database.test.ts index 0eb64916e5..7f02b45eee 100644 --- a/plugins/catalog-backend/src/database/Database.test.ts +++ b/plugins/catalog-backend/src/database/Database.test.ts @@ -28,6 +28,7 @@ import { DbEntityResponse, DbLocationsRow, } from './types'; +import { DescriptorEnvelope } from '../ingestion'; describe('Database', () => { let database: Knex; @@ -242,4 +243,107 @@ describe('Database', () => { ).rejects.toThrow(ConflictError); }); }); + + describe('entities', () => { + it('can get all entities with empty filters list', async () => { + const catalog = new Database(database, getVoidLogger()); + const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' }; + const e2: DescriptorEnvelope = { + apiVersion: 'a', + kind: 'b', + spec: { c: null }, + }; + await catalog.transaction(async tx => { + await catalog.addEntity(tx, { entity: e1 }); + await catalog.addEntity(tx, { entity: e2 }); + }); + await expect( + catalog.transaction(async tx => catalog.entities(tx, [])), + ).resolves.toEqual([ + { locationId: undefined, entity: expect.objectContaining(e1) }, + { 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[] = [ + { apiVersion: 'a', kind: 'b' }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: 'some' }, + }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: null }, + }, + ]; + + await catalog.transaction(async tx => { + for (const entity of entities) { + await catalog.addEntity(tx, { entity }); + } + }); + + await expect( + catalog.transaction(async tx => + catalog.entities(tx, [ + { key: 'kind', values: ['b'] }, + { key: 'spec.c', values: ['some'] }, + ]), + ), + ).resolves.toEqual([ + { locationId: undefined, entity: expect.objectContaining(entities[1]) }, + ]); + }); + + it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => { + const catalog = new Database(database, getVoidLogger()); + const entities: DescriptorEnvelope[] = [ + { apiVersion: 'a', kind: 'b' }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: 'some' }, + }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: null }, + }, + ]; + + await catalog.transaction(async tx => { + for (const entity of entities) { + await catalog.addEntity(tx, { entity }); + } + }); + + const rows = await catalog.transaction(async tx => + catalog.entities(tx, [ + { key: 'kind', values: ['b'] }, + { key: 'spec.c', values: [null, 'some'] }, + ]), + ); + + expect(rows.length).toEqual(3); + expect(rows).toEqual( + expect.arrayContaining([ + { + locationId: undefined, + entity: expect.objectContaining(entities[0]), + }, + { + locationId: undefined, + entity: expect.objectContaining(entities[1]), + }, + { + locationId: undefined, + entity: expect.objectContaining(entities[2]), + }, + ]), + ); + }); + }); }); diff --git a/plugins/catalog-backend/src/database/Database.ts b/plugins/catalog-backend/src/database/Database.ts index cd4ca731a3..3b616b4202 100644 --- a/plugins/catalog-backend/src/database/Database.ts +++ b/plugins/catalog-backend/src/database/Database.ts @@ -23,6 +23,7 @@ import Knex from 'knex'; import lodash from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; +import { EntityFilters } from '../catalog'; import { DescriptorEnvelope, EntityMeta } from '../ingestion'; import { buildEntitySearch } from './search'; import { @@ -309,10 +310,30 @@ export class Database { return { locationId: request.locationId, entity: newEntity }; } - async entities(tx: Knex.Transaction): Promise { - const rows = await tx('entities') + async entities( + tx: Knex.Transaction, + filters?: EntityFilters, + ): Promise { + let builder = tx('entities'); + for (const [index, filter] of (filters ?? []).entries()) { + builder = builder + .leftOuterJoin(`entities_search as t${index}`, function join() { + this.on('entities.id', '=', `t${index}.entity_id`).onIn( + `t${index}.value`, + filter.values.filter(x => x), + ); + if (filter.values.some(x => !x)) { + this.orOnNull(`t${index}.value`); + } + }) + .where(`t${index}.key`, '=', filter.key); + } + + const rows = await builder .orderBy('namespace', 'name') - .select(); + .select('entities.*') + .groupBy('id'); + return rows.map(row => toEntityResponse(row)); } diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index c4e21c9b0e..1b1f4c5034 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -14,12 +14,16 @@ * limitations under the License. */ +import { + errorHandler, + getVoidLogger, + InputError, +} from '@backstage/backend-common'; import express from 'express'; import request from 'supertest'; -import { createRouter } from './router'; -import { EntitiesCatalog, LocationsCatalog, Location } from '../catalog'; -import { getVoidLogger } from '@backstage/backend-common'; +import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog'; import { DescriptorEnvelope } from '../ingestion'; +import { createRouter } from './router'; class MockEntitiesCatalog implements EntitiesCatalog { entities = jest.fn(); @@ -50,7 +54,26 @@ describe('createRouter', () => { const response = await request(app).get('/entities'); expect(response.status).toEqual(200); - expect(JSON.parse(response.text)).toEqual(entities); + expect(response.body).toEqual(entities); + }); + + it('parses single and multiple request parameters and passes them down', async () => { + const catalog = new MockEntitiesCatalog(); + + const router = await createRouter({ + entitiesCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c='); + + expect(response.status).toEqual(200); + expect(catalog.entities).toHaveBeenCalledWith([ + { key: 'a', values: ['1', null, '3'] }, + { key: 'b', values: ['4'] }, + { key: 'c', values: [null] }, + ]); }); }); @@ -70,7 +93,26 @@ describe('createRouter', () => { const response = await request(app).get('/locations'); expect(response.status).toEqual(200); - expect(JSON.parse(response.text)).toEqual(locations); + expect(response.body).toEqual(locations); + }); + + it('rejects malformed locations', async () => { + const location = ({ + id: 'a', + typez: 'b', + target: 'c', + } as unknown) as Location; + + const catalog = new MockLocationsCatalog(); + const router = await createRouter({ + locationsCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).post('/locations').send(location); + + expect(response.status).toEqual(400); }); }); }); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index af714e4026..81533adfc5 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -14,12 +14,14 @@ * limitations under the License. */ +import { errorHandler } from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { addLocationSchema, EntitiesCatalog, + EntityFilters, LocationsCatalog, } from '../catalog'; import { validateRequestBody } from './util'; @@ -34,17 +36,33 @@ export async function createRouter( options: RouterOptions, ): Promise { const { entitiesCatalog, locationsCatalog } = options; + const router = Router(); + router.use(express.json()); if (entitiesCatalog) { - // Entities - router.get('/entities', async (_req, res) => { - const entities = await entitiesCatalog.entities(); + 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; + } + filters.push({ + key, + values: values.map(v => v || null) as string[], + }); + } + + const entities = await entitiesCatalog.entities(filters); + res.status(200).send(entities); }); } - // Locations if (locationsCatalog) { router .post('/locations', async (req, res) => { @@ -68,5 +86,6 @@ export async function createRouter( }); } + router.use(errorHandler()); return router; }