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.