feat(catalog-backend): permit entity field subselection (#3142)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user