From bed4174d86545cf28c85970f4ce0636902ff1157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 15:51:59 +0100 Subject: [PATCH 01/12] docs: add design for predicate-based facets filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- ...02-26-facets-predicate-filtering-design.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/plans/2026-02-26-facets-predicate-filtering-design.md diff --git a/docs/plans/2026-02-26-facets-predicate-filtering-design.md b/docs/plans/2026-02-26-facets-predicate-filtering-design.md new file mode 100644 index 0000000000..9b086fb8df --- /dev/null +++ b/docs/plans/2026-02-26-facets-predicate-filtering-design.md @@ -0,0 +1,43 @@ +# Predicate-based filtering for the catalog facets endpoint + +## Problem + +The `/entity-facets` endpoint only supports the old filter query parameter syntax. The `queryEntities` endpoint already has a POST variant that accepts predicate-based filtering (`$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, `$hasPrefix`). We need the same capability for facets. + +## Approach + +Mirror the pattern from `queryEntities`: add a POST variant of `/entity-facets` that accepts a JSON body with a `query` predicate, while keeping the existing GET endpoint for backward compatibility. + +## Changes + +### Client types (`packages/catalog-client/src/types/api.ts`) + +Add optional `query: FilterPredicate` field to `GetEntityFacetsRequest`. When both `filter` and `query` are provided, the client converts `filter` to a predicate and merges them with `$all`. + +### Client implementation (`packages/catalog-client/src/CatalogClient.ts`) + +If `query` is present, route to a new private method that POSTs to `/entity-facets`. Otherwise, use existing GET endpoint. + +### OpenAPI schema (`plugins/catalog-backend/src/schema/openapi.yaml`) + +Add POST operation `QueryEntityFacetsByPredicate` on `/entity-facets` with JSON body containing required `facets` array and optional `query` (JsonObject). + +### Generated OpenAPI client + +Add `queryEntityFacetsByPredicate` method. + +### Backend internal types (`plugins/catalog-backend/src/catalog/types.ts`) + +Add optional `query?: FilterPredicate` to `EntityFacetsRequest`. + +### Backend router (`plugins/catalog-backend/src/service/createRouter.ts`) + +Add POST handler that validates the query predicate with zod and calls `entitiesCatalog.facets`. + +### DefaultEntitiesCatalog.facets + +Pass `query` through to `applyEntityFilterToQuery` alongside existing `filter`. + +### AuthorizedEntitiesCatalog.facets + +No changes needed — permission conditions merge into `filter` only. From 193bd00374543d45c16456b2e03c69b2258618f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 15:55:37 +0100 Subject: [PATCH 02/12] docs: add implementation plan for facets predicate filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../2026-02-26-facets-predicate-filtering.md | 684 ++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 docs/plans/2026-02-26-facets-predicate-filtering.md diff --git a/docs/plans/2026-02-26-facets-predicate-filtering.md b/docs/plans/2026-02-26-facets-predicate-filtering.md new file mode 100644 index 0000000000..9f07d336c2 --- /dev/null +++ b/docs/plans/2026-02-26-facets-predicate-filtering.md @@ -0,0 +1,684 @@ +# Facets Predicate Filtering Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add predicate-based filtering (`$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, `$hasPrefix`) to the catalog `/entity-facets` endpoint, mirroring the pattern already used by `/entities/by-query`. + +**Architecture:** Add a POST variant of `/entity-facets` that accepts a JSON body with `query` (predicate) and `facets`. The client routes to POST when a `query` field is present, otherwise falls back to the existing GET endpoint. The backend validates the predicate with zod, then passes it through to `applyEntityFilterToQuery` which already supports both `filter` and `query`. + +**Tech Stack:** TypeScript, Express, Knex, zod, OpenAPI + +--- + +### Task 1: Backend internal types — add `query` to `EntityFacetsRequest` + +**Files:** + +- Modify: `plugins/catalog-backend/src/catalog/types.ts:118-137` + +**Step 1: Add `query` field to `EntityFacetsRequest`** + +In `plugins/catalog-backend/src/catalog/types.ts`, add an optional `query` field to `EntityFacetsRequest` after the existing `filter` field: + +```typescript +export interface EntityFacetsRequest { + filter?: EntityFilter; + /** Predicate-based query for filtering entities. */ + query?: FilterPredicate; + facets: string[]; + credentials: BackstageCredentials; +} +``` + +`FilterPredicate` is already imported at the top of this file from `@backstage/filter-predicates`. + +**Step 2: Run type checker** + +Run: `yarn tsc` in project root. +Expected: Should pass (the new field is optional, so no callers break). + +**Step 3: Commit** + +``` +feat(catalog): add query predicate field to EntityFacetsRequest +``` + +--- + +### Task 2: DefaultEntitiesCatalog — pass `query` through to filter application + +**Files:** + +- Modify: `plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts:675-712` + +**Step 1: Update the `facets` method to pass `query` to `applyEntityFilterToQuery`** + +Change the filter application block (lines 689-696) from: + +```typescript +if (request.filter) { + applyEntityFilterToQuery({ + filter: request.filter, + targetQuery: query, + onEntityIdField: 'search.entity_id', + knex: this.database, + }); +} +``` + +To: + +```typescript +if (request.filter || request.query) { + applyEntityFilterToQuery({ + filter: request.filter, + query: request.query, + targetQuery: query, + onEntityIdField: 'search.entity_id', + knex: this.database, + }); +} +``` + +**Step 2: Run type checker** + +Run: `yarn tsc` in project root. +Expected: Should pass. + +**Step 3: Commit** + +``` +feat(catalog): support query predicates in DefaultEntitiesCatalog.facets +``` + +--- + +### Task 3: Backend router — add POST `/entity-facets` handler + +**Files:** + +- Modify: `plugins/catalog-backend/src/service/createRouter.ts:552-574` +- Create: `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts` + +**Step 1: Create the request parser for POST facets** + +Create `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts`: + +```typescript +/* + * 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'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + +export interface ParsedEntityFacetsQuery { + facets: string[]; + query?: FilterPredicate; +} + +export function parseEntityFacetsQuery( + body: Record, +): ParsedEntityFacetsQuery { + // Parse facets + if (!Array.isArray(body.facets) || body.facets.length === 0) { + throw new InputError('Missing or empty facets parameter'); + } + const facets = body.facets.filter( + (f): f is string => typeof f === 'string' && f.length > 0, + ); + if (facets.length === 0) { + throw new InputError('Missing or empty facets parameter'); + } + + // Parse query predicate + let query: FilterPredicate | undefined; + if (body.query !== undefined) { + if ( + typeof body.query !== 'object' || + body.query === null || + Array.isArray(body.query) + ) { + throw new InputError('Query must be an object'); + } + const result = filterPredicateSchema.safeParse(body.query); + if (!result.success) { + throw new InputError(`Invalid query: ${fromZodError(result.error)}`); + } + query = result.data; + } + + return { facets, query }; +} +``` + +**Step 2: Add the POST handler in createRouter.ts** + +After the existing `.get('/entity-facets', ...)` block (which ends around line 574), add a new `.post('/entity-facets', ...)` handler. Change line 574 from: + +```typescript + }); +``` + +to: + +```typescript + }) + .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({ + filter: undefined, + query, + facets, + credentials: await httpAuth.credentials(req), + }); + + await auditorEvent?.success(); + + res.status(200).json(response); + } catch (err) { + await auditorEvent?.fail({ + error: err, + }); + throw err; + } + }); +``` + +Add the import at the top of createRouter.ts, alongside the other request parser imports: + +```typescript +import { parseEntityFacetsQuery } from './request/parseEntityFacetsQuery'; +``` + +**Step 3: Run type checker** + +Run: `yarn tsc` in project root. +Expected: Should pass. + +**Step 4: Commit** + +``` +feat(catalog): add POST /entity-facets endpoint with predicate support +``` + +--- + +### Task 4: Backend router tests — test the POST endpoint + +**Files:** + +- Modify: `plugins/catalog-backend/src/service/createRouter.test.ts` +- Create: `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts` + +**Step 1: Write unit tests for `parseEntityFacetsQuery`** + +Create `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts`: + +```typescript +/* + * 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({})).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 (not an object)', () => { + expect(() => + parseEntityFacetsQuery({ facets: ['kind'], query: 'bad' }), + ).toThrow('Query must be an object'); + }); + + it('throws on invalid query (array)', () => { + expect(() => + parseEntityFacetsQuery({ facets: ['kind'], query: [] }), + ).toThrow('Query must be an object'); + }); +}); +``` + +**Step 2: Run the parser tests** + +Run: `CI=1 yarn --cwd plugins/catalog-backend test src/service/request/parseEntityFacetsQuery.test.ts` +Expected: All tests pass. + +**Step 3: Add route-level tests in `createRouter.test.ts`** + +Add a new describe block for `POST /entity-facets` and `GET /entity-facets` to the existing test file. Find a suitable location (near end of the `'createRouter readonly disabled'` describe block, before its closing `}`). The test should exercise both the POST and GET routes using the mocked `entitiesCatalog.facets`. + +Look at how other route tests are structured in the file (e.g. `POST /entities/by-query`) and follow the same pattern with `request(app).post(...)`. + +**Step 4: Run the router tests** + +Run: `CI=1 yarn --cwd plugins/catalog-backend test src/service/createRouter.test.ts` +Expected: All tests pass. + +**Step 5: Commit** + +``` +test(catalog): add tests for POST /entity-facets endpoint +``` + +--- + +### Task 5: OpenAPI schema — add POST operation for `/entity-facets` + +**Files:** + +- Modify: `plugins/catalog-backend/src/schema/openapi.yaml:1160-1196` + +**Step 1: Add POST operation to the `/entity-facets` path** + +After the existing `get` operation block (which ends at line 1196 with `- $ref: '#/components/parameters/filter'`), add: + +```yaml +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' +``` + +**Step 2: Commit** + +``` +feat(catalog): add POST /entity-facets to OpenAPI schema +``` + +--- + +### Task 6: Generated OpenAPI client — add `queryEntityFacetsByPredicate` method + +**Files:** + +- Modify: `packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts` +- Create: `packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts` + +**Step 1: Create the request model** + +Create `packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts`: + +```typescript +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntityFacetsByPredicateRequest { + facets: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} +``` + +**Step 2: Add the request type and method to the API client** + +In `packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts`: + +Add the import near the other model imports: + +```typescript +import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model'; +``` + +Add the request type alongside the other exported types (after `QueryEntitiesByPredicate`): + +```typescript +export type QueryEntityFacetsByPredicate = { + body: QueryEntityFacetsByPredicateRequest; +}; +``` + +Add the method after the `getEntityFacets` method: + +```typescript + public async queryEntityFacetsByPredicate( + // @ts-ignore + request: QueryEntityFacetsByPredicate, + options?: RequestOptions, + ): Promise> { + 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), + }); + } +``` + +**Step 3: Run type checker** + +Run: `yarn tsc` in project root. +Expected: Should pass. + +**Step 4: Commit** + +``` +feat(catalog): add queryEntityFacetsByPredicate to generated OpenAPI client +``` + +--- + +### Task 7: Client types — add `query` field to `GetEntityFacetsRequest` + +**Files:** + +- Modify: `packages/catalog-client/src/types/api.ts:261-323` + +**Step 1: Add `query` field to `GetEntityFacetsRequest`** + +Add after the existing `filter` field (line 299): + +```typescript + /** + * 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; +``` + +`FilterPredicate` is already imported at the top of this file. + +**Step 2: Run type checker** + +Run: `yarn tsc` in project root. +Expected: Should pass. + +**Step 3: Commit** + +``` +feat(catalog): add query predicate to GetEntityFacetsRequest +``` + +--- + +### Task 8: Client implementation — route to POST when `query` is present + +**Files:** + +- Modify: `packages/catalog-client/src/CatalogClient.ts:478-491` + +**Step 1: Update `getEntityFacets` to route to POST when `query` is present** + +Replace the current `getEntityFacets` method (lines 478-491) with: + +```typescript + async getEntityFacets( + request: GetEntityFacetsRequest, + options?: CatalogRequestOptions, + ): Promise { + const { filter, query, facets } = request; + + // Route to POST endpoint if query predicate is provided + if (query || filter) { + return this.getEntityFacetsByPredicate(request, options); + } + + return await this.requestOptional( + await this.apiClient.getEntityFacets( + { + query: { facet: facets }, + }, + options, + ), + ); + } +``` + +**Step 2: Add the private `getEntityFacetsByPredicate` method** + +Add after `getEntityFacets`: + +```typescript + /** + * Get entity facets using predicate-based filters (POST endpoint). + * @internal + */ + private async getEntityFacetsByPredicate( + request: GetEntityFacetsRequest, + options?: CatalogRequestOptions, + ): Promise { + 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, + ), + ); + } +``` + +Make sure `InputError` is imported from `@backstage/errors` and `convertFilterToPredicate` is imported from `./utils` (check existing imports — `convertFilterToPredicate` is already used by `queryEntitiesByPredicate`). + +**Step 3: Run type checker** + +Run: `yarn tsc` in project root. +Expected: Should pass. + +**Step 4: Run existing client tests** + +Run: `CI=1 yarn --cwd packages/catalog-client test` +Expected: All tests pass. + +**Step 5: Commit** + +``` +feat(catalog): route facets requests to POST when query is present +``` + +--- + +### Task 9: Generate API reports and create changesets + +**Files:** + +- Create: `.changeset/.md` (two changesets) + +**Step 1: Run API reports** + +Run: `yarn build:api-reports` in project root. + +**Step 2: Create changeset for catalog-backend** + +Create `.changeset/facets-predicate-backend.md`: + +```markdown +--- +'@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. +``` + +**Step 3: Create changeset for catalog-client** + +Create `.changeset/facets-predicate-client.md`: + +```markdown +--- +'@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. +``` + +**Step 4: Commit changesets and API reports** + +``` +chore: add changesets and API reports for facets predicate support +``` + +--- + +### Task 10: Final verification + +**Step 1: Run type checker** + +Run: `yarn tsc` in project root. +Expected: Should pass. + +**Step 2: Run backend tests** + +Run: `CI=1 yarn --cwd plugins/catalog-backend test` +Expected: All tests pass. + +**Step 3: Run client tests** + +Run: `CI=1 yarn --cwd packages/catalog-client test` +Expected: All tests pass. + +**Step 4: Run linter** + +Run: `yarn lint --fix` in project root. +Expected: Should pass. From 2d1580b9bd764a518a85c21161150733e6da96c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:02:34 +0100 Subject: [PATCH 03/12] feat(catalog): add query predicate field to EntityFacetsRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend/src/catalog/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 5d733ad14c..d5c4b2b675 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -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. * From adcd98eb0b62b2404ed8bded0c8358dac059f41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:02:46 +0100 Subject: [PATCH 04/12] feat(catalog): support query predicates in DefaultEntitiesCatalog.facets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index dab605d176..a9d706582a 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -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, From e0fc8ddaec79e823fe27a35e3414347c4f6cd578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:03:16 +0100 Subject: [PATCH 05/12] feat(catalog): add POST /entity-facets endpoint with predicate support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../openapi/generated/apis/Api.client.ts | 32 +++++++++++ ...eryEntityFacetsByPredicateRequest.model.ts | 30 ++++++++++ .../schema/openapi/generated/models/index.ts | 1 + .../catalog-backend/src/schema/openapi.yaml | 34 +++++++++++ .../openapi/generated/apis/Api.server.ts | 10 ++++ ...eryEntityFacetsByPredicateRequest.model.ts | 30 ++++++++++ .../schema/openapi/generated/models/index.ts | 1 + .../src/schema/openapi/generated/router.ts | 51 +++++++++++++++++ .../src/service/createRouter.ts | 26 +++++++++ .../service/request/parseEntityFacetsQuery.ts | 56 +++++++++++++++++++ 10 files changed, 271 insertions(+) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts create mode 100644 plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 086f3695d4..899e310e94 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -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> { + 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 - diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts new file mode 100644 index 0000000000..14fd3a8bc0 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts @@ -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; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/index.ts b/packages/catalog-client/src/schema/openapi/generated/models/index.ts index 9461ac8d28..ebecb8dbb7 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -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'; diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 0531a9a1d3..58d7fed4ac 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -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 diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index dfb111359c..3362f903d0 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -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; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts new file mode 100644 index 0000000000..14fd3a8bc0 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts @@ -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; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index 9461ac8d28..ebecb8dbb7 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -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'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 6c739eb2ee..b423905955 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -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: { diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 48bf7961b8..90c76cc4d4 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -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({ diff --git a/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts new file mode 100644 index 0000000000..04e5e2ab82 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts @@ -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, +): 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 }; +} From 7a29905ba06aab72130d30d228ec2d1df02b773e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:19:43 +0100 Subject: [PATCH 06/12] feat(catalog): add query predicate to GetEntityFacetsRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/types/api.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index afd26076c9..13a7822df9 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -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. * From be6c10ef8cbc81f35359c8deb4135db27ce7ce47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:21:12 +0100 Subject: [PATCH 07/12] test(catalog): add tests for POST /entity-facets endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for parseEntityFacetsQuery covering valid inputs (simple queries, complex predicate queries, no query) and error cases (missing facets, empty facets, invalid query types). Add route-level tests for both GET and POST /entity-facets in createRouter.test.ts. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../src/service/createRouter.test.ts | 63 +++++++++++++++ .../request/parseEntityFacetsQuery.test.ts | 81 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 842a08c2d3..6d2b8ca70a 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -1190,6 +1190,69 @@ 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 }] }, + }); + }); + }); + + 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', () => { diff --git a/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts new file mode 100644 index 0000000000..473466f950 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts @@ -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(); + }); +}); From bd179b0d3ba71895f256ffa8b5d72df8b4db59f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:21:24 +0100 Subject: [PATCH 08/12] feat(catalog): route facets requests to POST when query is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 49 +++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index d1196bd8ba..cef743e5c6 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -479,11 +479,56 @@ export class CatalogClient implements CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise { - const { filter = [], facets } = request; + const { filter, query, facets } = request; + + // Route to POST endpoint if query predicate is provided + if (query || filter) { + return this.getEntityFacetsByPredicate(request, options); + } + return await this.requestOptional( await this.apiClient.getEntityFacets( { - query: { facet: facets, filter: this.getFilterValue(filter) }, + query: { facet: facets }, + }, + options, + ), + ); + } + + /** + * Get entity facets using predicate-based filters (POST endpoint). + * @internal + */ + private async getEntityFacetsByPredicate( + request: GetEntityFacetsRequest, + options?: CatalogRequestOptions, + ): Promise { + 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, ), From 56c908eed591497bcf85e8f5c0f15ea109814d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:25:31 +0100 Subject: [PATCH 09/12] chore: add changesets and API reports for facets predicate support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .changeset/facets-predicate-backend.md | 5 +++++ .changeset/facets-predicate-client.md | 5 +++++ packages/catalog-client/report.api.md | 1 + 3 files changed, 11 insertions(+) create mode 100644 .changeset/facets-predicate-backend.md create mode 100644 .changeset/facets-predicate-client.md diff --git a/.changeset/facets-predicate-backend.md b/.changeset/facets-predicate-backend.md new file mode 100644 index 0000000000..9ecdef11b6 --- /dev/null +++ b/.changeset/facets-predicate-backend.md @@ -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. diff --git a/.changeset/facets-predicate-client.md b/.changeset/facets-predicate-client.md new file mode 100644 index 0000000000..5493d7c82a --- /dev/null +++ b/.changeset/facets-predicate-client.md @@ -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. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 0d71e6bc59..8bfebe6ab1 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -280,6 +280,7 @@ export interface GetEntityAncestorsResponse { export interface GetEntityFacetsRequest { facets: string[]; filter?: EntityFilterQuery; + query?: FilterPredicate; } // @public From 12b790d3c3fe26f29d7188584a6827efe58a5d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 16:31:22 +0100 Subject: [PATCH 10/12] chore: remove working plan documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- ...02-26-facets-predicate-filtering-design.md | 43 -- .../2026-02-26-facets-predicate-filtering.md | 684 ------------------ 2 files changed, 727 deletions(-) delete mode 100644 docs/plans/2026-02-26-facets-predicate-filtering-design.md delete mode 100644 docs/plans/2026-02-26-facets-predicate-filtering.md diff --git a/docs/plans/2026-02-26-facets-predicate-filtering-design.md b/docs/plans/2026-02-26-facets-predicate-filtering-design.md deleted file mode 100644 index 9b086fb8df..0000000000 --- a/docs/plans/2026-02-26-facets-predicate-filtering-design.md +++ /dev/null @@ -1,43 +0,0 @@ -# Predicate-based filtering for the catalog facets endpoint - -## Problem - -The `/entity-facets` endpoint only supports the old filter query parameter syntax. The `queryEntities` endpoint already has a POST variant that accepts predicate-based filtering (`$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, `$hasPrefix`). We need the same capability for facets. - -## Approach - -Mirror the pattern from `queryEntities`: add a POST variant of `/entity-facets` that accepts a JSON body with a `query` predicate, while keeping the existing GET endpoint for backward compatibility. - -## Changes - -### Client types (`packages/catalog-client/src/types/api.ts`) - -Add optional `query: FilterPredicate` field to `GetEntityFacetsRequest`. When both `filter` and `query` are provided, the client converts `filter` to a predicate and merges them with `$all`. - -### Client implementation (`packages/catalog-client/src/CatalogClient.ts`) - -If `query` is present, route to a new private method that POSTs to `/entity-facets`. Otherwise, use existing GET endpoint. - -### OpenAPI schema (`plugins/catalog-backend/src/schema/openapi.yaml`) - -Add POST operation `QueryEntityFacetsByPredicate` on `/entity-facets` with JSON body containing required `facets` array and optional `query` (JsonObject). - -### Generated OpenAPI client - -Add `queryEntityFacetsByPredicate` method. - -### Backend internal types (`plugins/catalog-backend/src/catalog/types.ts`) - -Add optional `query?: FilterPredicate` to `EntityFacetsRequest`. - -### Backend router (`plugins/catalog-backend/src/service/createRouter.ts`) - -Add POST handler that validates the query predicate with zod and calls `entitiesCatalog.facets`. - -### DefaultEntitiesCatalog.facets - -Pass `query` through to `applyEntityFilterToQuery` alongside existing `filter`. - -### AuthorizedEntitiesCatalog.facets - -No changes needed — permission conditions merge into `filter` only. diff --git a/docs/plans/2026-02-26-facets-predicate-filtering.md b/docs/plans/2026-02-26-facets-predicate-filtering.md deleted file mode 100644 index 9f07d336c2..0000000000 --- a/docs/plans/2026-02-26-facets-predicate-filtering.md +++ /dev/null @@ -1,684 +0,0 @@ -# Facets Predicate Filtering Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add predicate-based filtering (`$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, `$hasPrefix`) to the catalog `/entity-facets` endpoint, mirroring the pattern already used by `/entities/by-query`. - -**Architecture:** Add a POST variant of `/entity-facets` that accepts a JSON body with `query` (predicate) and `facets`. The client routes to POST when a `query` field is present, otherwise falls back to the existing GET endpoint. The backend validates the predicate with zod, then passes it through to `applyEntityFilterToQuery` which already supports both `filter` and `query`. - -**Tech Stack:** TypeScript, Express, Knex, zod, OpenAPI - ---- - -### Task 1: Backend internal types — add `query` to `EntityFacetsRequest` - -**Files:** - -- Modify: `plugins/catalog-backend/src/catalog/types.ts:118-137` - -**Step 1: Add `query` field to `EntityFacetsRequest`** - -In `plugins/catalog-backend/src/catalog/types.ts`, add an optional `query` field to `EntityFacetsRequest` after the existing `filter` field: - -```typescript -export interface EntityFacetsRequest { - filter?: EntityFilter; - /** Predicate-based query for filtering entities. */ - query?: FilterPredicate; - facets: string[]; - credentials: BackstageCredentials; -} -``` - -`FilterPredicate` is already imported at the top of this file from `@backstage/filter-predicates`. - -**Step 2: Run type checker** - -Run: `yarn tsc` in project root. -Expected: Should pass (the new field is optional, so no callers break). - -**Step 3: Commit** - -``` -feat(catalog): add query predicate field to EntityFacetsRequest -``` - ---- - -### Task 2: DefaultEntitiesCatalog — pass `query` through to filter application - -**Files:** - -- Modify: `plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts:675-712` - -**Step 1: Update the `facets` method to pass `query` to `applyEntityFilterToQuery`** - -Change the filter application block (lines 689-696) from: - -```typescript -if (request.filter) { - applyEntityFilterToQuery({ - filter: request.filter, - targetQuery: query, - onEntityIdField: 'search.entity_id', - knex: this.database, - }); -} -``` - -To: - -```typescript -if (request.filter || request.query) { - applyEntityFilterToQuery({ - filter: request.filter, - query: request.query, - targetQuery: query, - onEntityIdField: 'search.entity_id', - knex: this.database, - }); -} -``` - -**Step 2: Run type checker** - -Run: `yarn tsc` in project root. -Expected: Should pass. - -**Step 3: Commit** - -``` -feat(catalog): support query predicates in DefaultEntitiesCatalog.facets -``` - ---- - -### Task 3: Backend router — add POST `/entity-facets` handler - -**Files:** - -- Modify: `plugins/catalog-backend/src/service/createRouter.ts:552-574` -- Create: `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts` - -**Step 1: Create the request parser for POST facets** - -Create `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts`: - -```typescript -/* - * 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'; - -const filterPredicateSchema = createZodV3FilterPredicateSchema(z); - -export interface ParsedEntityFacetsQuery { - facets: string[]; - query?: FilterPredicate; -} - -export function parseEntityFacetsQuery( - body: Record, -): ParsedEntityFacetsQuery { - // Parse facets - if (!Array.isArray(body.facets) || body.facets.length === 0) { - throw new InputError('Missing or empty facets parameter'); - } - const facets = body.facets.filter( - (f): f is string => typeof f === 'string' && f.length > 0, - ); - if (facets.length === 0) { - throw new InputError('Missing or empty facets parameter'); - } - - // Parse query predicate - let query: FilterPredicate | undefined; - if (body.query !== undefined) { - if ( - typeof body.query !== 'object' || - body.query === null || - Array.isArray(body.query) - ) { - throw new InputError('Query must be an object'); - } - const result = filterPredicateSchema.safeParse(body.query); - if (!result.success) { - throw new InputError(`Invalid query: ${fromZodError(result.error)}`); - } - query = result.data; - } - - return { facets, query }; -} -``` - -**Step 2: Add the POST handler in createRouter.ts** - -After the existing `.get('/entity-facets', ...)` block (which ends around line 574), add a new `.post('/entity-facets', ...)` handler. Change line 574 from: - -```typescript - }); -``` - -to: - -```typescript - }) - .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({ - filter: undefined, - query, - facets, - credentials: await httpAuth.credentials(req), - }); - - await auditorEvent?.success(); - - res.status(200).json(response); - } catch (err) { - await auditorEvent?.fail({ - error: err, - }); - throw err; - } - }); -``` - -Add the import at the top of createRouter.ts, alongside the other request parser imports: - -```typescript -import { parseEntityFacetsQuery } from './request/parseEntityFacetsQuery'; -``` - -**Step 3: Run type checker** - -Run: `yarn tsc` in project root. -Expected: Should pass. - -**Step 4: Commit** - -``` -feat(catalog): add POST /entity-facets endpoint with predicate support -``` - ---- - -### Task 4: Backend router tests — test the POST endpoint - -**Files:** - -- Modify: `plugins/catalog-backend/src/service/createRouter.test.ts` -- Create: `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts` - -**Step 1: Write unit tests for `parseEntityFacetsQuery`** - -Create `plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts`: - -```typescript -/* - * 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({})).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 (not an object)', () => { - expect(() => - parseEntityFacetsQuery({ facets: ['kind'], query: 'bad' }), - ).toThrow('Query must be an object'); - }); - - it('throws on invalid query (array)', () => { - expect(() => - parseEntityFacetsQuery({ facets: ['kind'], query: [] }), - ).toThrow('Query must be an object'); - }); -}); -``` - -**Step 2: Run the parser tests** - -Run: `CI=1 yarn --cwd plugins/catalog-backend test src/service/request/parseEntityFacetsQuery.test.ts` -Expected: All tests pass. - -**Step 3: Add route-level tests in `createRouter.test.ts`** - -Add a new describe block for `POST /entity-facets` and `GET /entity-facets` to the existing test file. Find a suitable location (near end of the `'createRouter readonly disabled'` describe block, before its closing `}`). The test should exercise both the POST and GET routes using the mocked `entitiesCatalog.facets`. - -Look at how other route tests are structured in the file (e.g. `POST /entities/by-query`) and follow the same pattern with `request(app).post(...)`. - -**Step 4: Run the router tests** - -Run: `CI=1 yarn --cwd plugins/catalog-backend test src/service/createRouter.test.ts` -Expected: All tests pass. - -**Step 5: Commit** - -``` -test(catalog): add tests for POST /entity-facets endpoint -``` - ---- - -### Task 5: OpenAPI schema — add POST operation for `/entity-facets` - -**Files:** - -- Modify: `plugins/catalog-backend/src/schema/openapi.yaml:1160-1196` - -**Step 1: Add POST operation to the `/entity-facets` path** - -After the existing `get` operation block (which ends at line 1196 with `- $ref: '#/components/parameters/filter'`), add: - -```yaml -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' -``` - -**Step 2: Commit** - -``` -feat(catalog): add POST /entity-facets to OpenAPI schema -``` - ---- - -### Task 6: Generated OpenAPI client — add `queryEntityFacetsByPredicate` method - -**Files:** - -- Modify: `packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts` -- Create: `packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts` - -**Step 1: Create the request model** - -Create `packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts`: - -```typescript -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** - -/** - * @public - */ -export interface QueryEntityFacetsByPredicateRequest { - facets: Array; - /** - * A type representing all allowed JSON object values. - */ - query?: { [key: string]: any }; -} -``` - -**Step 2: Add the request type and method to the API client** - -In `packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts`: - -Add the import near the other model imports: - -```typescript -import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model'; -``` - -Add the request type alongside the other exported types (after `QueryEntitiesByPredicate`): - -```typescript -export type QueryEntityFacetsByPredicate = { - body: QueryEntityFacetsByPredicateRequest; -}; -``` - -Add the method after the `getEntityFacets` method: - -```typescript - public async queryEntityFacetsByPredicate( - // @ts-ignore - request: QueryEntityFacetsByPredicate, - options?: RequestOptions, - ): Promise> { - 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), - }); - } -``` - -**Step 3: Run type checker** - -Run: `yarn tsc` in project root. -Expected: Should pass. - -**Step 4: Commit** - -``` -feat(catalog): add queryEntityFacetsByPredicate to generated OpenAPI client -``` - ---- - -### Task 7: Client types — add `query` field to `GetEntityFacetsRequest` - -**Files:** - -- Modify: `packages/catalog-client/src/types/api.ts:261-323` - -**Step 1: Add `query` field to `GetEntityFacetsRequest`** - -Add after the existing `filter` field (line 299): - -```typescript - /** - * 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; -``` - -`FilterPredicate` is already imported at the top of this file. - -**Step 2: Run type checker** - -Run: `yarn tsc` in project root. -Expected: Should pass. - -**Step 3: Commit** - -``` -feat(catalog): add query predicate to GetEntityFacetsRequest -``` - ---- - -### Task 8: Client implementation — route to POST when `query` is present - -**Files:** - -- Modify: `packages/catalog-client/src/CatalogClient.ts:478-491` - -**Step 1: Update `getEntityFacets` to route to POST when `query` is present** - -Replace the current `getEntityFacets` method (lines 478-491) with: - -```typescript - async getEntityFacets( - request: GetEntityFacetsRequest, - options?: CatalogRequestOptions, - ): Promise { - const { filter, query, facets } = request; - - // Route to POST endpoint if query predicate is provided - if (query || filter) { - return this.getEntityFacetsByPredicate(request, options); - } - - return await this.requestOptional( - await this.apiClient.getEntityFacets( - { - query: { facet: facets }, - }, - options, - ), - ); - } -``` - -**Step 2: Add the private `getEntityFacetsByPredicate` method** - -Add after `getEntityFacets`: - -```typescript - /** - * Get entity facets using predicate-based filters (POST endpoint). - * @internal - */ - private async getEntityFacetsByPredicate( - request: GetEntityFacetsRequest, - options?: CatalogRequestOptions, - ): Promise { - 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, - ), - ); - } -``` - -Make sure `InputError` is imported from `@backstage/errors` and `convertFilterToPredicate` is imported from `./utils` (check existing imports — `convertFilterToPredicate` is already used by `queryEntitiesByPredicate`). - -**Step 3: Run type checker** - -Run: `yarn tsc` in project root. -Expected: Should pass. - -**Step 4: Run existing client tests** - -Run: `CI=1 yarn --cwd packages/catalog-client test` -Expected: All tests pass. - -**Step 5: Commit** - -``` -feat(catalog): route facets requests to POST when query is present -``` - ---- - -### Task 9: Generate API reports and create changesets - -**Files:** - -- Create: `.changeset/.md` (two changesets) - -**Step 1: Run API reports** - -Run: `yarn build:api-reports` in project root. - -**Step 2: Create changeset for catalog-backend** - -Create `.changeset/facets-predicate-backend.md`: - -```markdown ---- -'@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. -``` - -**Step 3: Create changeset for catalog-client** - -Create `.changeset/facets-predicate-client.md`: - -```markdown ---- -'@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. -``` - -**Step 4: Commit changesets and API reports** - -``` -chore: add changesets and API reports for facets predicate support -``` - ---- - -### Task 10: Final verification - -**Step 1: Run type checker** - -Run: `yarn tsc` in project root. -Expected: Should pass. - -**Step 2: Run backend tests** - -Run: `CI=1 yarn --cwd plugins/catalog-backend test` -Expected: All tests pass. - -**Step 3: Run client tests** - -Run: `CI=1 yarn --cwd packages/catalog-client test` -Expected: All tests pass. - -**Step 4: Run linter** - -Run: `yarn lint --fix` in project root. -Expected: Should pass. From 4c4632ce1be7bb18d28ec09d9ff765b999a12221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 17:13:01 +0100 Subject: [PATCH 11/12] test(catalog): add GET /entity-facets backward compatibility test with filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../src/service/createRouter.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 6d2b8ca70a..ccdd034cae 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -1203,6 +1203,26 @@ describe('createRouter readonly disabled', () => { 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', () => { From 105befbbf0e31cee01cf82461a672c13efa9799e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 17:13:47 +0100 Subject: [PATCH 12/12] fix(catalog): only route facets to POST when query predicate is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve backward compatibility by keeping filter-only requests on the existing GET endpoint. Only route to POST when query is present, matching the pattern used by queryEntities. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index cef743e5c6..0d173022a3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -479,17 +479,17 @@ export class CatalogClient implements CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise { - const { filter, query, facets } = request; + const { filter = [], query, facets } = request; // Route to POST endpoint if query predicate is provided - if (query || filter) { + if (query) { return this.getEntityFacetsByPredicate(request, options); } return await this.requestOptional( await this.apiClient.getEntityFacets( { - query: { facet: facets }, + query: { facet: facets, filter: this.getFilterValue(filter) }, }, options, ),