Merge pull request #33031 from backstage/worktree-catalog-updates

feat(catalog): Add predicate-based filtering to the facets endpoint
This commit is contained in:
Fredrik Adelöw
2026-02-26 20:37:48 +01:00
committed by GitHub
19 changed files with 508 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Added support for predicate-based filtering on the `/entity-facets` endpoint via a new `POST` method. Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': minor
---
Added support for the `query` field in `getEntityFacets` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators.
+1
View File
@@ -280,6 +280,7 @@ export interface GetEntityAncestorsResponse {
export interface GetEntityFacetsRequest {
facets: string[];
filter?: EntityFilterQuery;
query?: FilterPredicate;
}
// @public
+46 -1
View File
@@ -479,7 +479,13 @@ export class CatalogClient implements CatalogApi {
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse> {
const { filter = [], facets } = request;
const { filter = [], query, facets } = request;
// Route to POST endpoint if query predicate is provided
if (query) {
return this.getEntityFacetsByPredicate(request, options);
}
return await this.requestOptional(
await this.apiClient.getEntityFacets(
{
@@ -490,6 +496,45 @@ export class CatalogClient implements CatalogApi {
);
}
/**
* Get entity facets using predicate-based filters (POST endpoint).
* @internal
*/
private async getEntityFacetsByPredicate(
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse> {
const { filter, query, facets } = request;
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 = filterPredicate
? { $all: [filterPredicate, converted] }
: converted;
}
return await this.requestOptional(
await this.apiClient.queryEntityFacetsByPredicate(
{
body: {
facets,
...(filterPredicate && {
query: filterPredicate as unknown as { [key: string]: any },
}),
},
},
options,
),
);
}
/**
* {@inheritdoc CatalogApi.addLocation}
*/
@@ -30,6 +30,7 @@ import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model';
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model';
@@ -146,6 +147,12 @@ export type GetEntityFacets = {
export type QueryEntitiesByPredicate = {
body: QueryEntitiesByPredicateRequest;
};
/**
* @public
*/
export type QueryEntityFacetsByPredicate = {
body: QueryEntityFacetsByPredicateRequest;
};
/**
* @public
*/
@@ -481,6 +488,31 @@ export class DefaultApiClient {
});
}
/**
* Get entity facets using predicate-based filters.
* @param queryEntityFacetsByPredicateRequest -
*/
public async queryEntityFacetsByPredicate(
// @ts-ignore
request: QueryEntityFacetsByPredicate,
options?: RequestOptions,
): Promise<TypedResponse<EntityFacetsResponse>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/entity-facets`;
const uri = parser.parse(uriTemplate).expand({});
return await this.fetchApi.fetch(`${baseUrl}${uri}`, {
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify(request.body),
});
}
/**
* Refresh the entity related to entityRef.
* @param refreshEntityRequest -
@@ -0,0 +1,30 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntityFacetsByPredicateRequest {
facets: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -48,6 +48,7 @@ export * from '../models/NullableEntity.model';
export * from '../models/QueryEntitiesByPredicateRequest.model';
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/QueryEntityFacetsByPredicateRequest.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
+10
View File
@@ -297,6 +297,16 @@ export interface GetEntityFacetsRequest {
* of that key, no matter what its value is.
*/
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;
/**
* Dot separated paths for the facets to extract from each entity.
*
@@ -120,6 +120,10 @@ export interface EntityFacetsRequest {
* A filter to apply on the full list of entities before computing the facets.
*/
filter?: EntityFilter;
/**
* Predicate-based query for filtering entities.
*/
query?: FilterPredicate;
/**
* The facets to compute.
*
@@ -1194,6 +1194,40 @@ paths:
value:
- spec.type
- $ref: '#/components/parameters/filter'
post:
operationId: QueryEntityFacetsByPredicate
tags:
- Entity
description: Get entity facets using predicate-based filters.
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/EntityFacetsResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- facets
properties:
facets:
type: array
items:
type: string
query:
$ref: '#/components/schemas/JsonObject'
/locations:
post:
operationId: CreateLocation
@@ -27,6 +27,7 @@ import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model';
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model';
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
@@ -128,6 +129,13 @@ export type QueryEntitiesByPredicate = {
body: QueryEntitiesByPredicateRequest;
response: EntitiesQueryResponse | Error | Error;
};
/**
* @public
*/
export type QueryEntityFacetsByPredicate = {
body: QueryEntityFacetsByPredicateRequest;
response: EntityFacetsResponse | Error | Error;
};
/**
* @public
*/
@@ -230,6 +238,8 @@ export type EndpointMap = {
'#post|/entities/by-query': QueryEntitiesByPredicate;
'#post|/entity-facets': QueryEntityFacetsByPredicate;
'#post|/refresh': RefreshEntity;
'#post|/validate-entity': ValidateEntity;
@@ -0,0 +1,30 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntityFacetsByPredicateRequest {
facets: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -48,6 +48,7 @@ export * from '../models/NullableEntity.model';
export * from '../models/QueryEntitiesByPredicateRequest.model';
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/QueryEntityFacetsByPredicateRequest.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
@@ -1373,6 +1373,57 @@ export const spec = {
},
],
},
post: {
operationId: 'QueryEntityFacetsByPredicate',
tags: ['Entity'],
description: 'Get entity facets using predicate-based filters.',
responses: {
'200': {
description: 'Ok',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/EntityFacetsResponse',
},
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
{
JWT: [],
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['facets'],
properties: {
facets: {
type: 'array',
items: {
type: 'string',
},
},
query: {
$ref: '#/components/schemas/JsonObject',
},
},
},
},
},
},
},
},
'/locations': {
post: {
@@ -686,9 +686,10 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
})
.groupBy(['search.key', 'search.original_value']);
if (request.filter) {
if (request.filter || request.query) {
applyEntityFilterToQuery({
filter: request.filter,
query: request.query,
targetQuery: query,
onEntityIdField: 'search.entity_id',
knex: this.database,
@@ -1190,6 +1190,89 @@ describe('createRouter readonly disabled', () => {
);
});
});
describe('GET /entity-facets', () => {
it('returns facets', async () => {
entitiesCatalog.facets.mockResolvedValue({
facets: { kind: [{ value: 'Component', count: 5 }] },
});
const response = await request(app).get('/entity-facets?facet=kind');
expect(response.status).toBe(200);
expect(response.body).toEqual({
facets: { kind: [{ value: 'Component', count: 5 }] },
});
});
it('returns facets with filter parameter', async () => {
entitiesCatalog.facets.mockResolvedValue({
facets: { 'spec.type': [{ value: 'service', count: 3 }] },
});
const response = await request(app).get(
'/entity-facets?facet=spec.type&filter=kind=Component',
);
expect(response.status).toBe(200);
expect(response.body).toEqual({
facets: { 'spec.type': [{ value: 'service', count: 3 }] },
});
expect(entitiesCatalog.facets).toHaveBeenCalledWith(
expect.objectContaining({
facets: ['spec.type'],
filter: { key: 'kind', values: ['Component'] },
}),
);
});
});
describe('POST /entity-facets', () => {
it('returns facets with predicate query', async () => {
entitiesCatalog.facets.mockResolvedValue({
facets: { 'spec.type': [{ value: 'service', count: 3 }] },
});
const response = await request(app)
.post('/entity-facets')
.send({
facets: ['spec.type'],
query: { kind: 'Component' },
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
facets: { 'spec.type': [{ value: 'service', count: 3 }] },
});
expect(entitiesCatalog.facets).toHaveBeenCalledWith(
expect.objectContaining({
facets: ['spec.type'],
query: { kind: 'Component' },
}),
);
});
it('returns facets without query predicate', async () => {
entitiesCatalog.facets.mockResolvedValue({
facets: { kind: [{ value: 'Component', count: 5 }] },
});
const response = await request(app)
.post('/entity-facets')
.send({ facets: ['kind'] });
expect(response.status).toBe(200);
expect(response.body).toEqual({
facets: { kind: [{ value: 'Component', count: 5 }] },
});
});
it('returns 400 for missing facets', async () => {
const response = await request(app)
.post('/entity-facets')
.send({ query: { kind: 'Component' } });
expect(response.status).toBe(400);
});
});
});
describe('createRouter readonly and raw json enabled', () => {
@@ -47,6 +47,7 @@ import {
parseQueryEntitiesParams,
} from './request';
import { parseEntityFacetParams } from './request/parseEntityFacetParams';
import { parseEntityFacetsQuery } from './request/parseEntityFacetsQuery';
import { parseEntityOrderParams } from './request/parseEntityOrderParams';
import { parseEntityPaginationParams } from './request/parseEntityPaginationParams';
import {
@@ -564,6 +565,31 @@ export async function createRouter(
await auditorEvent?.success();
res.status(200).json(response);
} catch (err) {
await auditorEvent?.fail({
error: err,
});
throw err;
}
})
.post('/entity-facets', async (req, res) => {
const auditorEvent = await auditor.createEvent({
eventId: 'entity-facets',
request: req,
});
try {
const { facets, query } = parseEntityFacetsQuery(req.body ?? {});
const response = await entitiesCatalog.facets({
query,
facets,
credentials: await httpAuth.credentials(req),
});
await auditorEvent?.success();
res.status(200).json(response);
} catch (err) {
await auditorEvent?.fail({
@@ -0,0 +1,81 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { parseEntityFacetsQuery } from './parseEntityFacetsQuery';
describe('parseEntityFacetsQuery', () => {
it('parses facets with no query', () => {
expect(parseEntityFacetsQuery({ facets: ['kind'] })).toEqual({
facets: ['kind'],
query: undefined,
});
});
it('parses facets with a simple query', () => {
expect(
parseEntityFacetsQuery({
facets: ['spec.type'],
query: { kind: 'Component' },
}),
).toEqual({
facets: ['spec.type'],
query: { kind: 'Component' },
});
});
it('parses facets with complex predicate query', () => {
const query = {
$all: [{ kind: 'Component' }, { 'spec.lifecycle': 'production' }],
};
expect(parseEntityFacetsQuery({ facets: ['spec.type'], query })).toEqual({
facets: ['spec.type'],
query,
});
});
it('throws on missing facets', () => {
expect(() => parseEntityFacetsQuery({} as any)).toThrow(
'Missing or empty facets parameter',
);
});
it('throws on empty facets array', () => {
expect(() => parseEntityFacetsQuery({ facets: [] })).toThrow(
'Missing or empty facets parameter',
);
});
it('throws on invalid query (null)', () => {
expect(() =>
parseEntityFacetsQuery({ facets: ['kind'], query: null as any }),
).toThrow();
});
it('throws on invalid query (array)', () => {
expect(() =>
parseEntityFacetsQuery({ facets: ['kind'], query: [] as any }),
).toThrow();
});
it('throws on invalid query (invalid operator)', () => {
expect(() =>
parseEntityFacetsQuery({
facets: ['kind'],
query: { $invalid: 'bad' } as any,
}),
).toThrow();
});
});
@@ -0,0 +1,56 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import {
createZodV3FilterPredicateSchema,
FilterPredicate,
} from '@backstage/filter-predicates';
import { z } from 'zod/v3';
import { fromZodError } from 'zod-validation-error/v3';
import { QueryEntityFacetsByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model';
const filterPredicateSchema = createZodV3FilterPredicateSchema(z);
export interface ParsedEntityFacetsQuery {
facets: string[];
query?: FilterPredicate;
}
export function parseEntityFacetsQuery(
request: Readonly<QueryEntityFacetsByPredicateRequest>,
): ParsedEntityFacetsQuery {
// Parse facets
if (!request.facets || request.facets.length === 0) {
throw new InputError('Missing or empty facets parameter');
}
const facets = request.facets.filter(f => f.length > 0);
if (facets.length === 0) {
throw new InputError('Missing or empty facets parameter');
}
// Parse query predicate
let query: FilterPredicate | undefined;
if (request.query !== undefined) {
const result = filterPredicateSchema.safeParse(request.query);
if (!result.success) {
throw new InputError(`Invalid query: ${fromZodError(result.error)}`);
}
query = result.data;
}
return { facets, query };
}