feat(catalog-backend): permit entity field subselection (#3142)

This commit is contained in:
Fredrik Adelöw
2020-10-28 15:39:10 +01:00
committed by GitHub
parent 5e8a88bc9a
commit 183e2a30de
5 changed files with 119 additions and 3 deletions
+11
View File
@@ -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`.
@@ -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'] } });
});
});
@@ -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<string, any>,
@@ -70,3 +73,50 @@ export function translateFilterQueryEntryToEntityFilters(
return filters;
}
type FieldMapper = (entity: Entity) => Entity;
export function translateQueryToFieldMapper(
query: Record<string, any>,
): 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<Entity> = {};
for (const field of fields) {
const value = lodash.get(input, field);
if (value !== undefined) {
lodash.set(output, field, value);
}
}
return output as Entity;
};
}
@@ -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=',
);
@@ -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);