From 183e2a30ded7e2bac5e77bf896c6f6eb69e59b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Oct 2020 15:39:10 +0100 Subject: [PATCH] feat(catalog-backend): permit entity field subselection (#3142) --- .changeset/alchemists-on-ice.md | 11 ++++ .../src/service/filterQuery.test.ts | 52 ++++++++++++++++++- .../src/service/filterQuery.ts | 50 ++++++++++++++++++ .../src/service/router.test.ts | 1 + plugins/catalog-backend/src/service/router.ts | 8 ++- 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 .changeset/alchemists-on-ice.md diff --git a/.changeset/alchemists-on-ice.md b/.changeset/alchemists-on-ice.md new file mode 100644 index 0000000000..24f41730d1 --- /dev/null +++ b/.changeset/alchemists-on-ice.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Add support for `fields` sub-selection of just parts of an entity when listing +entities in the catalog backend. + +Example: `.../entities?fields=metadata.name,spec.type` will return partial +entity objects with only those exact fields present and the rest cut out. +Fields do not have to be simple scalars - you can for example do +`fields=metadata`. diff --git a/plugins/catalog-backend/src/service/filterQuery.test.ts b/plugins/catalog-backend/src/service/filterQuery.test.ts index b4e205de7b..bfb58568b5 100644 --- a/plugins/catalog-backend/src/service/filterQuery.test.ts +++ b/plugins/catalog-backend/src/service/filterQuery.test.ts @@ -14,9 +14,11 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { - translateQueryToEntityFilters, translateFilterQueryEntryToEntityFilters, + translateQueryToEntityFilters, + translateQueryToFieldMapper, } from './filterQuery'; describe('translateQueryToEntityFilters', () => { @@ -73,3 +75,51 @@ describe('translateFilterQueryEntryToEntityFilters', () => { expect(result).toEqual({ a: [null], b: ['2'] }); }); }); + +describe('translateQueryToFieldMapper', () => { + const entity: Entity = { + apiVersion: 'av', + kind: 'k', + metadata: { + name: 'n', + tags: ['t1', 't2'], + }, + spec: { + type: 't', + }, + }; + + it('passes through when no fields given', () => { + expect(translateQueryToFieldMapper({})(entity)).toBe(entity); + expect(translateQueryToFieldMapper({ fields: [] })(entity)).toBe(entity); + expect(translateQueryToFieldMapper({ fields: [''] })(entity)).toBe(entity); + expect(translateQueryToFieldMapper({ fields: [','] })(entity)).toBe(entity); + }); + + it('rejects attempts at array filtering', () => { + expect(() => + translateQueryToFieldMapper({ fields: 'metadata.tags[0]' })(entity), + ).toThrow(/array/i); + }); + + it('accepts both strings and arrays of strings as input', () => { + expect(translateQueryToFieldMapper({ fields: 'kind' })(entity)).toEqual({ + kind: 'k', + }); + expect(translateQueryToFieldMapper({ fields: ['kind'] })(entity)).toEqual({ + kind: 'k', + }); + expect( + translateQueryToFieldMapper({ fields: ['kind', 'apiVersion'] })(entity), + ).toEqual({ apiVersion: 'av', kind: 'k' }); + }); + + it('supports sub-selection properly', () => { + expect( + translateQueryToFieldMapper({ fields: 'kind,metadata.name' })(entity), + ).toEqual({ kind: 'k', metadata: { name: 'n' } }); + expect( + translateQueryToFieldMapper({ fields: 'metadata' })(entity), + ).toEqual({ metadata: { name: 'n', tags: ['t1', 't2'] } }); + }); +}); diff --git a/plugins/catalog-backend/src/service/filterQuery.ts b/plugins/catalog-backend/src/service/filterQuery.ts index 23c7c40e8d..4ab471ffcd 100644 --- a/plugins/catalog-backend/src/service/filterQuery.ts +++ b/plugins/catalog-backend/src/service/filterQuery.ts @@ -15,7 +15,10 @@ */ import { InputError } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import lodash from 'lodash'; import { EntityFilters } from '../database'; +import { RecursivePartial } from '../ingestion/processors/ldap/util'; export function translateQueryToEntityFilters( query: Record, @@ -70,3 +73,50 @@ export function translateFilterQueryEntryToEntityFilters( return filters; } + +type FieldMapper = (entity: Entity) => Entity; + +export function translateQueryToFieldMapper( + query: Record, +): FieldMapper { + if (!query.fields) { + return x => x; + } + + const fieldsStrings = [query.fields].flat() as string[]; + + if (fieldsStrings.some(s => typeof s !== 'string')) { + throw new InputError( + 'Only string type fields query parameters are supported', + ); + } + + const fields = fieldsStrings + .map(s => s.split(',')) + .flat() + .map(s => s.trim()) + .filter(Boolean); + + if (!fields.length) { + return x => x; + } + + if (fields.some(f => f.includes('['))) { + throw new InputError( + 'Array type fields query parameters are not supported', + ); + } + + return input => { + const output: RecursivePartial = {}; + + for (const field of fields) { + const value = lodash.get(input, field); + if (value !== undefined) { + lodash.set(output, field, value); + } + } + + return output as Entity; + }; +} diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 03aca11d03..a6b22eaad9 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -76,6 +76,7 @@ describe('createRouter', () => { }); it('parses single and multiple request parameters and passes them down', async () => { + entitiesCatalog.entities.mockResolvedValueOnce([]); const response = await request(app).get( '/entities?filter=a=1,a=,a=3,b=4&filter=c=', ); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index e5392d98fa..785e44f68c 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -22,7 +22,10 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { HigherOrderOperation } from '../ingestion/types'; -import { translateQueryToEntityFilters } from './filterQuery'; +import { + translateQueryToEntityFilters, + translateQueryToFieldMapper, +} from './filterQuery'; import { requireRequestBody, validateRequestBody } from './util'; export interface RouterOptions { @@ -44,8 +47,9 @@ export async function createRouter( router .get('/entities', async (req, res) => { const filters = translateQueryToEntityFilters(req.query); + const fieldMapper = translateQueryToFieldMapper(req.query); const entities = await entitiesCatalog.entities(filters); - res.status(200).send(entities); + res.status(200).send(entities.map(fieldMapper)); }) .post('/entities', async (req, res) => { const body = await requireRequestBody(req);