feat(catalog): Add predicate-based filtering to the by-refs endpoint
Adds support for predicate-based filtering (`$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, `$hasPrefix`) to the catalog `/entities/by-refs` endpoint via a `query` field in the request body. The existing `filter` query parameter behavior is preserved for backward compatibility. The `query` predicate is only used when explicitly provided, and when both `filter` and `query` are given, they are merged with `$all`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Added support for predicate-based filtering on the `/entities/by-refs` endpoint via the `query` field in the request body. Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-client': minor
|
||||
---
|
||||
|
||||
Added support for the `query` field in `getEntitiesByRefs` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators.
|
||||
@@ -236,6 +236,7 @@ export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: string[];
|
||||
fields?: EntityFieldsQuery | undefined;
|
||||
filter?: EntityFilterQuery;
|
||||
query?: FilterPredicate;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -302,6 +302,103 @@ describe('CatalogClient', () => {
|
||||
|
||||
expect(response).toEqual({ items: [entity, undefined] });
|
||||
});
|
||||
|
||||
it('sends only query predicate in the body when query is provided without filter', async () => {
|
||||
expect.assertions(3);
|
||||
const entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Test2',
|
||||
namespace: 'test1',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('');
|
||||
await expect(req.json()).resolves.toEqual({
|
||||
entityRefs: ['k:n/a'],
|
||||
query: { kind: 'Component' },
|
||||
});
|
||||
return res(ctx.json({ items: [entity] }));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: ['k:n/a'],
|
||||
query: { kind: 'Component' },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ items: [entity] });
|
||||
});
|
||||
|
||||
it('merges filter and query into $all predicate when both are provided', async () => {
|
||||
expect.assertions(4);
|
||||
const entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Test2',
|
||||
namespace: 'test1',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('');
|
||||
const body = await req.json();
|
||||
expect(body.entityRefs).toEqual(['k:n/a']);
|
||||
expect(body.query).toEqual({
|
||||
$all: [{ kind: 'Component' }, { kind: 'API' }],
|
||||
});
|
||||
return res(ctx.json({ items: [entity] }));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: ['k:n/a'],
|
||||
query: { kind: 'Component' },
|
||||
filter: { kind: ['API'] },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ items: [entity] });
|
||||
});
|
||||
|
||||
it('sends filter as query parameter when only filter is provided (backward compat)', async () => {
|
||||
expect.assertions(4);
|
||||
const entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Test2',
|
||||
namespace: 'test1',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('?filter=kind%3DAPI');
|
||||
const body = await req.json();
|
||||
expect(body).toEqual({ entityRefs: ['k:n/a'] });
|
||||
expect(body.query).toBeUndefined();
|
||||
return res(ctx.json({ items: [entity] }));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: ['k:n/a'],
|
||||
filter: { kind: ['API'] },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ items: [entity] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryEntities', () => {
|
||||
|
||||
@@ -237,11 +237,36 @@ export class CatalogClient implements CatalogApi {
|
||||
request: GetEntitiesByRefsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesByRefsResponse> {
|
||||
const { filter, query } = request;
|
||||
|
||||
// Only convert and merge if both filter and query are provided, or if
|
||||
// query alone is provided. When only filter is given, preserve the old
|
||||
// query-parameter behavior for backward compatibility.
|
||||
let filterPredicate: FilterPredicate | undefined;
|
||||
if (query !== undefined) {
|
||||
if (typeof query !== 'object' || query === null || Array.isArray(query)) {
|
||||
throw new InputError('Query must be an object');
|
||||
}
|
||||
filterPredicate = query;
|
||||
if (filter !== undefined) {
|
||||
const converted = convertFilterToPredicate(filter);
|
||||
filterPredicate = { $all: [filterPredicate, converted] };
|
||||
}
|
||||
}
|
||||
|
||||
const getOneChunk = async (refs: string[]) => {
|
||||
const response = await this.apiClient.getEntitiesByRefs(
|
||||
{
|
||||
body: { entityRefs: refs, fields: request.fields },
|
||||
query: { filter: this.getFilterValue(request.filter) },
|
||||
body: {
|
||||
entityRefs: refs,
|
||||
fields: request.fields,
|
||||
...(filterPredicate && {
|
||||
query: filterPredicate as unknown as { [key: string]: any },
|
||||
}),
|
||||
},
|
||||
query: filterPredicate
|
||||
? {}
|
||||
: { filter: this.getFilterValue(request.filter) },
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
+4
@@ -24,4 +24,8 @@
|
||||
export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: Array<string>;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
|
||||
@@ -212,6 +212,16 @@ export interface GetEntitiesByRefsRequest {
|
||||
* If given, return only entities that match the given filter.
|
||||
*/
|
||||
filter?: EntityFilterQuery;
|
||||
/**
|
||||
* If given, return only entities that match the given predicate query.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Supports operators like `$all`, `$any`, `$not`, `$exists`, `$in`,
|
||||
* `$contains`, and `$hasPrefix`. When both `filter` and `query` are
|
||||
* provided, they are combined with `$all`.
|
||||
*/
|
||||
query?: FilterPredicate;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,6 +86,10 @@ export interface EntitiesBatchRequest {
|
||||
* they did not exist.
|
||||
*/
|
||||
filter?: EntityFilter;
|
||||
/**
|
||||
* Predicate-based query for filtering entities.
|
||||
*/
|
||||
query?: FilterPredicate;
|
||||
/**
|
||||
* Strips out only the parts of the entity bodies to include in the response.
|
||||
*/
|
||||
|
||||
@@ -1036,6 +1036,8 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
query:
|
||||
$ref: '#/components/schemas/JsonObject'
|
||||
examples:
|
||||
Fetch Backstage entities:
|
||||
value:
|
||||
|
||||
+4
@@ -24,4 +24,8 @@
|
||||
export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: Array<string>;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
|
||||
@@ -1124,6 +1124,9 @@ export const spec = {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
query: {
|
||||
$ref: '#/components/schemas/JsonObject',
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
|
||||
@@ -224,9 +224,10 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
})
|
||||
.whereIn('final_entities.entity_ref', chunk);
|
||||
|
||||
if (request?.filter) {
|
||||
if (request?.filter || request?.query) {
|
||||
query = applyEntityFilterToQuery({
|
||||
filter: request.filter,
|
||||
query: request.query,
|
||||
targetQuery: query,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex: this.database,
|
||||
|
||||
@@ -725,6 +725,54 @@ describe('createRouter readonly disabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /entities/by-refs with query predicate', () => {
|
||||
it('can fetch entities by refs with a predicate query', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'component',
|
||||
metadata: {
|
||||
name: 'a',
|
||||
},
|
||||
};
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
entitiesCatalog.entitiesBatch.mockResolvedValue({
|
||||
items: { type: 'object', entities: [entity] },
|
||||
});
|
||||
const response = await request(app)
|
||||
.post('/entities/by-refs')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(
|
||||
JSON.stringify({
|
||||
entityRefs: [entityRef],
|
||||
query: { kind: 'Component' },
|
||||
}),
|
||||
);
|
||||
expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({
|
||||
entityRefs: [entityRef],
|
||||
fields: undefined,
|
||||
credentials: mockCredentials.user(),
|
||||
filter: undefined,
|
||||
query: { kind: 'Component' },
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ items: [entity] });
|
||||
});
|
||||
|
||||
it('rejects invalid query predicate', async () => {
|
||||
const response = await request(app)
|
||||
.post('/entities/by-refs')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(
|
||||
JSON.stringify({
|
||||
entityRefs: ['component:default/a'],
|
||||
query: { $invalid: 'bad' },
|
||||
}),
|
||||
);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /locations', () => {
|
||||
it('happy path: lists locations', async () => {
|
||||
const locations: Location[] = [
|
||||
|
||||
@@ -525,6 +525,7 @@ export async function createRouter(
|
||||
const { items } = await entitiesCatalog.entitiesBatch({
|
||||
entityRefs: request.entityRefs,
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
query: request.query,
|
||||
fields: parseEntityTransformParams(req.query, request.fields),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
@@ -15,20 +15,51 @@
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import {
|
||||
createZodV3FilterPredicateSchema,
|
||||
FilterPredicate,
|
||||
} from '@backstage/filter-predicates';
|
||||
import { Request } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { z as zodV3 } from 'zod/v3';
|
||||
import { fromZodError } from 'zod-validation-error/v3';
|
||||
|
||||
const schema = z.object({
|
||||
entityRefs: z.array(z.string()),
|
||||
fields: z.array(z.string()).optional(),
|
||||
query: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export function entitiesBatchRequest(req: Request): z.infer<typeof schema> {
|
||||
const filterPredicateSchema = createZodV3FilterPredicateSchema(zodV3);
|
||||
|
||||
export interface ParsedEntitiesBatchRequest {
|
||||
entityRefs: string[];
|
||||
fields?: string[];
|
||||
query?: FilterPredicate;
|
||||
}
|
||||
|
||||
export function entitiesBatchRequest(req: Request): ParsedEntitiesBatchRequest {
|
||||
let parsed: z.infer<typeof schema>;
|
||||
try {
|
||||
return schema.parse(req.body);
|
||||
parsed = schema.parse(req.body);
|
||||
} catch (error) {
|
||||
throw new InputError(
|
||||
`Malformed request body (did you remember to specify an application/json content type?), ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
let query: FilterPredicate | undefined;
|
||||
if (parsed.query !== undefined) {
|
||||
const result = filterPredicateSchema.safeParse(parsed.query);
|
||||
if (!result.success) {
|
||||
throw new InputError(`Invalid query: ${fromZodError(result.error)}`);
|
||||
}
|
||||
query = result.data;
|
||||
}
|
||||
|
||||
return {
|
||||
entityRefs: parsed.entityRefs,
|
||||
fields: parsed.fields,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user