diff --git a/.changeset/by-refs-predicate-backend.md b/.changeset/by-refs-predicate-backend.md new file mode 100644 index 0000000000..a2a0346122 --- /dev/null +++ b/.changeset/by-refs-predicate-backend.md @@ -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. diff --git a/.changeset/by-refs-predicate-client.md b/.changeset/by-refs-predicate-client.md new file mode 100644 index 0000000000..5ed136fbe1 --- /dev/null +++ b/.changeset/by-refs-predicate-client.md @@ -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. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 8bfebe6ab1..c155084ccd 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -236,6 +236,7 @@ export interface GetEntitiesByRefsRequest { entityRefs: string[]; fields?: EntityFieldsQuery | undefined; filter?: EntityFilterQuery; + query?: FilterPredicate; } // @public diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 819fa70381..70265bd6cb 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -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', () => { diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 0d173022a3..a671a155d3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -237,11 +237,36 @@ export class CatalogClient implements CatalogApi { request: GetEntitiesByRefsRequest, options?: CatalogRequestOptions, ): Promise { + 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, ); diff --git a/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts index 0e7255f9c6..607c992ace 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts @@ -24,4 +24,8 @@ export interface GetEntitiesByRefsRequest { entityRefs: Array; fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 13a7822df9..fac4ed5928 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -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; } /** diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index d5c4b2b675..3d6aff55ba 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -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. */ diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 58d7fed4ac..382dfedade 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1036,6 +1036,8 @@ paths: type: array items: type: string + query: + $ref: '#/components/schemas/JsonObject' examples: Fetch Backstage entities: value: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts index 0e7255f9c6..607c992ace 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts @@ -24,4 +24,8 @@ export interface GetEntitiesByRefsRequest { entityRefs: Array; fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index b423905955..74668310c5 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1124,6 +1124,9 @@ export const spec = { type: 'string', }, }, + query: { + $ref: '#/components/schemas/JsonObject', + }, }, }, examples: { diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index a9d706582a..38cd145421 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -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, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index ccdd034cae..62856ba7e4 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -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[] = [ diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 90c76cc4d4..1a7f1bf6fc 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -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), }); diff --git a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts index d5469315be..e3d8252d4c 100644 --- a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts +++ b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts @@ -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 { +const filterPredicateSchema = createZodV3FilterPredicateSchema(zodV3); + +export interface ParsedEntitiesBatchRequest { + entityRefs: string[]; + fields?: string[]; + query?: FilterPredicate; +} + +export function entitiesBatchRequest(req: Request): ParsedEntitiesBatchRequest { + let parsed: z.infer; 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, + }; }